diff --git a/.gitignore b/.gitignore index b9205033..7d556efc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ env/ /liberation_preferences.json /state.json -logs/ +/logs/ qt_ui/logs/liberation.log diff --git a/changelog.md b/changelog.md index d8f11ab9..f1486a61 100644 --- a/changelog.md +++ b/changelog.md @@ -1,19 +1,65 @@ # 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 -# 4.0.1 +* **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning. -Saves from 4.0.0 are compatible with 4.0.1. +# 4.1.0 + +Saves from 4.0.0 are compatible with 4.1.0. ## Features/Improvements +* **[Campaign]** Air defense sites now generate a fixed number of launchers per type. +* **[Campaign]** Added support for Mariana Islands map. +* **[Campaign AI]** Adjustments to aircraft selection priorities for most mission types. +* **[Engine]** Support for DCS 2.7.4.9632 and newer, including the Marianas map, F-16 JSOWs, NASAMS, and Tin Shield EWR. +* **[Flight Planning]** CAP patrol altitudes are now set per-aircraft. By default the altitude will be set based on the aircraft's maximum speed. +* **[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 +* **[Mods]** Support for latest version of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts. +* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. +* **[Plugins]** Updated SkynetIADS to 2.2.0 (adds NASAMS support). +* **[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). +* **[UI]** Google search link added to unit information when there is no information provided. +* **[UI]** Control point name displayed with ground object group name on map. +* **[UI]** Buy or Replace will now show the correct price for generated ground objects like sams. +* **[UI]** Improved logging for frontline movement to be more descriptive about what happened and why. +* **[UI]** Brought ruler map module into source, which should fix file integrity issues with the module. + ## Fixes +* **[Campaign]** Fixed the Silkworm generator to include launchers and not all radars. +* **[Data]** Fixed Introduction dates for targeting pods (ATFLIR and LITENING were both a few years too early). +* **[Data]** Removed SA-10 from Syria 2011 faction. +* **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money +* **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles. +* **[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]** Planned transfers which will be impossible after a base capture will no longer prevent the mission result submit. +* **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed. +* **[Mission Generation]** Fixed a potential bug with laser code generation where it would generate invalid codes. +* **[UI]** Statistics window tick marks are now always integers. +* **[UI]** Statistics window now shows the correct info for the turn +* **[UI]** Toggling custom loadout for an aircraft with no preset loadouts no longer breaks the flight. + # 4.0.0 Saves from 3.x are not compatible with 4.0. 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 new file mode 100644 index 00000000..b6e681f9 --- /dev/null +++ b/game/coalition.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from dcs import Point +from faker import Faker + +from game.commander import TheaterCommander +from game.commander.missionscheduler import MissionScheduler +from game.income import Income +from game.inventory import GlobalAircraftInventory +from game.navmesh import NavMesh +from game.orderedset import OrderedSet +from game.profiling import logged_duration, MultiEventTracer +from game.savecompat import has_save_compat_for +from game.threatzones import ThreatZones +from game.transfers import PendingTransfers + +if TYPE_CHECKING: + from game import Game +from game.data.doctrine import Doctrine +from game.factions.faction import Faction +from game.procurement import AircraftProcurementRequest, ProcurementAi +from game.squadrons import AirWing +from game.theater.bullseye import Bullseye +from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder +from gen import AirTaskingOrder + + +class Coalition: + def __init__( + self, game: Game, faction: Faction, budget: float, player: bool + ) -> None: + self.game = game + self.player = player + self.faction = faction + self.budget = budget + self.ato = AirTaskingOrder() + self.transit_network = TransitNetwork() + self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet() + self.bullseye = Bullseye(Point(0, 0)) + self.faker = Faker(self.faction.locales) + self.air_wing = AirWing(game, self) + self.transfers = PendingTransfers(game, player) + + # Late initialized because the two coalitions in the game are mutually + # dependent, so must be both constructed before this property can be set. + self._opponent: Optional[Coalition] = None + + # Volatile properties that are not persisted to the save file since they can be + # recomputed on load. Keeping this data out of the save file makes save compat + # breaks less frequent. Each of these properties has a non-underscore-prefixed + # @property that should be used for non-Optional access. + # + # All of these are late-initialized (whether via on_load or called later), but + # will be non-None after the game has finished loading. + self._threat_zone: Optional[ThreatZones] = None + self._navmesh: Optional[NavMesh] = None + self.on_load() + + @property + def doctrine(self) -> Doctrine: + return self.faction.doctrine + + @property + def coalition_id(self) -> int: + if self.player: + return 2 + return 1 + + @property + def country_name(self) -> str: + return self.faction.country + + @property + def opponent(self) -> Coalition: + assert self._opponent is not None + return self._opponent + + @property + def threat_zone(self) -> ThreatZones: + assert self._threat_zone is not None + return self._threat_zone + + @property + def nav_mesh(self) -> NavMesh: + assert self._navmesh is not None + return self._navmesh + + @property + def aircraft_inventory(self) -> GlobalAircraftInventory: + return self.game.aircraft_inventory + + def __getstate__(self) -> dict[str, Any]: + state = self.__dict__.copy() + # Avoid persisting any volatile types that can be deterministically + # recomputed on load for the sake of save compatibility. + del state["_threat_zone"] + del state["_navmesh"] + del state["faker"] + return state + + @has_save_compat_for(5) + def __setstate__(self, state: dict[str, Any]) -> None: + # Begin save compat + old_procurement_requests = state["procurement_requests"] + if isinstance(old_procurement_requests, list): + state["procurement_requests"] = OrderedSet(old_procurement_requests) + # End save compat + + self.__dict__.update(state) + # Regenerate any state that was not persisted. + self.on_load() + + def on_load(self) -> None: + self.faker = Faker(self.faction.locales) + + def set_opponent(self, opponent: Coalition) -> None: + if self._opponent is not None: + raise RuntimeError("Double-initialization of Coalition.opponent") + self._opponent = opponent + + def adjust_budget(self, amount: float) -> None: + self.budget += amount + + def compute_threat_zones(self) -> None: + self._threat_zone = ThreatZones.for_faction(self.game, self.player) + + def compute_nav_meshes(self) -> None: + self._navmesh = NavMesh.from_threat_zones( + self.opponent.threat_zone, self.game.theater + ) + + def update_transit_network(self) -> None: + self.transit_network = TransitNetworkBuilder( + self.game.theater, self.player + ).build() + + def set_bullseye(self, bullseye: Bullseye) -> None: + self.bullseye = bullseye + + def end_turn(self) -> None: + """Processes coalition-specific turn finalization. + + For more information on turn finalization in general, see the documentation for + `Game.finish_turn`. + """ + self.air_wing.replenish() + self.budget += Income(self.game, self.player).total + + # Need to recompute before transfers and deliveries to account for captures. + # This happens in in initialize_turn as well, because cheating doesn't advance a + # turn but can capture bases so we need to recompute there as well. + self.update_transit_network() + + # Must happen *before* unit deliveries are handled, or else new units will spawn + # one hop ahead. ControlPoint.process_turn handles unit deliveries. The + # coalition-specific turn-end happens before the theater-wide turn-end, so this + # 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. + + For more information on turn initialization in general, see the documentation + for `Game.initialize_turn`. + """ + # Needs to happen *before* planning transfers so we don't cancel them. + self.ato.clear() + self.air_wing.reset() + self.refund_outstanding_orders() + self.procurement_requests.clear() + + with logged_duration("Transit network identification"): + self.update_transit_network() + with logged_duration("Procurement of airlift assets"): + self.transfers.order_airlift_assets() + with logged_duration("Transport planning"): + self.transfers.plan_transports() + + self.plan_missions() + self.plan_procurement() + + def refund_outstanding_orders(self) -> None: + # TODO: Split orders between air and ground units. + # This isn't quite right. If the player has ground purchases automated we should + # be refunding the ground units, and if they have air automated but not ground + # we should be refunding air units. + if self.player and not self.game.settings.automate_aircraft_reinforcements: + return + + for cp in self.game.theater.control_points_for(self.player): + cp.pending_unit_deliveries.refund_all(self) + + def plan_missions(self) -> None: + color = "Blue" if self.player else "Red" + with MultiEventTracer() as tracer: + with tracer.trace(f"{color} mission planning"): + with tracer.trace(f"{color} mission identification"): + TheaterCommander(self.game, self.player).plan_missions(tracer) + with tracer.trace(f"{color} mission scheduling"): + MissionScheduler( + self, self.game.settings.desired_player_mission_duration + ).schedule_missions() + + def plan_procurement(self) -> None: + # The first turn needs to buy a *lot* of aircraft to fill CAPs, so it gets much + # more of the budget that turn. Otherwise budget (after repairs) is split evenly + # between air and ground. For the default starting budget of 2000 this gives 600 + # to ground forces and 1400 to aircraft. After that the budget will be spent + # proportionally based on how much is already invested. + + if self.player: + manage_runways = self.game.settings.automate_runway_repair + manage_front_line = self.game.settings.automate_front_line_reinforcements + manage_aircraft = self.game.settings.automate_aircraft_reinforcements + else: + manage_runways = True + manage_front_line = True + manage_aircraft = True + + self.budget = ProcurementAi( + self.game, + self.player, + self.faction, + manage_runways, + manage_front_line, + manage_aircraft, + ).spend_budget(self.budget) + + def add_procurement_request(self, request: AircraftProcurementRequest) -> None: + self.procurement_requests.add(request) diff --git a/game/commander/__init__.py b/game/commander/__init__.py new file mode 100644 index 00000000..ac46c5ef --- /dev/null +++ b/game/commander/__init__.py @@ -0,0 +1 @@ +from .theatercommander import TheaterCommander diff --git a/game/commander/aircraftallocator.py b/game/commander/aircraftallocator.py new file mode 100644 index 00000000..a50dbd22 --- /dev/null +++ b/game/commander/aircraftallocator.py @@ -0,0 +1,78 @@ +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, 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 + + +class AircraftAllocator: + """Finds suitable aircraft for proposed missions.""" + + def __init__( + self, + air_wing: AirWing, + closest_airfields: ClosestAirfields, + global_inventory: GlobalAircraftInventory, + is_player: bool, + ) -> None: + self.air_wing = air_wing + self.closest_airfields = closest_airfields + self.global_inventory = global_inventory + self.is_player = is_player + + def find_squadron_for_flight( + self, target: MissionTarget, flight: ProposedFlight + ) -> Optional[Tuple[ControlPoint, Squadron]]: + """Finds aircraft suitable for the given mission. + + Searches for aircraft capable of performing the given mission within the + maximum allowed range. If insufficient aircraft are available for the + mission, None is returned. + + Airfields are searched ordered nearest to farthest from the target and + searched twice. The first search looks for aircraft which prefer the + mission type, and the second search looks for any aircraft which are + capable of the mission type. For example, an F-14 from a nearby carrier + will be preferred for the CAP of an airfield that has only F-16s, but if + the carrier has only F/A-18s the F-16s will be used for CAP instead. + + Note that aircraft *will* be removed from the global inventory on + success. This is to ensure that the same aircraft are not matched twice + 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(target, flight, flight.task) + + def find_aircraft_for_task( + self, target: MissionTarget, flight: ProposedFlight, task: FlightType + ) -> Optional[Tuple[ControlPoint, Squadron]]: + types = aircraft_for_task(task) + 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) + for aircraft in types: + if not airfield.can_operate(aircraft): + 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.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/garrisons.py b/game/commander/garrisons.py new file mode 100644 index 00000000..8ed43843 --- /dev/null +++ b/game/commander/garrisons.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from collections import Iterator +from dataclasses import dataclass + +from game.theater import ControlPoint +from game.theater.theatergroundobject import VehicleGroupGroundObject +from game.utils import meters + + +@dataclass +class Garrisons: + blocking_capture: list[VehicleGroupGroundObject] + defending_front_line: list[VehicleGroupGroundObject] + + @property + def in_priority_order(self) -> Iterator[VehicleGroupGroundObject]: + yield from self.blocking_capture + yield from self.defending_front_line + + def eliminate(self, garrison: VehicleGroupGroundObject) -> None: + if garrison in self.blocking_capture: + self.blocking_capture.remove(garrison) + if garrison in self.defending_front_line: + self.defending_front_line.remove(garrison) + + def __contains__(self, item: VehicleGroupGroundObject) -> bool: + return item in self.in_priority_order + + @classmethod + def for_control_point(cls, control_point: ControlPoint) -> Garrisons: + """Categorize garrison groups based on target priority. + + Any garrisons blocking base capture are the highest priority. + """ + blocking = [] + defending = [] + garrisons = [ + tgo + for tgo in control_point.ground_objects + if isinstance(tgo, VehicleGroupGroundObject) and not tgo.is_dead + ] + for garrison in garrisons: + if ( + meters(garrison.distance_to(control_point)) + < ControlPoint.CAPTURE_DISTANCE + ): + blocking.append(garrison) + else: + defending.append(garrison) + + return Garrisons(blocking, defending) diff --git a/game/commander/missionproposals.py b/game/commander/missionproposals.py new file mode 100644 index 00000000..a13802b8 --- /dev/null +++ b/game/commander/missionproposals.py @@ -0,0 +1,58 @@ +from dataclasses import field, dataclass +from enum import Enum, auto +from typing import Optional + +from game.theater import MissionTarget +from gen.flights.flight import FlightType + + +class EscortType(Enum): + AirToAir = auto() + Sead = auto() + + +@dataclass(frozen=True) +class ProposedFlight: + """A flight outline proposed by the mission planner. + + Proposed flights haven't been assigned specific aircraft yet. They have only + a task, a required number of aircraft, and a maximum distance allowed + between the objective and the departure airfield. + """ + + #: The flight's role. + task: FlightType + + #: The number of aircraft required. + num_aircraft: int + + #: 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 + #: field is None. + escort_type: Optional[EscortType] = field(default=None) + + def __str__(self) -> str: + return f"{self.task} {self.num_aircraft} ship" + + +@dataclass(frozen=True) +class ProposedMission: + """A mission outline proposed by the mission planner. + + Proposed missions haven't been assigned aircraft yet. They have only an + objective location and a list of proposed flights that are required for the + mission. + """ + + #: The mission objective. + location: MissionTarget + + #: The proposed flights that are required for the mission. + flights: list[ProposedFlight] + + asap: bool = field(default=False) + + def __str__(self) -> str: + flights = ", ".join([str(f) for f in self.flights]) + return f"{self.location.name}: {flights}" diff --git a/game/commander/missionscheduler.py b/game/commander/missionscheduler.py new file mode 100644 index 00000000..26889a97 --- /dev/null +++ b/game/commander/missionscheduler.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import logging +import random +from collections import defaultdict +from datetime import timedelta +from typing import Iterator, Dict, TYPE_CHECKING + +from game.theater import MissionTarget +from gen.flights.flight import FlightType +from gen.flights.traveltime import TotEstimator + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class MissionScheduler: + def __init__(self, coalition: Coalition, desired_mission_length: timedelta) -> None: + self.coalition = coalition + self.desired_mission_length = desired_mission_length + + def schedule_missions(self) -> None: + """Identifies and plans mission for the turn.""" + + def start_time_generator( + count: int, earliest: int, latest: int, margin: int + ) -> Iterator[timedelta]: + interval = (latest - earliest) // count + for time in range(earliest, latest, interval): + error = random.randint(-margin, margin) + yield timedelta(seconds=max(0, time + error)) + + dca_types = { + FlightType.BARCAP, + FlightType.TARCAP, + } + + previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta) + non_dca_packages = [ + p for p in self.coalition.ato.packages if p.primary_task not in dca_types + ] + + start_time = start_time_generator( + count=len(non_dca_packages), + earliest=5 * 60, + latest=int(self.desired_mission_length.total_seconds()), + margin=5 * 60, + ) + for package in self.coalition.ato.packages: + tot = TotEstimator(package).earliest_tot() + if package.primary_task in dca_types: + previous_end_time = previous_cap_end_time[package.target] + if tot > previous_end_time: + # Can't get there exactly on time, so get there ASAP. This + # will typically only happen for the first CAP at each + # target. + package.time_over_target = tot + else: + package.time_over_target = previous_end_time + + departure_time = package.mission_departure_time + # Should be impossible for CAPs + if departure_time is None: + logging.error(f"Could not determine mission end time for {package}") + continue + previous_cap_end_time[package.target] = departure_time + elif package.auto_asap: + package.set_tot_asap() + else: + # But other packages should be spread out a bit. Note that take + # times are delayed, but all aircraft will become active at + # mission start. This makes it more worthwhile to attack enemy + # airfields to hit grounded aircraft, since they're more likely + # to be present. Runway and air started aircraft will be + # delayed until their takeoff time by AirConflictGenerator. + package.time_over_target = next(start_time) + tot diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py new file mode 100644 index 00000000..cf5c6102 --- /dev/null +++ b/game/commander/objectivefinder.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import math +import operator +from collections import Iterator, Iterable +from typing import TypeVar, TYPE_CHECKING + +from game.theater import ( + ControlPoint, + OffMapSpawn, + MissionTarget, + Fob, + FrontLine, + Airfield, +) +from game.theater.theatergroundobject import ( + BuildingGroundObject, + IadsGroundObject, + NavalGroundObject, +) +from game.utils import meters, nautical_miles +from gen.flights.closestairfields import ObjectiveDistanceCache, ClosestAirfields + +if TYPE_CHECKING: + from game import Game + from game.transfers import CargoShip, Convoy + +MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget) + + +class ObjectiveFinder: + """Identifies potential objectives for the mission planner.""" + + # TODO: Merge into doctrine. + AIRFIELD_THREAT_RANGE = nautical_miles(150) + SAM_THREAT_RANGE = nautical_miles(100) + + def __init__(self, game: Game, is_player: bool) -> None: + self.game = game + self.is_player = is_player + + def enemy_air_defenses(self) -> Iterator[IadsGroundObject]: + """Iterates over all enemy SAM sites.""" + for cp in self.enemy_control_points(): + for ground_object in cp.ground_objects: + if ground_object.is_dead: + continue + + if isinstance(ground_object, IadsGroundObject): + yield ground_object + + def enemy_ships(self) -> Iterator[NavalGroundObject]: + for cp in self.enemy_control_points(): + for ground_object in cp.ground_objects: + if not isinstance(ground_object, NavalGroundObject): + continue + + if ground_object.is_dead: + continue + + yield ground_object + + def threatening_ships(self) -> Iterator[NavalGroundObject]: + """Iterates over enemy ships near friendly control points. + + Groups are sorted by their closest proximity to any friendly control + point (airfield or fleet). + """ + return self._targets_by_range(self.enemy_ships()) + + def _targets_by_range( + self, targets: Iterable[MissionTargetType] + ) -> Iterator[MissionTargetType]: + target_ranges: list[tuple[MissionTargetType, float]] = [] + for target in targets: + ranges: list[float] = [] + for cp in self.friendly_control_points(): + ranges.append(target.distance_to(cp)) + target_ranges.append((target, min(ranges))) + + target_ranges = sorted(target_ranges, key=operator.itemgetter(1)) + for target, _range in target_ranges: + yield target + + def strike_targets(self) -> Iterator[BuildingGroundObject]: + """Iterates over enemy strike targets. + + Targets are sorted by their closest proximity to any friendly control + point (airfield or fleet). + """ + targets: list[tuple[BuildingGroundObject, float]] = [] + # Building objectives are made of several individual TGOs (one per + # building). + found_targets: set[str] = set() + for enemy_cp in self.enemy_control_points(): + for ground_object in enemy_cp.ground_objects: + # TODO: Reuse ground_object.mission_types. + # The mission types for ground objects are currently not + # accurate because we include things like strike and BAI for all + # targets since they have different planning behavior (waypoint + # generation is better for players with strike when the targets + # are stationary, AI behavior against weaker air defenses is + # better with BAI), so that's not a useful filter. Once we have + # better control over planning profiles and target dependent + # loadouts we can clean this up. + if not isinstance(ground_object, BuildingGroundObject): + # Other group types (like ships, SAMs, garrisons, etc) have better + # suited mission types like anti-ship, DEAD, and BAI. + continue + + if isinstance(enemy_cp, Fob) and ground_object.is_control_point: + # This is the FOB structure itself. Can't be repaired or + # targeted by the player, so shouldn't be targetable by the + # AI. + continue + + if ground_object.is_dead: + continue + if ground_object.name in found_targets: + continue + ranges: list[float] = [] + for friendly_cp in self.friendly_control_points(): + ranges.append(ground_object.distance_to(friendly_cp)) + targets.append((ground_object, min(ranges))) + found_targets.add(ground_object.name) + targets = sorted(targets, key=operator.itemgetter(1)) + for target, _range in targets: + yield target + + def front_lines(self) -> Iterator[FrontLine]: + """Iterates over all active front lines in the theater.""" + yield from self.game.theater.conflicts() + + def vulnerable_control_points(self) -> Iterator[ControlPoint]: + """Iterates over friendly CPs that are vulnerable to enemy CPs. + + Vulnerability is defined as any enemy CP within threat range of of the + CP. + """ + for cp in self.friendly_control_points(): + if isinstance(cp, OffMapSpawn): + # Off-map spawn locations don't need protection. + continue + airfields_in_proximity = self.closest_airfields_to(cp) + airfields_in_threat_range = ( + airfields_in_proximity.operational_airfields_within( + self.AIRFIELD_THREAT_RANGE + ) + ) + for airfield in airfields_in_threat_range: + if not airfield.is_friendly(self.is_player): + yield cp + break + + def oca_targets(self, min_aircraft: int) -> Iterator[ControlPoint]: + airfields = [] + for control_point in self.enemy_control_points(): + if not isinstance(control_point, Airfield): + continue + if control_point.base.total_aircraft >= min_aircraft: + airfields.append(control_point) + return self._targets_by_range(airfields) + + def convoys(self) -> Iterator[Convoy]: + for front_line in self.front_lines(): + yield from self.game.coalition_for( + self.is_player + ).transfers.convoys.travelling_to( + front_line.control_point_hostile_to(self.is_player) + ) + + def cargo_ships(self) -> Iterator[CargoShip]: + for front_line in self.front_lines(): + yield from self.game.coalition_for( + self.is_player + ).transfers.cargo_ships.travelling_to( + front_line.control_point_hostile_to(self.is_player) + ) + + def friendly_control_points(self) -> Iterator[ControlPoint]: + """Iterates over all friendly control points.""" + return ( + c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player) + ) + + def farthest_friendly_control_point(self) -> ControlPoint: + """Finds the friendly control point that is farthest from any threats.""" + threat_zones = self.game.threat_zone_for(not self.is_player) + + farthest = None + max_distance = meters(0) + for cp in self.friendly_control_points(): + if isinstance(cp, OffMapSpawn): + continue + distance = threat_zones.distance_to_threat(cp.position) + if distance > max_distance: + farthest = cp + max_distance = distance + + if farthest is None: + raise RuntimeError("Found no friendly control points. You probably lost.") + return farthest + + def closest_friendly_control_point(self) -> ControlPoint: + """Finds the friendly control point that is closest to any threats.""" + threat_zones = self.game.threat_zone_for(not self.is_player) + + closest = None + min_distance = meters(math.inf) + for cp in self.friendly_control_points(): + if isinstance(cp, OffMapSpawn): + continue + distance = threat_zones.distance_to_threat(cp.position) + if distance < min_distance: + closest = cp + min_distance = distance + + if closest is None: + raise RuntimeError("Found no friendly control points. You probably lost.") + return closest + + def enemy_control_points(self) -> Iterator[ControlPoint]: + """Iterates over all enemy control points.""" + return ( + c + for c in self.game.theater.controlpoints + if not c.is_friendly(self.is_player) + ) + + def prioritized_unisolated_points(self) -> list[ControlPoint]: + prioritized = [] + capturable_later = [] + for cp in self.game.theater.control_points_for(not self.is_player): + if cp.is_isolated: + continue + if cp.has_active_frontline: + prioritized.append(cp) + else: + capturable_later.append(cp) + prioritized.extend(self._targets_by_range(capturable_later)) + return prioritized + + @staticmethod + def closest_airfields_to(location: MissionTarget) -> ClosestAirfields: + """Returns the closest airfields to the given location.""" + return ObjectiveDistanceCache.get_closest_airfields(location) diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py new file mode 100644 index 00000000..da96a8e2 --- /dev/null +++ b/game/commander/packagebuilder.py @@ -0,0 +1,98 @@ +from typing import Optional + +from game.commander.missionproposals import ProposedFlight +from game.dcs.aircrafttype import AircraftType +from game.inventory import GlobalAircraftInventory +from game.squadrons import AirWing +from game.theater import MissionTarget, OffMapSpawn, ControlPoint +from game.utils import nautical_miles +from gen import Package +from game.commander.aircraftallocator import AircraftAllocator +from gen.flights.closestairfields import ClosestAirfields +from gen.flights.flight import Flight + + +class PackageBuilder: + """Builds a Package for the flights it receives.""" + + def __init__( + self, + location: MissionTarget, + closest_airfields: ClosestAirfields, + global_inventory: GlobalAircraftInventory, + air_wing: AirWing, + is_player: bool, + package_country: str, + start_type: str, + asap: bool, + ) -> None: + self.closest_airfields = closest_airfields + self.is_player = is_player + self.package_country = package_country + self.package = Package(location, auto_asap=asap) + self.allocator = AircraftAllocator( + air_wing, closest_airfields, global_inventory, is_player + ) + self.global_inventory = global_inventory + self.start_type = start_type + + def plan_flight(self, plan: ProposedFlight) -> bool: + """Allocates aircraft for the given flight and adds them to the package. + + If no suitable aircraft are available, False is returned. If the failed + flight was critical and the rest of the mission will be scrubbed, the + caller should return any previously planned flights to the inventory + using release_planned_aircraft. + """ + assignment = self.allocator.find_squadron_for_flight(self.package.target, plan) + if assignment is None: + return False + airfield, squadron = assignment + if isinstance(airfield, OffMapSpawn): + start_type = "In Flight" + else: + start_type = self.start_type + + flight = Flight( + self.package, + self.package_country, + squadron, + plan.num_aircraft, + plan.task, + start_type, + departure=airfield, + arrival=airfield, + divert=self.find_divert_field(squadron.aircraft, airfield), + ) + self.package.add_flight(flight) + return True + + def find_divert_field( + self, aircraft: AircraftType, arrival: ControlPoint + ) -> Optional[ControlPoint]: + divert_limit = nautical_miles(150) + for airfield in self.closest_airfields.operational_airfields_within( + divert_limit + ): + if airfield.captured != self.is_player: + continue + if airfield == arrival: + continue + if not airfield.can_operate(aircraft): + continue + if isinstance(airfield, OffMapSpawn): + continue + return airfield + return None + + def build(self) -> Package: + """Returns the built package.""" + return self.package + + def release_planned_aircraft(self) -> None: + """Returns any planned flights to the inventory.""" + flights = list(self.package.flights) + for flight in flights: + self.global_inventory.return_from_flight(flight) + flight.clear_roster() + self.package.remove_flight(flight) diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py new file mode 100644 index 00000000..83dbcf76 --- /dev/null +++ b/game/commander/packagefulfiller.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional + +from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType +from game.data.doctrine import Doctrine +from game.inventory import GlobalAircraftInventory +from game.procurement import AircraftProcurementRequest +from game.profiling import MultiEventTracer +from game.settings import Settings +from game.squadrons import AirWing +from game.theater import ConflictTheater +from game.threatzones import ThreatZones +from gen import AirTaskingOrder, Package +from game.commander.packagebuilder import PackageBuilder +from gen.flights.closestairfields import ObjectiveDistanceCache +from gen.flights.flight import FlightType +from gen.flights.flightplan import FlightPlanBuilder + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class PackageFulfiller: + """Responsible for package aircraft allocation and flight plan layout.""" + + def __init__( + self, + coalition: Coalition, + theater: ConflictTheater, + aircraft_inventory: GlobalAircraftInventory, + settings: Settings, + ) -> None: + self.coalition = coalition + self.theater = theater + self.aircraft_inventory = aircraft_inventory + self.player_missions_asap = settings.auto_ato_player_missions_asap + self.default_start_type = settings.default_start_type + + @property + def is_player(self) -> bool: + return self.coalition.player + + @property + def ato(self) -> AirTaskingOrder: + return self.coalition.ato + + @property + def air_wing(self) -> AirWing: + return self.coalition.air_wing + + @property + def doctrine(self) -> Doctrine: + return self.coalition.doctrine + + @property + def threat_zones(self) -> ThreatZones: + return self.coalition.opponent.threat_zone + + def add_procurement_request(self, request: AircraftProcurementRequest) -> None: + self.coalition.add_procurement_request(request) + + def air_wing_can_plan(self, mission_type: FlightType) -> bool: + """Returns True if it is possible for the air wing to plan this mission type. + + Not all mission types can be fulfilled by all air wings. Many factions do not + have AEW&C aircraft, so they will never be able to plan those missions. It's + also possible for the player to exclude mission types from their squadron + designs. + """ + return self.air_wing.can_auto_plan(mission_type) + + def plan_flight( + self, + mission: ProposedMission, + flight: ProposedFlight, + builder: PackageBuilder, + missing_types: Set[FlightType], + purchase_multiplier: int, + ) -> None: + if not builder.plan_flight(flight): + missing_types.add(flight.task) + purchase_order = AircraftProcurementRequest( + near=mission.location, + task_capability=flight.task, + number=flight.num_aircraft * purchase_multiplier, + ) + # Reserves are planned for critical missions, so prioritize those orders + # over aircraft needed for non-critical missions. + self.add_procurement_request(purchase_order) + + def scrub_mission_missing_aircraft( + self, + mission: ProposedMission, + builder: PackageBuilder, + missing_types: Set[FlightType], + not_attempted: Iterable[ProposedFlight], + purchase_multiplier: int, + ) -> None: + # Try to plan the rest of the mission just so we can count the missing + # types to buy. + for flight in not_attempted: + self.plan_flight( + mission, flight, builder, missing_types, purchase_multiplier + ) + + missing_types_str = ", ".join(sorted([t.name for t in missing_types])) + builder.release_planned_aircraft() + color = "Blue" if self.is_player else "Red" + logging.debug( + f"{color}: not enough aircraft in range for {mission.location.name} " + f"capable of: {missing_types_str}" + ) + + def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]: + threats = defaultdict(bool) + for flight in builder.package.flights: + if self.threat_zones.waypoints_threatened_by_aircraft( + flight.flight_plan.escorted_waypoints() + ): + threats[EscortType.AirToAir] = True + if self.threat_zones.waypoints_threatened_by_radar_sam( + list(flight.flight_plan.escorted_waypoints()) + ): + threats[EscortType.Sead] = True + return threats + + def plan_mission( + self, + mission: ProposedMission, + purchase_multiplier: int, + tracer: MultiEventTracer, + ) -> Optional[Package]: + """Allocates aircraft for a proposed mission and adds it to the ATO.""" + builder = PackageBuilder( + mission.location, + ObjectiveDistanceCache.get_closest_airfields(mission.location), + self.aircraft_inventory, + self.air_wing, + self.is_player, + self.coalition.country_name, + self.default_start_type, + mission.asap, + ) + + # Attempt to plan all the main elements of the mission first. Escorts + # will be planned separately so we can prune escorts for packages that + # are not expected to encounter that type of threat. + missing_types: Set[FlightType] = set() + escorts = [] + for proposed_flight in mission.flights: + if not self.air_wing_can_plan(proposed_flight.task): + # This air wing can never plan this mission type because they do not + # have compatible aircraft or squadrons. Skip fulfillment so that we + # don't place the purchase request. + continue + if proposed_flight.escort_type is not None: + # Escorts are planned after the primary elements of the package. + # If the package does not need escorts they may be pruned. + escorts.append(proposed_flight) + continue + with tracer.trace("Flight planning"): + self.plan_flight( + mission, + proposed_flight, + builder, + missing_types, + purchase_multiplier, + ) + + if missing_types: + self.scrub_mission_missing_aircraft( + mission, builder, missing_types, escorts, purchase_multiplier + ) + return None + + if not builder.package.flights: + # The non-escort part of this mission is unplannable by this faction. Scrub + # the mission and do not attempt planning escorts because there's no reason + # to buy them because this mission will never be planned. + return None + + # Create flight plans for the main flights of the package so we can + # determine threats. This is done *after* creating all of the flights + # rather than as each flight is added because the flight plan for + # flights that will rendezvous with their package will be affected by + # the other flights in the package. Escorts will not be able to + # contribute to this. + flight_plan_builder = FlightPlanBuilder( + builder.package, self.coalition, self.theater + ) + for flight in builder.package.flights: + with tracer.trace("Flight plan population"): + flight_plan_builder.populate_flight_plan(flight) + + needed_escorts = self.check_needed_escorts(builder) + for escort in escorts: + # This list was generated from the not None set, so this should be + # impossible. + assert escort.escort_type is not None + if needed_escorts[escort.escort_type]: + with tracer.trace("Flight planning"): + self.plan_flight( + mission, escort, builder, missing_types, purchase_multiplier + ) + + # Check again for unavailable aircraft. If the escort was required and + # none were found, scrub the mission. + if missing_types: + self.scrub_mission_missing_aircraft( + mission, builder, missing_types, escorts, purchase_multiplier + ) + return None + + package = builder.build() + # Add flight plans for escorts. + for flight in package.flights: + if not flight.flight_plan.waypoints: + with tracer.trace("Flight plan population"): + flight_plan_builder.populate_flight_plan(flight) + + if package.has_players and self.player_missions_asap: + package.auto_asap = True + package.set_tot_asap() + + return package diff --git a/game/commander/tasks/compound/aewcsupport.py b/game/commander/tasks/compound/aewcsupport.py new file mode 100644 index 00000000..5e66cb01 --- /dev/null +++ b/game/commander/tasks/compound/aewcsupport.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.aewc import PlanAewc +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class PlanAewcSupport(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for target in state.aewc_targets: + yield [PlanAewc(target)] diff --git a/game/commander/tasks/compound/attackairinfrastructure.py b/game/commander/tasks/compound/attackairinfrastructure.py new file mode 100644 index 00000000..993ce73e --- /dev/null +++ b/game/commander/tasks/compound/attackairinfrastructure.py @@ -0,0 +1,15 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.primitive.oca import PlanOcaStrike +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class AttackAirInfrastructure(CompoundTask[TheaterState]): + aircraft_cold_start: bool + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for garrison in state.oca_targets: + yield [PlanOcaStrike(garrison, self.aircraft_cold_start)] diff --git a/game/commander/tasks/compound/attackbuildings.py b/game/commander/tasks/compound/attackbuildings.py new file mode 100644 index 00000000..21e9a652 --- /dev/null +++ b/game/commander/tasks/compound/attackbuildings.py @@ -0,0 +1,15 @@ +from collections import Iterator + +from game.commander.tasks.primitive.strike import PlanStrike +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class AttackBuildings(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for building in state.strike_targets: + # Ammo depots are targeted based on the needs of the front line by + # ReduceEnemyFrontLineCapacity. No reason to target them before that front + # line is active. + if not building.is_ammo_depot: + yield [PlanStrike(building)] diff --git a/game/commander/tasks/compound/attackgarrisons.py b/game/commander/tasks/compound/attackgarrisons.py new file mode 100644 index 00000000..479bcc71 --- /dev/null +++ b/game/commander/tasks/compound/attackgarrisons.py @@ -0,0 +1,12 @@ +from collections import Iterator + +from game.commander.tasks.primitive.bai import PlanBai +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class AttackGarrisons(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for garrisons in state.enemy_garrisons.values(): + for garrison in garrisons.in_priority_order: + yield [PlanBai(garrison)] diff --git a/game/commander/tasks/compound/capturebase.py b/game/commander/tasks/compound/capturebase.py new file mode 100644 index 00000000..11936033 --- /dev/null +++ b/game/commander/tasks/compound/capturebase.py @@ -0,0 +1,51 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.destroyenemygroundunits import ( + DestroyEnemyGroundUnits, +) +from game.commander.tasks.compound.reduceenemyfrontlinecapacity import ( + ReduceEnemyFrontLineCapacity, +) +from game.commander.tasks.primitive.breakthroughattack import BreakthroughAttack +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import FrontLine, ControlPoint + + +@dataclass(frozen=True) +class CaptureBase(CompoundTask[TheaterState]): + front_line: FrontLine + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [BreakthroughAttack(self.front_line, state.context.coalition.player)] + yield [DestroyEnemyGroundUnits(self.front_line)] + if self.worth_destroying_ammo_depots(state): + yield [ReduceEnemyFrontLineCapacity(self.enemy_cp(state))] + + def enemy_cp(self, state: TheaterState) -> ControlPoint: + return self.front_line.control_point_hostile_to(state.context.coalition.player) + + def units_deployable(self, state: TheaterState, player: bool) -> int: + cp = self.front_line.control_point_friendly_to(player) + ammo_depots = list(state.ammo_dumps_at(cp)) + return cp.deployable_front_line_units_with(len(ammo_depots)) + + def unit_cap(self, state: TheaterState, player: bool) -> int: + cp = self.front_line.control_point_friendly_to(player) + ammo_depots = list(state.ammo_dumps_at(cp)) + return cp.front_line_capacity_with(len(ammo_depots)) + + def enemy_has_ammo_dumps(self, state: TheaterState) -> bool: + return bool(state.ammo_dumps_at(self.enemy_cp(state))) + + def worth_destroying_ammo_depots(self, state: TheaterState) -> bool: + if not self.enemy_has_ammo_dumps(state): + return False + + friendly_cap = self.unit_cap(state, state.context.coalition.player) + enemy_deployable = self.units_deployable(state, state.context.coalition.player) + + # If the enemy can currently deploy 50% more units than we possibly could, it's + # worth killing an ammo depot. + return enemy_deployable / friendly_cap > 1.5 diff --git a/game/commander/tasks/compound/capturebases.py b/game/commander/tasks/compound/capturebases.py new file mode 100644 index 00000000..3d338046 --- /dev/null +++ b/game/commander/tasks/compound/capturebases.py @@ -0,0 +1,13 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.capturebase import CaptureBase +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class CaptureBases(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for front in state.active_front_lines: + yield [CaptureBase(front)] diff --git a/game/commander/tasks/compound/defendbase.py b/game/commander/tasks/compound/defendbase.py new file mode 100644 index 00000000..e7071489 --- /dev/null +++ b/game/commander/tasks/compound/defendbase.py @@ -0,0 +1,19 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.primitive.cas import PlanCas +from game.commander.tasks.primitive.defensivestance import DefensiveStance +from game.commander.tasks.primitive.retreatstance import RetreatStance +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import FrontLine + + +@dataclass(frozen=True) +class DefendBase(CompoundTask[TheaterState]): + front_line: FrontLine + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [DefensiveStance(self.front_line, state.context.coalition.player)] + yield [RetreatStance(self.front_line, state.context.coalition.player)] + yield [PlanCas(self.front_line)] diff --git a/game/commander/tasks/compound/defendbases.py b/game/commander/tasks/compound/defendbases.py new file mode 100644 index 00000000..df18fdc3 --- /dev/null +++ b/game/commander/tasks/compound/defendbases.py @@ -0,0 +1,13 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.defendbase import DefendBase +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class DefendBases(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for front in state.active_front_lines: + yield [DefendBase(front)] diff --git a/game/commander/tasks/compound/degradeiads.py b/game/commander/tasks/compound/degradeiads.py new file mode 100644 index 00000000..21ddd02e --- /dev/null +++ b/game/commander/tasks/compound/degradeiads.py @@ -0,0 +1,24 @@ +from collections import Iterator +from typing import Union + +from game.commander.tasks.primitive.antiship import PlanAntiShip +from game.commander.tasks.primitive.dead import PlanDead +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject + + +class DegradeIads(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for air_defense in state.threatening_air_defenses: + yield [self.plan_against(air_defense)] + for detector in state.detecting_air_defenses: + yield [self.plan_against(detector)] + + @staticmethod + def plan_against( + target: Union[IadsGroundObject, NavalGroundObject] + ) -> Union[PlanDead, PlanAntiShip]: + if isinstance(target, IadsGroundObject): + return PlanDead(target) + return PlanAntiShip(target) diff --git a/game/commander/tasks/compound/destroyenemygroundunits.py b/game/commander/tasks/compound/destroyenemygroundunits.py new file mode 100644 index 00000000..327acecd --- /dev/null +++ b/game/commander/tasks/compound/destroyenemygroundunits.py @@ -0,0 +1,19 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.primitive.aggressiveattack import AggressiveAttack +from game.commander.tasks.primitive.cas import PlanCas +from game.commander.tasks.primitive.eliminationattack import EliminationAttack +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import FrontLine + + +@dataclass(frozen=True) +class DestroyEnemyGroundUnits(CompoundTask[TheaterState]): + front_line: FrontLine + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [EliminationAttack(self.front_line, state.context.coalition.player)] + yield [AggressiveAttack(self.front_line, state.context.coalition.player)] + yield [PlanCas(self.front_line)] diff --git a/game/commander/tasks/compound/frontlinedefense.py b/game/commander/tasks/compound/frontlinedefense.py new file mode 100644 index 00000000..11ed083e --- /dev/null +++ b/game/commander/tasks/compound/frontlinedefense.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.cas import PlanCas +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class FrontLineDefense(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for front_line in state.vulnerable_front_lines: + yield [PlanCas(front_line)] diff --git a/game/commander/tasks/compound/interdictreinforcements.py b/game/commander/tasks/compound/interdictreinforcements.py new file mode 100644 index 00000000..a76921db --- /dev/null +++ b/game/commander/tasks/compound/interdictreinforcements.py @@ -0,0 +1,27 @@ +from collections import Iterator + +from game.commander.tasks.primitive.antishipping import PlanAntiShipping +from game.commander.tasks.primitive.convoyinterdiction import PlanConvoyInterdiction +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class InterdictReinforcements(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + # These will only rarely get planned. When a convoy is travelling multiple legs, + # they're targetable after the first leg. The reason for this is that + # procurement happens *after* mission planning so that the missions that could + # not be filled will guide the procurement process. Procurement is the stage + # that convoys are created (because they're created to move ground units that + # were just purchased), so we haven't created any yet. Any incomplete transfers + # from the previous turn (multi-leg journeys) will still be present though so + # they can be targeted. + # + # Even after this is fixed, the player's convoys that were created through the + # UI will never be targeted on the first turn of their journey because the AI + # stops planning after the start of the turn. We could potentially fix this by + # moving opfor mission planning until the takeoff button is pushed. + for convoy in state.enemy_convoys: + yield [PlanConvoyInterdiction(convoy)] + for ship in state.enemy_shipping: + yield [PlanAntiShipping(ship)] diff --git a/game/commander/tasks/compound/nextaction.py b/game/commander/tasks/compound/nextaction.py new file mode 100644 index 00000000..3b4559d3 --- /dev/null +++ b/game/commander/tasks/compound/nextaction.py @@ -0,0 +1,34 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.attackairinfrastructure import ( + AttackAirInfrastructure, +) +from game.commander.tasks.compound.attackbuildings import AttackBuildings +from game.commander.tasks.compound.attackgarrisons import AttackGarrisons +from game.commander.tasks.compound.capturebases import CaptureBases +from game.commander.tasks.compound.defendbases import DefendBases +from game.commander.tasks.compound.degradeiads import DegradeIads +from game.commander.tasks.compound.interdictreinforcements import ( + InterdictReinforcements, +) +from game.commander.tasks.compound.protectairspace import ProtectAirSpace +from game.commander.tasks.compound.theatersupport import TheaterSupport +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class PlanNextAction(CompoundTask[TheaterState]): + aircraft_cold_start: bool + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [TheaterSupport()] + yield [ProtectAirSpace()] + yield [CaptureBases()] + yield [DefendBases()] + yield [InterdictReinforcements()] + yield [AttackGarrisons()] + yield [AttackAirInfrastructure(self.aircraft_cold_start)] + yield [AttackBuildings()] + yield [DegradeIads()] diff --git a/game/commander/tasks/compound/protectairspace.py b/game/commander/tasks/compound/protectairspace.py new file mode 100644 index 00000000..79306c65 --- /dev/null +++ b/game/commander/tasks/compound/protectairspace.py @@ -0,0 +1,12 @@ +from collections import Iterator + +from game.commander.tasks.primitive.barcap import PlanBarcap +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class ProtectAirSpace(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for cp, needed in state.barcaps_needed.items(): + if needed > 0: + yield [PlanBarcap(cp, needed)] diff --git a/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py b/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py new file mode 100644 index 00000000..1b8b0e7c --- /dev/null +++ b/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py @@ -0,0 +1,16 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.primitive.strike import PlanStrike +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import ControlPoint + + +@dataclass(frozen=True) +class ReduceEnemyFrontLineCapacity(CompoundTask[TheaterState]): + control_point: ControlPoint + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for ammo_dump in state.ammo_dumps_at(self.control_point): + yield [PlanStrike(ammo_dump)] diff --git a/game/commander/tasks/compound/refuelingsupport.py b/game/commander/tasks/compound/refuelingsupport.py new file mode 100644 index 00000000..6e2b141a --- /dev/null +++ b/game/commander/tasks/compound/refuelingsupport.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.refueling import PlanRefueling +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class PlanRefuelingSupport(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for target in state.refueling_targets: + yield [PlanRefueling(target)] diff --git a/game/commander/tasks/compound/theatersupport.py b/game/commander/tasks/compound/theatersupport.py new file mode 100644 index 00000000..379ba7c2 --- /dev/null +++ b/game/commander/tasks/compound/theatersupport.py @@ -0,0 +1,14 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.aewcsupport import PlanAewcSupport +from game.commander.tasks.compound.refuelingsupport import PlanRefuelingSupport +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class TheaterSupport(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [PlanAewcSupport()] + yield [PlanRefuelingSupport()] diff --git a/game/commander/tasks/frontlinestancetask.py b/game/commander/tasks/frontlinestancetask.py new file mode 100644 index 00000000..f8c3b8d1 --- /dev/null +++ b/game/commander/tasks/frontlinestancetask.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import math +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from game.commander.tasks.theatercommandertask import TheaterCommanderTask +from game.commander.theaterstate import TheaterState +from game.theater import FrontLine +from gen.ground_forces.combat_stance import CombatStance + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class FrontLineStanceTask(TheaterCommanderTask, ABC): + def __init__(self, front_line: FrontLine, player: bool) -> None: + self.front_line = front_line + self.friendly_cp = self.front_line.control_point_friendly_to(player) + self.enemy_cp = self.front_line.control_point_hostile_to(player) + + @property + @abstractmethod + def stance(self) -> CombatStance: + ... + + @staticmethod + def management_allowed(state: TheaterState) -> bool: + return ( + not state.context.coalition.player + or state.context.settings.automate_front_line_stance + ) + + def better_stance_already_set(self, state: TheaterState) -> bool: + current_stance = state.front_line_stances[self.front_line] + if current_stance is None: + return False + preference = ( + CombatStance.RETREAT, + CombatStance.DEFENSIVE, + CombatStance.AMBUSH, + CombatStance.AGGRESSIVE, + CombatStance.ELIMINATION, + CombatStance.BREAKTHROUGH, + ) + current_rating = preference.index(current_stance) + new_rating = preference.index(self.stance) + return current_rating >= new_rating + + @property + @abstractmethod + def have_sufficient_front_line_advantage(self) -> bool: + ... + + @property + def ground_force_balance(self) -> float: + # TODO: Planned CAS missions should reduce the expected opposing force size. + friendly_forces = self.friendly_cp.deployable_front_line_units + enemy_forces = self.enemy_cp.deployable_front_line_units + if enemy_forces == 0: + return math.inf + return friendly_forces / enemy_forces + + def preconditions_met(self, state: TheaterState) -> bool: + if not self.management_allowed(state): + return False + if self.better_stance_already_set(state): + return False + return self.have_sufficient_front_line_advantage + + def apply_effects(self, state: TheaterState) -> None: + state.front_line_stances[self.front_line] = self.stance + + def execute(self, coalition: Coalition) -> None: + self.friendly_cp.stances[self.enemy_cp.id] = self.stance diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py new file mode 100644 index 00000000..cf75eb1b --- /dev/null +++ b/game/commander/tasks/packageplanningtask.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import itertools +import operator +from abc import abstractmethod +from dataclasses import dataclass, field +from enum import unique, IntEnum, auto +from typing import TYPE_CHECKING, Optional, Generic, TypeVar, Iterator, Union + +from game.commander.missionproposals import ProposedFlight, EscortType, ProposedMission +from game.commander.packagefulfiller import PackageFulfiller +from game.commander.tasks.theatercommandertask import TheaterCommanderTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.settings import AutoAtoBehavior +from game.theater import MissionTarget +from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject +from game.utils import Distance, meters +from gen import Package +from gen.flights.flight import FlightType + +if TYPE_CHECKING: + from game.coalition import Coalition + +MissionTargetT = TypeVar("MissionTargetT", bound=MissionTarget) + + +@unique +class RangeType(IntEnum): + Detection = auto() + Threat = auto() + + +# TODO: Refactor so that we don't need to call up to the mission planner. +# Bypass type checker due to https://github.com/python/mypy/issues/5374 +@dataclass # type: ignore +class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): + target: MissionTargetT + flights: list[ProposedFlight] = field(init=False) + package: Optional[Package] = field(init=False, default=None) + + def __post_init__(self) -> None: + self.flights = [] + self.package = Package(self.target) + + def preconditions_met(self, state: TheaterState) -> bool: + if ( + state.context.coalition.player + and state.context.settings.auto_ato_behavior is AutoAtoBehavior.Disabled + ): + return False + return self.fulfill_mission(state) + + def execute(self, coalition: Coalition) -> None: + if self.package is None: + raise RuntimeError("Attempted to execute failed package planning task") + for flight in self.package.flights: + coalition.aircraft_inventory.claim_for_flight(flight) + coalition.ato.add_package(self.package) + + @abstractmethod + def propose_flights(self) -> None: + ... + + def propose_flight( + self, + task: FlightType, + num_aircraft: int, + escort_type: Optional[EscortType] = None, + ) -> None: + self.flights.append(ProposedFlight(task, num_aircraft, escort_type)) + + @property + def asap(self) -> bool: + return False + + @property + def purchase_multiplier(self) -> int: + """The multiplier for aircraft quantity when missions could not be fulfilled. + + For missions that do not schedule in rounds like BARCAPs do, this should be one + to ensure that the we only purchase enough aircraft to plan the mission once. + + For missions that repeat within the same turn, however, we may need to buy for + the same mission more than once. If three rounds of BARCAP still need to be + fulfilled, this would return 3, and we'd triplicate the purchase order. + + There is a small misbehavior here that's not symptomatic for our current mission + planning: multi-round, multi-flight packages will only purchase multiple sets of + aircraft for whatever is unavailable for the *first* failed package. For + example, if we extend this to CAS and have no CAS aircraft but enough TARCAP + aircraft for one round, we'll order CAS for every round but will not order any + TARCAP aircraft, since we can't know that TARCAP aircraft are needed until we + attempt to plan the second mission *without returning the first round aircraft*. + """ + return 1 + + def fulfill_mission(self, state: TheaterState) -> bool: + self.propose_flights() + fulfiller = PackageFulfiller( + state.context.coalition, + state.context.theater, + state.available_aircraft, + state.context.settings, + ) + self.package = fulfiller.plan_mission( + ProposedMission(self.target, self.flights), + self.purchase_multiplier, + state.context.tracer, + ) + return self.package is not None + + 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 + ) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]: + target_ranges: list[ + tuple[Union[IadsGroundObject, NavalGroundObject], Distance] + ] = [] + all_iads: Iterator[ + Union[IadsGroundObject, NavalGroundObject] + ] = itertools.chain(state.enemy_air_defenses, state.enemy_ships) + for target in all_iads: + distance = meters(target.distance_to(self.target)) + if range_type is RangeType.Detection: + target_range = target.max_detection_range() + elif range_type is RangeType.Threat: + target_range = target.max_threat_range() + else: + raise ValueError(f"Unknown RangeType: {range_type}") + if not target_range: + continue + + # IADS out of range of our target area will have a positive + # distance_to_threat and should be pruned. The rest have a decreasing + # distance_to_threat as overlap increases. The most negative distance has + # the greatest coverage of the target and should be treated as the highest + # priority threat. + distance_to_threat = distance - target_range + if distance_to_threat > meters(0): + continue + target_ranges.append((target, distance_to_threat)) + + # TODO: Prioritize IADS by vulnerability? + target_ranges = sorted(target_ranges, key=operator.itemgetter(1)) + for target, _range in target_ranges: + yield target + + def iter_detecting_iads( + self, state: TheaterState + ) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]: + return self.iter_iads_ranges(state, RangeType.Detection) + + def iter_iads_threats( + self, state: TheaterState + ) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]: + return self.iter_iads_ranges(state, RangeType.Threat) + + def target_area_preconditions_met( + self, state: TheaterState, ignore_iads: bool = False + ) -> bool: + """Checks if the target area has been cleared of threats.""" + threatened = False + + # Non-blocking, but analyzed so we can pick detectors worth eliminating. + for detector in self.iter_detecting_iads(state): + if detector not in state.detecting_air_defenses: + state.detecting_air_defenses.append(detector) + + if not ignore_iads: + for iads_threat in self.iter_iads_threats(state): + threatened = True + if iads_threat not in state.threatening_air_defenses: + state.threatening_air_defenses.append(iads_threat) + return not threatened diff --git a/game/commander/tasks/primitive/aewc.py b/game/commander/tasks/primitive/aewc.py new file mode 100644 index 00000000..f9c6a7d2 --- /dev/null +++ b/game/commander/tasks/primitive/aewc.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.theater import MissionTarget +from gen.flights.flight import FlightType + + +@dataclass +class PlanAewc(PackagePlanningTask[MissionTarget]): + def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False + return self.target in state.aewc_targets + + def apply_effects(self, state: TheaterState) -> None: + state.aewc_targets.remove(self.target) + + def propose_flights(self) -> None: + self.propose_flight(FlightType.AEWC, 1) + + @property + def asap(self) -> bool: + # Supports all the early CAP flights, so should be in the air ASAP. + return True diff --git a/game/commander/tasks/primitive/aggressiveattack.py b/game/commander/tasks/primitive/aggressiveattack.py new file mode 100644 index 00000000..a5928dd3 --- /dev/null +++ b/game/commander/tasks/primitive/aggressiveattack.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class AggressiveAttack(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.AGGRESSIVE + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 0.8 diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py new file mode 100644 index 00000000..a135e1cd --- /dev/null +++ b/game/commander/tasks/primitive/antiship.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +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.theater.theatergroundobject import NavalGroundObject +from gen.flights.flight import FlightType + + +@dataclass +class PlanAntiShip(PackagePlanningTask[NavalGroundObject]): + def preconditions_met(self, state: TheaterState) -> bool: + if self.target not in state.threatening_air_defenses: + return False + if not self.target_area_preconditions_met(state, ignore_iads=True): + return False + return super().preconditions_met(state) + + def apply_effects(self, state: TheaterState) -> None: + state.eliminate_ship(self.target) + + 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 new file mode 100644 index 00000000..64279d1b --- /dev/null +++ b/game/commander/tasks/primitive/antishipping.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.transfers import CargoShip +from gen.flights.flight import FlightType + + +@dataclass +class PlanAntiShipping(PackagePlanningTask[CargoShip]): + def preconditions_met(self, state: TheaterState) -> bool: + if self.target not in state.enemy_shipping: + return False + if not self.target_area_preconditions_met(state): + return False + return super().preconditions_met(state) + + def apply_effects(self, state: TheaterState) -> None: + state.enemy_shipping.remove(self.target) + + 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 new file mode 100644 index 00000000..4878171d --- /dev/null +++ b/game/commander/tasks/primitive/bai.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.theater.theatergroundobject import VehicleGroupGroundObject +from gen.flights.flight import FlightType + + +@dataclass +class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): + def preconditions_met(self, state: TheaterState) -> bool: + if not state.has_garrison(self.target): + return False + if not self.target_area_preconditions_met(state): + return False + return super().preconditions_met(state) + + def apply_effects(self, state: TheaterState) -> None: + state.eliminate_garrison(self.target) + + 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 new file mode 100644 index 00000000..b4e8455e --- /dev/null +++ b/game/commander/tasks/primitive/barcap.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.theater import ControlPoint +from gen.flights.flight import FlightType + + +@dataclass +class PlanBarcap(PackagePlanningTask[ControlPoint]): + max_orders: int + + def preconditions_met(self, state: TheaterState) -> bool: + if not state.barcaps_needed[self.target]: + return False + return super().preconditions_met(state) + + def apply_effects(self, state: TheaterState) -> None: + state.barcaps_needed[self.target] -= 1 + + def propose_flights(self) -> None: + self.propose_flight(FlightType.BARCAP, 2) + + @property + def purchase_multiplier(self) -> int: + return self.max_orders diff --git a/game/commander/tasks/primitive/breakthroughattack.py b/game/commander/tasks/primitive/breakthroughattack.py new file mode 100644 index 00000000..eb17b5ac --- /dev/null +++ b/game/commander/tasks/primitive/breakthroughattack.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from game.commander.theaterstate import TheaterState +from gen.ground_forces.combat_stance import CombatStance + + +class BreakthroughAttack(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.BREAKTHROUGH + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 2.0 + + def opposing_garrisons_eliminated(self, state: TheaterState) -> bool: + garrisons = state.enemy_garrisons[self.enemy_cp] + return not bool(garrisons.blocking_capture) + + def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False + return self.opposing_garrisons_eliminated(state) + + def apply_effects(self, state: TheaterState) -> None: + super().apply_effects(state) + state.active_front_lines.remove(self.front_line) diff --git a/game/commander/tasks/primitive/cas.py b/game/commander/tasks/primitive/cas.py new file mode 100644 index 00000000..c2785405 --- /dev/null +++ b/game/commander/tasks/primitive/cas.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.theater import FrontLine +from gen.flights.flight import FlightType + + +@dataclass +class PlanCas(PackagePlanningTask[FrontLine]): + def preconditions_met(self, state: TheaterState) -> bool: + if self.target not in state.vulnerable_front_lines: + return False + return super().preconditions_met(state) + + def apply_effects(self, state: TheaterState) -> None: + state.vulnerable_front_lines.remove(self.target) + + 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 new file mode 100644 index 00000000..285326c7 --- /dev/null +++ b/game/commander/tasks/primitive/convoyinterdiction.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +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 Convoy +from gen.flights.flight import FlightType + + +@dataclass +class PlanConvoyInterdiction(PackagePlanningTask[Convoy]): + def preconditions_met(self, state: TheaterState) -> bool: + if self.target not in state.enemy_convoys: + return False + if not self.target_area_preconditions_met(state): + return False + return super().preconditions_met(state) + + def apply_effects(self, state: TheaterState) -> None: + state.enemy_convoys.remove(self.target) + + 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 new file mode 100644 index 00000000..45da3cc3 --- /dev/null +++ b/game/commander/tasks/primitive/dead.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +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.theater.theatergroundobject import IadsGroundObject +from gen.flights.flight import FlightType + + +@dataclass +class PlanDead(PackagePlanningTask[IadsGroundObject]): + def preconditions_met(self, state: TheaterState) -> bool: + if ( + self.target not in state.threatening_air_defenses + and self.target not in state.detecting_air_defenses + ): + return False + if not self.target_area_preconditions_met(state, ignore_iads=True): + return False + return super().preconditions_met(state) + + def apply_effects(self, state: TheaterState) -> None: + state.eliminate_air_defense(self.target) + + 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 + # working track radar. + # + # For SAMs without track radars and EWRs, we still want a SEAD escort if + # needed. + # + # Note that there is a quirk here: we should potentially be included a SEAD + # escort *and* SEAD when the target is a radar SAM but the flight path is + # also threatened by SAMs. We don't want to include a SEAD escort if the + # 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) + else: + self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead) + self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir) diff --git a/game/commander/tasks/primitive/defensivestance.py b/game/commander/tasks/primitive/defensivestance.py new file mode 100644 index 00000000..3e3510e2 --- /dev/null +++ b/game/commander/tasks/primitive/defensivestance.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class DefensiveStance(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.DEFENSIVE + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 0.5 diff --git a/game/commander/tasks/primitive/eliminationattack.py b/game/commander/tasks/primitive/eliminationattack.py new file mode 100644 index 00000000..409ecf97 --- /dev/null +++ b/game/commander/tasks/primitive/eliminationattack.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class EliminationAttack(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.ELIMINATION + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 1.5 diff --git a/game/commander/tasks/primitive/oca.py b/game/commander/tasks/primitive/oca.py new file mode 100644 index 00000000..be88df32 --- /dev/null +++ b/game/commander/tasks/primitive/oca.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.theater import ControlPoint +from gen.flights.flight import FlightType + + +@dataclass +class PlanOcaStrike(PackagePlanningTask[ControlPoint]): + aircraft_cold_start: bool + + def preconditions_met(self, state: TheaterState) -> bool: + if self.target not in state.oca_targets: + return False + if not self.target_area_preconditions_met(state): + return False + return super().preconditions_met(state) + + def apply_effects(self, state: TheaterState) -> None: + state.oca_targets.remove(self.target) + + def propose_flights(self) -> None: + self.propose_flight(FlightType.OCA_RUNWAY, 2) + if self.aircraft_cold_start: + 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 new file mode 100644 index 00000000..5f17f3df --- /dev/null +++ b/game/commander/tasks/primitive/refueling.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.theater import MissionTarget +from gen.flights.flight import FlightType + + +@dataclass +class PlanRefueling(PackagePlanningTask[MissionTarget]): + def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False + return self.target in state.refueling_targets + + def apply_effects(self, state: TheaterState) -> None: + state.refueling_targets.remove(self.target) + + def propose_flights(self) -> None: + self.propose_flight(FlightType.REFUELING, 1) diff --git a/game/commander/tasks/primitive/retreatstance.py b/game/commander/tasks/primitive/retreatstance.py new file mode 100644 index 00000000..d3e4bcc8 --- /dev/null +++ b/game/commander/tasks/primitive/retreatstance.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class RetreatStance(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.RETREAT + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return True diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py new file mode 100644 index 00000000..e89c9cac --- /dev/null +++ b/game/commander/tasks/primitive/strike.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.theater.theatergroundobject import TheaterGroundObject +from gen.flights.flight import FlightType + + +@dataclass +class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]): + def preconditions_met(self, state: TheaterState) -> bool: + if self.target not in state.strike_targets: + return False + if not self.target_area_preconditions_met(state): + return False + return super().preconditions_met(state) + + def apply_effects(self, state: TheaterState) -> None: + state.strike_targets.remove(self.target) + + def propose_flights(self) -> None: + self.propose_flight(FlightType.STRIKE, 2) + self.propose_common_escorts() diff --git a/game/commander/tasks/theatercommandertask.py b/game/commander/tasks/theatercommandertask.py new file mode 100644 index 00000000..5daa6b6c --- /dev/null +++ b/game/commander/tasks/theatercommandertask.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING + +from game.commander.theaterstate import TheaterState +from game.htn import PrimitiveTask + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class TheaterCommanderTask(PrimitiveTask[TheaterState]): + @abstractmethod + def execute(self, coalition: Coalition) -> None: + ... diff --git a/game/commander/theatercommander.py b/game/commander/theatercommander.py new file mode 100644 index 00000000..3066ff54 --- /dev/null +++ b/game/commander/theatercommander.py @@ -0,0 +1,88 @@ +"""The Theater Commander is the highest level campaign AI. + +Target selection is performed with a hierarchical-task-network (HTN, linked below). +These work by giving the planner an initial "task" which decomposes into other tasks +until a concrete set of actions is formed. For example, the "capture base" task may +decompose in the following manner: + +* Defend + * Reinforce front line + * Set front line stance to defend + * Destroy enemy front line units + * Set front line stance to elimination + * Plan CAS at front line +* Prepare + * Destroy enemy IADS + * Plan DEAD against SAM Armadillo + * ... + * Destroy enemy front line units + * Set front line stance to elimination + * Plan CAS at front line +* Inhibit + * Destroy enemy unit production infrastructure + * Destroy factory at Palmyra + * ... + * Destroy enemy front line units + * Set front line stance to elimination + * Plan CAS at front line +* Attack + * Set front line stance to breakthrough + * Destroy enemy front line units + * Set front line stance to elimination + * Plan CAS at front line + +This is not a reflection of the actual task composition but illustrates the capability +of the system. Each task has preconditions which are checked before the task is +decomposed. If preconditions are not met the task is ignored and the next is considered. +For example the task to destroy the factory at Palmyra might be excluded until the air +defenses protecting it are eliminated; or defensive air operations might be excluded if +the enemy does not have sufficient air forces, or if the protected target has sufficient +SAM coverage. + +Each action updates the world state, which causes each action to account for the result +of the tasks executed before it. Above, the preconditions for attacking the factory at +Palmyra may not have been met due to the IADS coverage, leading the planning to decide +on an attack against the IADS in the area instead. When planning the next task in the +same turn, the world state will have been updated to account for the (hopefully) +destroyed SAM sites, allowing the planner to choose the mission to attack the factory. + +Preconditions can be aware of previous actions as well. A precondition for "Plan CAS at +front line" can be "No CAS missions planned at front line" to avoid over-planning CAS +even though it is a primitive task used by many other tasks. + +https://en.wikipedia.org/wiki/Hierarchical_task_network +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from game.commander.tasks.compound.nextaction import PlanNextAction +from game.commander.tasks.theatercommandertask import TheaterCommanderTask +from game.commander.theaterstate import TheaterState +from game.htn import Planner +from game.profiling import MultiEventTracer + +if TYPE_CHECKING: + from game import Game + + +class TheaterCommander(Planner[TheaterState, TheaterCommanderTask]): + def __init__(self, game: Game, player: bool) -> None: + super().__init__( + PlanNextAction( + aircraft_cold_start=game.settings.default_start_type == "Cold" + ) + ) + self.game = game + self.player = player + + def plan_missions(self, tracer: MultiEventTracer) -> None: + state = TheaterState.from_game(self.game, self.player, tracer) + while True: + result = self.plan(state) + if result is None: + # Planned all viable tasks this turn. + return + for task in result.tasks: + task.execute(self.game.coalition_for(self.player)) + state = result.end_state diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py new file mode 100644 index 00000000..4450c95b --- /dev/null +++ b/game/commander/theaterstate.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import dataclasses +import itertools +import math +from collections import Iterator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Union, Optional + +from game.commander.garrisons import Garrisons +from game.commander.objectivefinder import ObjectiveFinder +from game.htn import WorldState +from game.inventory import GlobalAircraftInventory +from game.profiling import MultiEventTracer +from game.settings import Settings +from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater +from game.theater.theatergroundobject import ( + TheaterGroundObject, + NavalGroundObject, + IadsGroundObject, + VehicleGroupGroundObject, + BuildingGroundObject, +) +from game.threatzones import ThreatZones +from gen.ground_forces.combat_stance import CombatStance + +if TYPE_CHECKING: + from game import Game + from game.coalition import Coalition + from game.transfers import Convoy, CargoShip + + +@dataclass(frozen=True) +class PersistentContext: + coalition: Coalition + theater: ConflictTheater + settings: Settings + tracer: MultiEventTracer + + +@dataclass +class TheaterState(WorldState["TheaterState"]): + context: PersistentContext + barcaps_needed: dict[ControlPoint, int] + active_front_lines: list[FrontLine] + front_line_stances: dict[FrontLine, Optional[CombatStance]] + vulnerable_front_lines: list[FrontLine] + aewc_targets: list[MissionTarget] + refueling_targets: list[MissionTarget] + enemy_air_defenses: list[IadsGroundObject] + threatening_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]] + detecting_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]] + enemy_convoys: list[Convoy] + enemy_shipping: list[CargoShip] + enemy_ships: list[NavalGroundObject] + enemy_garrisons: dict[ControlPoint, Garrisons] + oca_targets: list[ControlPoint] + strike_targets: list[TheaterGroundObject[Any]] + enemy_barcaps: list[ControlPoint] + threat_zones: ThreatZones + available_aircraft: GlobalAircraftInventory + + def _rebuild_threat_zones(self) -> None: + """Recreates the theater's threat zones based on the current planned state.""" + self.threat_zones = ThreatZones.for_threats( + self.context.coalition.opponent.doctrine, + barcap_locations=self.enemy_barcaps, + air_defenses=itertools.chain(self.enemy_air_defenses, self.enemy_ships), + ) + + def eliminate_air_defense(self, target: IadsGroundObject) -> None: + if target in self.threatening_air_defenses: + self.threatening_air_defenses.remove(target) + if target in self.detecting_air_defenses: + self.detecting_air_defenses.remove(target) + self.enemy_air_defenses.remove(target) + self._rebuild_threat_zones() + + def eliminate_ship(self, target: NavalGroundObject) -> None: + if target in self.threatening_air_defenses: + self.threatening_air_defenses.remove(target) + if target in self.detecting_air_defenses: + self.detecting_air_defenses.remove(target) + self.enemy_ships.remove(target) + self._rebuild_threat_zones() + + def has_garrison(self, target: VehicleGroupGroundObject) -> bool: + return target in self.enemy_garrisons[target.control_point] + + def eliminate_garrison(self, target: VehicleGroupGroundObject) -> None: + self.enemy_garrisons[target.control_point].eliminate(target) + + def ammo_dumps_at( + self, control_point: ControlPoint + ) -> Iterator[BuildingGroundObject]: + for target in self.strike_targets: + if target.control_point != control_point: + continue + if target.is_ammo_depot: + assert isinstance(target, BuildingGroundObject) + yield target + + def clone(self) -> TheaterState: + # Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly + # expensive. + return TheaterState( + context=self.context, + barcaps_needed=dict(self.barcaps_needed), + active_front_lines=list(self.active_front_lines), + front_line_stances=dict(self.front_line_stances), + vulnerable_front_lines=list(self.vulnerable_front_lines), + aewc_targets=list(self.aewc_targets), + refueling_targets=list(self.refueling_targets), + enemy_air_defenses=list(self.enemy_air_defenses), + enemy_convoys=list(self.enemy_convoys), + enemy_shipping=list(self.enemy_shipping), + enemy_ships=list(self.enemy_ships), + enemy_garrisons={ + cp: dataclasses.replace(g) for cp, g in self.enemy_garrisons.items() + }, + oca_targets=list(self.oca_targets), + strike_targets=list(self.strike_targets), + enemy_barcaps=list(self.enemy_barcaps), + threat_zones=self.threat_zones, + available_aircraft=self.available_aircraft.clone(), + # Persistent properties are not copied. These are a way for failed subtasks + # to communicate requirements to other tasks. For example, the task to + # attack enemy garrisons might fail because the target area has IADS + # protection. In that case, the preconditions of PlanBai would fail, but + # would add the IADS that prevented it from being planned to the list of + # IADS threats so that DegradeIads will consider it a threat later. + threatening_air_defenses=self.threatening_air_defenses, + detecting_air_defenses=self.detecting_air_defenses, + ) + + @classmethod + def from_game( + cls, game: Game, player: bool, tracer: MultiEventTracer + ) -> TheaterState: + coalition = game.coalition_for(player) + finder = ObjectiveFinder(game, player) + ordered_capturable_points = finder.prioritized_unisolated_points() + + context = PersistentContext(coalition, game.theater, game.settings, tracer) + + # Plan enough rounds of CAP that the target has coverage over the expected + # mission duration. + mission_duration = game.settings.desired_player_mission_duration.total_seconds() + barcap_duration = coalition.doctrine.cap_duration.total_seconds() + barcap_rounds = math.ceil(mission_duration / barcap_duration) + + return TheaterState( + context=context, + barcaps_needed={ + cp: barcap_rounds for cp in finder.vulnerable_control_points() + }, + active_front_lines=list(finder.front_lines()), + front_line_stances={f: None for f in finder.front_lines()}, + vulnerable_front_lines=list(finder.front_lines()), + aewc_targets=[finder.farthest_friendly_control_point()], + refueling_targets=[finder.closest_friendly_control_point()], + enemy_air_defenses=list(finder.enemy_air_defenses()), + threatening_air_defenses=[], + detecting_air_defenses=[], + enemy_convoys=list(finder.convoys()), + enemy_shipping=list(finder.cargo_ships()), + enemy_ships=list(finder.enemy_ships()), + enemy_garrisons={ + cp: Garrisons.for_control_point(cp) for cp in ordered_capturable_points + }, + oca_targets=list(finder.oca_targets(min_aircraft=20)), + strike_targets=list(finder.strike_targets()), + enemy_barcaps=list(game.theater.control_points_for(not player)), + threat_zones=game.threat_zone_for(not player), + available_aircraft=game.aircraft_inventory.clone(), + ) diff --git a/game/data/alic.py b/game/data/alic.py index f7700425..f8bc5e43 100644 --- a/game/data/alic.py +++ b/game/data/alic.py @@ -25,6 +25,7 @@ class AlicCodes: AirDefence.SNR_75V.id: 126, AirDefence.HQ_7_LN_SP.id: 127, AirDefence.HQ_7_STR_SP.id: 128, + AirDefence.RLS_19J6.id: 130, AirDefence.Roland_ADS.id: 201, AirDefence.Patriot_str.id: 202, AirDefence.Hawk_sr.id: 203, @@ -33,6 +34,7 @@ class AlicCodes: AirDefence.Hawk_cwar.id: 206, AirDefence.Gepard.id: 207, AirDefence.Vulcan.id: 208, + AirDefence.NASAMS_Radar_MPQ64F1.id: 209, } @classmethod diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 262d5fa5..7ef7c59a 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,9 +1,10 @@ from dataclasses import dataclass from datetime import timedelta -from dcs.task import Reconnaissance +from typing import Any -from game.utils import Distance, feet, nautical_miles from game.data.groundunitclass import GroundUnitClass +from game.savecompat import has_save_compat_for +from game.utils import Distance, feet, nautical_miles @dataclass @@ -26,13 +27,26 @@ class Doctrine: antiship: bool rendezvous_altitude: Distance + + #: The minimum distance between the departure airfield and the hold point. hold_distance: Distance + + #: The minimum distance between the hold point and the join point. push_distance: Distance + + #: The distance between the join point and the ingress point. Only used for the + #: fallback flight plan layout (when the departure airfield is near a threat zone). join_distance: Distance - split_distance: Distance - ingress_egress_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 - egress_altitude: Distance min_patrol_altitude: Distance max_patrol_altitude: Distance @@ -65,6 +79,32 @@ class Doctrine: ground_unit_procurement_ratios: GroundUnitProcurementRatios + @has_save_compat_for(5) + def __setstate__(self, state: dict[str, Any]) -> None: + 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, @@ -73,13 +113,12 @@ 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), - split_distance=nautical_miles(20), - ingress_egress_distance=nautical_miles(45), + max_ingress_distance=nautical_miles(45), + min_ingress_distance=nautical_miles(10), ingress_altitude=feet(20000), - egress_altitude=feet(20000), min_patrol_altitude=feet(15000), max_patrol_altitude=feet(33000), pattern_altitude=feet(5000), @@ -111,13 +150,12 @@ 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), - split_distance=nautical_miles(10), - ingress_egress_distance=nautical_miles(30), + max_ingress_distance=nautical_miles(30), + min_ingress_distance=nautical_miles(10), ingress_altitude=feet(18000), - egress_altitude=feet(18000), min_patrol_altitude=feet(10000), max_patrol_altitude=feet(24000), pattern_altitude=feet(5000), @@ -148,14 +186,13 @@ 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), - split_distance=nautical_miles(5), rendezvous_altitude=feet(10000), - ingress_egress_distance=nautical_miles(7), + max_ingress_distance=nautical_miles(7), + min_ingress_distance=nautical_miles(5), ingress_altitude=feet(8000), - egress_altitude=feet(8000), min_patrol_altitude=feet(4000), max_patrol_altitude=feet(15000), pattern_altitude=feet(5000), diff --git a/game/data/weapons.py b/game/data/weapons.py index 22aa53b9..8e7c86c9 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -3,74 +3,224 @@ from __future__ import annotations import datetime import inspect import logging -from collections import defaultdict from dataclasses import dataclass, field -from typing import Dict, Iterator, Optional, Set, Tuple, Union, cast +from enum import unique, Enum +from functools import cached_property +from pathlib import Path +from typing import Iterator, Optional, Any, ClassVar +import yaml from dcs.unitgroup import FlyingGroup -from dcs.weapons_data import Weapons, weapon_ids +from dcs.weapons_data import weapon_ids from game.dcs.aircrafttype import AircraftType -PydcsWeapon = Dict[str, Union[int, str]] -PydcsWeaponAssignment = Tuple[int, PydcsWeapon] +PydcsWeapon = Any +PydcsWeaponAssignment = tuple[int, PydcsWeapon] @dataclass(frozen=True) class Weapon: - """Wraps a pydcs weapon dict in a hashable type.""" + """Wrapper for DCS weapons.""" - cls_id: str - name: str = field(compare=False) - weight: int = field(compare=False) + #: The CLSID used by DCS. + clsid: str + + #: The group this weapon belongs to. + weapon_group: WeaponGroup = field(compare=False) + + _by_clsid: ClassVar[dict[str, Weapon]] = {} + _loaded: ClassVar[bool] = False + + def __str__(self) -> str: + return self.name + + @cached_property + def pydcs_data(self) -> PydcsWeapon: + if self.clsid == "": + # Special case for a "weapon" that isn't exposed by pydcs. + return { + "clsid": self.clsid, + "name": "Clean", + "weight": 0, + } + return weapon_ids[self.clsid] + + @property + def name(self) -> str: + return self.pydcs_data["name"] + + def __setstate__(self, state: dict[str, Any]) -> None: + # Update any existing models with new data on load. + updated = Weapon.with_clsid(state["clsid"]) + state.update(updated.__dict__) + self.__dict__.update(state) + + @classmethod + def register(cls, weapon: Weapon) -> None: + if weapon.clsid in cls._by_clsid: + duplicate = cls._by_clsid[weapon.clsid] + raise ValueError( + "Weapon CLSID used in more than one weapon type: " + f"{duplicate.name} and {weapon.name}: {weapon.clsid}" + ) + cls._by_clsid[weapon.clsid] = weapon + + @classmethod + def with_clsid(cls, clsid: str) -> Weapon: + if not cls._loaded: + cls._load_all() + return cls._by_clsid[clsid] + + @classmethod + def _load_all(cls) -> None: + WeaponGroup.load_all() + cls._loaded = True def available_on(self, date: datetime.date) -> bool: - introduction_year = WEAPON_INTRODUCTION_YEARS.get(self) + introduction_year = self.weapon_group.introduction_year if introduction_year is None: - logging.warning( - f"No introduction year for {self}, assuming always available" - ) return True return date >= datetime.date(introduction_year, 1, 1) - @property - def as_pydcs(self) -> PydcsWeapon: - return { - "clsid": self.cls_id, - "name": self.name, - "weight": self.weight, - } - @property def fallbacks(self) -> Iterator[Weapon]: yield self - fallback = WEAPON_FALLBACK_MAP[self] - if fallback is not None: - yield from fallback.fallbacks + fallback: Optional[WeaponGroup] = self.weapon_group + while fallback is not None: + yield from fallback.weapons + fallback = fallback.fallback - @classmethod - def from_pydcs(cls, weapon_data: PydcsWeapon) -> Weapon: - return cls( - cast(str, weapon_data["clsid"]), - cast(str, weapon_data["name"]), - cast(int, weapon_data["weight"]), - ) - @classmethod - def from_clsid(cls, clsid: str) -> Optional[Weapon]: - data = weapon_ids.get(clsid) - if clsid == "": - # Special case for a "weapon" that isn't exposed by pydcs. - return Weapon(clsid, "Clean", 0) - if data is None: +@unique +class WeaponType(Enum): + LGB = "LGB" + TGP = "TGP" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class WeaponGroup: + """Group of "identical" weapons loaded from resources/weapons. + + DCS has multiple unique "weapons" for each type of weapon. There are four distinct + class IDs for the AIM-7M, some unique to certain aircraft. We group them in the + resources to make year/fallback data easier to track. + """ + + #: The name of the weapon group in the resource file. + name: str + + #: The type of the weapon group. + type: WeaponType = field(compare=False) + + #: The year of introduction. + introduction_year: Optional[int] = field(compare=False) + + #: The name of the fallback weapon group. + fallback_name: Optional[str] = field(compare=False) + + #: The specific weapons that belong to this weapon group. + weapons: list[Weapon] = field(init=False, default_factory=list) + + _by_name: ClassVar[dict[str, WeaponGroup]] = {} + _loaded: ClassVar[bool] = False + + def __str__(self) -> str: + return self.name + + @property + def fallback(self) -> Optional[WeaponGroup]: + if self.fallback_name is None: return None - return cls.from_pydcs(data) + return WeaponGroup.named(self.fallback_name) + + def __setstate__(self, state: dict[str, Any]) -> None: + # Update any existing models with new data on load. + updated = WeaponGroup.named(state["name"]) + state.update(updated.__dict__) + self.__dict__.update(state) + + @classmethod + def register(cls, group: WeaponGroup) -> None: + if group.name in cls._by_name: + duplicate = cls._by_name[group.name] + raise ValueError( + "Weapon group name used in more than one weapon type: " + f"{duplicate.name} and {group.name}" + ) + cls._by_name[group.name] = group + + @classmethod + def named(cls, name: str) -> WeaponGroup: + if not cls._loaded: + cls.load_all() + return cls._by_name[name] + + @classmethod + def _each_weapon_group(cls) -> Iterator[WeaponGroup]: + for group_file_path in Path("resources/weapons").glob("**/*.yaml"): + 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, weapon_type, year, fallback_name) + for clsid in data["clsids"]: + weapon = Weapon(clsid, group) + Weapon.register(weapon) + group.weapons.append(weapon) + yield group + + @classmethod + def register_clean_pylon(cls) -> None: + group = WeaponGroup( + "Clean pylon", + type=WeaponType.UNKNOWN, + introduction_year=None, + fallback_name=None, + ) + cls.register(group) + weapon = Weapon("", group) + Weapon.register(weapon) + group.weapons.append(weapon) + + @classmethod + def register_unknown_weapons(cls, seen_clsids: set[str]) -> None: + unknown_weapons = set(weapon_ids.keys()) - seen_clsids + group = WeaponGroup( + "Unknown", + type=WeaponType.UNKNOWN, + introduction_year=None, + fallback_name=None, + ) + cls.register(group) + for clsid in unknown_weapons: + weapon = Weapon(clsid, group) + Weapon.register(weapon) + group.weapons.append(weapon) + + @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) + seen_clsids.update(w.clsid for w in group.weapons) + cls.register_clean_pylon() + cls.register_unknown_weapons(seen_clsids) + cls._loaded = True @dataclass(frozen=True) class Pylon: number: int - allowed: Set[Weapon] + allowed: set[Weapon] def can_equip(self, weapon: Weapon) -> bool: # TODO: Fix pydcs to support the "weapon". @@ -81,15 +231,15 @@ class Pylon: # A similar hack exists in QPylonEditor to forcibly add "Clean" to the list of # valid configurations for that pylon if a loadout has been seen with that # configuration. - return weapon in self.allowed or weapon.cls_id == "" + return weapon in self.allowed or weapon.clsid == "" - def equip(self, group: FlyingGroup, weapon: Weapon) -> None: + def equip(self, group: FlyingGroup[Any], weapon: Weapon) -> None: if not self.can_equip(weapon): logging.error(f"Pylon {self.number} cannot equip {weapon.name}") group.load_pylon(self.make_pydcs_assignment(weapon), self.number) def make_pydcs_assignment(self, weapon: Weapon) -> PydcsWeaponAssignment: - return self.number, weapon.as_pydcs + return self.number, weapon.pydcs_data def available_on(self, date: datetime.date) -> Iterator[Weapon]: for weapon in self.allowed: @@ -116,7 +266,7 @@ class Pylon: pylon_number, weapon = value if pylon_number != number: continue - allowed.add(Weapon.from_pydcs(weapon)) + allowed.add(Weapon.with_clsid(weapon["clsid"])) return cls(number, allowed) @@ -124,1053 +274,3 @@ class Pylon: def iter_pylons(cls, aircraft: AircraftType) -> Iterator[Pylon]: for pylon in sorted(list(aircraft.dcs_unit_type.pylons)): yield cls.for_aircraft(aircraft, pylon) - - -_WEAPON_FALLBACKS = [ - # ADM-141 TALD - (Weapons.ADM_141A, None), - (Weapons.ADM_141A_, None), - (Weapons.ADM_141A_TALD, None), - (Weapons.ADM_141B_TALD, None), - # AGM-114K Hellfire - (Weapons.AGM114x2_OH_58, Weapons.M260_HYDRA), # assuming OH-58 and not MQ-9 - (Weapons.AGM_114K, None), # Only for RQ-1 - (Weapons.AGM_114K___4, Weapons.LAU_61_pod___19_x_2_75_Hydra__UnGd_Rkts_M151__HE), - # AGM-119 Penguin - (Weapons.AGM_119B_Penguin_ASM, Weapons.Mk_82), - # AGM-122 Sidearm - (Weapons.AGM_122_Sidearm, Weapons.GBU_12), # outer pylons harrier - ( - Weapons.AGM_122_Sidearm_, - Weapons.LAU_117_with_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_, - ), # internal pylons harrier - # AGM-154 JSOW - ( - Weapons.AGM_154A___JSOW_CEB__CBU_type_, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), - ( - Weapons.BRU_55_with_2_x_AGM_154A___JSOW_CEB__CBU_type_, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), - ( - Weapons.BRU_57_with_2_x_AGM_154A___JSOW_CEB__CBU_type_, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), # doesn't exist on any aircraft yet - (Weapons.AGM_154B___JSOW_Anti_Armour, Weapons.CBU_105___10_x_SFW__CBU_with_WCMD), - ( - Weapons.AGM_154C___JSOW_Unitary_BROACH, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), - ( - Weapons.BRU_55_with_2_x_AGM_154C___JSOW_Unitary_BROACH, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), - # AGM-45 Shrike - (Weapons.AGM_45A_Shrike_ARM, None), - (Weapons.LAU_118a_with_AGM_45B_Shrike_ARM__Imp_, Weapons.AGM_45A_Shrike_ARM), - (Weapons.AGM_45B_Shrike_ARM__Imp_, Weapons.AGM_45A_Shrike_ARM), - # AGM-62 Walleye - (Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, Weapons.Mk_84), - # AGM-65 Maverick - ( - Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), # Walleye is the predecessor to the maverick - (Weapons.LAU_117_with_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_, None), - (Weapons.LAU_117_AGM_65F, Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_), - (Weapons.LAU_117_AGM_65G, Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_), - (Weapons.LAU_117_AGM_65H, Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_), - ( - Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_, - Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_, - ), - (Weapons.LAU_117_AGM_65L, None), - (Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_, None), - (Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, None), - (Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_, None), - (Weapons.LAU_88_AGM_65D_ONE, None), - ( - Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_, - ), - ( - Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd__, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, - ), - ( - Weapons.LAU_88_with_3_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_, - Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_, - ), - (Weapons.LAU_88_AGM_65H, Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_), - ( - Weapons.LAU_88_AGM_65H_2_L, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, - ), - ( - Weapons.LAU_88_AGM_65H_2_R, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, - ), - (Weapons.LAU_88_AGM_65H_3, Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_), - ( - Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM_, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_, - ), - ( - Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM__, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, - ), - ( - Weapons.LAU_88_with_3_x_AGM_65K___Maverick_K__CCD_Imp_ASM_, - Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_, - ), - # AGM-84 Harpoon - (Weapons.AGM_84A_Harpoon_ASM, Weapons.Mk_82), - (Weapons._8_x_AGM_84A_Harpoon_ASM, Weapons._27_x_Mk_82___500lb_GP_Bombs_LD), - ( - Weapons.AGM_84D_Harpoon_AShM, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), - ( - Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile_, - Weapons.LAU_117_AGM_65F, - ), - ( - Weapons.AGM_84H_SLAM_ER__Expanded_Response_, - Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile_, - ), - # AGM-86 ALCM - (Weapons.AGM_86C_ALCM, Weapons._27_x_Mk_82___500lb_GP_Bombs_LD), - (Weapons._8_x_AGM_86C_ALCM, Weapons._27_x_Mk_82___500lb_GP_Bombs_LD), - ( - Weapons._6_x_AGM_86C_ALCM_on_MER, - Weapons.MER12_with_12_x_Mk_82___500lb_GP_Bombs_LD, - ), - # AGM-88 HARM - ( - Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile, - Weapons.LAU_88_AGM_65D_ONE, - ), - ( - Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile_, - Weapons.LAU_88_AGM_65D_ONE, - ), - # AIM-120 AMRAAM - (Weapons.AIM_120B_AMRAAM___Active_Rdr_AAM, Weapons.AIM_7MH), - ( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM, - Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar, - ), - ( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM_, - Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar, - ), - ( - Weapons.LAU_115_2_LAU_127_AIM_120B, - Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar, - ), - ( - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, - Weapons.AIM_120B_AMRAAM___Active_Rdr_AAM, - ), - ( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120C_5_AMRAAM___Active_Rdr_AAM, - Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM, - ), - ( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120C_5_AMRAAM___Active_Rdr_AAM_, - Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM_, - ), - (Weapons.LAU_115_2_LAU_127_AIM_120C, Weapons.LAU_115_2_LAU_127_AIM_120B), - # AIM-54 Phoenix - (Weapons.AIM_54A_Mk47, None), - (Weapons.AIM_54A_Mk47_, None), - (Weapons.AIM_54A_Mk47__, None), - (Weapons.AIM_54A_Mk60, Weapons.AIM_54A_Mk47), - (Weapons.AIM_54A_Mk60_, Weapons.AIM_54A_Mk47_), - (Weapons.AIM_54A_Mk60__, Weapons.AIM_54A_Mk47__), - (Weapons.AIM_54C_Mk47, Weapons.AIM_54A_Mk60), - (Weapons.AIM_54C_Mk47_, Weapons.AIM_54A_Mk60_), - (Weapons.AIM_54C_Mk47__, Weapons.AIM_54A_Mk60__), - # AIM-7 Sparrow - (Weapons.AIM_7E_Sparrow_Semi_Active_Radar, None), - ( - Weapons.AIM_7F_Sparrow_Semi_Active_Radar, - Weapons.AIM_7E_Sparrow_Semi_Active_Radar, - ), - (Weapons.AIM_7F_, None), - (Weapons.AIM_7M, Weapons.AIM_7F_Sparrow_Semi_Active_Radar), - (Weapons.AIM_7M_, Weapons.AIM_7F_), - (Weapons.AIM_7MH, Weapons.AIM_7M), - (Weapons.AIM_7MH_, Weapons.AIM_7M_), - (Weapons.LAU_115C_with_AIM_7F_Sparrow_Semi_Active_Radar, None), - ( - Weapons.LAU_115_with_AIM_7M_Sparrow_Semi_Active_Radar, - Weapons.LAU_115C_with_AIM_7F_Sparrow_Semi_Active_Radar, - ), - ( - Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar, - Weapons.LAU_115_with_AIM_7M_Sparrow_Semi_Active_Radar, - ), - (Weapons.LAU_115C_with_AIM_7E_Sparrow_Semi_Active_Radar, None), - # AIM-9 Sidewinder - (Weapons.AIM_9M_Sidewinder_IR_AAM, Weapons.AIM_9P5_Sidewinder_IR_AAM), - (Weapons.AIM_9P5_Sidewinder_IR_AAM, Weapons.AIM_9P_Sidewinder_IR_AAM), - (Weapons.AIM_9P_Sidewinder_IR_AAM, Weapons.AIM_9L_Sidewinder_IR_AAM), - (Weapons.AIM_9X_Sidewinder_IR_AAM, Weapons.AIM_9P_Sidewinder_IR_AAM), - (Weapons.LAU_105_1_AIM_9L_L, None), - (Weapons.LAU_105_1_AIM_9L_R, None), - (Weapons.LAU_105_1_AIM_9M_L, Weapons.LAU_105_1_AIM_9L_L), - (Weapons.LAU_105_1_AIM_9M_R, Weapons.LAU_105_1_AIM_9L_R), - (Weapons.LAU_105_2_AIM_9L, None), - (Weapons.LAU_105_2_AIM_9P5, Weapons.LAU_105_with_2_x_AIM_9P_Sidewinder_IR_AAM), - (Weapons.LAU_105_with_2_x_AIM_9M_Sidewinder_IR_AAM, Weapons.LAU_105_2_AIM_9L), - ( - Weapons.LAU_105_with_2_x_AIM_9P_Sidewinder_IR_AAM, - Weapons.LAU_105_with_2_x_AIM_9M_Sidewinder_IR_AAM, - ), - (Weapons.LAU_115_2_LAU_127_AIM_9L, None), - (Weapons.LAU_115_2_LAU_127_AIM_9M, Weapons.LAU_115_2_LAU_127_AIM_9L), - (Weapons.LAU_115_2_LAU_127_AIM_9X, Weapons.LAU_115_2_LAU_127_AIM_9M), - (Weapons.LAU_115_LAU_127_AIM_9L, None), - (Weapons.LAU_115_LAU_127_AIM_9M, Weapons.LAU_115_LAU_127_AIM_9L), - (Weapons.LAU_115_LAU_127_AIM_9X, Weapons.LAU_115_LAU_127_AIM_9M), - (Weapons.LAU_127_AIM_9L, None), - (Weapons.LAU_127_AIM_9M, Weapons.LAU_127_AIM_9L), - (Weapons.LAU_127_AIM_9X, Weapons.LAU_127_AIM_9M), - (Weapons.LAU_138_AIM_9L, None), - (Weapons.LAU_138_AIM_9M, Weapons.LAU_138_AIM_9L), - (Weapons.LAU_7_AIM_9L, None), - (Weapons.LAU_7_AIM_9M, Weapons.LAU_7_AIM_9L), - ( - Weapons.LAU_7_with_AIM_9M_Sidewinder_IR_AAM, - Weapons.LAU_7_with_AIM_9P5_Sidewinder_IR_AAM, - ), - ( - Weapons.LAU_7_with_AIM_9P5_Sidewinder_IR_AAM, - Weapons.LAU_7_with_AIM_9P_Sidewinder_IR_AAM, - ), - (Weapons.LAU_7_with_AIM_9P_Sidewinder_IR_AAM, Weapons.LAU_7_AIM_9L), - ( - Weapons.LAU_7_with_AIM_9X_Sidewinder_IR_AAM, - Weapons.LAU_7_with_AIM_9M_Sidewinder_IR_AAM, - ), - ( - Weapons.LAU_7_with_2_x_AIM_9M_Sidewinder_IR_AAM, - Weapons.LAU_7_with_2_x_AIM_9P5_Sidewinder_IR_AAM, - ), - ( - Weapons.LAU_7_with_2_x_AIM_9P5_Sidewinder_IR_AAM, - Weapons.LAU_7_with_2_x_AIM_9P_Sidewinder_IR_AAM, - ), - ( - Weapons.LAU_7_with_2_x_AIM_9P_Sidewinder_IR_AAM, - Weapons.LAU_7_with_2_x_AIM_9L_Sidewinder_IR_AAM, - ), - # ALQ ECM Pods - (Weapons.ALQ_131___ECM_Pod, None), - (Weapons.ALQ_184, Weapons.ALQ_131___ECM_Pod), - (Weapons.AN_ALQ_164_DECM_Pod, None), - # TGP Pods - (Weapons.AN_AAQ_28_LITENING___Targeting_Pod_, None), - (Weapons.AN_AAQ_28_LITENING___Targeting_Pod, Weapons.Lantirn_F_16), - (Weapons.AN_ASQ_228_ATFLIR___Targeting_Pod, None), - (Weapons.AN_ASQ_173_Laser_Spot_Tracker_Strike_CAMera__LST_SCAM_, None), - (Weapons.AWW_13_DATALINK_POD, None), - (Weapons.LANTIRN_Targeting_Pod, None), - (Weapons.Lantirn_F_16, None), - (Weapons.Lantirn_Target_Pod, None), - (Weapons.Pavetack_F_111, None), - # BLU-107 - (Weapons.BLU_107___440lb_Anti_Runway_Penetrator_Bomb, None), - ( - Weapons.MER6_with_6_x_BLU_107___440lb_Anti_Runway_Penetrator_Bombs, - Weapons.MER6_with_6_x_Mk_82___500lb_GP_Bombs_LD, - ), - # GBU-10 LGB - (Weapons.DIS_GBU_10, Weapons.Mk_84), - (Weapons.GBU_10, Weapons.Mk_84), - (Weapons.BRU_42_with_2_x_GBU_10___2000lb_Laser_Guided_Bombs, Weapons.Mk_84), - (Weapons.DIS_GBU_10, Weapons.Mk_84), - # GBU-12 LGB - (Weapons.AUF2_GBU_12_x_2, None), - ( - Weapons.BRU_33_with_2_x_GBU_12___500lb_Laser_Guided_Bomb, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - (Weapons.BRU_42_3_GBU_12, Weapons._3_Mk_82), - (Weapons.DIS_GBU_12, Weapons.Mk_82), - ( - Weapons.DIS_GBU_12_DUAL_GDJ_II19_L, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.DIS_GBU_12_DUAL_GDJ_II19_R, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - (Weapons.GBU_12, Weapons.Mk_82), - ( - Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb_, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD_, - ), - (Weapons._2_GBU_12, Weapons._2_Mk_82), - (Weapons._2_GBU_12_, Weapons._2_Mk_82_), - # GBU-16 LGB - (Weapons.BRU_33_with_2_x_GBU_16___1000lb_Laser_Guided_Bomb, None), - (Weapons.DIS_GBU_16, Weapons.Mk_83), - (Weapons.GBU_16, Weapons.Mk_83), - (Weapons.BRU_42_with_3_x_GBU_16___1000lb_Laser_Guided_Bombs, None), - # GBU-24 LGB - (Weapons.GBU_24, Weapons.GBU_10), - ( - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb, - Weapons.GBU_16___1000lb_Laser_Guided_Bomb, - ), - ( - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb_, - Weapons.GBU_10___2000lb_Laser_Guided_Bomb, - ), - # GBU-27 LGB - ( - Weapons.GBU_27___2000lb_Laser_Guided_Penetrator_Bomb, - Weapons.GBU_16___1000lb_Laser_Guided_Bomb, - ), - # GBU-28 LGB - (Weapons.GBU_28___5000lb_Laser_Guided_Penetrator_Bomb, None), - # GBU-31 JDAM - (Weapons.GBU_31V3B_8, Weapons.B_1B_Mk_84_8), - (Weapons.GBU_31_8, Weapons.B_1B_Mk_84_8), - ( - Weapons.GBU_31_V_1_B___JDAM__2000lb_GPS_Guided_Bomb, - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb, - ), - ( - Weapons.GBU_31_V_2_B___JDAM__2000lb_GPS_Guided_Bomb, - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb, - ), - ( - Weapons.GBU_31_V_3_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb, - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb, - ), - ( - Weapons.GBU_31_V_4_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb, - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb, - ), - # GBU-32 JDAM - (Weapons.GBU_32_V_2_B___JDAM__1000lb_GPS_Guided_Bomb, Weapons.GBU_16), - # GBU-32 JDAM - ( - Weapons.BRU_55_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.BRU_57_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb, - None, - ), # Doesn't exist - (Weapons.GBU_38___JDAM__500lb_GPS_Guided_Bomb, Weapons.Mk_82), - (Weapons.GBU_38_16, Weapons.MK_82_28), - (Weapons._2_GBU_38, Weapons._2_Mk_82), - (Weapons._2_GBU_38_, Weapons._2_Mk_82_), - (Weapons._3_GBU_38, Weapons._3_Mk_82), - # GBU-54 LJDAM - ( - Weapons.GBU_54B___LJDAM__500lb_Laser__GPS_Guided_Bomb_LD, - Weapons.GBU_38___JDAM__500lb_GPS_Guided_Bomb, - ), - (Weapons._2_GBU_54_V_1_B, Weapons._2_GBU_38), - (Weapons._2_GBU_54_V_1_B_, Weapons._2_GBU_38_), - (Weapons._3_GBU_54_V_1_B, Weapons._3_GBU_38), - # CBU-52 - (Weapons.CBU_52B___220_x_HE_Frag_bomblets, None), - # CBU-87 CEM - (Weapons.CBU_87___202_x_CEM_Cluster_Bomb, Weapons.Mk_82), - ( - Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb_, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.TER_9A_with_3_x_CBU_87___202_x_CEM_Cluster_Bomb, - Weapons.TER_9A_with_3_x_Mk_82___500lb_GP_Bomb_LD, - ), - # CBU-97 - (Weapons.CBU_97___10_x_SFW_Cluster_Bomb, Weapons.Mk_82), - ( - Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb_, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD_, - ), - ( - Weapons.TER_9A_with_3_x_CBU_97___10_x_SFW_Cluster_Bomb, - Weapons.TER_9A_with_3_x_Mk_82___500lb_GP_Bomb_LD, - ), - # CBU-99 (It's a bomb made in 1968, I'm not bothering right now with backups) - # CBU-103 - ( - Weapons.CBU_103___202_x_CEM__CBU_with_WCMD, - Weapons.CBU_87___202_x_CEM_Cluster_Bomb, - ), - # CBU-105 - (Weapons.CBU_105___10_x_SFW__CBU_with_WCMD, Weapons.CBU_97___10_x_SFW_Cluster_Bomb), - ( - Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS, - Weapons.LAU_131_pod___7_x_2_75_Hydra__UnGd_Rkts_M151__HE, - ), - ( - Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS, - Weapons.LAU_131_pod___7_x_2_75_Hydra__UnGd_Rkts_M151__HE, - ), - ( - Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS, - Weapons.BRU_42_with_3_x_LAU_68_pods___21_x_2_75_Hydra__UnGd_Rkts_M151__HE, - ), - ( - Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS, - Weapons.BRU_42_with_3_x_LAU_68_pods___21_x_2_75_Hydra__UnGd_Rkts_M151__HE, - ), - # Russia - # KAB-1500 - (Weapons.KAB_1500Kr___1500kg_TV_Guided_Bomb, None), - ( - Weapons.KAB_1500LG_Pr___1500kg_Laser_Guided_Penetrator_Bomb, - Weapons.KAB_1500Kr___1500kg_TV_Guided_Bomb, - ), - ( - Weapons.KAB_1500L___1500kg_Laser_Guided_Bomb, - Weapons.KAB_1500LG_Pr___1500kg_Laser_Guided_Penetrator_Bomb, - ), - # KAB-500 - (Weapons.KAB_500Kr___500kg_TV_Guided_Bomb, Weapons.FAB_500_M_62___500kg_GP_Bomb_LD), - ( - Weapons.KAB_500LG___500kg_Laser_Guided_Bomb, - Weapons.KAB_500Kr___500kg_TV_Guided_Bomb, - ), - ( - Weapons.KAB_500S___500kg_GPS_Guided_Bomb, - Weapons.KAB_500LG___500kg_Laser_Guided_Bomb, - ), - # KH Series - (Weapons.Kh_22__AS_4_Kitchen____1000kg__AShM__IN__Act_Pas_Rdr, None), - (Weapons.Kh_23L_Grom__AS_7_Kerry____286kg__ASM__Laser_Guided, None), - (Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser, None), - (Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser_, None), - (Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser__, None), - (Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, None), - ( - Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr, - Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, - ), - (Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__10km__RC_Guided, None), - (Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided, None), - (Weapons.Kh_28__AS_9_Kyle____720kg__ARM__Pas_Rdr, None), - ( - Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser, - Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser, - ), - ( - Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser_, - Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser_, - ), - ( - Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser__, - Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser__, - ), - ( - Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided, - Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__10km__RC_Guided, - ), - ( - Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided_, - Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided, - ), - ( - Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided_, - Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided, - ), - (Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr, None), - (Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr_, None), - (Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr__, None), - ( - Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr, - Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, - ), - ( - Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr_, - Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, - ), - ( - Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr__, - Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, - ), - ( - Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr, - Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr, - ), - ( - Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr_, - Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr_, - ), - (Weapons._6_x_Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr, None), - (Weapons.Kh_41__SS_N_22_Sunburn____4500kg__AShM__IN__Act_Rdr, None), - ( - Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr, - Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr, - ), - ( - Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr_, - Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr_, - ), - ( - Weapons.Kh_59M__AS_18_Kazoo____930kg__ASM__IN, - Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr, - ), - (Weapons.Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC, None), - (Weapons._6_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC, None), - (Weapons._8_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC, None), - (Weapons.Kh_66_Grom__21__APU_68, None), - # ECM - (Weapons.L175V_Khibiny_ECM_pod, None), - # R-13 - (Weapons.R_13M, None), - (Weapons.R_13M1, Weapons.R_13M), - # R-24 - (Weapons.R_24R__AA_7_Apex_SA____Semi_Act_Rdr, None), - (Weapons.R_24T__AA_7_Apex_IR____Infra_Red, None), - # R-27 - ( - Weapons.R_27T__AA_10_Alamo_B____Infra_Red, - Weapons.R_24T__AA_7_Apex_IR____Infra_Red, - ), - ( - Weapons.R_27R__AA_10_Alamo_A____Semi_Act_Rdr, - Weapons.R_24R__AA_7_Apex_SA____Semi_Act_Rdr, - ), - ( - Weapons.R_27ER__AA_10_Alamo_C____Semi_Act_Extended_Range, - Weapons.R_27R__AA_10_Alamo_A____Semi_Act_Rdr, - ), - ( - Weapons.R_27ET__AA_10_Alamo_D____IR_Extended_Range, - Weapons.R_27T__AA_10_Alamo_B____Infra_Red, - ), - # R-33 - (Weapons.R_33__AA_9_Amos____Semi_Act_Rdr, None), - # R-3 - (Weapons.R_3S, Weapons.R_13M), - (Weapons.R_3R, Weapons.R_3S), - # R-40 - (Weapons.R_40R__AA_6_Acrid____Semi_Act_Rdr, None), - (Weapons.R_40T__AA_6_Acrid____Infra_Red, None), - # R-55 - (Weapons.R_55, None), - (Weapons.RS2US, None), - # R-60 - (Weapons.R_60, Weapons.R_13M1), - (Weapons.R_60_x_2, Weapons.R_13M1), - (Weapons.R_60_x_2_, Weapons.R_13M1), - (Weapons.R_60M, Weapons.R_60), - (Weapons.APU_60_1M_with_R_60M__AA_8_Aphid____Infra_Red, Weapons.R_60), - (Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red, Weapons.R_60M), - (Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red_, Weapons.R_60M), - (Weapons.R_60M_x_2, Weapons.R_60M), - (Weapons.R_60M_x_2_, Weapons.R_60M), - # R-73 - (Weapons.R_73__AA_11_Archer____Infra_Red, Weapons.R_60M), - (Weapons.R_73__AA_11_Archer____Infra_Red_, None), - # R-77 - ( - Weapons.R_77__AA_12_Adder____Active_Rdr, - Weapons.R_27ER__AA_10_Alamo_C____Semi_Act_Extended_Range, - ), - (Weapons.R_77__AA_12_Adder____Active_Rdr_, None), - # UK - # ALARM - (Weapons.ALARM, None), - # France - # BLG-66 Belouga - (Weapons.AUF2_BLG_66_AC_x_2, Weapons.AUF2_MK_82_x_2), - (Weapons.BLG_66_AC_Belouga, Weapons.Mk_82), - (Weapons.BLG_66_Belouga___290kg_CBU__151_Frag_Pen_bomblets, Weapons.Mk_82), - # HOT-3 - (Weapons.HOT3, None), - (Weapons.HOT3_, None), - # Magic 2 - (Weapons.Matra_Magic_II, None), - (Weapons.R_550_Magic_2, None), - # Super 530D - (Weapons.Matra_Super_530D, Weapons.Matra_Magic_II), - (Weapons.Super_530D, None), -] - -WEAPON_FALLBACK_MAP: Dict[Weapon, Optional[Weapon]] = defaultdict( - lambda: cast(Optional[Weapon], None), - ( - (Weapon.from_pydcs(a), b if b is None else Weapon.from_pydcs(b)) - for a, b in _WEAPON_FALLBACKS - ), -) - - -WEAPON_INTRODUCTION_YEARS = { - # USA - # ADM-141 TALD - Weapon.from_pydcs(Weapons.ADM_141A): 1987, - Weapon.from_pydcs(Weapons.ADM_141A_): 1987, - Weapon.from_pydcs(Weapons.ADM_141A_TALD): 1987, - Weapon.from_pydcs(Weapons.ADM_141B_TALD): 1987, - # AGM-114K Hellfire - Weapon.from_pydcs(Weapons.AGM114x2_OH_58): 1993, - Weapon.from_pydcs(Weapons.AGM_114K): 1993, - Weapon.from_pydcs(Weapons.AGM_114K___4): 1993, - # AGM-119 Penguin - Weapon.from_pydcs(Weapons.AGM_119B_Penguin_ASM): 1972, - # AGM-122 Sidearm - Weapon.from_pydcs(Weapons.AGM_122_Sidearm___light_ARM): 1986, - Weapon.from_pydcs(Weapons.AGM_122_Sidearm): 1986, - Weapon.from_pydcs(Weapons.AGM_122_Sidearm_): 1986, - # AGM-154 JSOW - Weapon.from_pydcs(Weapons.AGM_154A___JSOW_CEB__CBU_type_): 1998, - Weapon.from_pydcs(Weapons.BRU_55_with_2_x_AGM_154A___JSOW_CEB__CBU_type_): 1998, - Weapon.from_pydcs(Weapons.BRU_57_with_2_x_AGM_154A___JSOW_CEB__CBU_type_): 1998, - Weapon.from_pydcs(Weapons.AGM_154B___JSOW_Anti_Armour): 2005, - Weapon.from_pydcs(Weapons.AGM_154C___JSOW_Unitary_BROACH): 2005, - Weapon.from_pydcs(Weapons._4_x_AGM_154C___JSOW_Unitary_BROACH): 2005, - Weapon.from_pydcs(Weapons.BRU_55_with_2_x_AGM_154C___JSOW_Unitary_BROACH): 2005, - # AGM-45 Shrike - Weapon.from_pydcs(Weapons.AGM_45A_Shrike_ARM): 1965, - Weapon.from_pydcs(Weapons.AGM_45B_Shrike_ARM__Imp_): 1970, - Weapon.from_pydcs(Weapons.LAU_118a_with_AGM_45B_Shrike_ARM__Imp_): 1970, - # AGM-62 Walleye - Weapon.from_pydcs(Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_): 1972, - # AGM-65 Maverick - Weapon.from_pydcs(Weapons.LAU_88_AGM_65D_ONE): 1983, - Weapon.from_pydcs(Weapons.AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_): 1985, - Weapon.from_pydcs(Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_): 2007, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65A): 1972, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65B): 1972, - Weapon.from_pydcs(Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_): 1986, - Weapon.from_pydcs( - Weapons.LAU_117_with_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_ - ): 1990, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65F): 1991, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65G): 1989, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65H): 2002, - Weapon.from_pydcs(Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_): 2002, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65L): 1985, - Weapon.from_pydcs(Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_): 1983, - Weapon.from_pydcs(Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__): 1983, - Weapon.from_pydcs(Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_): 1983, - Weapon.from_pydcs(Weapons.LAU_88_AGM_65D_ONE): 1983, - Weapon.from_pydcs( - Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_ - ): 1985, - Weapon.from_pydcs( - Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd__ - ): 1985, - Weapon.from_pydcs( - Weapons.LAU_88_with_3_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_ - ): 1985, - Weapon.from_pydcs(Weapons.LAU_88_AGM_65H): 2007, - Weapon.from_pydcs(Weapons.LAU_88_AGM_65H_2_L): 2007, - Weapon.from_pydcs(Weapons.LAU_88_AGM_65H_2_R): 2007, - Weapon.from_pydcs(Weapons.LAU_88_AGM_65H_3): 2007, - Weapon.from_pydcs(Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM_): 2007, - Weapon.from_pydcs( - Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM__ - ): 2007, - Weapon.from_pydcs(Weapons.LAU_88_with_3_x_AGM_65K___Maverick_K__CCD_Imp_ASM_): 2007, - # AGM-84 Harpoon - Weapon.from_pydcs(Weapons.AGM_84): 1979, - Weapon.from_pydcs(Weapons.AGM_84A_Harpoon_ASM): 1979, - Weapon.from_pydcs(Weapons._8_x_AGM_84A_Harpoon_ASM): 1979, - Weapon.from_pydcs(Weapons.AGM_84D_Harpoon_AShM): 1979, - Weapon.from_pydcs( - Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile_ - ): 1990, - Weapon.from_pydcs( - Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile__ - ): 1990, - Weapon.from_pydcs(Weapons.AGM_84H_SLAM_ER__Expanded_Response_): 1998, - # AGM-86 ALCM - Weapon.from_pydcs(Weapons.AGM_86C_ALCM): 1986, - Weapon.from_pydcs(Weapons._20_x_AGM_86C_ALCM): 1986, - Weapon.from_pydcs(Weapons._8_x_AGM_86C_ALCM): 1986, - Weapon.from_pydcs(Weapons._6_x_AGM_86C_ALCM_on_MER): 1986, - # AGM-88 HARM - Weapon.from_pydcs(Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile): 1983, - Weapon.from_pydcs(Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile_): 1983, - # for future reference: 1983 is the A model IOC. B model in 1986 and C model in 1994. - # AIM-120 AMRAAM - Weapon.from_pydcs(Weapons.AIM_120B_AMRAAM___Active_Rdr_AAM): 1994, - Weapon.from_pydcs(Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM): 1996, - Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_120B): 1994, - Weapon.from_pydcs( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM - ): 1994, - Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_120C): 1996, - Weapon.from_pydcs( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120C_5_AMRAAM___Active_Rdr_AAM - ): 1996, - # AIM-54 Phoenix - Weapon.from_pydcs(Weapons.AIM_54A_Mk47): 1974, - Weapon.from_pydcs(Weapons.AIM_54A_Mk47_): 1974, - Weapon.from_pydcs(Weapons.AIM_54A_Mk47__): 1974, - Weapon.from_pydcs(Weapons.AIM_54A_Mk60): 1974, - Weapon.from_pydcs(Weapons.AIM_54A_Mk60_): 1974, - Weapon.from_pydcs(Weapons.AIM_54A_Mk60__): 1974, - Weapon.from_pydcs(Weapons.AIM_54C_Mk47_Phoenix_IN__Semi_Active_Radar): 1974, - Weapon.from_pydcs(Weapons.AIM_54C_Mk47): 1974, - Weapon.from_pydcs(Weapons.AIM_54C_Mk47_): 1974, - Weapon.from_pydcs(Weapons.AIM_54C_Mk47__): 1974, - # AIM-7 Sparrow - Weapon.from_pydcs(Weapons.AIM_7E_Sparrow_Semi_Active_Radar): 1963, - Weapon.from_pydcs(Weapons.AIM_7F_Sparrow_Semi_Active_Radar): 1976, - Weapon.from_pydcs(Weapons.AIM_7F_): 1976, - Weapon.from_pydcs(Weapons.AIM_7F): 1976, - Weapon.from_pydcs(Weapons.AIM_7M): 1982, - Weapon.from_pydcs(Weapons.AIM_7M_): 1982, - Weapon.from_pydcs(Weapons.LAU_115_with_AIM_7M_Sparrow_Semi_Active_Radar): 1982, - Weapon.from_pydcs(Weapons.AIM_7MH): 1987, - Weapon.from_pydcs(Weapons.AIM_7MH_): 1987, - Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar): 1987, - Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7E_Sparrow_Semi_Active_Radar): 1963, - Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7F_Sparrow_Semi_Active_Radar): 1976, - Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar): 1987, - # AIM-9 Sidewinder - Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9B_Sidewinder_IR_AAM): 1956, - Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9B_Sidewinder_IR_AAM): 1956, - Weapon.from_pydcs(Weapons.AIM_9L_Sidewinder_IR_AAM): 1977, - Weapon.from_pydcs(Weapons.AIM_9M_Sidewinder_IR_AAM): 1982, - Weapon.from_pydcs(Weapons.AIM_9P5_Sidewinder_IR_AAM): 1980, - Weapon.from_pydcs(Weapons.AIM_9P_Sidewinder_IR_AAM): 1978, - Weapon.from_pydcs(Weapons.AIM_9X_Sidewinder_IR_AAM): 2003, - Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9L_L): 1977, - Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9L_R): 1977, - Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9M_L): 1982, - Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9M_R): 1982, - Weapon.from_pydcs(Weapons.LAU_105_2_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_105_2_AIM_9P5): 1980, - Weapon.from_pydcs(Weapons.LAU_105_with_2_x_AIM_9M_Sidewinder_IR_AAM): 1982, - Weapon.from_pydcs(Weapons.LAU_105_with_2_x_AIM_9P_Sidewinder_IR_AAM): 1978, - Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_9M): 1982, - Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_9X): 2003, - Weapon.from_pydcs(Weapons.LAU_115_LAU_127_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_115_LAU_127_AIM_9M): 1982, - Weapon.from_pydcs(Weapons.LAU_115_LAU_127_AIM_9X): 2003, - Weapon.from_pydcs(Weapons.LAU_127_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_127_AIM_9M): 1982, - Weapon.from_pydcs(Weapons.LAU_127_AIM_9X): 2003, - Weapon.from_pydcs(Weapons.LAU_138_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_138_AIM_9M): 1982, - Weapon.from_pydcs(Weapons.LAU_7_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_7_AIM_9M): 1982, - Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9M_Sidewinder_IR_AAM): 1982, - Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9P5_Sidewinder_IR_AAM): 1980, - Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9P_Sidewinder_IR_AAM): 1978, - Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9X_Sidewinder_IR_AAM): 2003, - Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9L_Sidewinder_IR_AAM): 1977, - Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9M_Sidewinder_IR_AAM): 1982, - Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9P5_Sidewinder_IR_AAM): 1980, - Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9P_Sidewinder_IR_AAM): 1978, - # ALQ ECM Pods - Weapon.from_pydcs(Weapons.ALQ_131___ECM_Pod): 1970, - Weapon.from_pydcs(Weapons.ALQ_184): 1989, - Weapon.from_pydcs(Weapons.AN_ALQ_164_DECM_Pod): 1984, - # TGP Pods - Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod): 1995, - Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod_): 1995, - Weapon.from_pydcs(Weapons.AN_ASQ_228_ATFLIR___Targeting_Pod): 1993, - Weapon.from_pydcs( - Weapons.AN_ASQ_173_Laser_Spot_Tracker_Strike_CAMera__LST_SCAM_ - ): 1993, - Weapon.from_pydcs(Weapons.AWW_13_DATALINK_POD): 1967, - Weapon.from_pydcs(Weapons.LANTIRN_Targeting_Pod): 1985, - Weapon.from_pydcs(Weapons.Lantirn_F_16): 1985, - Weapon.from_pydcs(Weapons.Lantirn_Target_Pod): 1985, - Weapon.from_pydcs(Weapons.Pavetack_F_111): 1982, - # BLU-107 - Weapon.from_pydcs(Weapons.BLU_107___440lb_Anti_Runway_Penetrator_Bomb): 1983, - Weapon.from_pydcs( - Weapons.MER6_with_6_x_BLU_107___440lb_Anti_Runway_Penetrator_Bombs - ): 1983, - # GBU-10 LGB - Weapon.from_pydcs(Weapons.DIS_GBU_10): 1976, - Weapon.from_pydcs(Weapons.GBU_10): 1976, - Weapon.from_pydcs(Weapons.BRU_42_with_2_x_GBU_10___2000lb_Laser_Guided_Bombs): 1976, - Weapon.from_pydcs(Weapons.GBU_10___2000lb_Laser_Guided_Bomb): 1976, - # GBU-12 LGB - Weapon.from_pydcs(Weapons.AUF2_GBU_12_x_2): 1976, - Weapon.from_pydcs(Weapons.BRU_33_with_2_x_GBU_12___500lb_Laser_Guided_Bomb): 1976, - Weapon.from_pydcs(Weapons.BRU_42_3_GBU_12): 1976, - Weapon.from_pydcs(Weapons.DIS_GBU_12): 1976, - Weapon.from_pydcs(Weapons.DIS_GBU_12_DUAL_GDJ_II19_L): 1976, - Weapon.from_pydcs(Weapons.DIS_GBU_12_DUAL_GDJ_II19_R): 1976, - Weapon.from_pydcs(Weapons.GBU_12): 1976, - Weapon.from_pydcs(Weapons.GBU_12): 1976, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb): 1976, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb_): 1976, - Weapon.from_pydcs(Weapons._2_GBU_12): 1976, - Weapon.from_pydcs(Weapons._2_GBU_12_): 1976, - Weapon.from_pydcs(Weapons._3_GBU_12): 1976, - # GBU-16 LGB - Weapon.from_pydcs(Weapons.BRU_33_with_2_x_GBU_16___1000lb_Laser_Guided_Bomb): 1976, - Weapon.from_pydcs(Weapons.DIS_GBU_16): 1976, - Weapon.from_pydcs(Weapons.GBU_16): 1976, - Weapon.from_pydcs(Weapons.GBU_16___1000lb_Laser_Guided_Bomb): 1976, - Weapon.from_pydcs(Weapons._2_GBU_16): 1976, - Weapon.from_pydcs(Weapons._2_GBU_16_): 1976, - Weapon.from_pydcs(Weapons._3_GBU_16): 1976, - Weapon.from_pydcs(Weapons.BRU_42_with_3_x_GBU_16___1000lb_Laser_Guided_Bombs): 1976, - # GBU-24 LGB - Weapon.from_pydcs(Weapons.GBU_24): 1986, - Weapon.from_pydcs(Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb): 1986, - Weapon.from_pydcs(Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb_): 1986, - # GBU-27 LGB - Weapon.from_pydcs(Weapons.GBU_27___2000lb_Laser_Guided_Penetrator_Bomb): 1991, - Weapon.from_pydcs( - Weapons.BRU_42_with_2_x_GBU_27___2000lb_Laser_Guided_Penetrator_Bombs - ): 1991, - # GBU-28 - Weapon.from_pydcs(Weapons.GBU_28___5000lb_Laser_Guided_Penetrator_Bomb): 1991, - # GBU-31 JDAM - Weapon.from_pydcs(Weapons.GBU_31V3B_8): 2001, - Weapon.from_pydcs(Weapons.GBU_31_8): 2001, - Weapon.from_pydcs(Weapons.GBU_31_V_1_B___JDAM__2000lb_GPS_Guided_Bomb): 2001, - Weapon.from_pydcs(Weapons.GBU_31_V_2_B___JDAM__2000lb_GPS_Guided_Bomb): 2001, - Weapon.from_pydcs( - Weapons.GBU_31_V_3_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb - ): 2001, - Weapon.from_pydcs( - Weapons.GBU_31_V_4_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb - ): 2001, - # GBU-32 JDAM - Weapon.from_pydcs(Weapons.GBU_32_V_2_B___JDAM__1000lb_GPS_Guided_Bomb): 2002, - # GBU-38 JDAM - Weapon.from_pydcs( - Weapons.BRU_55_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb - ): 2005, - Weapon.from_pydcs( - Weapons.BRU_57_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb - ): 2005, - Weapon.from_pydcs(Weapons.GBU_38___JDAM__500lb_GPS_Guided_Bomb): 2005, - Weapon.from_pydcs(Weapons.GBU_38_16): 2005, - Weapon.from_pydcs(Weapons._2_GBU_38): 2005, - Weapon.from_pydcs(Weapons._2_GBU_38_): 2005, - Weapon.from_pydcs(Weapons._3_GBU_38): 2005, - # GBU-54 LJDAM - Weapon.from_pydcs(Weapons.GBU_54B___LJDAM__500lb_Laser__GPS_Guided_Bomb_LD): 2008, - Weapon.from_pydcs(Weapons._2_GBU_54_V_1_B): 2008, - Weapon.from_pydcs(Weapons._2_GBU_54_V_1_B_): 2008, - Weapon.from_pydcs(Weapons._3_GBU_54_V_1_B): 2008, - # CBU-52 - Weapon.from_pydcs(Weapons.CBU_52B___220_x_HE_Frag_bomblets): 1970, - # CBU-87 CEM - Weapon.from_pydcs(Weapons.CBU_87___202_x_CEM_Cluster_Bomb): 1986, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb): 1986, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb_): 1986, - Weapon.from_pydcs(Weapons.TER_9A_with_3_x_CBU_87___202_x_CEM_Cluster_Bomb): 1986, - # CBU-97 - Weapon.from_pydcs(Weapons.CBU_97___10_x_SFW_Cluster_Bomb): 1992, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb): 1992, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb_): 1992, - Weapon.from_pydcs(Weapons.TER_9A_with_3_x_CBU_97___10_x_SFW_Cluster_Bomb): 1992, - # CBU-99 - Weapon.from_pydcs( - Weapons.BRU_33_with_2_x_CBU_99___490lbs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs( - Weapons.BRU_33_with_2_x_CBU_99___490lbs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs( - Weapons.BRU_33_with_2_x_CBU_99___490lbs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs(Weapons.DIS_MK_20): 1968, - Weapon.from_pydcs(Weapons.DIS_MK_20_DUAL_GDJ_II19_L): 1968, - Weapon.from_pydcs(Weapons.DIS_MK_20_DUAL_GDJ_II19_R): 1968, - Weapon.from_pydcs( - Weapons.HSAB_with_9_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs(Weapons.MAK79_2_MK_20): 1968, - Weapon.from_pydcs(Weapons.MAK79_2_MK_20_): 1968, - Weapon.from_pydcs(Weapons.MAK79_MK_20): 1968, - Weapon.from_pydcs(Weapons.MAK79_MK_20_): 1968, - Weapon.from_pydcs( - Weapons.MER6_with_6_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs(Weapons.Mk_20): 1968, - Weapon.from_pydcs(Weapons.Mk_20_Rockeye___490lbs_CBU__247_x_HEAT_Bomblets): 1968, - Weapon.from_pydcs(Weapons.Mk_20_18): 1968, - Weapon.from_pydcs( - Weapons._6_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs(Weapons._2_MK_20): 1968, - Weapon.from_pydcs(Weapons._2_MK_20_): 1968, - Weapon.from_pydcs(Weapons._2_MK_20__): 1968, - Weapon.from_pydcs(Weapons._2_MK_20___): 1968, - Weapon.from_pydcs(Weapons._2_MK_20____): 1968, - Weapon.from_pydcs(Weapons._2_MK_20_____): 1968, - Weapon.from_pydcs(Weapons._2_Mk_20_Rockeye): 1968, - Weapon.from_pydcs(Weapons._2_Mk_20_Rockeye_): 1968, - Weapon.from_pydcs( - Weapons.MER2_with_2_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets - ): 1968, - # CBU-103 - Weapon.from_pydcs(Weapons.BRU_57_with_2_x_CBU_103___202_x_CEM__CBU_with_WCMD): 2000, - Weapon.from_pydcs(Weapons.CBU_103___202_x_CEM__CBU_with_WCMD): 2000, - # CBU-105 - Weapon.from_pydcs(Weapons.BRU_57_with_2_x_CBU_105___10_x_SFW__CBU_with_WCMD): 2000, - Weapon.from_pydcs(Weapons.CBU_105___10_x_SFW__CBU_with_WCMD): 2000, - # APKWS - Weapon.from_pydcs( - Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS - ): 2016, - Weapon.from_pydcs( - Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS - ): 2016, - Weapon.from_pydcs( - Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS - ): 2016, - Weapon.from_pydcs( - Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS - ): 2016, - # Russia - # KAB-1500 - Weapon.from_pydcs(Weapons.KAB_1500Kr___1500kg_TV_Guided_Bomb): 1985, - Weapon.from_pydcs(Weapons.KAB_1500L___1500kg_Laser_Guided_Bomb): 1995, - Weapon.from_pydcs( - Weapons.KAB_1500LG_Pr___1500kg_Laser_Guided_Penetrator_Bomb - ): 1990, - # KAB-500 - Weapon.from_pydcs(Weapons.KAB_500Kr___500kg_TV_Guided_Bomb): 1980, - Weapon.from_pydcs(Weapons.KAB_500LG___500kg_Laser_Guided_Bomb): 1995, - Weapon.from_pydcs(Weapons.KAB_500S___500kg_GPS_Guided_Bomb): 2000, - # Kh Series - Weapon.from_pydcs( - Weapons.Kh_22__AS_4_Kitchen____1000kg__AShM__IN__Act_Pas_Rdr - ): 1962, - Weapon.from_pydcs( - Weapons.Kh_23L_Grom__AS_7_Kerry____286kg__ASM__Laser_Guided - ): 1975, - Weapon.from_pydcs(Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser): 1975, - Weapon.from_pydcs( - Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser_ - ): 1975, - Weapon.from_pydcs( - Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser__ - ): 1975, - Weapon.from_pydcs(Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr): 1975, - Weapon.from_pydcs( - Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr - ): 1980, - Weapon.from_pydcs( - Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr_ - ): 1980, - Weapon.from_pydcs( - Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr__ - ): 1980, - Weapon.from_pydcs( - Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__10km__RC_Guided - ): 1975, - Weapon.from_pydcs(Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided): 1975, - Weapon.from_pydcs(Weapons.Kh_28__AS_9_Kyle____720kg__ARM__Pas_Rdr): 1973, - Weapon.from_pydcs(Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser): 1980, - Weapon.from_pydcs(Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser_): 1980, - Weapon.from_pydcs( - Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser__ - ): 1980, - Weapon.from_pydcs(Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided): 1980, - Weapon.from_pydcs(Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided_): 1980, - Weapon.from_pydcs(Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided__): 1980, - Weapon.from_pydcs(Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr): 1980, - Weapon.from_pydcs(Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr_): 1980, - Weapon.from_pydcs( - Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr__ - ): 1980, - Weapon.from_pydcs(Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr): 1980, - Weapon.from_pydcs(Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr_): 1980, - Weapon.from_pydcs(Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr__): 1980, - Weapon.from_pydcs(Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr): 2003, - Weapon.from_pydcs(Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr_): 2003, - Weapon.from_pydcs( - Weapons._6_x_Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr - ): 2003, - Weapon.from_pydcs( - Weapons.Kh_41__SS_N_22_Sunburn____4500kg__AShM__IN__Act_Rdr - ): 1984, - Weapon.from_pydcs(Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr): 1985, - Weapon.from_pydcs(Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr_): 1985, - Weapon.from_pydcs(Weapons.Kh_59M__AS_18_Kazoo____930kg__ASM__IN): 1990, - Weapon.from_pydcs(Weapons.Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC): 1992, - Weapon.from_pydcs(Weapons._6_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC): 1992, - Weapon.from_pydcs(Weapons._8_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC): 1992, - Weapon.from_pydcs(Weapons.Kh_66_Grom__21__APU_68): 1968, - # ECM - Weapon.from_pydcs(Weapons.L175V_Khibiny_ECM_pod): 1982, - # R-13 - Weapon.from_pydcs(Weapons.R_13M): 1961, - Weapon.from_pydcs(Weapons.R_13M1): 1965, - # R-24 - Weapon.from_pydcs(Weapons.R_24R__AA_7_Apex_SA____Semi_Act_Rdr): 1981, - Weapon.from_pydcs(Weapons.R_24T__AA_7_Apex_IR____Infra_Red): 1981, - # R-27 - Weapon.from_pydcs(Weapons.R_27ER__AA_10_Alamo_C____Semi_Act_Extended_Range): 1983, - Weapon.from_pydcs(Weapons.R_27ET__AA_10_Alamo_D____IR_Extended_Range): 1986, - Weapon.from_pydcs(Weapons.R_27R__AA_10_Alamo_A____Semi_Act_Rdr): 1983, - Weapon.from_pydcs(Weapons.R_27T__AA_10_Alamo_B____Infra_Red): 1983, - # R-33 - Weapon.from_pydcs(Weapons.R_33__AA_9_Amos____Semi_Act_Rdr): 1981, - # R-3 - Weapon.from_pydcs(Weapons.R_3R): 1966, - Weapon.from_pydcs(Weapons.R_3S): 1962, - # R-40 - Weapon.from_pydcs(Weapons.R_40R__AA_6_Acrid____Semi_Act_Rdr): 1976, - Weapon.from_pydcs(Weapons.R_40T__AA_6_Acrid____Infra_Red): 1976, - # R-55 - Weapon.from_pydcs(Weapons.R_55): 1957, - Weapon.from_pydcs(Weapons.RS2US): 1957, - # R-60 - Weapon.from_pydcs(Weapons.R_60): 1973, - Weapon.from_pydcs(Weapons.R_60_x_2): 1973, - Weapon.from_pydcs(Weapons.R_60_x_2_): 1973, - Weapon.from_pydcs(Weapons.APU_60_1M_with_R_60M__AA_8_Aphid____Infra_Red): 1982, - Weapon.from_pydcs(Weapons.R_60M): 1982, - Weapon.from_pydcs(Weapons.R_60M__AA_8_Aphid____Infra_Red): 1982, - Weapon.from_pydcs(Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red): 1982, - Weapon.from_pydcs(Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red_): 1982, - Weapon.from_pydcs(Weapons.R_60M_x_2): 1982, - Weapon.from_pydcs(Weapons.R_60M_x_2_): 1982, - # R-73 - Weapon.from_pydcs(Weapons.R_73__AA_11_Archer____Infra_Red): 1984, - Weapon.from_pydcs(Weapons.R_73__AA_11_Archer____Infra_Red_): 1984, - # R-77 - Weapon.from_pydcs(Weapons.R_77__AA_12_Adder____Active_Rdr): 2002, - Weapon.from_pydcs(Weapons.R_77__AA_12_Adder____Active_Rdr_): 2002, - # UK - # ALARM - Weapon.from_pydcs(Weapons.ALARM): 1990, - # France - # BLG-66 Belouga - Weapon.from_pydcs(Weapons.AUF2_BLG_66_AC_x_2): 1979, - Weapon.from_pydcs(Weapons.BLG_66_AC_Belouga): 1979, - Weapon.from_pydcs(Weapons.BLG_66_Belouga___290kg_CBU__151_Frag_Pen_bomblets): 1979, - # HOT-3 - Weapon.from_pydcs(Weapons.HOT3): 1998, - Weapon.from_pydcs(Weapons.HOT3_): 1998, - # Magic 2 - Weapon.from_pydcs(Weapons.Matra_Magic_II): 1986, - Weapon.from_pydcs(Weapons.R_550_Magic_2): 1986, - # Super 530D - Weapon.from_pydcs(Weapons.Matra_Super_530D): 1988, - Weapon.from_pydcs(Weapons.Super_530D): 1988, -} diff --git a/game/db.py b/game/db.py index 61426d12..4a2fbf75 100644 --- a/game/db.py +++ b/game/db.py @@ -29,8 +29,9 @@ from dcs.ships import ( CV_1143_5, ) from dcs.terrain.terrain import Airport +from dcs.unit import Ship from dcs.unitgroup import ShipGroup, StaticGroup -from dcs.unittype import UnitType +from dcs.unittype import UnitType, FlyingType, ShipType, VehicleType from dcs.vehicles import ( vehicle_map, ) @@ -255,7 +256,7 @@ Aircraft livery overrides. Syntax as follows: `Identifier` is aircraft identifier (as used troughout the file) and "LiveryName" (with double quotes) is livery name as found in mission editor. """ -PLANE_LIVERY_OVERRIDES = { +PLANE_LIVERY_OVERRIDES: dict[Type[FlyingType], str] = { FA_18C_hornet: "VFA-34", # default livery for the hornet is blue angels one } @@ -317,6 +318,8 @@ REWARDS = { "comms": 10, "oil": 10, "derrick": 8, + "village": 0.25, + "allycamp": 0.5, } """ @@ -326,7 +329,7 @@ REWARDS = { StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point] -def upgrade_to_supercarrier(unit, name: str): +def upgrade_to_supercarrier(unit: Type[ShipType], name: str) -> Type[ShipType]: if unit == Stennis: if name == "CVN-71 Theodore Roosevelt": return CVN_71 @@ -359,7 +362,15 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]: return None -def country_id_from_name(name): +def vehicle_type_from_name(name: str) -> Type[VehicleType]: + return vehicle_map[name] + + +def ship_type_from_name(name: str) -> Type[ShipType]: + return ship_map[name] + + +def country_id_from_name(name: str) -> int: for k, v in country_dict.items(): if v.name == name: return k @@ -372,7 +383,7 @@ class DefaultLiveries: OH_58D.Liveries = DefaultLiveries -F_16C_50.Liveries = DefaultLiveries +F_16C_50.Liveries = DefaultLiveries # type: ignore P_51D_30_NA.Liveries = DefaultLiveries Ju_88A4.Liveries = DefaultLiveries B_17G.Liveries = DefaultLiveries diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index 05b3d557..bebc1123 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 @@ -98,7 +98,7 @@ class PatrolConfig: @classmethod def from_data(cls, data: dict[str, Any]) -> PatrolConfig: altitude = data.get("altitude", None) - speed = data.get("altitude", None) + speed = data.get("speed", None) return PatrolConfig( feet(altitude) if altitude is not None else None, knots(speed) if speed is not None else None, @@ -106,18 +106,55 @@ class PatrolConfig: @dataclass(frozen=True) -class AircraftType(UnitType[FlyingType]): +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]]): carrier_capable: bool 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] @@ -147,13 +184,52 @@ class AircraftType(UnitType[FlyingType]): def max_speed(self) -> Speed: return kph(self.dcs_unit_type.max_speed) + @property + def preferred_patrol_altitude(self) -> Distance: + if self.patrol_altitude: + return self.patrol_altitude + else: + # Estimate based on max speed. + # Aircaft with max speed 600 kph will prefer patrol at 10 000 ft + # Aircraft with max speed 2800 kph will prefer pratrol at 33 000 ft + altitude_for_lowest_speed = feet(10 * 1000) + altitude_for_highest_speed = feet(33 * 1000) + lowest_speed = kph(600) + highest_speed = kph(2800) + factor = (self.max_speed - lowest_speed).kph / ( + highest_speed - lowest_speed + ).kph + altitude = ( + altitude_for_lowest_speed + + (altitude_for_highest_speed - altitude_for_lowest_speed) * factor + ) + logging.debug( + f"Preferred patrol altitude for {self.dcs_unit_type.id}: {altitude.feet}" + ) + rounded_altitude = feet(round(1000 * round(altitude.feet / 1000))) + return max( + altitude_for_lowest_speed, + min(altitude_for_highest_speed, rounded_altitude), + ) + def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency: - from gen.radios import ChannelInUseError, MHz + from gen.radios import ChannelInUseError, kHz if self.intra_flight_radio is not None: return radio_registry.alloc_for_radio(self.intra_flight_radio) - freq = MHz(self.dcs_unit_type.radio_frequency) + # The default radio frequency is set in megahertz. For some aircraft, it is a + # floating point value. For all current aircraft, adjusting to kilohertz will be + # sufficient to convert to an integer. + in_khz = float(self.dcs_unit_type.radio_frequency) * 1000 + if not in_khz.is_integer(): + logging.warning( + f"Found unexpected sub-kHz default radio for {self}: {in_khz} kHz. " + "Truncating to integer. The truncated frequency may not be valid for " + "the aircraft." + ) + + freq = kHz(int(in_khz)) try: radio_registry.reserve(freq) except ChannelInUseError: @@ -222,6 +298,25 @@ class AircraftType(UnitType[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: @@ -233,7 +328,10 @@ class AircraftType(UnitType[FlyingType]): yield AircraftType( dcs_unit_type=aircraft, name=variant, - description=data.get("description", "No data."), + description=data.get( + "description", + f"No data. Google {variant}", + ), year_introduced=introduction, country_of_origin=data.get("origin", "No data."), manufacturer=data.get("manufacturer", "No data."), @@ -246,6 +344,8 @@ class AircraftType(UnitType[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/dcs/groundunittype.py b/game/dcs/groundunittype.py index 7cf92fcf..c22d8a21 100644 --- a/game/dcs/groundunittype.py +++ b/game/dcs/groundunittype.py @@ -15,7 +15,7 @@ from game.dcs.unittype import UnitType @dataclass(frozen=True) -class GroundUnitType(UnitType[VehicleType]): +class GroundUnitType(UnitType[Type[VehicleType]]): unit_class: Optional[GroundUnitClass] spawn_weight: int @@ -88,7 +88,10 @@ class GroundUnitType(UnitType[VehicleType]): unit_class=unit_class, spawn_weight=data.get("spawn_weight", 0), name=variant, - description=data.get("description", "No data."), + description=data.get( + "description", + f"No data. Google {variant}", + ), year_introduced=introduction, country_of_origin=data.get("origin", "No data."), manufacturer=data.get("manufacturer", "No data."), diff --git a/game/dcs/unittype.py b/game/dcs/unittype.py index 25181a66..2fc6ec9f 100644 --- a/game/dcs/unittype.py +++ b/game/dcs/unittype.py @@ -4,12 +4,12 @@ from typing import TypeVar, Generic, Type from dcs.unittype import UnitType as DcsUnitType -DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=DcsUnitType) +DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType]) @dataclass(frozen=True) class UnitType(Generic[DcsUnitTypeT]): - dcs_unit_type: Type[DcsUnitTypeT] + dcs_unit_type: DcsUnitTypeT name: str description: str year_introduced: str diff --git a/game/debriefing.py b/game/debriefing.py index 212ea7a4..b2a155ad 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -15,9 +15,9 @@ from typing import ( Iterator, List, TYPE_CHECKING, + Union, ) -from game import db from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.theater import Airfield, ControlPoint @@ -77,8 +77,8 @@ class GroundLosses: player_airlifts: List[AirliftUnits] = field(default_factory=list) enemy_airlifts: List[AirliftUnits] = field(default_factory=list) - player_ground_objects: List[GroundObjectUnit] = field(default_factory=list) - enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list) + player_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list) + enemy_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list) player_buildings: List[Building] = field(default_factory=list) enemy_buildings: List[Building] = field(default_factory=list) @@ -104,8 +104,9 @@ class StateData: #: Names of vehicle (and ship) units that were killed during the mission. killed_ground_units: List[str] - #: Names of static units that were destroyed during the mission. - destroyed_statics: List[str] + #: List of descriptions of destroyed statics. Format of each element is a mapping of + #: the coordinate type ("x", "y", "z", "type", "orientation") to the value. + destroyed_statics: List[dict[str, Union[float, str]]] #: Mangled names of bases that were captured during the mission. base_capture_events: List[str] @@ -134,10 +135,8 @@ class Debriefing: self.game = game self.unit_map = unit_map - self.player_country = game.player_country - self.enemy_country = game.enemy_country - self.player_country_id = db.country_id_from_name(game.player_country) - self.enemy_country_id = db.country_id_from_name(game.enemy_country) + self.player_country = game.blue.country_name + self.enemy_country = game.red.country_name self.air_losses = self.dead_aircraft() self.ground_losses = self.dead_ground_units() @@ -164,7 +163,7 @@ class Debriefing: yield from self.ground_losses.enemy_airlifts @property - def ground_object_losses(self) -> Iterator[GroundObjectUnit]: + def ground_object_losses(self) -> Iterator[GroundObjectUnit[Any]]: yield from self.ground_losses.player_ground_objects yield from self.ground_losses.enemy_ground_objects @@ -370,13 +369,13 @@ class PollDebriefingFileThread(threading.Thread): self.game = game self.unit_map = unit_map - def stop(self): + def stop(self) -> None: self._stop_event.set() - def stopped(self): + def stopped(self) -> bool: return self._stop_event.is_set() - def run(self): + def run(self) -> None: if os.path.isfile("state.json"): last_modified = os.path.getmtime("state.json") else: @@ -401,7 +400,7 @@ class PollDebriefingFileThread(threading.Thread): def wait_for_debriefing( - callback: Callable[[Debriefing], None], game: Game, unit_map + callback: Callable[[Debriefing], None], game: Game, unit_map: UnitMap ) -> PollDebriefingFileThread: thread = PollDebriefingFileThread(callback, game, unit_map) thread.start() diff --git a/game/event/airwar.py b/game/event/airwar.py index ed22f3af..7b860a1b 100644 --- a/game/event/airwar.py +++ b/game/event/airwar.py @@ -1,14 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING from .event import Event -if TYPE_CHECKING: - from game.theater import ConflictTheater - class AirWarEvent(Event): """Event handler for the air battle""" - def __str__(self): + def __str__(self) -> str: return "AirWar" diff --git a/game/event/event.py b/game/event/event.py index 1d554fca..757b9be1 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -5,7 +5,6 @@ from typing import List, TYPE_CHECKING, Type from dcs.mapping import Point from dcs.task import Task -from dcs.unittype import VehicleType from game import persistency from game.debriefing import AirLosses, Debriefing @@ -38,13 +37,13 @@ class Event: def __init__( self, - game, + game: Game, from_cp: ControlPoint, target_cp: ControlPoint, location: Point, attacker_name: str, defender_name: str, - ): + ) -> None: self.game = game self.from_cp = from_cp self.to_cp = target_cp @@ -54,7 +53,7 @@ class Event: @property def is_player_attacking(self) -> bool: - return self.attacker_name == self.game.player_faction.name + return self.attacker_name == self.game.blue.faction.name @property def tasks(self) -> List[Type[Task]]: @@ -115,10 +114,10 @@ class Event: def complete_aircraft_transfers(self, debriefing: Debriefing) -> None: self._transfer_aircraft( - self.game.blue_ato, debriefing.air_losses, for_player=True + self.game.blue.ato, debriefing.air_losses, for_player=True ) self._transfer_aircraft( - self.game.red_ato, debriefing.air_losses, for_player=False + self.game.red.ato, debriefing.air_losses, for_player=False ) def commit_air_losses(self, debriefing: Debriefing) -> None: @@ -155,8 +154,8 @@ class Event: pilot.record.missions_flown += 1 def commit_pilot_experience(self) -> None: - self._commit_pilot_experience(self.game.blue_ato) - self._commit_pilot_experience(self.game.red_ato) + self._commit_pilot_experience(self.game.blue.ato) + self._commit_pilot_experience(self.game.red.ato) @staticmethod def commit_front_line_losses(debriefing: Debriefing) -> None: @@ -220,10 +219,10 @@ class Event: for loss in debriefing.ground_object_losses: # TODO: This should be stored in the TGO, not in the pydcs Group. if not hasattr(loss.group, "units_losts"): - loss.group.units_losts = [] + loss.group.units_losts = [] # type: ignore loss.group.units.remove(loss.unit) - loss.group.units_losts.append(loss.unit) + loss.group.units_losts.append(loss.unit) # type: ignore def commit_building_losses(self, debriefing: Debriefing) -> None: for loss in debriefing.building_losses: @@ -265,7 +264,7 @@ class Event: except Exception: logging.exception(f"Could not process base capture {captured}") - def commit(self, debriefing: Debriefing): + def commit(self, debriefing: Debriefing) -> None: logging.info("Committing mission results") self.commit_air_losses(debriefing) @@ -298,15 +297,16 @@ class Event: delta = 0.0 player_won = True + status_msg: str = "" ally_casualties = debriefing.casualty_count(cp) enemy_casualties = debriefing.casualty_count(enemy_cp) ally_units_alive = cp.base.total_armor enemy_units_alive = enemy_cp.base.total_armor - print(ally_units_alive) - print(enemy_units_alive) - print(ally_casualties) - print(enemy_casualties) + print(f"Remaining allied units: {ally_units_alive}") + print(f"Remaining enemy units: {enemy_units_alive}") + print(f"Allied casualties {ally_casualties}") + print(f"Enemy casualties {enemy_casualties}") ratio = (1.0 + enemy_casualties) / (1.0 + ally_casualties) @@ -319,24 +319,31 @@ class Event: if ally_units_alive == 0: player_won = False delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"No allied units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces suffer a strong defeat." elif enemy_units_alive == 0: player_won = True delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"No enemy units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces win a strong victory." elif cp.stances[enemy_cp.id] == CombatStance.RETREAT: player_won = False delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"Allied forces are retreating along the {cp.name}-{enemy_cp.name} frontline, suffering a strong defeat." else: if enemy_casualties > ally_casualties: player_won = True if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH: delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"Allied forces break through the {cp.name}-{enemy_cp.name} frontline, winning a strong victory" else: if ratio > 3: delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"Enemy casualties massively outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a strong victory." elif ratio < 1.5: delta = MINOR_DEFEAT_INFLUENCE + status_msg = f"Enemy casualties minorly outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a minor victory." else: delta = DEFEAT_INFLUENCE + status_msg = f"Enemy casualties outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces claim a victory." elif ally_casualties > enemy_casualties: if ( @@ -346,54 +353,66 @@ class Event: # Even with casualties if the enemy is overwhelmed, they are going to lose ground player_won = True delta = MINOR_DEFEAT_INFLUENCE + status_msg = f"Despite suffering losses, allied forces still outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a minor victory." elif ( ally_units_alive > 3 * enemy_units_alive and player_aggresive ): player_won = True delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"Despite suffering losses, allied forces still heavily outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a major victory." else: - # But is the enemy is not outnumbered, we lose + # But if the enemy is not outnumbered, we lose player_won = False if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH: delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces have overextended themselves, suffering a major defeat." else: - delta = STRONG_DEFEAT_INFLUENCE + delta = DEFEAT_INFLUENCE + status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces suffer a defeat." # No progress with defensive strategies if player_won and cp.stances[enemy_cp.id] in [ CombatStance.DEFENSIVE, CombatStance.AMBUSH, ]: - print("Defensive stance, progress is limited") + print( + f"Allied forces have adopted a defensive stance along the {cp.name}-{enemy_cp.name} " + f"frontline, making only limited progress." + ) delta = MINOR_DEFEAT_INFLUENCE - if player_won: - print(cp.name + " won ! factor > " + str(delta)) - cp.base.affect_strength(delta) - enemy_cp.base.affect_strength(-delta) + # Handle the case where there are no casualties at all on either side but both sides still have units + if delta == 0.0: + print(status_msg) info = Information( "Frontline Report", - "Our ground forces from " - + cp.name - + " are making progress toward " - + enemy_cp.name, + f"Our ground forces from {cp.name} reached a stalemate with enemy forces from {enemy_cp.name}.", self.game.turn, ) self.game.informations.append(info) else: - print(cp.name + " lost ! factor > " + str(delta)) - enemy_cp.base.affect_strength(delta) - cp.base.affect_strength(-delta) - info = Information( - "Frontline Report", - "Our ground forces from " - + cp.name - + " are losing ground against the enemy forces from " - + enemy_cp.name, - self.game.turn, - ) - self.game.informations.append(info) + if player_won: + print(status_msg) + cp.base.affect_strength(delta) + enemy_cp.base.affect_strength(-delta) + info = Information( + "Frontline Report", + f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}", + self.game.turn, + ) + self.game.informations.append(info) + else: + print(status_msg) + enemy_cp.base.affect_strength(delta) + cp.base.affect_strength(-delta) + info = Information( + "Frontline Report", + f"Our ground forces from {cp.name} are losing ground against the enemy forces from " + f"{enemy_cp.name}. {status_msg}", + self.game.turn, + ) + self.game.informations.append(info) def redeploy_units(self, cp: ControlPoint) -> None: """ " diff --git a/game/event/frontlineattack.py b/game/event/frontlineattack.py index d7749a2a..fefb3617 100644 --- a/game/event/frontlineattack.py +++ b/game/event/frontlineattack.py @@ -8,5 +8,5 @@ class FrontlineAttackEvent(Event): future unique Event handling """ - def __str__(self): + def __str__(self) -> str: return "Frontline attack" diff --git a/game/factions/faction.py b/game/factions/faction.py index 83a8f0fa..382525de 100644 --- a/game/factions/faction.py +++ b/game/factions/faction.py @@ -3,7 +3,7 @@ from __future__ import annotations import itertools import logging from dataclasses import dataclass, field -from typing import Optional, Dict, Type, List, Any, Iterator +from typing import Optional, Dict, Type, List, Any, Iterator, TYPE_CHECKING import dcs from dcs.countries import country_dict @@ -25,6 +25,9 @@ from game.data.groundunitclass import GroundUnitClass from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType +if TYPE_CHECKING: + from game.theater.start_generator import ModSettings + @dataclass class Faction: @@ -81,10 +84,10 @@ class Faction: requirements: Dict[str, str] = field(default_factory=dict) # possible aircraft carrier units - aircraft_carrier: List[Type[UnitType]] = field(default_factory=list) + aircraft_carrier: List[Type[ShipType]] = field(default_factory=list) # possible helicopter carrier units - helicopter_carrier: List[Type[UnitType]] = field(default_factory=list) + helicopter_carrier: List[Type[ShipType]] = field(default_factory=list) # Possible carrier names carrier_names: List[str] = field(default_factory=list) @@ -257,7 +260,7 @@ class Faction: if unit.unit_class is unit_class: yield unit - def apply_mod_settings(self, mod_settings) -> Faction: + def apply_mod_settings(self, mod_settings: ModSettings) -> Faction: # aircraft if not mod_settings.a4_skyhawk: self.remove_aircraft("A-4E-C") @@ -319,17 +322,17 @@ class Faction: self.remove_air_defenses("KS19Generator") return self - def remove_aircraft(self, name): + def remove_aircraft(self, name: str) -> None: for i in self.aircrafts: if i.dcs_unit_type.id == name: self.aircrafts.remove(i) - def remove_air_defenses(self, name): + def remove_air_defenses(self, name: str) -> None: for i in self.air_defenses: if i == name: self.air_defenses.remove(i) - def remove_vehicle(self, name): + def remove_vehicle(self, name: str) -> None: for i in self.frontline_units: if i.dcs_unit_type.id == name: self.frontline_units.remove(i) @@ -342,7 +345,7 @@ def load_ship(name: str) -> Optional[Type[ShipType]]: return None -def load_all_ships(data) -> List[Type[ShipType]]: +def load_all_ships(data: list[str]) -> List[Type[ShipType]]: items = [] for name in data: item = load_ship(name) 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 0f9eacbb..dab02f8a 100644 --- a/game/game.py +++ b/game/game.py @@ -1,48 +1,43 @@ -from game.dcs.aircrafttype import AircraftType import itertools import logging -import random -import sys +import math +from collections import Iterator from datetime import date, datetime, timedelta from enum import Enum -from typing import Any, List +from typing import Any, List, Type, Union, cast from dcs.action import Coalition from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike from dcs.vehicles import AirDefence -from pydcs_extensions.a4ec.a4ec import A_4E_C from faker import Faker -from game import db from game.inventory import GlobalAircraftInventory from game.models.game_stats import GameStats from game.plugins import LuaPluginManager -from gen import aircraft, naming +from gen import naming from gen.ato import AirTaskingOrder from gen.conflictgen import Conflict -from gen.flights.ai_flight_planner import CoalitionMissionPlanner from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType from gen.ground_forces.ai_ground_planner import GroundPlanner from . import persistency +from .coalition import Coalition from .debriefing import Debriefing from .event.event import Event from .event.frontlineattack import FrontlineAttackEvent from .factions.faction import Faction -from .income import Income from .infos.information import Information from .navmesh import NavMesh -from .procurement import AircraftProcurementRequest, ProcurementAi +from .procurement import AircraftProcurementRequest from .profiling import logged_duration -from .settings import Settings, AutoAtoBehavior +from .settings import Settings from .squadrons import AirWing -from .theater import ConflictTheater +from .theater import ConflictTheater, ControlPoint from .theater.bullseye import Bullseye from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder from .threatzones import ThreatZones -from .transfers import PendingTransfers from .unitmap import UnitMap from .weather import Conditions, TimeOfDay @@ -100,151 +95,97 @@ class Game: self.settings = settings self.events: List[Event] = [] self.theater = theater - self.player_faction = player_faction - self.player_country = player_faction.country - self.enemy_faction = enemy_faction - self.enemy_country = enemy_faction.country # pass_turn() will be called when initialization is complete which will # increment this to turn 0 before it reaches the player. self.turn = -1 # NB: This is the *start* date. It is never updated. self.date = date(start_date.year, start_date.month, start_date.day) self.game_stats = GameStats() - self.game_stats.update(self) + self.notes = "" self.ground_planners: dict[int, GroundPlanner] = {} self.informations = [] self.informations.append(Information("Game Start", "-" * 40, 0)) # Culling Zones are for areas around points of interest that contain things we may not wish to cull. self.__culling_zones: List[Point] = [] - self.__destroyed_units: List[str] = [] + self.__destroyed_units: list[dict[str, Union[float, str]]] = [] self.savepath = "" - self.budget = player_budget - self.enemy_budget = enemy_budget self.current_unit_id = 0 self.current_group_id = 0 self.name_generator = naming.namegen self.conditions = self.generate_conditions() - self.blue_transit_network = TransitNetwork() - self.red_transit_network = TransitNetwork() - - self.blue_procurement_requests: List[AircraftProcurementRequest] = [] - self.red_procurement_requests: List[AircraftProcurementRequest] = [] - - self.blue_ato = AirTaskingOrder() - self.red_ato = AirTaskingOrder() - - self.blue_bullseye = Bullseye(Point(0, 0)) - self.red_bullseye = Bullseye(Point(0, 0)) + self.sanitize_sides(player_faction, enemy_faction) + self.blue = Coalition(self, player_faction, player_budget, player=True) + self.red = Coalition(self, enemy_faction, enemy_budget, player=False) + self.blue.set_opponent(self.red) + self.red.set_opponent(self.blue) self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints) - self.transfers = PendingTransfers(self) - - self.sanitize_sides() - - self.blue_faker = Faker(self.player_faction.locales) - self.red_faker = Faker(self.enemy_faction.locales) - - self.blue_air_wing = AirWing(self, player=True) - self.red_air_wing = AirWing(self, player=False) - self.on_load(game_still_initializing=True) - def __getstate__(self) -> dict[str, Any]: - state = self.__dict__.copy() - # Avoid persisting any volatile types that can be deterministically - # recomputed on load for the sake of save compatibility. - del state["blue_threat_zone"] - del state["red_threat_zone"] - del state["blue_navmesh"] - del state["red_navmesh"] - del state["blue_faker"] - del state["red_faker"] - return state - def __setstate__(self, state: dict[str, Any]) -> None: self.__dict__.update(state) # Regenerate any state that was not persisted. self.on_load() - def ato_for(self, player: bool) -> AirTaskingOrder: - if player: - return self.blue_ato - return self.red_ato + @property + def coalitions(self) -> Iterator[Coalition]: + yield self.blue + yield self.red - def procurement_requests_for( - self, player: bool - ) -> List[AircraftProcurementRequest]: - if player: - return self.blue_procurement_requests - return self.red_procurement_requests + def ato_for(self, player: bool) -> AirTaskingOrder: + return self.coalition_for(player).ato def transit_network_for(self, player: bool) -> TransitNetwork: - if player: - return self.blue_transit_network - return self.red_transit_network + return self.coalition_for(player).transit_network def generate_conditions(self) -> Conditions: return Conditions.generate( self.theater, self.current_day, self.current_turn_time_of_day, self.settings ) - def sanitize_sides(self): + @staticmethod + def sanitize_sides(player_faction: Faction, enemy_faction: Faction) -> None: """ Make sure the opposing factions are using different countries :return: """ - if self.player_country == self.enemy_country: - if self.player_country == "USA": - self.enemy_country = "USAF Aggressors" - elif self.player_country == "Russia": - self.enemy_country = "USSR" + if player_faction.country == enemy_faction.country: + if player_faction.country == "USA": + enemy_faction.country = "USAF Aggressors" + elif player_faction.country == "Russia": + enemy_faction.country = "USSR" else: - self.enemy_country = "Russia" + enemy_faction.country = "Russia" def faction_for(self, player: bool) -> Faction: - if player: - return self.player_faction - return self.enemy_faction + return self.coalition_for(player).faction def faker_for(self, player: bool) -> Faker: - if player: - return self.blue_faker - return self.red_faker + return self.coalition_for(player).faker def air_wing_for(self, player: bool) -> AirWing: - if player: - return self.blue_air_wing - return self.red_air_wing + return self.coalition_for(player).air_wing def country_for(self, player: bool) -> str: - if player: - return self.player_country - return self.enemy_country + return self.coalition_for(player).country_name def bullseye_for(self, player: bool) -> Bullseye: - if player: - return self.blue_bullseye - return self.red_bullseye + return self.coalition_for(player).bullseye - def _roll(self, prob, mult): - if self.settings.version == "dev": - # always generate all events for dev - return 100 - else: - return random.randint(1, 100) <= prob * mult - - def _generate_player_event(self, event_class, player_cp, enemy_cp): + def _generate_player_event( + self, event_class: Type[Event], player_cp: ControlPoint, enemy_cp: ControlPoint + ) -> None: self.events.append( event_class( self, player_cp, enemy_cp, enemy_cp.position, - self.player_faction.name, - self.enemy_faction.name, + self.blue.faction.name, + self.red.faction.name, ) ) @@ -259,7 +200,7 @@ class Game: else: return USAFAggressors - def _generate_events(self): + def _generate_events(self) -> None: for front_line in self.theater.conflicts(): self._generate_player_event( FrontlineAttackEvent, @@ -267,27 +208,21 @@ class Game: front_line.red_cp, ) - def adjust_budget(self, amount: float, player: bool) -> None: + def coalition_for(self, player: bool) -> Coalition: if player: - self.budget += amount - else: - self.enemy_budget += amount + return self.blue + return self.red - def process_player_income(self): - self.budget += Income(self, player=True).total + def adjust_budget(self, amount: float, player: bool) -> None: + self.coalition_for(player).adjust_budget(amount) - def process_enemy_income(self): - # TODO: Clean up save compat. - if not hasattr(self, "enemy_budget"): - self.enemy_budget = 0 - self.enemy_budget += Income(self, player=False).total - - def initiate_event(self, event: Event) -> UnitMap: + @staticmethod + def initiate_event(event: Event) -> UnitMap: # assert event in self.events logging.info("Generating {} (regular)".format(event)) return event.generate() - def finish_event(self, event: Event, debriefing: Debriefing): + def finish_event(self, event: Event, debriefing: Debriefing) -> None: logging.info("Finishing event {}".format(event)) event.commit(debriefing) @@ -296,16 +231,6 @@ class Game: else: logging.info("finish_event: event not in the events!") - def is_player_attack(self, event): - if isinstance(event, Event): - return ( - event - and event.attacker_name - and event.attacker_name == self.player_faction.name - ) - else: - raise RuntimeError(f"{event} was passed when an Event type was expected") - def on_load(self, game_still_initializing: bool = False) -> None: if not hasattr(self, "name_generator"): self.name_generator = naming.namegen @@ -320,36 +245,50 @@ class Game: self.compute_conflicts_position() if not game_still_initializing: self.compute_threat_zones() - self.blue_faker = Faker(self.faction_for(player=True).locales) - self.red_faker = Faker(self.faction_for(player=False).locales) - - def reset_ato(self) -> None: - self.blue_ato.clear() - self.red_ato.clear() def finish_turn(self, skipped: bool = False) -> None: + """Finalizes the current turn and advances to the next turn. + + This handles the turn-end portion of passing a turn. Initialization of the next + turn is handled by `initialize_turn`. These are separate processes because while + turns may be initialized more than once under some circumstances (see the + documentation for `initialize_turn`), `finish_turn` performs the work that + should be guaranteed to happen only once per turn: + + * Turn counter increment. + * Delivering units ordered the previous turn. + * Transfer progress. + * Squadron replenishment. + * Income distribution. + * Base strength (front line position) adjustment. + * Weather/time-of-day generation. + + Some actions (like transit network assembly) will happen both here and in + `initialize_turn`. We need the network to be up to date so we can account for + base captures when processing the transfers that occurred last turn, but we also + need it to be up to date in the case of a re-initialization in `initialize_turn` + (such as to account for a cheat base capture) so that orders are only placed + where a supply route exists to the destination. This is a relatively cheap + operation so duplicating the effort is not a problem. + + Args: + skipped: True if the turn was skipped. + """ self.informations.append( Information("End of turn #" + str(self.turn), "-" * 40, 0) ) self.turn += 1 - # Need to recompute before transfers and deliveries to account for captures. - # This happens in in initialize_turn as well, because cheating doesn't advance a - # turn but can capture bases so we need to recompute there as well. - self.compute_transit_networks() + # The coalition-specific turn finalization *must* happen before unit deliveries, + # since the coalition-specific finalization handles transit network updates and + # transfer processing. If in the other order, units may be delivered to captured + # bases, and freshly delivered units will spawn one leg through their journey. + self.blue.end_turn() + self.red.end_turn() - # Must happen *before* unit deliveries are handled, or else new units will spawn - # one hop ahead. ControlPoint.process_turn handles unit deliveries. - self.transfers.perform_transfers() - - # Needs to happen *before* planning transfers so we don't cancel them. - self.reset_ato() for control_point in self.theater.controlpoints: control_point.process_turn(self) - self.blue_air_wing.replenish() - self.red_air_wing.replenish() - if not skipped: for cp in self.theater.player_points(): cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY) @@ -360,14 +299,21 @@ class Game: self.conditions = self.generate_conditions() - self.process_enemy_income() - self.process_player_income() - 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: + """Ends the current turn and initializes the new turn. + + Called both when skipping a turn or by ending the turn as the result of combat. + + Args: + no_action: True if the turn was skipped. + """ logging.info("Pass turn") with logged_duration("Turn finalization"): self.finish_turn(no_action) @@ -377,7 +323,7 @@ class Game: # Autosave progress persistency.autosave(self) - def check_win_loss(self): + def check_win_loss(self) -> TurnState: player_airbases = { cp for cp in self.theater.player_points() if cp.runway_is_operational() } @@ -394,24 +340,50 @@ class Game: def set_bullseye(self) -> None: player_cp, enemy_cp = self.theater.closest_opposing_control_points() - self.blue_bullseye = Bullseye(enemy_cp.position) - self.red_bullseye = Bullseye(player_cp.position) + self.blue.bullseye = Bullseye(enemy_cp.position) + self.red.bullseye = Bullseye(player_cp.position) - def initialize_turn(self) -> None: + def initialize_turn(self, for_red: bool = True, for_blue: bool = True) -> None: + """Performs turn initialization for the specified players. + + Turn initialization performs all of the beginning-of-turn actions. *End-of-turn* + processing happens in `pass_turn` (despite the name, it's called both for + skipping the turn and ending the turn after combat). + + Special care needs to be taken here because initialization can occur more than + once per turn. A number of events can require re-initializing a turn: + + * Cheat capture. Bases changing hands invalidates many missions in both ATOs, + purchase orders, threat zones, transit networks, etc. Practically speaking, + after a base capture the turn needs to be treated as fully new. The game might + even be over after a capture. + * Cheat front line position. CAS missions are no longer in the correct location, + and the ground planner may also need changes. + * Selling/buying units at TGOs. Selling a TGO might leave missions in the ATO + with invalid targets. Buying a new SAM (or even replacing some units in a SAM) + potentially changes the threat zone and may alter mission priorities and + flight planning. + + Most of the work is delegated to initialize_turn_for, which handles the + coalition-specific turn initialization. In some cases only one coalition will be + (re-) initialized. This is the case when buying or selling TGO units, since we + don't want to force the player to redo all their planning just because they + repaired a SAM, but should replan opfor when that happens. On the other hand, + base captures are significant enough (and likely enough to be the first thing + the player does in a turn) that we replan blue as well. Front lines are less + impactful but also likely to be early, so they also cause a blue replan. + + Args: + for_red: True if opfor should be re-initialized. + for_blue: True if the player coalition should be re-initialized. + """ self.events = [] self._generate_events() - self.set_bullseye() # Update statistics self.game_stats.update(self) - self.blue_air_wing.reset() - self.red_air_wing.reset() - self.aircraft_inventory.reset() - for cp in self.theater.controlpoints: - self.aircraft_inventory.set_from_control_point(cp) - # Check for win or loss condition turn_state = self.check_win_loss() if turn_state in (TurnState.LOSS, TurnState.WIN): @@ -422,59 +394,26 @@ class Game: self.compute_conflicts_position() with logged_duration("Threat zone computation"): self.compute_threat_zones() - with logged_duration("Transit network identification"): - self.compute_transit_networks() + + # Plan Coalition specific turn + if for_blue: + self.initialize_turn_for(player=True) + if for_red: + self.initialize_turn_for(player=False) + + # Plan GroundWar self.ground_planners = {} - - self.blue_procurement_requests.clear() - self.red_procurement_requests.clear() - - with logged_duration("Procurement of airlift assets"): - self.transfers.order_airlift_assets() - with logged_duration("Transport planning"): - self.transfers.plan_transports() - - with logged_duration("Blue mission planning"): - if self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled: - blue_planner = CoalitionMissionPlanner(self, is_player=True) - blue_planner.plan_missions() - - with logged_duration("Red mission planning"): - red_planner = CoalitionMissionPlanner(self, is_player=False) - red_planner.plan_missions() - for cp in self.theater.controlpoints: if cp.has_frontline: gplanner = GroundPlanner(cp, self) gplanner.plan_groundwar() self.ground_planners[cp.id] = gplanner - self.plan_procurement() - - def plan_procurement(self) -> None: - # The first turn needs to buy a *lot* of aircraft to fill CAPs, so it - # gets much more of the budget that turn. Otherwise budget (after - # repairs) is split evenly between air and ground. For the default - # starting budget of 2000 this gives 600 to ground forces and 1400 to - # aircraft. After that the budget will be spend proportionally based on how much is already invested - - self.budget = ProcurementAi( - self, - for_player=True, - faction=self.player_faction, - manage_runways=self.settings.automate_runway_repair, - manage_front_line=self.settings.automate_front_line_reinforcements, - manage_aircraft=self.settings.automate_aircraft_reinforcements, - ).spend_budget(self.budget) - - self.enemy_budget = ProcurementAi( - self, - for_player=False, - faction=self.enemy_faction, - manage_runways=True, - manage_front_line=True, - manage_aircraft=True, - ).spend_budget(self.enemy_budget) + def initialize_turn_for(self, player: bool) -> None: + self.aircraft_inventory.reset(player) + for cp in self.theater.control_points_for(player): + self.aircraft_inventory.set_from_control_point(cp) + self.coalition_for(player).initialize_turn() def message(self, text: str) -> None: self.informations.append(Information(text, turn=self.turn)) @@ -487,48 +426,36 @@ class Game: def current_day(self) -> date: return self.date + timedelta(days=self.turn // 4) - def next_unit_id(self): + def next_unit_id(self) -> int: """ Next unit id for pre-generated units """ self.current_unit_id += 1 return self.current_unit_id - def next_group_id(self): + def next_group_id(self) -> int: """ Next unit id for pre-generated units """ self.current_group_id += 1 return self.current_group_id - def compute_transit_networks(self) -> None: - self.blue_transit_network = self.compute_transit_network_for(player=True) - self.red_transit_network = self.compute_transit_network_for(player=False) - def compute_transit_network_for(self, player: bool) -> TransitNetwork: return TransitNetworkBuilder(self.theater, player).build() def compute_threat_zones(self) -> None: - self.blue_threat_zone = ThreatZones.for_faction(self, player=True) - self.red_threat_zone = ThreatZones.for_faction(self, player=False) - self.blue_navmesh = NavMesh.from_threat_zones( - self.red_threat_zone, self.theater - ) - self.red_navmesh = NavMesh.from_threat_zones( - self.blue_threat_zone, self.theater - ) + self.blue.compute_threat_zones() + self.red.compute_threat_zones() + self.blue.compute_nav_meshes() + self.red.compute_nav_meshes() def threat_zone_for(self, player: bool) -> ThreatZones: - if player: - return self.blue_threat_zone - return self.red_threat_zone + return self.coalition_for(player).threat_zone def navmesh_for(self, player: bool) -> NavMesh: - if player: - return self.blue_navmesh - return self.red_navmesh + return self.coalition_for(player).nav_mesh - def compute_conflicts_position(self): + def compute_conflicts_position(self) -> None: """ Compute the current conflict center position(s), mainly used for culling calculation :return: List of points of interests @@ -551,7 +478,7 @@ class Game: # If there is no conflict take the center point between the two nearest opposing bases if len(zones) == 0: cpoint = None - min_distance = sys.maxsize + min_distance = math.inf for cp in self.theater.player_points(): for cp2 in self.theater.enemy_points(): d = cp.position.distance_to_point(cp2.position) @@ -569,7 +496,7 @@ class Game: if cpoint is not None: zones.append(cpoint) - packages = itertools.chain(self.blue_ato.packages, self.red_ato.packages) + packages = itertools.chain(self.blue.ato.packages, self.red.ato.packages) for package in packages: if package.primary_task is FlightType.BARCAP: # BARCAPs will be planned at most locations on smaller theaters, @@ -587,15 +514,15 @@ class Game: self.__culling_zones = zones - def add_destroyed_units(self, data): - pos = Point(data["x"], data["z"]) + def add_destroyed_units(self, data: dict[str, Union[float, str]]) -> None: + pos = Point(cast(float, data["x"]), cast(float, data["z"])) if self.theater.is_on_land(pos): self.__destroyed_units.append(data) - def get_destroyed_units(self): + def get_destroyed_units(self) -> list[dict[str, Union[float, str]]]: return self.__destroyed_units - def position_culled(self, pos): + def position_culled(self, pos: Point) -> bool: """ Check if unit can be generated at given position depending on culling performance settings :param pos: Position you are tryng to spawn stuff at @@ -608,38 +535,17 @@ class Game: return False return True - def get_culling_zones(self): + def get_culling_zones(self) -> list[Point]: """ Check culling points :return: List of culling zones """ return self.__culling_zones - # 1 = red, 2 = blue - def get_player_coalition_id(self): - return 2 - - def get_enemy_coalition_id(self): - return 1 - - def get_player_coalition(self): - return Coalition.Blue - - def get_enemy_coalition(self): - return Coalition.Red - - def get_player_color(self): - return "blue" - - def get_enemy_color(self): - return "red" - - def process_win_loss(self, turn_state: TurnState): + def process_win_loss(self, turn_state: TurnState) -> None: if turn_state is TurnState.WIN: - return self.message( - "Congratulations, you are victorious! Start a new campaign to continue." + self.message( + "Congratulations, you are victorious! Start a new campaign to continue." ) elif turn_state is TurnState.LOSS: - return self.message( - "Game Over, you lose. Start a new campaign to continue." - ) + self.message("Game Over, you lose. Start a new campaign to continue.") diff --git a/game/htn.py b/game/htn.py new file mode 100644 index 00000000..49699892 --- /dev/null +++ b/game/htn.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections import Iterator, deque, Sequence +from dataclasses import dataclass +from typing import Any, Generic, Optional, TypeVar + +WorldStateT = TypeVar("WorldStateT", bound="WorldState[Any]") + + +class WorldState(ABC, Generic[WorldStateT]): + @abstractmethod + def clone(self) -> WorldStateT: + ... + + +class Task(Generic[WorldStateT]): + pass + + +Method = Sequence[Task[WorldStateT]] + + +class PrimitiveTask(Task[WorldStateT], Generic[WorldStateT], ABC): + @abstractmethod + def preconditions_met(self, state: WorldStateT) -> bool: + ... + + @abstractmethod + def apply_effects(self, state: WorldStateT) -> None: + ... + + +class CompoundTask(Task[WorldStateT], Generic[WorldStateT], ABC): + @abstractmethod + def each_valid_method(self, state: WorldStateT) -> Iterator[Method[WorldStateT]]: + ... + + +PrimitiveTaskT = TypeVar("PrimitiveTaskT", bound=PrimitiveTask[Any]) + + +@dataclass +class PlanningState(Generic[WorldStateT, PrimitiveTaskT]): + state: WorldStateT + tasks_to_process: deque[Task[WorldStateT]] + plan: list[PrimitiveTaskT] + methods: Optional[Iterator[Method[WorldStateT]]] + + +@dataclass(frozen=True) +class PlanningResult(Generic[WorldStateT, PrimitiveTaskT]): + tasks: list[PrimitiveTaskT] + end_state: WorldStateT + + +class PlanningHistory(Generic[WorldStateT, PrimitiveTaskT]): + def __init__(self) -> None: + self.states: list[PlanningState[WorldStateT, PrimitiveTaskT]] = [] + + def push(self, planning_state: PlanningState[WorldStateT, PrimitiveTaskT]) -> None: + self.states.append(planning_state) + + def pop(self) -> PlanningState[WorldStateT, PrimitiveTaskT]: + return self.states.pop() + + +class Planner(Generic[WorldStateT, PrimitiveTaskT]): + def __init__(self, main_task: Task[WorldStateT]) -> None: + self.main_task = main_task + + def plan( + self, initial_state: WorldStateT + ) -> Optional[PlanningResult[WorldStateT, PrimitiveTaskT]]: + planning_state: PlanningState[WorldStateT, PrimitiveTaskT] = PlanningState( + initial_state, deque([self.main_task]), [], None + ) + history: PlanningHistory[WorldStateT, PrimitiveTaskT] = PlanningHistory() + while planning_state.tasks_to_process: + task = planning_state.tasks_to_process.popleft() + if isinstance(task, PrimitiveTask): + if task.preconditions_met(planning_state.state): + task.apply_effects(planning_state.state) + # Ignore type erasure. We've already verified that this is a Planner + # with a WorldStateT and a PrimitiveTaskT, so we know that the task + # list is a list of CompoundTask[WorldStateT] and PrimitiveTaskT. We + # could scatter more unions throughout to be more explicit but + # there's no way around the type erasure that mypy uses for + # isinstance. + planning_state.plan.append(task) # type: ignore + else: + planning_state = history.pop() + else: + assert isinstance(task, CompoundTask) + # If the methods field of our current state is not None that means we're + # resuming a prior attempt to execute this task after a subtask of the + # previously selected method failed. + # + # Otherwise this is the first exectution of this task so we need to + # create the generator. + if planning_state.methods is None: + methods = task.each_valid_method(planning_state.state) + else: + methods = planning_state.methods + try: + method = next(methods) + # Push the current node back onto the stack so that we resume + # handling this task when we pop back to this state. + resume_tasks: deque[Task[WorldStateT]] = deque([task]) + resume_tasks.extend(planning_state.tasks_to_process) + history.push( + PlanningState( + planning_state.state.clone(), + resume_tasks, + planning_state.plan, + methods, + ) + ) + planning_state.methods = None + planning_state.tasks_to_process.extendleft(reversed(method)) + except StopIteration: + try: + planning_state = history.pop() + except IndexError: + # No valid plan was found. + return None + return PlanningResult(planning_state.plan, planning_state.state) diff --git a/game/income.py b/game/income.py index dd2be887..f9a74eb6 100644 --- a/game/income.py +++ b/game/income.py @@ -14,10 +14,10 @@ class BuildingIncome: name: str category: str number: int - income_per_building: int + income_per_building: float @property - def income(self) -> int: + def income(self) -> float: return self.number * self.income_per_building diff --git a/game/infos/information.py b/game/infos/information.py index 1c132d46..efc3fb96 100644 --- a/game/infos/information.py +++ b/game/infos/information.py @@ -2,13 +2,13 @@ import datetime class Information: - def __init__(self, title="", text="", turn=0): + def __init__(self, title: str = "", text: str = "", turn: int = 0) -> None: self.title = title self.text = text self.turn = turn self.timestamp = datetime.datetime.now() - def __str__(self): + def __str__(self) -> str: return "[{}][{}] {} {}".format( self.timestamp.strftime("%Y-%m-%d %H:%M:%S") if self.timestamp is not None diff --git a/game/inventory.py b/game/inventory.py index 4014c05c..f7f0dbe1 100644 --- a/game/inventory.py +++ b/game/inventory.py @@ -1,10 +1,8 @@ """Inventory management APIs.""" from __future__ import annotations -from collections import defaultdict -from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING, Type - -from dcs.unittype import FlyingType +from collections import defaultdict, Iterator, Iterable +from typing import TYPE_CHECKING from game.dcs.aircrafttype import AircraftType from gen.flights.flight import Flight @@ -18,7 +16,12 @@ class ControlPointAircraftInventory: def __init__(self, control_point: ControlPoint) -> None: self.control_point = control_point - self.inventory: Dict[AircraftType, int] = defaultdict(int) + self.inventory: dict[AircraftType, int] = defaultdict(int) + + def clone(self) -> ControlPointAircraftInventory: + new = ControlPointAircraftInventory(self.control_point) + new.inventory = self.inventory.copy() + return new def add_aircraft(self, aircraft: AircraftType, count: int) -> None: """Adds aircraft to the inventory. @@ -67,7 +70,7 @@ class ControlPointAircraftInventory: yield aircraft @property - def all_aircraft(self) -> Iterator[Tuple[AircraftType, int]]: + def all_aircraft(self) -> Iterator[tuple[AircraftType, int]]: """Iterates over all available aircraft types, including amounts.""" for aircraft, count in self.inventory.items(): if count > 0: @@ -82,14 +85,22 @@ class GlobalAircraftInventory: """Game-wide aircraft inventory.""" def __init__(self, control_points: Iterable[ControlPoint]) -> None: - self.inventories: Dict[ControlPoint, ControlPointAircraftInventory] = { + self.inventories: dict[ControlPoint, ControlPointAircraftInventory] = { cp: ControlPointAircraftInventory(cp) for cp in control_points } - def reset(self) -> None: - """Clears all control points and their inventories.""" + def clone(self) -> GlobalAircraftInventory: + new = GlobalAircraftInventory([]) + new.inventories = { + cp: inventory.clone() for cp, inventory in self.inventories.items() + } + return new + + def reset(self, for_player: bool) -> None: + """Clears the inventory of every control point owned by the given coalition.""" for inventory in self.inventories.values(): - inventory.clear() + if inventory.control_point.captured == for_player: + inventory.clear() def set_from_control_point(self, control_point: ControlPoint) -> None: """Set the control point's aircraft inventory. @@ -110,7 +121,7 @@ class GlobalAircraftInventory: @property def available_types_for_player(self) -> Iterator[AircraftType]: """Iterates over all aircraft types available to the player.""" - seen: Set[AircraftType] = set() + seen: set[AircraftType] = set() for control_point, inventory in self.inventories.items(): if control_point.captured: for aircraft in inventory.types_available: diff --git a/game/models/destroyed_units.py b/game/models/destroyed_units.py deleted file mode 100644 index 7d0de042..00000000 --- a/game/models/destroyed_units.py +++ /dev/null @@ -1,13 +0,0 @@ -class DestroyedUnit: - """ - Store info about a destroyed unit - """ - - x: int - y: int - name: str - - def __init__(self, x, y, name): - self.x = x - self.y = y - self.name = name diff --git a/game/models/game_stats.py b/game/models/game_stats.py index a4d8e623..7e828021 100644 --- a/game/models/game_stats.py +++ b/game/models/game_stats.py @@ -1,4 +1,9 @@ -from typing import List +from __future__ import annotations + +from typing import List, TYPE_CHECKING + +if TYPE_CHECKING: + from game import Game class FactionTurnMetadata: @@ -10,7 +15,7 @@ class FactionTurnMetadata: vehicles_count: int = 0 sam_count: int = 0 - def __init__(self): + def __init__(self) -> None: self.aircraft_count = 0 self.vehicles_count = 0 self.sam_count = 0 @@ -24,7 +29,7 @@ class GameTurnMetadata: allied_units: FactionTurnMetadata enemy_units: FactionTurnMetadata - def __init__(self): + def __init__(self) -> None: self.allied_units = FactionTurnMetadata() self.enemy_units = FactionTurnMetadata() @@ -34,15 +39,19 @@ class GameStats: Store statistics for the current game """ - def __init__(self): + def __init__(self) -> None: self.data_per_turn: List[GameTurnMetadata] = [] - def update(self, game): + def update(self, game: Game) -> None: """ Save data for current turn :param game: Game we want to save the data about """ + # Remove the current turn if its just an update for this turn + if 0 < game.turn < len(self.data_per_turn): + del self.data_per_turn[-1] + turn_data = GameTurnMetadata() for cp in game.theater.controlpoints: diff --git a/game/operation/operation.py b/game/operation/operation.py index 6b44cb9f..299aaf15 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import os from pathlib import Path -from typing import Iterable, List, Set, TYPE_CHECKING +from typing import List, Set, TYPE_CHECKING, cast from dcs import Mission from dcs.action import DoScript, DoScriptFile @@ -16,11 +16,11 @@ from dcs.triggers import TriggerStart from game.plugins import LuaPluginManager from game.theater.theatergroundobject import TheaterGroundObject -from gen import Conflict, FlightType, VisualGenerator, Bullseye +from gen import Conflict, FlightType, VisualGenerator, Bullseye, AirSupport from gen.aircraft import AircraftConflictGenerator, FlightData from gen.airfields import AIRFIELD_DATA -from gen.airsupportgen import AirSupport, AirSupportConflictGenerator -from gen.armor import GroundConflictGenerator, JtacInfo +from gen.airsupportgen import AirSupportConflictGenerator +from gen.armor import GroundConflictGenerator from gen.beacons import load_beacons_for_terrain from gen.briefinggen import BriefingGenerator, MissionInfoGenerator from gen.cargoshipgen import CargoShipGenerator @@ -29,6 +29,7 @@ from gen.environmentgen import EnvironmentGenerator from gen.forcedoptionsgen import ForcedOptionsGenerator from gen.groundobjectsgen import GroundObjectsGenerator from gen.kneeboard import KneeboardGenerator +from gen.lasercoderegistry import LaserCodeRegistry from gen.naming import namegen from gen.radios import RadioFrequency, RadioRegistry from gen.tacan import TacanRegistry @@ -50,6 +51,7 @@ class Operation: groundobjectgen: GroundObjectsGenerator radio_registry: RadioRegistry tacan_registry: TacanRegistry + laser_code_registry: LaserCodeRegistry game: Game trigger_radius = TRIGGER_RADIUS_MEDIUM is_quick = None @@ -58,11 +60,11 @@ class Operation: enemy_awacs_enabled = True ca_slots = 1 unit_map: UnitMap - jtacs: List[JtacInfo] = [] plugin_scripts: List[str] = [] + air_support = AirSupport() @classmethod - def prepare(cls, game: Game): + def prepare(cls, game: Game) -> None: with open("resources/default_options.lua", "r") as f: options_dict = loads(f.read())["options"] cls._set_mission(Mission(game.theater.terrain)) @@ -70,20 +72,6 @@ class Operation: cls._setup_mission_coalitions() cls.current_mission.options.load_from_dict(options_dict) - @classmethod - def conflicts(cls) -> Iterable[Conflict]: - assert cls.game - for frontline in cls.game.theater.conflicts(): - yield Conflict( - cls.game.theater, - frontline, - cls.game.player_faction.name, - cls.game.enemy_faction.name, - cls.game.player_country, - cls.game.enemy_country, - frontline.position, - ) - @classmethod def air_conflict(cls) -> Conflict: assert cls.game @@ -95,10 +83,10 @@ class Operation: return Conflict( cls.game.theater, FrontLine(player_cp, enemy_cp), - cls.game.player_faction.name, - cls.game.enemy_faction.name, - cls.game.player_country, - cls.game.enemy_country, + cls.game.blue.faction.name, + cls.game.red.faction.name, + cls.current_mission.country(cls.game.blue.country_name), + cls.current_mission.country(cls.game.red.country_name), mid_point, ) @@ -107,20 +95,19 @@ class Operation: cls.current_mission = mission @classmethod - def _setup_mission_coalitions(cls): + def _setup_mission_coalitions(cls) -> None: cls.current_mission.coalition["blue"] = Coalition( - "blue", bullseye=cls.game.blue_bullseye.to_pydcs() + "blue", bullseye=cls.game.blue.bullseye.to_pydcs() ) cls.current_mission.coalition["red"] = Coalition( - "red", bullseye=cls.game.red_bullseye.to_pydcs() + "red", bullseye=cls.game.red.bullseye.to_pydcs() ) cls.current_mission.coalition["neutrals"] = Coalition( "neutrals", bullseye=Bullseye(Point(0, 0)).to_pydcs() ) - p_country = cls.game.player_country - e_country = cls.game.enemy_country - + p_country = cls.game.blue.country_name + e_country = cls.game.red.country_name cls.current_mission.coalition["blue"].add_country( country_dict[db.country_id_from_name(p_country)]() ) @@ -174,10 +161,9 @@ class Operation: def notify_info_generators( cls, groundobjectgen: GroundObjectsGenerator, - airsupportgen: AirSupportConflictGenerator, - jtacs: List[JtacInfo], + air_support: AirSupport, airgen: AircraftConflictGenerator, - ): + ) -> None: """Generates subscribed MissionInfoGenerator objects (currently kneeboards and briefings)""" gens: List[MissionInfoGenerator] = [ @@ -188,15 +174,15 @@ class Operation: for dynamic_runway in groundobjectgen.runways.values(): gen.add_dynamic_runway(dynamic_runway) - for tanker in airsupportgen.air_support.tankers: + for tanker in air_support.tankers: if tanker.blue: gen.add_tanker(tanker) - for aewc in airsupportgen.air_support.awacs: + for aewc in air_support.awacs: if aewc.blue: gen.add_awacs(aewc) - for jtac in jtacs: + for jtac in air_support.jtacs: if jtac.blue: gen.add_jtac(jtac) @@ -221,6 +207,10 @@ class Operation: for frequency in unique_map_frequencies: cls.radio_registry.reserve(frequency) + @classmethod + def create_laser_code_registry(cls) -> None: + cls.laser_code_registry = LaserCodeRegistry() + @classmethod def assign_channels_to_flights( cls, flights: List[FlightData], air_support: AirSupport @@ -265,7 +255,7 @@ class Operation: # beacon list. @classmethod - def _generate_ground_units(cls): + def _generate_ground_units(cls) -> None: cls.groundobjectgen = GroundObjectsGenerator( cls.current_mission, cls.game, @@ -280,18 +270,23 @@ class Operation: """Add destroyed units to the Mission""" for d in cls.game.get_destroyed_units(): try: - utype = db.unit_type_from_name(d["type"]) + type_name = d["type"] + if not isinstance(type_name, str): + raise TypeError( + "Expected the type of the destroyed static to be a string" + ) + utype = db.unit_type_from_name(type_name) except KeyError: continue - pos = Point(d["x"], d["z"]) + pos = Point(cast(float, d["x"]), cast(float, d["z"])) if ( utype is not None and not cls.game.position_culled(pos) and cls.game.settings.perf_destroyed_units ): cls.current_mission.static_group( - country=cls.current_mission.country(cls.game.player_country), + country=cls.current_mission.country(cls.game.blue.country_name), name="", _type=utype, hidden=True, @@ -303,18 +298,22 @@ class Operation: @classmethod def generate(cls) -> UnitMap: """Build the final Mission to be exported""" + cls.air_support = AirSupport() cls.create_unit_map() cls.create_radio_registries() + cls.create_laser_code_registry() # Set mission time and weather conditions. EnvironmentGenerator(cls.current_mission, cls.game.conditions).generate() cls._generate_ground_units() cls._generate_transports() cls._generate_destroyed_units() + # Generate ground conflicts first so the JTACs get the first laser code (1688) + # rather than the first player flight with a TGP. + cls._generate_ground_conflicts() cls._generate_air_units() cls.assign_channels_to_flights( cls.airgen.flights, cls.airsupportgen.air_support ) - cls._generate_ground_conflicts() # Triggers triggersgen = TriggersGenerator(cls.current_mission, cls.game) @@ -334,7 +333,7 @@ class Operation: if cls.game.settings.perf_smoke_gen: visualgen.generate() - cls.generate_lua(cls.airgen, cls.airsupportgen, cls.jtacs) + cls.generate_lua(cls.airgen, cls.air_support) # Inject Plugins Lua Scripts and data cls.plugin_scripts.clear() @@ -346,9 +345,7 @@ class Operation: cls.assign_channels_to_flights( cls.airgen.flights, cls.airsupportgen.air_support ) - cls.notify_info_generators( - cls.groundobjectgen, cls.airsupportgen, cls.jtacs, cls.airgen - ) + cls.notify_info_generators(cls.groundobjectgen, cls.air_support, cls.airgen) cls.reset_naming_ids() return cls.unit_map @@ -364,6 +361,7 @@ class Operation: cls.game, cls.radio_registry, cls.tacan_registry, + cls.air_support, ) cls.airsupportgen.generate() @@ -374,6 +372,7 @@ class Operation: cls.game, cls.radio_registry, cls.tacan_registry, + cls.laser_code_registry, cls.unit_map, air_support=cls.airsupportgen.air_support, ) @@ -381,32 +380,31 @@ class Operation: cls.airgen.clear_parking_slots() cls.airgen.generate_flights( - cls.current_mission.country(cls.game.player_country), - cls.game.blue_ato, + cls.current_mission.country(cls.game.blue.country_name), + cls.game.blue.ato, cls.groundobjectgen.runways, ) cls.airgen.generate_flights( - cls.current_mission.country(cls.game.enemy_country), - cls.game.red_ato, + cls.current_mission.country(cls.game.red.country_name), + cls.game.red.ato, cls.groundobjectgen.runways, ) cls.airgen.spawn_unused_aircraft( - cls.current_mission.country(cls.game.player_country), - cls.current_mission.country(cls.game.enemy_country), + cls.current_mission.country(cls.game.blue.country_name), + cls.current_mission.country(cls.game.red.country_name), ) @classmethod def _generate_ground_conflicts(cls) -> None: """For each frontline in the Operation, generate the ground conflicts and JTACs""" - cls.jtacs = [] for front_line in cls.game.theater.conflicts(): player_cp = front_line.blue_cp enemy_cp = front_line.red_cp conflict = Conflict.frontline_cas_conflict( - cls.game.player_faction.name, - cls.game.enemy_faction.name, - cls.current_mission.country(cls.game.player_country), - cls.current_mission.country(cls.game.enemy_country), + cls.game.blue.faction.name, + cls.game.red.faction.name, + cls.current_mission.country(cls.game.blue.country_name), + cls.current_mission.country(cls.game.red.country_name), front_line, cls.game.theater, ) @@ -420,10 +418,13 @@ class Operation: player_gp, enemy_gp, player_cp.stances[enemy_cp.id], + enemy_cp.stances[player_cp.id], cls.unit_map, + cls.radio_registry, + cls.air_support, + cls.laser_code_registry, ) ground_conflict_gen.generate() - cls.jtacs.extend(ground_conflict_gen.jtacs) @classmethod def _generate_transports(cls) -> None: @@ -432,15 +433,12 @@ class Operation: CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate() @classmethod - def reset_naming_ids(cls): + def reset_naming_ids(cls) -> None: namegen.reset_numbers() @classmethod def generate_lua( - cls, - airgen: AircraftConflictGenerator, - airsupportgen: AirSupportConflictGenerator, - jtacs: List[JtacInfo], + cls, airgen: AircraftConflictGenerator, air_support: AirSupport ) -> None: # TODO: Refactor this luaData = { @@ -453,8 +451,8 @@ class Operation: "BlueAA": {}, } # type: ignore - for tanker in airsupportgen.air_support.tankers: - luaData["Tankers"][tanker.callsign] = { + for i, tanker in enumerate(air_support.tankers): + luaData["Tankers"][i] = { "dcsGroupName": tanker.group_name, "callsign": tanker.callsign, "variant": tanker.variant, @@ -462,23 +460,23 @@ class Operation: "tacan": str(tanker.tacan.number) + tanker.tacan.band.name, } - if airsupportgen.air_support.awacs: - for awacs in airsupportgen.air_support.awacs: - luaData["AWACs"][awacs.callsign] = { - "dcsGroupName": awacs.group_name, - "callsign": awacs.callsign, - "radio": awacs.freq.mhz, - } + for i, awacs in enumerate(air_support.awacs): + luaData["AWACs"][i] = { + "dcsGroupName": awacs.group_name, + "callsign": awacs.callsign, + "radio": awacs.freq.mhz, + } - for jtac in jtacs: - luaData["JTACs"][jtac.callsign] = { + for i, jtac in enumerate(air_support.jtacs): + luaData["JTACs"][i] = { "dcsGroupName": jtac.group_name, "callsign": jtac.callsign, "zone": jtac.region, "dcsUnit": jtac.unit_name, "laserCode": jtac.code, + "radio": jtac.freq.mhz, } - + flight_count = 0 for flight in airgen.flights: if flight.friendly and flight.flight_type in [ FlightType.ANTISHIP, @@ -499,7 +497,7 @@ class Operation: elif hasattr(flightTarget, "name"): flightTargetName = flightTarget.name flightTargetType = flightType + " TGT (Airbase)" - luaData["TargetPoints"][flightTargetName] = { + luaData["TargetPoints"][flight_count] = { "name": flightTargetName, "type": flightTargetType, "position": { @@ -507,6 +505,7 @@ class Operation: "y": flightTarget.position.y, }, } + flight_count += 1 for cp in cls.game.theater.controlpoints: for ground_object in cp.ground_objects: @@ -592,7 +591,8 @@ class Operation: zone = data["zone"] laserCode = data["laserCode"] dcsUnit = data["dcsUnit"] - lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n" + radio = data["radio"] + lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}', radio='{radio}' }}, \n" lua += "}" # Process the Target Points diff --git a/game/orderedset.py b/game/orderedset.py new file mode 100644 index 00000000..07a5964d --- /dev/null +++ b/game/orderedset.py @@ -0,0 +1,23 @@ +from collections import Iterator, Iterable +from typing import Generic, TypeVar, Optional + +ValueT = TypeVar("ValueT") + + +class OrderedSet(Generic[ValueT]): + def __init__(self, initial_data: Optional[Iterable[ValueT]] = None) -> None: + if initial_data is None: + initial_data = [] + self._data: dict[ValueT, None] = {v: None for v in initial_data} + + def __iter__(self) -> Iterator[ValueT]: + yield from self._data + + def __contains__(self, item: ValueT) -> bool: + return item in self._data + + def add(self, item: ValueT) -> None: + self._data[item] = None + + def clear(self) -> None: + self._data.clear() diff --git a/game/persistency.py b/game/persistency.py index d9d9d135..7685dd09 100644 --- a/game/persistency.py +++ b/game/persistency.py @@ -1,15 +1,19 @@ +from __future__ import annotations + import logging import os import pickle import shutil from pathlib import Path -from typing import Optional +from typing import Optional, TYPE_CHECKING +if TYPE_CHECKING: + from game import Game _dcs_saved_game_folder: Optional[str] = None -def setup(user_folder: str): +def setup(user_folder: str) -> None: global _dcs_saved_game_folder _dcs_saved_game_folder = user_folder if not save_dir().exists(): @@ -38,7 +42,7 @@ def mission_path_for(name: str) -> str: return os.path.join(base_path(), "Missions", name) -def load_game(path): +def load_game(path: str) -> Optional[Game]: with open(path, "rb") as f: try: save = pickle.load(f) @@ -49,7 +53,7 @@ def load_game(path): return None -def save_game(game) -> bool: +def save_game(game: Game) -> bool: try: with open(_temporary_save_file(), "wb") as f: pickle.dump(game, f) @@ -60,7 +64,7 @@ def save_game(game) -> bool: return False -def autosave(game) -> bool: +def autosave(game: Game) -> bool: """ Autosave to the autosave location :param game: Game to save diff --git a/game/plugins/luaplugin.py b/game/plugins/luaplugin.py index b58446a9..5799c748 100644 --- a/game/plugins/luaplugin.py +++ b/game/plugins/luaplugin.py @@ -38,7 +38,7 @@ class PluginSettings: self.settings = Settings() self.initialize_settings() - def set_settings(self, settings: Settings): + def set_settings(self, settings: Settings) -> None: self.settings = settings self.initialize_settings() @@ -146,7 +146,7 @@ class LuaPlugin(PluginSettings): return cls(definition) - def set_settings(self, settings: Settings): + def set_settings(self, settings: Settings) -> None: super().set_settings(settings) for option in self.definition.options: option.set_settings(self.settings) diff --git a/game/point_with_heading.py b/game/point_with_heading.py index fa322723..7eed4da2 100644 --- a/game/point_with_heading.py +++ b/game/point_with_heading.py @@ -1,13 +1,16 @@ +from __future__ import annotations + from dcs import Point +from game.utils import Heading class PointWithHeading(Point): - def __init__(self): + 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): + def from_point(point: Point, heading: Heading) -> PointWithHeading: p = PointWithHeading() p.x = point.x p.y = point.y diff --git a/game/positioned.py b/game/positioned.py new file mode 100644 index 00000000..09952351 --- /dev/null +++ b/game/positioned.py @@ -0,0 +1,9 @@ +from typing import Protocol + +from dcs import Point + + +class Positioned(Protocol): + @property + def position(self) -> Point: + raise NotImplementedError diff --git a/game/procurement.py b/game/procurement.py index 2e2c0e79..950e19d0 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -2,7 +2,7 @@ from __future__ import annotations import math import random -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple from game import db @@ -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: @@ -72,7 +70,9 @@ class ProcurementAi: return 1 for cp in self.owned_points: - cp_ground_units = cp.allocated_ground_units(self.game.transfers) + cp_ground_units = cp.allocated_ground_units( + self.game.coalition_for(self.is_player).transfers + ) armor_investment += cp_ground_units.total_value cp_aircraft = cp.allocated_aircraft(self.game) aircraft_investment += cp_aircraft.total_value @@ -209,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 @@ -239,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]: @@ -265,7 +262,7 @@ class ProcurementAi: return budget, False def purchase_aircraft(self, budget: float) -> float: - for request in self.game.procurement_requests_for(self.is_player): + for request in self.game.coalition_for(self.is_player).procurement_requests: if not list(self.best_airbases_for(request)): # No airbases in range of this request. Skip it. continue @@ -291,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: @@ -316,7 +313,9 @@ class ProcurementAi: continue purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR - allocated = cp.allocated_ground_units(self.game.transfers) + allocated = cp.allocated_ground_units( + self.game.coalition_for(self.is_player).transfers + ) if allocated.total >= purchase_target: # Control point is already sufficiently defended. continue @@ -343,7 +342,9 @@ class ProcurementAi: if not cp.can_recruit_ground_units(self.game): continue - allocated = cp.allocated_ground_units(self.game.transfers) + allocated = cp.allocated_ground_units( + self.game.coalition_for(self.is_player).transfers + ) if allocated.total >= self.game.settings.reserves_procurement_target: continue @@ -356,7 +357,9 @@ class ProcurementAi: def cost_ratio_of_ground_unit( self, control_point: ControlPoint, unit_class: GroundUnitClass ) -> float: - allocations = control_point.allocated_ground_units(self.game.transfers) + allocations = control_point.allocated_ground_units( + self.game.coalition_for(self.is_player).transfers + ) class_cost = 0 total_cost = 0 for unit_type, count in allocations.all.items(): diff --git a/game/profiling.py b/game/profiling.py index 82c2c326..219453d9 100644 --- a/game/profiling.py +++ b/game/profiling.py @@ -5,7 +5,8 @@ import timeit from collections import defaultdict from contextlib import contextmanager from datetime import timedelta -from typing import Iterator +from types import TracebackType +from typing import Iterator, Optional, Type @contextmanager @@ -23,7 +24,12 @@ class MultiEventTracer: def __enter__(self) -> MultiEventTracer: return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: for event, duration in self.events.items(): logging.debug("%s took %s", event, duration) diff --git a/game/radio/channels.py b/game/radio/channels.py index 83df8e6c..4fbf7e23 100644 --- a/game/radio/channels.py +++ b/game/radio/channels.py @@ -72,6 +72,9 @@ class CommonRadioChannelAllocator(RadioChannelAllocator): for awacs in air_support.awacs: flight.assign_channel(radio_id, next(channel_alloc), awacs.freq) + for jtac in air_support.jtacs: + flight.assign_channel(radio_id, next(channel_alloc), jtac.freq) + if flight.arrival != flight.departure and flight.arrival.atc is not None: flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc) diff --git a/game/savecompat.py b/game/savecompat.py new file mode 100644 index 00000000..5388dd24 --- /dev/null +++ b/game/savecompat.py @@ -0,0 +1,48 @@ +"""Tools for aiding in save compat removal after compatibility breaks.""" +from collections import Callable +from typing import TypeVar + +from game.version import MAJOR_VERSION + +ReturnT = TypeVar("ReturnT") + + +class DeprecatedSaveCompatError(RuntimeError): + def __init__(self, function_name: str) -> None: + super().__init__( + f"{function_name} has save compat code for a different major version." + ) + + +def has_save_compat_for( + major: int, +) -> Callable[[Callable[..., ReturnT]], Callable[..., ReturnT]]: + """Declares a function or method as having save compat code for a given version. + + If the function has save compatibility for the current major version, there is no + change in behavior. + + If the function has save compatibility for a *different* (future or past) major + version, DeprecatedSaveCompatError will be raised during startup. Since a break in + save compatibility is the definition of a major version break, there's no need to + keep around old save compat code; it only serves to mask initialization bugs. + + Args: + major: The major version for which the decorated function has save + compatibility. + + Returns: + The decorated function or method. + + Raises: + DeprecatedSaveCompatError: The decorated function has save compat code for + another version of liberation, and that code (and the decorator declaring it) + should be removed from this branch. + """ + + def decorator(func: Callable[..., ReturnT]) -> Callable[..., ReturnT]: + if major != MAJOR_VERSION: + raise DeprecatedSaveCompatError(func.__name__) + return func + + return decorator diff --git a/game/settings.py b/game/settings.py index 49aee945..e76e0816 100644 --- a/game/settings.py +++ b/game/settings.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from datetime import timedelta from enum import Enum, unique -from typing import Dict, Optional +from typing import Dict, Optional, Any from dcs.forcedoptions import ForcedOptions @@ -55,6 +55,7 @@ class Settings: automate_runway_repair: bool = False automate_front_line_reinforcements: bool = False automate_aircraft_reinforcements: bool = False + automate_front_line_stance: bool = True restrict_weapons_by_date: bool = False disable_legacy_aewc: bool = True disable_legacy_tanker: bool = True @@ -104,7 +105,7 @@ class Settings: def set_plugin_option(self, identifier: str, enabled: bool) -> None: self.plugins[self.plugin_settings_key(identifier)] = enabled - def __setstate__(self, state) -> None: + def __setstate__(self, state: dict[str, Any]) -> None: # __setstate__ is called with the dict of the object being unpickled. We # can provide save compatibility for new settings options (which # normally would not be present in the unpickled object) by creating a diff --git a/game/squadrons.py b/game/squadrons.py index 9b13eed3..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 @@ -13,17 +14,20 @@ from typing import ( Optional, Iterator, Sequence, + Any, ) import yaml from faker import Faker from game.dcs.aircrafttype import AircraftType -from game.settings import AutoAtoBehavior +from game.settings import AutoAtoBehavior, Settings if TYPE_CHECKING: from game import Game + from game.coalition import Coalition from gen.flights.flight import FlightType + from game.theater import ControlPoint @dataclass @@ -71,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 @@ -80,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 @@ -95,16 +127,10 @@ class Squadron: init=False, hash=False, compare=False ) - # We need a reference to the Game so that we can access the Faker without needing to - # persist it to the save game, or having to reconstruct it (it's not cheap) each - # time we create or load a squadron. - game: Game = field(hash=False, compare=False) - player: bool + coalition: Coalition = field(hash=False, compare=False) + 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.game.settings.squadron_pilot_limit) self.auto_assignable_mission_types = set(self.mission_types) def __str__(self) -> str: @@ -112,9 +138,13 @@ class Squadron: return self.name return f'{self.name} "{self.nickname}"' + @property + def player(self) -> bool: + return self.coalition.player + @property def pilot_limits_enabled(self) -> bool: - return self.game.settings.enable_squadron_pilot_limits + return self.settings.enable_squadron_pilot_limits def claim_new_pilot_if_allowed(self) -> Optional[Pilot]: if self.pilot_limits_enabled: @@ -130,7 +160,7 @@ class Squadron: if not self.player: return self.available_pilots.pop() - preference = self.game.settings.auto_ato_behavior + preference = self.settings.auto_ato_behavior # No preference, so the first pilot is fine. if preference is AutoAtoBehavior.Default: @@ -178,12 +208,17 @@ 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 replenish_count = min( - self.game.settings.squadron_replenishment_rate, + self.settings.squadron_replenishment_rate, self._number_of_unfilled_pilot_slots, ) if replenish_count > 0: @@ -196,7 +231,7 @@ class Squadron: def send_on_leave(pilot: Pilot) -> None: pilot.send_on_leave() - def return_from_leave(self, pilot: Pilot): + def return_from_leave(self, pilot: Pilot) -> None: if not self.has_unfilled_pilot_slots: raise RuntimeError( f"Cannot return {pilot} from leave because {self} is full" @@ -205,7 +240,7 @@ class Squadron: @property def faker(self) -> Faker: - return self.game.faker_for(self.player) + return self.coalition.faker def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]: return [p for p in self.current_roster if p.status == status] @@ -227,7 +262,7 @@ class Squadron: @property def _number_of_unfilled_pilot_slots(self) -> int: - return self.game.settings.squadron_pilot_limit - len(self.active_pilots) + return self.settings.squadron_pilot_limit - len(self.active_pilots) @property def number_of_available_pilots(self) -> int: @@ -247,11 +282,19 @@ 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] @classmethod - def from_yaml(cls, path: Path, game: Game, player: bool) -> Squadron: + def from_yaml(cls, path: Path, game: Game, coalition: Coalition) -> Squadron: from gen.flights.ai_flight_planner_db import tasks_for_aircraft from gen.flights.flight import FlightType @@ -285,12 +328,13 @@ 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, - game=game, - player=player, + coalition=coalition, + settings=game.settings, ) - def __setstate__(self, state) -> None: + def __setstate__(self, state: dict[str, Any]) -> None: # TODO: Remove save compat. if "auto_assignable_mission_types" not in state: state["auto_assignable_mission_types"] = set(state["mission_types"]) @@ -298,9 +342,9 @@ class Squadron: class SquadronLoader: - def __init__(self, game: Game, player: bool) -> None: + def __init__(self, game: Game, coalition: Coalition) -> None: self.game = game - self.player = player + self.coalition = coalition @staticmethod def squadron_directories() -> Iterator[Path]: @@ -311,8 +355,8 @@ class SquadronLoader: def load(self) -> dict[AircraftType, list[Squadron]]: squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list) - country = self.game.country_for(self.player) - faction = self.game.faction_for(self.player) + country = self.coalition.country_name + faction = self.coalition.faction any_country = country.startswith("Combined Joint Task Forces ") for directory in self.squadron_directories(): for path, squadron in self.load_squadrons_from(directory): @@ -346,7 +390,7 @@ class SquadronLoader: for squadron_path in directory.glob("*/*.yaml"): try: yield squadron_path, Squadron.from_yaml( - squadron_path, self.game, self.player + squadron_path, self.game, self.coalition ) except Exception as ex: raise RuntimeError( @@ -355,29 +399,29 @@ class SquadronLoader: class AirWing: - def __init__(self, game: Game, player: bool) -> None: + def __init__(self, game: Game, coalition: Coalition) -> None: from gen.flights.ai_flight_planner_db import tasks_for_aircraft self.game = game - self.player = player - self.squadrons = SquadronLoader(game, player).load() + self.squadrons = SquadronLoader(game, coalition).load() count = itertools.count(1) - for aircraft in game.faction_for(player).aircrafts: + for aircraft in coalition.faction.aircrafts: if aircraft in self.squadrons: continue self.squadrons[aircraft] = [ Squadron( name=f"Squadron {next(count):03}", nickname=self.random_nickname(), - country=game.country_for(player), + country=coalition.country_name, role="Flying Squadron", aircraft=aircraft, livery=None, mission_types=tuple(tasks_for_aircraft(aircraft)), + operating_bases=OperatingBases.default_for_aircraft(aircraft), pilot_pool=[], - game=game, - player=player, + coalition=coalition, + settings=game.settings, ) ] @@ -412,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/base.py b/game/theater/base.py index 4547e3d3..02839481 100644 --- a/game/theater/base.py +++ b/game/theater/base.py @@ -6,15 +6,15 @@ from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.dcs.unittype import UnitType -BASE_MAX_STRENGTH = 1 -BASE_MIN_STRENGTH = 0 +BASE_MAX_STRENGTH = 1.0 +BASE_MIN_STRENGTH = 0.0 class Base: - def __init__(self): + def __init__(self) -> None: self.aircraft: dict[AircraftType, int] = {} self.armor: dict[GroundUnitType, int] = {} - self.strength = 1 + self.strength = 1.0 @property def total_aircraft(self) -> int: @@ -31,7 +31,7 @@ class Base: total += unit_type.price * count return total - def total_units_of_type(self, unit_type: UnitType) -> int: + def total_units_of_type(self, unit_type: UnitType[Any]) -> int: return sum( [ c @@ -40,7 +40,7 @@ class Base: ] ) - def commission_units(self, units: dict[Any, int]): + def commission_units(self, units: dict[Any, int]) -> None: for unit_type, unit_count in units.items(): if unit_count <= 0: continue @@ -56,7 +56,7 @@ class Base: target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count - def commit_losses(self, units_lost: dict[Any, int]): + def commit_losses(self, units_lost: dict[Any, int]) -> None: for unit_type, count in units_lost.items(): target_dict: dict[Any, int] if unit_type in self.aircraft: @@ -75,7 +75,7 @@ class Base: if target_dict[unit_type] == 0: del target_dict[unit_type] - def affect_strength(self, amount): + def affect_strength(self, amount: float) -> None: self.strength += amount if self.strength > BASE_MAX_STRENGTH: self.strength = BASE_MAX_STRENGTH diff --git a/game/theater/caucasus.py b/game/theater/caucasus.py index 8d0d0adc..6b1cb67e 100644 --- a/game/theater/caucasus.py +++ b/game/theater/caucasus.py @@ -1,3 +1,5 @@ +# DO NOT EDIT: +# This file is generated by resources/tools/export_coordinates.py. from game.theater.projections import TransverseMercator PARAMETERS = TransverseMercator( diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index d075a20c..6651e918 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -5,7 +5,7 @@ import math from dataclasses import dataclass from functools import cached_property from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING from dcs import Mission from dcs.countries import ( @@ -29,14 +29,14 @@ from dcs.terrain import ( persiangulf, syria, thechannel, + marianaislands, ) from dcs.terrain.terrain import Airport, Terrain from dcs.unitgroup import ( - FlyingGroup, - Group, ShipGroup, StaticGroup, VehicleGroup, + PlaneGroup, ) from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed from pyproj import CRS, Transformer @@ -51,15 +51,20 @@ from .controlpoint import ( MissionTarget, OffMapSpawn, ) +from .seasonalconditions import SeasonalConditions from .frontline import FrontLine from .landmap import Landmap, load_landmap, poly_contains from .latlon import LatLon from .projections import TransverseMercator from ..helipad import Helipad 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 SIZE_TINY = 150 SIZE_SMALL = 600 @@ -182,7 +187,7 @@ class MizCampaignLoader: def red(self) -> Country: return self.country(blue=False) - def off_map_spawns(self, blue: bool) -> Iterator[FlyingGroup]: + def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]: for group in self.country(blue).plane_group: if group.units[0].type == self.OFF_MAP_UNIT_TYPE: yield group @@ -306,26 +311,26 @@ class MizCampaignLoader: control_point.captured = blue control_point.captured_invert = group.late_activation control_points[control_point.id] = control_point - for group in self.carriers(blue): + for ship in self.carriers(blue): # TODO: Name the carrier. control_point = Carrier( - "carrier", group.position, next(self.control_point_id) + "carrier", ship.position, next(self.control_point_id) ) control_point.captured = blue - control_point.captured_invert = group.late_activation + control_point.captured_invert = ship.late_activation control_points[control_point.id] = control_point - for group in self.lhas(blue): + for ship in self.lhas(blue): # TODO: Name the LHA.db - control_point = Lha("lha", group.position, next(self.control_point_id)) + control_point = Lha("lha", ship.position, next(self.control_point_id)) control_point.captured = blue - control_point.captured_invert = group.late_activation + control_point.captured_invert = ship.late_activation control_points[control_point.id] = control_point - for group in self.fobs(blue): + for fob in self.fobs(blue): control_point = Fob( - str(group.name), group.position, next(self.control_point_id) + str(fob.name), fob.position, next(self.control_point_id) ) control_point.captured = blue - control_point.captured_invert = group.late_activation + control_point.captured_invert = fob.late_activation control_points[control_point.id] = control_point return control_points @@ -386,99 +391,129 @@ class MizCampaignLoader: origin, list(reversed(waypoints)) ) - def objective_info(self, group: Group) -> Tuple[ControlPoint, Distance]: - closest = self.theater.closest_control_point(group.position) - distance = meters(closest.position.distance_to_point(group.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 def add_preset_locations(self) -> None: - for group in self.offshore_strike_targets: - closest, distance = self.objective_info(group) + for static in self.offshore_strike_targets: + closest, distance = self.objective_info(static) closest.preset_locations.offshore_strike_locations.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) - for group in self.ships: - closest, distance = self.objective_info(group) + for ship in self.ships: + closest, distance = self.objective_info(ship, allow_naval=True) closest.preset_locations.ships.append( - PointWithHeading.from_point(group.position, group.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 group in self.helipads: - closest, distance = self.objective_info(group) + for static in self.helipads: + closest, distance = self.objective_info(static) closest.helipads.append( - Helipad.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) - for group in self.factories: - closest, distance = self.objective_info(group) + for static in self.factories: + closest, distance = self.objective_info(static) closest.preset_locations.factories.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) - for group in self.ammunition_depots: - closest, distance = self.objective_info(group) + for static in self.ammunition_depots: + closest, distance = self.objective_info(static) closest.preset_locations.ammunition_depots.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) - for group in self.strike_targets: - closest, distance = self.objective_info(group) + for static in self.strike_targets: + closest, distance = self.objective_info(static) closest.preset_locations.strike_locations.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) - for group in self.scenery: - closest, distance = self.objective_info(group) - closest.preset_locations.scenery.append(group) + for scenery_group in self.scenery: + closest, distance = self.objective_info(scenery_group) + closest.preset_locations.scenery.append(scenery_group) def populate_theater(self) -> None: for control_point in self.control_points.values(): @@ -505,7 +540,7 @@ class ConflictTheater: """ daytime_map: Dict[str, Tuple[int, int]] - def __init__(self): + def __init__(self) -> None: self.controlpoints: List[ControlPoint] = [] self.point_to_ll_transformer = Transformer.from_crs( self.projection_parameters.to_crs(), CRS("WGS84") @@ -537,10 +572,12 @@ class ConflictTheater: CRS("WGS84"), self.projection_parameters.to_crs() ) - def add_controlpoint(self, point: ControlPoint): + def add_controlpoint(self, point: ControlPoint) -> None: self.controlpoints.append(point) - def find_ground_objects_by_obj_name(self, obj_name): + def find_ground_objects_by_obj_name( + self, obj_name: str + ) -> list[TheaterGroundObject[Any]]: found = [] for cp in self.controlpoints: for g in cp.ground_objects: @@ -582,12 +619,12 @@ class ConflictTheater: return True - def nearest_land_pos(self, point: Point, extend_dist: int = 50) -> Point: + def nearest_land_pos(self, near: Point, extend_dist: int = 50) -> Point: """Returns the nearest point inside a land exclusion zone from point `extend_dist` determines how far inside the zone the point should be placed""" - if self.is_on_land(point): - return point - point = geometry.Point(point.x, point.y) + if self.is_on_land(near): + return near + point = geometry.Point(near.x, near.y) nearest_points = [] if not self.landmap: raise RuntimeError("Landmap not initialized") @@ -628,10 +665,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 @@ -699,6 +740,7 @@ class ConflictTheater: "Normandy": NormandyTheater, "The Channel": TheChannelTheater, "Syria": SyriaTheater, + "MarianaIslands": MarianaIslandsTheater, } theater = theaters[data["theater"]] t = theater() @@ -713,6 +755,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 @@ -742,6 +788,12 @@ class CaucasusTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + from .seasonalconditions.caucasus import CONDITIONS + + return CONDITIONS + @property def projection_parameters(self) -> TransverseMercator: from .caucasus import PARAMETERS @@ -764,6 +816,12 @@ class PersianGulfTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + from .seasonalconditions.persiangulf import CONDITIONS + + return CONDITIONS + @property def projection_parameters(self) -> TransverseMercator: from .persiangulf import PARAMETERS @@ -786,6 +844,12 @@ class NevadaTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + from .seasonalconditions.nevada import CONDITIONS + + return CONDITIONS + @property def projection_parameters(self) -> TransverseMercator: from .nevada import PARAMETERS @@ -808,6 +872,12 @@ class NormandyTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + from .seasonalconditions.normandy import CONDITIONS + + return CONDITIONS + @property def projection_parameters(self) -> TransverseMercator: from .normandy import PARAMETERS @@ -830,6 +900,12 @@ class TheChannelTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + from .seasonalconditions.thechannel import CONDITIONS + + return CONDITIONS + @property def projection_parameters(self) -> TransverseMercator: from .thechannel import PARAMETERS @@ -852,8 +928,39 @@ class SyriaTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + from .seasonalconditions.syria import CONDITIONS + + return CONDITIONS + @property def projection_parameters(self) -> TransverseMercator: from .syria import PARAMETERS return PARAMETERS + + +class MarianaIslandsTheater(ConflictTheater): + terrain = marianaislands.MarianaIslands() + overview_image = "marianaislands.gif" + + landmap = load_landmap("resources\\marianaislandslandmap.p") + daytime_map = { + "dawn": (6, 8), + "day": (8, 16), + "dusk": (16, 18), + "night": (0, 5), + } + + @property + def seasonal_conditions(self) -> SeasonalConditions: + from .seasonalconditions.marianaislands import CONDITIONS + + return CONDITIONS + + @property + def projection_parameters(self) -> TransverseMercator: + from .marianaislands import PARAMETERS + + return PARAMETERS diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index d4ceb7d1..68df933b 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -36,6 +36,7 @@ from dcs.unittype import FlyingType 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 @@ -44,6 +45,7 @@ from .missiontarget import MissionTarget from .theatergroundobject import ( GenericCarrierGroundObject, TheaterGroundObject, + BuildingGroundObject, ) from ..dcs.aircrafttype import AircraftType from ..dcs.groundunittype import GroundUnitType @@ -272,6 +274,9 @@ class ControlPointStatus(IntEnum): class ControlPoint(MissionTarget, ABC): + # Not sure what distance DCS uses, but assuming it's about 2NM since that's roughly + # the distance of the circle on the map. + CAPTURE_DISTANCE = nautical_miles(2) position = None # type: Point name = None # type: str @@ -292,15 +297,15 @@ class ControlPoint(MissionTarget, ABC): at: db.StartingPosition, size: int, importance: float, - has_frontline=True, - cptype=ControlPointType.AIRBASE, - ): + has_frontline: bool = True, + cptype: ControlPointType = ControlPointType.AIRBASE, + ) -> None: super().__init__(name, position) # TODO: Should be Airbase specific. self.id = cp_id self.full_name = name self.at = at - self.connected_objectives: List[TheaterGroundObject] = [] + self.connected_objectives: List[TheaterGroundObject[Any]] = [] self.preset_locations = PresetLocations() self.helipads: List[Helipad] = [] @@ -324,25 +329,29 @@ class ControlPoint(MissionTarget, ABC): self.target_position: Optional[Point] = None - def __repr__(self): - return f"<{__class__}: {self.name}>" + def __repr__(self) -> str: + return f"<{self.__class__}: {self.name}>" @property - def ground_objects(self) -> List[TheaterGroundObject]: + def ground_objects(self) -> List[TheaterGroundObject[Any]]: return list(self.connected_objectives) @property @abstractmethod - def heading(self) -> int: + def heading(self) -> Heading: ... - def __str__(self): + def __str__(self) -> str: return self.name @property - def is_global(self): + def is_isolated(self) -> bool: return not self.connected_points + @property + def is_global(self) -> bool: + return self.is_isolated + def transitive_connected_friendly_points( self, seen: Optional[Set[ControlPoint]] = None ) -> List[ControlPoint]: @@ -430,21 +439,21 @@ class ControlPoint(MissionTarget, ABC): return False @property - def is_carrier(self): + def is_carrier(self) -> bool: """ :return: Whether this control point is an aircraft carrier """ return False @property - def is_fleet(self): + def is_fleet(self) -> bool: """ :return: Whether this control point is a boat (mobile) """ return False @property - def is_lha(self): + def is_lha(self) -> bool: """ :return: Whether this control point is an LHA """ @@ -464,7 +473,7 @@ class ControlPoint(MissionTarget, ABC): @property @abstractmethod - def total_aircraft_parking(self): + def total_aircraft_parking(self) -> int: """ :return: The maximum number of aircraft that can be stored in this control point @@ -496,7 +505,7 @@ class ControlPoint(MissionTarget, ABC): ... # TODO: Should be naval specific. - def get_carrier_group_name(self): + def get_carrier_group_name(self) -> Optional[str]: """ Get the carrier group name if the airbase is a carrier :return: Carrier group name @@ -522,10 +531,12 @@ class ControlPoint(MissionTarget, ABC): return None # TODO: Should be Airbase specific. - def is_connected(self, to) -> bool: + def is_connected(self, to: ControlPoint) -> bool: return to in self.connected_points - def find_ground_objects_by_obj_name(self, obj_name): + def find_ground_objects_by_obj_name( + self, obj_name: str + ) -> list[TheaterGroundObject[Any]]: found = [] for g in self.ground_objects: if g.obj_name == obj_name: @@ -547,7 +558,7 @@ class ControlPoint(MissionTarget, ABC): f"vehicles have been captured and sold for ${total}M." ) - def retreat_ground_units(self, game: Game): + def retreat_ground_units(self, game: Game) -> None: # When there are multiple valid destinations, deliver units to whichever # base is least defended first. The closest approximation of unit # strength we have is price @@ -621,7 +632,7 @@ class ControlPoint(MissionTarget, ABC): # TODO: Should be Airbase specific. def capture(self, game: Game, for_player: bool) -> None: - self.pending_unit_deliveries.refund_all(game) + self.pending_unit_deliveries.refund_all(game.coalition_for(for_player)) self.retreat_ground_units(game) self.retreat_air_units(game) self.depopulate_uncapturable_tgos() @@ -638,11 +649,7 @@ class ControlPoint(MissionTarget, ABC): ... def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]: - if self.captured: - ato = game.blue_ato - else: - ato = game.red_ato - + ato = game.coalition_for(self.captured).ato transferring: defaultdict[AircraftType, int] = defaultdict(int) for package in ato.packages: for flight in package.flights: @@ -750,27 +757,48 @@ class ControlPoint(MissionTarget, ABC): return self.captured != other.captured @property - def frontline_unit_count_limit(self) -> int: + def deployable_front_line_units(self) -> int: + return self.deployable_front_line_units_with(self.active_ammo_depots_count) + + def deployable_front_line_units_with(self, ammo_depot_count: int) -> int: + return min( + self.front_line_capacity_with(ammo_depot_count), self.base.total_armor + ) + + @classmethod + def front_line_capacity_with(cls, ammo_depot_count: int) -> int: return ( FREE_FRONTLINE_UNIT_SUPPLY - + self.active_ammo_depots_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION + + ammo_depot_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION ) + @property + def frontline_unit_count_limit(self) -> int: + return self.front_line_capacity_with(self.active_ammo_depots_count) + + @property + def all_ammo_depots(self) -> Iterator[BuildingGroundObject]: + for tgo in self.connected_objectives: + if not tgo.is_ammo_depot: + continue + assert isinstance(tgo, BuildingGroundObject) + yield tgo + + @property + def active_ammo_depots(self) -> Iterator[BuildingGroundObject]: + for tgo in self.all_ammo_depots: + if not tgo.is_dead: + yield tgo + @property def active_ammo_depots_count(self) -> int: """Return the number of available ammo depots""" - return len( - [ - obj - for obj in self.connected_objectives - if obj.category == "ammo" and not obj.is_dead - ] - ) + return len(list(self.active_ammo_depots)) @property def total_ammo_depots_count(self) -> int: """Return the number of ammo depots, including dead ones""" - return len([obj for obj in self.connected_objectives if obj.category == "ammo"]) + return len(list(self.all_ammo_depots)) @property def active_fuel_depots_count(self) -> int: @@ -789,7 +817,7 @@ class ControlPoint(MissionTarget, ABC): return len([obj for obj in self.connected_objectives if obj.category == "fuel"]) @property - def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: return [] @property @@ -805,8 +833,8 @@ class ControlPoint(MissionTarget, ABC): class Airfield(ControlPoint): def __init__( - self, airport: Airport, size: int, importance: float, has_frontline=True - ): + self, airport: Airport, size: int, importance: float, has_frontline: bool = True + ) -> None: super().__init__( airport.id, airport.name, @@ -852,8 +880,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 @@ -917,12 +945,15 @@ 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) -> TheaterGroundObject: + def find_main_tgo(self) -> GenericCarrierGroundObject: for g in self.ground_objects: - if g.dcs_identifier in ["CARRIER", "LHA"]: + if isinstance(g, GenericCarrierGroundObject) and g.dcs_identifier in [ + "CARRIER", + "LHA", + ]: return g raise RuntimeError(f"Found no carrier/LHA group for {self.name}") @@ -944,7 +975,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 @@ -1001,7 +1034,7 @@ class Carrier(NavalControlPoint): raise RuntimeError("Carriers cannot be captured") @property - def is_carrier(self): + def is_carrier(self) -> bool: return True def can_operate(self, aircraft: AircraftType) -> bool: @@ -1082,14 +1115,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: @@ -1131,7 +1166,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: @@ -1158,8 +1195,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 8d46327c..98aa88f6 100644 --- a/game/theater/frontline.py +++ b/game/theater/frontline.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from dataclasses import dataclass -from typing import Iterator, List, Tuple +from typing import Iterator, List, Tuple, Any from dcs.mapping import Point @@ -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: @@ -66,12 +66,31 @@ class FrontLine(MissionTarget): self.segments: List[FrontLineSegment] = [ FrontLineSegment(a, b) for a, b in pairwise(route) ] - self.name = f"Front line {blue_point}/{red_point}" + super().__init__( + f"Front line {blue_point}/{red_point}", + self.point_from_a(self._position_distance), + ) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, FrontLine): + return False + return (self.blue_cp, self.red_cp) == (other.blue_cp, other.red_cp) + + def __hash__(self) -> int: + return hash((self.blue_cp, self.red_cp)) + + def __setstate__(self, state: dict[str, Any]) -> None: + self.__dict__.update(state) + if not hasattr(self, "position"): + self.position = self.point_from_a(self._position_distance) + + def control_point_friendly_to(self, player: bool) -> ControlPoint: + if player: + return self.blue_cp + return self.red_cp def control_point_hostile_to(self, player: bool) -> ControlPoint: - if player: - return self.red_cp - return self.blue_cp + return self.control_point_friendly_to(not player) def is_friendly(self, to_player: bool) -> bool: """Returns True if the objective is in friendly territory.""" @@ -87,14 +106,6 @@ class FrontLine(MissionTarget): ] yield from super().mission_types(for_player) - @property - def position(self): - """ - The position where the conflict should occur - according to the current strength of each control point. - """ - return self.point_from_a(self._position_distance) - @property def points(self) -> Iterator[Point]: yield self.segments[0].point_a @@ -107,12 +118,12 @@ class FrontLine(MissionTarget): return self.blue_cp, self.red_cp @property - def attack_distance(self): + def attack_distance(self) -> float: """The total distance of all segments""" return sum(i.attack_distance for i in self.segments) @property - def attack_heading(self): + def attack_heading(self) -> Heading: """The heading of the active attack segment from player to enemy control point""" return self.active_segment.attack_heading @@ -139,16 +150,19 @@ 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 + raise RuntimeError( + f"Could not find front line point {distance} from {self.blue_cp}" + ) @property def _position_distance(self) -> float: diff --git a/game/theater/landmap.py b/game/theater/landmap.py index 29d551b3..2cc3867c 100644 --- a/game/theater/landmap.py +++ b/game/theater/landmap.py @@ -14,7 +14,7 @@ class Landmap: exclusion_zones: MultiPolygon sea_zones: MultiPolygon - def __post_init__(self): + def __post_init__(self) -> None: if not self.inclusion_zones.is_valid: raise RuntimeError("Inclusion zones not valid") if not self.exclusion_zones.is_valid: @@ -36,13 +36,5 @@ def load_landmap(filename: str) -> Optional[Landmap]: return None -def poly_contains(x, y, poly: Union[MultiPolygon, Polygon]): +def poly_contains(x: float, y: float, poly: Union[MultiPolygon, Polygon]) -> bool: return poly.contains(geometry.Point(x, y)) - - -def poly_centroid(poly) -> Tuple[float, float]: - x_list = [vertex[0] for vertex in poly] - y_list = [vertex[1] for vertex in poly] - x = sum(x_list) / len(poly) - y = sum(y_list) / len(poly) - return (x, y) diff --git a/game/theater/marianaislands.py b/game/theater/marianaislands.py new file mode 100644 index 00000000..3fc39672 --- /dev/null +++ b/game/theater/marianaislands.py @@ -0,0 +1,10 @@ +# DO NOT EDIT: +# This file is generated by resources/tools/export_coordinates.py. +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=147, + false_easting=238417.99999989968, + false_northing=-1491840.000000048, + scale_factor=0.9996, +) diff --git a/game/theater/missiontarget.py b/game/theater/missiontarget.py index ea426603..a475bc9f 100644 --- a/game/theater/missiontarget.py +++ b/game/theater/missiontarget.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import Sequence from typing import Iterator, TYPE_CHECKING, List, Union from dcs.mapping import Point @@ -20,7 +21,7 @@ class MissionTarget: self.name = name self.position = position - def distance_to(self, other: MissionTarget) -> int: + def distance_to(self, other: MissionTarget) -> float: """Computes the distance to the given mission target.""" return self.position.distance_to_point(other.position) @@ -45,5 +46,5 @@ class MissionTarget: ] @property - def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: return [] diff --git a/game/theater/nevada.py b/game/theater/nevada.py index ad245611..e18700d6 100644 --- a/game/theater/nevada.py +++ b/game/theater/nevada.py @@ -1,3 +1,5 @@ +# DO NOT EDIT: +# This file is generated by resources/tools/export_coordinates.py. from game.theater.projections import TransverseMercator PARAMETERS = TransverseMercator( diff --git a/game/theater/normandy.py b/game/theater/normandy.py index a74c692d..8f91ab78 100644 --- a/game/theater/normandy.py +++ b/game/theater/normandy.py @@ -1,3 +1,5 @@ +# DO NOT EDIT: +# This file is generated by resources/tools/export_coordinates.py. from game.theater.projections import TransverseMercator PARAMETERS = TransverseMercator( diff --git a/game/theater/persiangulf.py b/game/theater/persiangulf.py index 600801dd..69ce5288 100644 --- a/game/theater/persiangulf.py +++ b/game/theater/persiangulf.py @@ -1,3 +1,5 @@ +# DO NOT EDIT: +# This file is generated by resources/tools/export_coordinates.py. from game.theater.projections import TransverseMercator PARAMETERS = TransverseMercator( diff --git a/game/theater/seasonalconditions/__init__.py b/game/theater/seasonalconditions/__init__.py new file mode 100644 index 00000000..713a85f5 --- /dev/null +++ b/game/theater/seasonalconditions/__init__.py @@ -0,0 +1 @@ +from .seasonalconditions import * diff --git a/game/theater/seasonalconditions/caucasus.py b/game/theater/seasonalconditions/caucasus.py new file mode 100644 index 00000000..e605a543 --- /dev/null +++ b/game/theater/seasonalconditions/caucasus.py @@ -0,0 +1,36 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=30.02, # TODO: Find real-world data + winter_avg_pressure=29.72, # TODO: Find real-world data + summer_avg_temperature=22.5, + winter_avg_temperature=3.0, + temperature_day_night_difference=6.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=60, + clear_skies=20, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=40, + clear_skies=40, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=30, + cloudy=50, + clear_skies=20, + ), + }, +) diff --git a/game/theater/seasonalconditions/marianaislands.py b/game/theater/seasonalconditions/marianaislands.py new file mode 100644 index 00000000..0d662908 --- /dev/null +++ b/game/theater/seasonalconditions/marianaislands.py @@ -0,0 +1,38 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=30.02, # TODO: Find real-world data + winter_avg_pressure=29.82, # TODO: Find real-world data + summer_avg_temperature=28.0, + winter_avg_temperature=27.0, + temperature_day_night_difference=1.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=2, + raining=20, + cloudy=40, + clear_skies=40, + ), + Season.Spring: WeatherTypeChances( + # Spring is dry/sunny in Marianas + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=2, + raining=20, + cloudy=40, + clear_skies=40, + ), + Season.Fall: WeatherTypeChances( + # Rain season + thunderstorm=5, + raining=45, + cloudy=30, + clear_skies=20, + ), + }, +) diff --git a/game/theater/seasonalconditions/nevada.py b/game/theater/seasonalconditions/nevada.py new file mode 100644 index 00000000..352ca456 --- /dev/null +++ b/game/theater/seasonalconditions/nevada.py @@ -0,0 +1,36 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=30.02, # TODO: Find real-world data + winter_avg_pressure=29.72, # TODO: Find real-world data + summer_avg_temperature=31.5, + winter_avg_temperature=5.0, + temperature_day_night_difference=6.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=50, + clear_skies=40, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=5, + cloudy=45, + clear_skies=50, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=5, + cloudy=25, + clear_skies=70, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=45, + clear_skies=45, + ), + }, +) diff --git a/game/theater/seasonalconditions/normandy.py b/game/theater/seasonalconditions/normandy.py new file mode 100644 index 00000000..109c781f --- /dev/null +++ b/game/theater/seasonalconditions/normandy.py @@ -0,0 +1,36 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=30.02, # TODO: Find real-world data + winter_avg_pressure=29.72, # TODO: Find real-world data + summer_avg_temperature=20.0, + winter_avg_temperature=0.0, + temperature_day_night_difference=5.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=60, + clear_skies=20, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=40, + clear_skies=40, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=30, + cloudy=50, + clear_skies=20, + ), + }, +) diff --git a/game/theater/seasonalconditions/persiangulf.py b/game/theater/seasonalconditions/persiangulf.py new file mode 100644 index 00000000..467168ab --- /dev/null +++ b/game/theater/seasonalconditions/persiangulf.py @@ -0,0 +1,37 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=29.98, # TODO: Find real-world data + winter_avg_pressure=29.80, # TODO: Find real-world data + summer_avg_temperature=32.5, + winter_avg_temperature=15.0, + temperature_day_night_difference=2.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + # Winter there is some rain in PG (Dubai) + thunderstorm=1, + raining=15, + cloudy=35, + clear_skies=50, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=2, + cloudy=18, + clear_skies=80, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=1, + cloudy=8, + clear_skies=90, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=2, + cloudy=18, + clear_skies=80, + ), + }, +) diff --git a/game/theater/seasonalconditions/seasonalconditions.py b/game/theater/seasonalconditions/seasonalconditions.py new file mode 100644 index 00000000..2280d15e --- /dev/null +++ b/game/theater/seasonalconditions/seasonalconditions.py @@ -0,0 +1,48 @@ +import datetime +from dataclasses import dataclass +from enum import Enum + + +class Season(Enum): + Winter = "winter" + Spring = "spring" + Summer = "summer" + Fall = "fall" + + +def determine_season(day: datetime.date) -> Season: + # Note: This logic doesn't need to be very precise + # Currently refers strictly to northern-hemisphere seasons + day_of_year = day.timetuple().tm_yday + season_length = 365.0 / 4 + winter_end_day = season_length / 2 + if day_of_year < winter_end_day: + return Season.Winter + elif day_of_year < winter_end_day + season_length: + return Season.Spring + elif day_of_year < winter_end_day + season_length * 2: + return Season.Summer + elif day_of_year < winter_end_day + season_length * 3: + return Season.Fall + else: + return Season.Winter + + +@dataclass(frozen=True) +class WeatherTypeChances: + thunderstorm: float + raining: float + cloudy: float + clear_skies: float + + +@dataclass(frozen=True) +class SeasonalConditions: + # Units are inHg and degrees Celsius + summer_avg_pressure: float + winter_avg_pressure: float + summer_avg_temperature: float + winter_avg_temperature: float + temperature_day_night_difference: float + + weather_type_chances: dict[Season, WeatherTypeChances] diff --git a/game/theater/seasonalconditions/syria.py b/game/theater/seasonalconditions/syria.py new file mode 100644 index 00000000..0a6c7ec1 --- /dev/null +++ b/game/theater/seasonalconditions/syria.py @@ -0,0 +1,36 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=29.98, # TODO: Find real-world data + winter_avg_pressure=29.86, # TODO: Find real-world data + summer_avg_temperature=28.5, + winter_avg_temperature=10.0, + temperature_day_night_difference=8.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=1, + raining=25, + cloudy=25, + clear_skies=50, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=3, + cloudy=20, + clear_skies=77, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + }, +) diff --git a/game/theater/seasonalconditions/thechannel.py b/game/theater/seasonalconditions/thechannel.py new file mode 100644 index 00000000..109c781f --- /dev/null +++ b/game/theater/seasonalconditions/thechannel.py @@ -0,0 +1,36 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=30.02, # TODO: Find real-world data + winter_avg_pressure=29.72, # TODO: Find real-world data + summer_avg_temperature=20.0, + winter_avg_temperature=0.0, + temperature_day_night_difference=5.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=60, + clear_skies=20, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=40, + clear_skies=40, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=30, + cloudy=50, + clear_skies=20, + ), + }, +) diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 4ec827ec..61cc25af 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -11,7 +11,7 @@ from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike from dcs.vehicles import AirDefence -from game import Game, db +from game import Game from game.factions.faction import Faction from game.scenery_group import SceneryGroup from game.theater import Carrier, Lha, PointWithHeading @@ -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: @@ -171,14 +171,11 @@ class ControlPointGroundObjectGenerator: @property def faction_name(self) -> str: - if self.control_point.captured: - return self.game.player_faction.name - else: - return self.game.enemy_faction.name + return self.faction.name @property def faction(self) -> Faction: - return db.FACTIONS[self.faction_name] + return self.game.coalition_for(self.control_point.captured).faction def generate(self) -> bool: self.control_point.connected_objectives = [] @@ -389,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"], ) @@ -589,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/syria.py b/game/theater/syria.py index 7fe83db3..6daff280 100644 --- a/game/theater/syria.py +++ b/game/theater/syria.py @@ -1,3 +1,5 @@ +# DO NOT EDIT: +# This file is generated by resources/tools/export_coordinates.py. from game.theater.projections import TransverseMercator PARAMETERS = TransverseMercator( diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index df637cbc..d3bfae64 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -2,13 +2,14 @@ from __future__ import annotations import itertools import logging -from typing import Iterator, List, TYPE_CHECKING, Union +from abc import ABC +from collections import Sequence +from typing import Iterator, List, TYPE_CHECKING, Union, Generic, TypeVar from dcs.mapping import Point from dcs.triggers import TriggerZone from dcs.unit import Unit -from dcs.unitgroup import Group -from dcs.unittype import VehicleType +from dcs.unitgroup import ShipGroup, VehicleGroup from .. import db from ..data.radar_db import ( @@ -16,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 @@ -47,14 +48,17 @@ NAME_BY_CATEGORY = { } -class TheaterGroundObject(MissionTarget): +GroupT = TypeVar("GroupT", ShipGroup, VehicleGroup) + + +class TheaterGroundObject(MissionTarget, Generic[GroupT]): def __init__( self, name: str, category: str, group_id: int, position: Point, - heading: int, + heading: Heading, control_point: ControlPoint, dcs_identifier: str, sea_object: bool, @@ -66,7 +70,7 @@ class TheaterGroundObject(MissionTarget): self.control_point = control_point self.dcs_identifier = dcs_identifier self.sea_object = sea_object - self.groups: List[Group] = [] + self.groups: List[GroupT] = [] @property def is_dead(self) -> bool: @@ -147,7 +151,7 @@ class TheaterGroundObject(MissionTarget): return True return False - def _max_range_of_type(self, group: Group, range_type: str) -> Distance: + def _max_range_of_type(self, group: GroupT, range_type: str) -> Distance: if not self.might_have_aa: return meters(0) @@ -168,15 +172,19 @@ class TheaterGroundObject(MissionTarget): def max_detection_range(self) -> Distance: return max(self.detection_range(g) for g in self.groups) - def detection_range(self, group: Group) -> Distance: + def detection_range(self, group: GroupT) -> Distance: return self._max_range_of_type(group, "detection_range") def max_threat_range(self) -> Distance: return max(self.threat_range(g) for g in self.groups) - def threat_range(self, group: Group, radar_only: bool = False) -> Distance: + def threat_range(self, group: GroupT, radar_only: bool = False) -> Distance: return self._max_range_of_type(group, "threat_range") + @property + def is_ammo_depot(self) -> bool: + return self.category == "ammo" + @property def is_factory(self) -> bool: return self.category == "factory" @@ -187,7 +195,7 @@ class TheaterGroundObject(MissionTarget): return False @property - def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: return self.units @property @@ -206,7 +214,7 @@ class TheaterGroundObject(MissionTarget): raise NotImplementedError -class BuildingGroundObject(TheaterGroundObject): +class BuildingGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, @@ -214,10 +222,10 @@ class BuildingGroundObject(TheaterGroundObject): group_id: int, object_id: int, position: Point, - heading: int, + heading: Heading, control_point: ControlPoint, dcs_identifier: str, - is_fob_structure=False, + is_fob_structure: bool = False, ) -> None: super().__init__( name=name, @@ -253,13 +261,17 @@ class BuildingGroundObject(TheaterGroundObject): def kill(self) -> None: self._dead = True - def iter_building_group(self) -> Iterator[TheaterGroundObject]: + def iter_building_group(self) -> Iterator[BuildingGroundObject]: for tgo in self.control_point.ground_objects: - if tgo.obj_name == self.obj_name and not tgo.is_dead: + if ( + tgo.obj_name == self.obj_name + and not tgo.is_dead + and isinstance(tgo, BuildingGroundObject) + ): yield tgo @property - def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + def strike_targets(self) -> List[BuildingGroundObject]: return list(self.iter_building_group()) @property @@ -298,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, @@ -322,7 +334,7 @@ class FactoryGroundObject(BuildingGroundObject): name: str, group_id: int, position: Point, - heading: int, + heading: Heading, control_point: ControlPoint, ) -> None: super().__init__( @@ -338,7 +350,7 @@ class FactoryGroundObject(BuildingGroundObject): ) -class NavalGroundObject(TheaterGroundObject): +class NavalGroundObject(TheaterGroundObject[ShipGroup]): def mission_types(self, for_player: bool) -> Iterator[FlightType]: from gen.flights.flight import FlightType @@ -373,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, @@ -394,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, @@ -407,7 +419,7 @@ class LhaGroundObject(GenericCarrierGroundObject): return f"{self.faction_color}|EWR|{super().group_name}" -class MissileSiteGroundObject(TheaterGroundObject): +class MissileSiteGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, group_id: int, position: Point, control_point: ControlPoint ) -> None: @@ -416,7 +428,7 @@ class MissileSiteGroundObject(TheaterGroundObject): 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, @@ -431,14 +443,14 @@ class MissileSiteGroundObject(TheaterGroundObject): return False -class CoastalSiteGroundObject(TheaterGroundObject): +class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, group_id: int, position: Point, control_point: ControlPoint, - heading, + heading: Heading, ) -> None: super().__init__( name=name, @@ -460,10 +472,19 @@ class CoastalSiteGroundObject(TheaterGroundObject): return False -# TODO: Differentiate types. -# This type gets used both for AA sites (SAM, AAA, or SHORAD). These should each -# be split into their own types. -class SamGroundObject(TheaterGroundObject): +class IadsGroundObject(TheaterGroundObject[VehicleGroup], ABC): + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + from gen.flights.flight import FlightType + + if not self.is_friendly(for_player): + yield FlightType.DEAD + yield from super().mission_types(for_player) + + +# The SamGroundObject represents all type of AA +# The TGO can have multiple types of units (AAA,SAM,Support...) +# Differentiation can be made during generation with the airdefensegroupgenerator +class SamGroundObject(IadsGroundObject): def __init__( self, name: str, @@ -476,23 +497,11 @@ class SamGroundObject(TheaterGroundObject): 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, ) - # Set by the SAM unit generator if the generated group is compatible - # with Skynet. - self.skynet_capable = False - - @property - def group_name(self) -> str: - if self.skynet_capable: - # Prefix the group names of SAM sites with the side color so Skynet - # can find them. - return f"{self.faction_color}|SAM|{self.group_id}" - else: - return super().group_name def mission_types(self, for_player: bool) -> Iterator[FlightType]: from gen.flights.flight import FlightType @@ -500,39 +509,35 @@ class SamGroundObject(TheaterGroundObject): if not self.is_friendly(for_player): yield FlightType.DEAD yield FlightType.SEAD - yield from super().mission_types(for_player) + for mission_type in super().mission_types(for_player): + # We yielded this ourselves to move it to the top of the list. Don't yield + # it twice. + if mission_type is not FlightType.DEAD: + yield mission_type @property def might_have_aa(self) -> bool: return True - def threat_range(self, group: Group, radar_only: bool = False) -> Distance: + def threat_range(self, group: VehicleGroup, radar_only: bool = False) -> Distance: max_non_radar = meters(0) live_trs = set() max_telar_range = meters(0) launchers = set() for unit in group.units: - unit_type = db.unit_type_from_name(unit.type) - if unit_type is None or not issubclass(unit_type, VehicleType): - continue + unit_type = db.vehicle_type_from_name(unit.type) if unit_type in TRACK_RADARS: live_trs.add(unit_type) elif unit_type in TELARS: - max_telar_range = max( - max_telar_range, meters(getattr(unit_type, "threat_range", 0)) - ) + max_telar_range = max(max_telar_range, meters(unit_type.threat_range)) elif unit_type in LAUNCHER_TRACKER_PAIRS: launchers.add(unit_type) else: - max_non_radar = max( - max_non_radar, meters(getattr(unit_type, "threat_range", 0)) - ) + max_non_radar = max(max_non_radar, meters(unit_type.threat_range)) max_tel_range = meters(0) for launcher in launchers: if LAUNCHER_TRACKER_PAIRS[launcher] in live_trs: - max_tel_range = max( - max_tel_range, meters(getattr(launcher, "threat_range")) - ) + max_tel_range = max(max_tel_range, meters(unit_type.threat_range)) if radar_only: return max(max_tel_range, max_telar_range) else: @@ -547,7 +552,7 @@ class SamGroundObject(TheaterGroundObject): return True -class VehicleGroupGroundObject(TheaterGroundObject): +class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, @@ -560,7 +565,7 @@ class VehicleGroupGroundObject(TheaterGroundObject): 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, @@ -575,7 +580,7 @@ class VehicleGroupGroundObject(TheaterGroundObject): return True -class EwrGroundObject(TheaterGroundObject): +class EwrGroundObject(IadsGroundObject): def __init__( self, name: str, @@ -588,7 +593,7 @@ class EwrGroundObject(TheaterGroundObject): 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, @@ -600,13 +605,6 @@ class EwrGroundObject(TheaterGroundObject): # Use Group Id and uppercase EWR return f"{self.faction_color}|EWR|{self.group_id}" - def mission_types(self, for_player: bool) -> Iterator[FlightType]: - from gen.flights.flight import FlightType - - if not self.is_friendly(for_player): - yield FlightType.DEAD - yield from super().mission_types(for_player) - @property def might_have_aa(self) -> bool: return True @@ -629,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/theater/thechannel.py b/game/theater/thechannel.py index 33137bd7..0ac20788 100644 --- a/game/theater/thechannel.py +++ b/game/theater/thechannel.py @@ -1,3 +1,5 @@ +# DO NOT EDIT: +# This file is generated by resources/tools/export_coordinates.py. from game.theater.projections import TransverseMercator PARAMETERS = TransverseMercator( diff --git a/game/threatzones.py b/game/threatzones.py index 4d29c6c3..16416573 100644 --- a/game/threatzones.py +++ b/game/threatzones.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import singledispatchmethod -from typing import Optional, TYPE_CHECKING, Union, Iterable +from typing import Optional, TYPE_CHECKING, Union, Iterable, Any from dcs.mapping import Point as DcsPoint from shapely.geometry import ( @@ -13,7 +13,8 @@ from shapely.geometry import ( from shapely.geometry.base import BaseGeometry from shapely.ops import nearest_points, unary_union -from game.theater import ControlPoint, MissionTarget +from game.data.doctrine import Doctrine +from game.theater import ControlPoint, MissionTarget, TheaterGroundObject from game.utils import Distance, meters, nautical_miles from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import Flight, FlightWaypoint @@ -27,7 +28,10 @@ ThreatPoly = Union[MultiPolygon, Polygon] class ThreatZones: def __init__( - self, airbases: ThreatPoly, air_defenses: ThreatPoly, radar_sam_threats + self, + airbases: ThreatPoly, + air_defenses: ThreatPoly, + radar_sam_threats: ThreatPoly, ) -> None: self.airbases = airbases self.air_defenses = air_defenses @@ -44,8 +48,10 @@ class ThreatZones: boundary = self.closest_boundary(point) return meters(boundary.distance_to_point(point)) + # Type checking ignored because singledispatchmethod doesn't work with required type + # definitions. The implementation methods are all typed, so should be fine. @singledispatchmethod - def threatened(self, position) -> bool: + def threatened(self, position) -> bool: # type: ignore raise NotImplementedError @threatened.register @@ -61,8 +67,10 @@ class ThreatZones: LineString([self.dcs_to_shapely_point(a), self.dcs_to_shapely_point(b)]) ) + # Type checking ignored because singledispatchmethod doesn't work with required type + # definitions. The implementation methods are all typed, so should be fine. @singledispatchmethod - def threatened_by_aircraft(self, target) -> bool: + def threatened_by_aircraft(self, target) -> bool: # type: ignore raise NotImplementedError @threatened_by_aircraft.register @@ -75,6 +83,10 @@ class ThreatZones: LineString((self.dcs_to_shapely_point(p.position) for p in flight.points)) ) + @threatened_by_aircraft.register + def _threatened_by_aircraft_mission_target(self, target: MissionTarget) -> bool: + return self.threatened_by_aircraft(self.dcs_to_shapely_point(target.position)) + def waypoints_threatened_by_aircraft( self, waypoints: Iterable[FlightWaypoint] ) -> bool: @@ -82,8 +94,10 @@ class ThreatZones: LineString((self.dcs_to_shapely_point(p.position) for p in waypoints)) ) + # Type checking ignored because singledispatchmethod doesn't work with required type + # definitions. The implementation methods are all typed, so should be fine. @singledispatchmethod - def threatened_by_air_defense(self, target) -> bool: + def threatened_by_air_defense(self, target) -> bool: # type: ignore raise NotImplementedError @threatened_by_air_defense.register @@ -102,8 +116,10 @@ class ThreatZones: self.dcs_to_shapely_point(target.position) ) + # Type checking ignored because singledispatchmethod doesn't work with required type + # definitions. The implementation methods are all typed, so should be fine. @singledispatchmethod - def threatened_by_radar_sam(self, target) -> bool: + def threatened_by_radar_sam(self, target) -> bool: # type: ignore raise NotImplementedError @threatened_by_radar_sam.register @@ -134,8 +150,9 @@ class ThreatZones: return None @classmethod - def barcap_threat_range(cls, game: Game, control_point: ControlPoint) -> Distance: - doctrine = game.faction_for(control_point.captured).doctrine + def barcap_threat_range( + cls, doctrine: Doctrine, control_point: ControlPoint + ) -> Distance: cap_threat_range = ( doctrine.cap_max_distance_from_cp + doctrine.cap_engagement_range ) @@ -174,33 +191,59 @@ class ThreatZones: """ air_threats = [] air_defenses = [] - radar_sam_threats = [] - for control_point in game.theater.controlpoints: - if control_point.captured != player: - continue - if control_point.runway_is_operational(): - point = ShapelyPoint(control_point.position.x, control_point.position.y) - cap_threat_range = cls.barcap_threat_range(game, control_point) - air_threats.append(point.buffer(cap_threat_range.meters)) + for control_point in game.theater.control_points_for(player): + air_threats.append(control_point) + air_defenses.extend(control_point.ground_objects) - for tgo in control_point.ground_objects: - for group in tgo.groups: - threat_range = tgo.threat_range(group) - # Any system with a shorter range than this is not worth - # even avoiding. - if threat_range > nautical_miles(3): - point = ShapelyPoint(tgo.position.x, tgo.position.y) - threat_zone = point.buffer(threat_range.meters) - air_defenses.append(threat_zone) - radar_threat_range = tgo.threat_range(group, radar_only=True) - if radar_threat_range > nautical_miles(3): - point = ShapelyPoint(tgo.position.x, tgo.position.y) - threat_zone = point.buffer(threat_range.meters) - radar_sam_threats.append(threat_zone) + return cls.for_threats( + game.faction_for(player).doctrine, air_threats, air_defenses + ) + + @classmethod + def for_threats( + cls, + doctrine: Doctrine, + barcap_locations: Iterable[ControlPoint], + air_defenses: Iterable[TheaterGroundObject[Any]], + ) -> ThreatZones: + """Generates the threat zones projected by the given locations. + + Args: + doctrine: The doctrine of the owning coalition. + barcap_locations: The locations that will be considered for BARCAP planning. + air_defenses: TGOs that may have air defenses. + + Returns: + The threat zones projected by the given locations. If the threat zone + belongs to the player, it is the zone that will be avoided by the enemy and + vice versa. + """ + air_threats = [] + air_defense_threats = [] + radar_sam_threats = [] + for barcap in barcap_locations: + point = ShapelyPoint(barcap.position.x, barcap.position.y) + cap_threat_range = cls.barcap_threat_range(doctrine, barcap) + air_threats.append(point.buffer(cap_threat_range.meters)) + + for tgo in air_defenses: + for group in tgo.groups: + threat_range = tgo.threat_range(group) + # Any system with a shorter range than this is not worth + # even avoiding. + if threat_range > nautical_miles(3): + point = ShapelyPoint(tgo.position.x, tgo.position.y) + threat_zone = point.buffer(threat_range.meters) + air_defense_threats.append(threat_zone) + radar_threat_range = tgo.threat_range(group, radar_only=True) + if radar_threat_range > nautical_miles(3): + point = ShapelyPoint(tgo.position.x, tgo.position.y) + threat_zone = point.buffer(threat_range.meters) + radar_sam_threats.append(threat_zone) return cls( airbases=unary_union(air_threats), - air_defenses=unary_union(air_defenses), + air_defenses=unary_union(air_defense_threats), radar_sam_threats=unary_union(radar_sam_threats), ) diff --git a/game/transfers.py b/game/transfers.py index fadbf3dc..ffb879e4 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -130,7 +130,10 @@ class TransferOrder: def kill_unit(self, unit_type: GroundUnitType) -> None: if unit_type not in self.units or not self.units[unit_type]: raise KeyError(f"{self} has no {unit_type} remaining") - self.units[unit_type] -= 1 + if self.units[unit_type] == 1: + del self.units[unit_type] + else: + self.units[unit_type] -= 1 @property def size(self) -> int: @@ -163,21 +166,58 @@ class TransferOrder: return self.transport.find_escape_route() return None - def proceed(self) -> None: - if not self.destination.is_friendly(self.player): - logging.info(f"Transfer destination {self.destination} was captured.") - if self.position.is_friendly(self.player): - self.disband_at(self.position) - elif (escape_route := self.find_escape_route()) is not None: - self.disband_at(escape_route) - else: - logging.info( - f"No escape route available. Units were surrounded and destroyed " - "during transfer." - ) - self.kill_all() - return + def disband(self) -> None: + """ + Disbands the specific transfer at the current position if friendly, at a + possible escape route or kills all units if none is possible + """ + if self.position.is_friendly(self.player): + self.disband_at(self.position) + elif (escape_route := self.find_escape_route()) is not None: + self.disband_at(escape_route) + else: + logging.info( + f"No escape route available. Units were surrounded and destroyed " + "during transfer." + ) + self.kill_all() + def is_completable(self, network: TransitNetwork) -> bool: + """ + Checks if the transfer can be completed with the current theater state / transit + network to ensure that there is possible route between the current position and + the planned destination. This also ensures that the points are friendly. + """ + if self.transport is None: + # Check if unplanned transfers could be completed + if not self.position.is_friendly(self.player): + logging.info( + f"Current position ({self.position}) " + f"of the halting transfer was captured." + ) + return False + if not network.has_path_between(self.position, self.destination): + logging.info( + f"Destination of transfer ({self.destination}) " + f"can not be reached anymore." + ) + return False + + if self.transport is not None and not self.next_stop.is_friendly(self.player): + # check if already proceeding transfers can reach the next stop + logging.info( + f"The next stop of the transfer ({self.next_stop}) " + f"was captured while transfer was on route." + ) + return False + + return True + + def proceed(self) -> None: + """ + Let the transfer proceed to the next stop and disbands it if the next stop + is the destination + """ if self.transport is None: return @@ -313,7 +353,9 @@ class AirliftPlanner: capacity = flight_size * capacity_each if capacity < self.transfer.size: - transfer = self.game.transfers.split_transfer(self.transfer, capacity) + transfer = self.game.coalition_for( + self.for_player + ).transfers.split_transfer(self.transfer, capacity) else: transfer = self.transfer @@ -335,7 +377,9 @@ class AirliftPlanner: transfer.transport = transport self.package.add_flight(flight) - planner = FlightPlanBuilder(self.game, self.package, self.for_player) + planner = FlightPlanBuilder( + self.package, self.game.coalition_for(self.for_player), self.game.theater + ) planner.populate_flight_plan(flight) self.game.aircraft_inventory.claim_for_flight(flight) return flight_size @@ -516,14 +560,14 @@ class TransportMap(Generic[TransportType]): yield from destination_dict.values() -class ConvoyMap(TransportMap): +class ConvoyMap(TransportMap[Convoy]): def create_transport( self, origin: ControlPoint, destination: ControlPoint ) -> Convoy: return Convoy(origin, destination) -class CargoShipMap(TransportMap): +class CargoShipMap(TransportMap[CargoShip]): def create_transport( self, origin: ControlPoint, destination: ControlPoint ) -> CargoShip: @@ -531,8 +575,9 @@ class CargoShipMap(TransportMap): class PendingTransfers: - def __init__(self, game: Game) -> None: + def __init__(self, game: Game, player: bool) -> None: self.game = game + self.player = player self.convoys = ConvoyMap() self.cargo_ships = CargoShipMap() self.pending_transfers: List[TransferOrder] = [] @@ -589,8 +634,14 @@ class PendingTransfers: self.pending_transfers.append(new_transfer) return new_transfer + # Type checking ignored because singledispatchmethod doesn't work with required type + # definitions. The implementation methods are all typed, so should be fine. @singledispatchmethod - def cancel_transport(self, transport, transfer: TransferOrder) -> None: + def cancel_transport( # type: ignore + self, + transport, + transfer: TransferOrder, + ) -> None: pass @cancel_transport.register @@ -600,7 +651,7 @@ class PendingTransfers: flight = transport.flight flight.package.remove_flight(flight) if not flight.package.flights: - self.game.ato_for(transport.player_owned).remove_package(flight.package) + self.game.ato_for(self.player).remove_package(flight.package) self.game.aircraft_inventory.return_from_flight(flight) flight.clear_roster() @@ -623,6 +674,12 @@ class PendingTransfers: transfer.origin.base.commission_units(transfer.units) def perform_transfers(self) -> None: + """ + Performs completable transfers from the list of pending transfers and adds + uncompleted transfers which are en route back to the list of pending transfers. + Disbands all convoys and cargo ships + """ + self.disband_uncompletable_transfers() incomplete = [] for transfer in self.pending_transfers: transfer.proceed() @@ -633,12 +690,33 @@ class PendingTransfers: self.cargo_ships.disband_all() def plan_transports(self) -> None: + """ + Plan transports for all pending and completable transfers which don't have a + transport assigned already. This calculates the shortest path between current + position and destination on every execution to ensure the route is adopted to + recent changes in the theater state / transit network. + """ + self.disband_uncompletable_transfers() for transfer in self.pending_transfers: if transfer.transport is None: self.arrange_transport(transfer) + def disband_uncompletable_transfers(self) -> None: + """ + Disbands all transfers from the list of pending_transfers which can not be + completed anymore because the theater state changed or the transit network does + not allow a route to the destination anymore + """ + completable_transfers = [] + for transfer in self.pending_transfers: + if not transfer.is_completable(self.network_for(transfer.position)): + transfer.disband() + else: + completable_transfers.append(transfer) + self.pending_transfers = completable_transfers + def order_airlift_assets(self) -> None: - for control_point in self.game.theater.controlpoints: + for control_point in self.game.theater.control_points_for(self.player): if self.game.air_wing_for(control_point.captured).can_auto_plan( FlightType.TRANSPORT ): @@ -673,8 +751,6 @@ class PendingTransfers: # aesthetic. gap += 1 - self.game.procurement_requests_for(player=control_point.captured).append( - AircraftProcurementRequest( - control_point, nautical_miles(200), FlightType.TRANSPORT, gap - ) + self.game.coalition_for(self.player).add_procurement_request( + AircraftProcurementRequest(control_point, FlightType.TRANSPORT, gap) ) diff --git a/game/unitdelivery.py b/game/unitdelivery.py index a6de6a71..cf1af512 100644 --- a/game/unitdelivery.py +++ b/game/unitdelivery.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Optional, TYPE_CHECKING, Any from game.theater import ControlPoint +from .coalition import Coalition from .dcs.groundunittype import GroundUnitType from .dcs.unittype import UnitType from .theater.transitnetwork import ( @@ -28,62 +29,64 @@ class PendingUnitDeliveries: self.destination = destination # Maps unit type to order quantity. - self.units: dict[UnitType, int] = defaultdict(int) + self.units: dict[UnitType[Any], int] = defaultdict(int) def __str__(self) -> str: return f"Pending delivery to {self.destination}" - def order(self, units: dict[UnitType, int]) -> None: + def order(self, units: dict[UnitType[Any], int]) -> None: for k, v in units.items(): self.units[k] += v - def sell(self, units: dict[UnitType, int]) -> None: + 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, game: Game) -> None: - self.refund(game, self.units) + def refund_all(self, coalition: Coalition) -> None: + self.refund(coalition, self.units) self.units = defaultdict(int) - def refund_ground_units(self, game: Game) -> None: + def refund_ground_units(self, coalition: Coalition) -> None: ground_units: dict[UnitType[Any], int] = { u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType) } - self.refund(game, ground_units) + self.refund(coalition, ground_units) for gu in ground_units.keys(): del self.units[gu] - def refund(self, game: Game, units: dict[UnitType, int]) -> None: + def refund(self, coalition: Coalition, units: dict[UnitType[Any], int]) -> None: for unit_type, count in units.items(): logging.info(f"Refunding {count} {unit_type} at {self.destination.name}") - game.adjust_budget( - unit_type.price * count, player=self.destination.captured - ) + coalition.adjust_budget(unit_type.price * count) - def pending_orders(self, unit_type: UnitType) -> int: + def pending_orders(self, unit_type: UnitType[Any]) -> int: pending_units = self.units.get(unit_type) if pending_units is None: pending_units = 0 return pending_units - def available_next_turn(self, unit_type: UnitType) -> int: + def available_next_turn(self, unit_type: UnitType[Any]) -> int: current_units = self.destination.base.total_units_of_type(unit_type) return self.pending_orders(unit_type) + current_units def process(self, game: Game) -> None: + coalition = game.coalition_for(self.destination.captured) ground_unit_source = self.find_ground_unit_source(game) if ground_unit_source is None: game.message( f"{self.destination.name} lost its source for ground unit " "reinforcements. Refunding purchase price." ) - self.refund_ground_units(game) + self.refund_ground_units(coalition) - bought_units: dict[UnitType, int] = {} + bought_units: dict[UnitType[Any], int] = {} units_needing_transfer: dict[GroundUnitType, int] = {} - sold_units: dict[UnitType, int] = {} + sold_units: dict[UnitType[Any], int] = {} for unit_type, count in self.units.items(): - coalition = "Ally" if self.destination.captured else "Enemy" + allegiance = "Ally" if self.destination.captured else "Enemy" d: dict[Any, int] if ( isinstance(unit_type, GroundUnitType) @@ -98,11 +101,11 @@ class PendingUnitDeliveries: if count >= 0: d[unit_type] = count game.message( - f"{coalition} reinforcements: {unit_type} x {count} at {source}" + f"{allegiance} reinforcements: {unit_type} x {count} at {source}" ) else: sold_units[unit_type] = -count - game.message(f"{coalition} sold: {unit_type} x {-count} at {source}") + game.message(f"{allegiance} sold: {unit_type} x {-count} at {source}") self.units = defaultdict(int) self.destination.base.commission_units(bought_units) @@ -111,16 +114,19 @@ class PendingUnitDeliveries: if units_needing_transfer: if ground_unit_source is None: raise RuntimeError( - f"ground unit source could not be found for {self.destination} but still tried to " - f"transfer units to there" + f"Ground unit source could not be found for {self.destination} but " + "still tried to transfer units to there" ) ground_unit_source.base.commission_units(units_needing_transfer) - self.create_transfer(game, ground_unit_source, units_needing_transfer) + self.create_transfer(coalition, ground_unit_source, units_needing_transfer) def create_transfer( - self, game: Game, source: ControlPoint, units: dict[GroundUnitType, int] + self, + coalition: Coalition, + source: ControlPoint, + units: dict[GroundUnitType, int], ) -> None: - game.transfers.new_transfer(TransferOrder(source, self.destination, units)) + coalition.transfers.new_transfer(TransferOrder(source, self.destination, units)) def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]: # This is running *after* the turn counter has been incremented, so this is the diff --git a/game/unitmap.py b/game/unitmap.py index 98793991..a1d5c110 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -2,10 +2,10 @@ import itertools import math from dataclasses import dataclass -from typing import Dict, Optional +from typing import Dict, Optional, Any, Union, TypeVar, Generic -from dcs.unit import Unit -from dcs.unitgroup import FlyingGroup, Group, VehicleGroup +from dcs.unit import Vehicle, Ship +from dcs.unitgroup import FlyingGroup, VehicleGroup, StaticGroup, ShipGroup, MovingGroup from game.dcs.groundunittype import GroundUnitType from game.squadrons import Pilot @@ -27,11 +27,14 @@ class FrontLineUnit: origin: ControlPoint +UnitT = TypeVar("UnitT", Ship, Vehicle) + + @dataclass(frozen=True) -class GroundObjectUnit: - ground_object: TheaterGroundObject - group: Group - unit: Unit +class GroundObjectUnit(Generic[UnitT]): + ground_object: TheaterGroundObject[Any] + group: MovingGroup[UnitT] + unit: UnitT @dataclass(frozen=True) @@ -56,13 +59,13 @@ class UnitMap: self.aircraft: Dict[str, FlyingUnit] = {} self.airfields: Dict[str, Airfield] = {} self.front_line_units: Dict[str, FrontLineUnit] = {} - self.ground_object_units: Dict[str, GroundObjectUnit] = {} + self.ground_object_units: Dict[str, GroundObjectUnit[Any]] = {} self.buildings: Dict[str, Building] = {} self.convoys: Dict[str, ConvoyUnit] = {} self.cargo_ships: Dict[str, CargoShip] = {} self.airlifts: Dict[str, AirliftUnits] = {} - def add_aircraft(self, group: FlyingGroup, flight: Flight) -> None: + def add_aircraft(self, group: FlyingGroup[Any], flight: Flight) -> None: for pilot, unit in zip(flight.roster.pilots, group.units): # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. @@ -85,7 +88,7 @@ class UnitMap: return self.airfields.get(name, None) def add_front_line_units( - self, group: Group, origin: ControlPoint, unit_type: GroundUnitType + self, group: VehicleGroup, origin: ControlPoint, unit_type: GroundUnitType ) -> None: for unit in group.units: # The actual name is a String (the pydcs translatable string), which @@ -100,9 +103,9 @@ class UnitMap: def add_ground_object_units( self, - ground_object: TheaterGroundObject, - persistence_group: Group, - miz_group: Group, + ground_object: TheaterGroundObject[Any], + persistence_group: Union[ShipGroup, VehicleGroup], + miz_group: Union[ShipGroup, VehicleGroup], ) -> None: """Adds a group associated with a TGO to the unit map. @@ -131,10 +134,10 @@ class UnitMap: ground_object, persistence_group, persistent_unit ) - def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit]: + def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit[Any]]: return self.ground_object_units.get(name, None) - def add_convoy_units(self, group: Group, convoy: Convoy) -> None: + def add_convoy_units(self, group: VehicleGroup, convoy: Convoy) -> None: for unit, unit_type in zip(group.units, convoy.iter_units()): # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. @@ -146,7 +149,7 @@ class UnitMap: def convoy_unit(self, name: str) -> Optional[ConvoyUnit]: return self.convoys.get(name, None) - def add_cargo_ship(self, group: Group, ship: CargoShip) -> None: + def add_cargo_ship(self, group: ShipGroup, ship: CargoShip) -> None: if len(group.units) > 1: # Cargo ship "groups" are single units. Killing the one ship kills the whole # transfer. If we ever want to add escorts or create multiple cargo ships in @@ -163,7 +166,9 @@ class UnitMap: def cargo_ship(self, name: str) -> Optional[CargoShip]: return self.cargo_ships.get(name, None) - def add_airlift_units(self, group: FlyingGroup, transfer: TransferOrder) -> None: + def add_airlift_units( + self, group: FlyingGroup[Any], transfer: TransferOrder + ) -> None: capacity_each = math.ceil(transfer.size / len(group.units)) for idx, transport in enumerate(group.units): # Slice the units in groups based on the capacity of each unit. Cargo is @@ -186,7 +191,9 @@ class UnitMap: def airlift_unit(self, name: str) -> Optional[AirliftUnits]: return self.airlifts.get(name, None) - def add_building(self, ground_object: BuildingGroundObject, group: Group) -> None: + def add_building( + self, ground_object: BuildingGroundObject, group: StaticGroup + ) -> None: # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. # The name of the initiator in the DCS dead event will have " object" diff --git a/game/utils.py b/game/utils.py index 0bd1f79c..119a741a 100644 --- a/game/utils.py +++ b/game/utils.py @@ -2,8 +2,10 @@ from __future__ import annotations import itertools import math +import random +from collections import Iterable from dataclasses import dataclass -from typing import Union +from typing import Union, Any, TypeVar METERS_TO_FEET = 3.28084 FEET_TO_METERS = 1 / METERS_TO_FEET @@ -15,19 +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, a) -> int: - h += a - if h > 360: - return h - 360 - elif h < 0: - return 360 + h - else: - return h - - -def opposite_heading(h): - return heading_sum(h, 180) +INHG_TO_HPA = 33.86389 +INHG_TO_MMHG = 25.400002776728 @dataclass(frozen=True, order=True) @@ -185,7 +176,85 @@ def mach(value: float, altitude: Distance) -> Speed: SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5) -def pairwise(iterable): +@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), ... @@ -193,3 +262,15 @@ def pairwise(iterable): 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 bcb8b8cf..87d8a841 100644 --- a/game/version.py +++ b/game/version.py @@ -1,8 +1,15 @@ from pathlib import Path +MAJOR_VERSION = 5 +MINOR_VERSION = 0 +MICRO_VERSION = 0 + + def _build_version_string() -> str: - components = ["5.0.0"] + components = [ + ".".join(str(v) for v in (MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION)) + ] build_number_path = Path("resources/buildnumber") if build_number_path.exists(): with build_number_path.open("r") as build_number_file: @@ -96,4 +103,11 @@ VERSION = _build_version_string() #: mission using map buildings as strike targets must check and potentially recreate #: all those objectives. This definitely affects all Syria campaigns, other maps are #: not yet verified. -CAMPAIGN_FORMAT_VERSION = (7, 0) +#: +#: Version 7.1 +#: * Support for Mariana Islands terrain +#: +#: 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 fc077634..fb0ea68c 100644 --- a/game/weather.py +++ b/game/weather.py @@ -5,16 +5,20 @@ 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 + +from game.theater.seasonalconditions import determine_season if TYPE_CHECKING: from game.theater import ConflictTheater + from game.theater.seasonalconditions import SeasonalConditions class TimeOfDay(Enum): @@ -24,6 +28,22 @@ class TimeOfDay(Enum): Night = "night" +@dataclass(frozen=True) +class AtmosphericConditions: + #: 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: at_0m: Wind @@ -63,11 +83,66 @@ 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( + 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, + 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 + logging.debug( + "Weather: Before random: temp {} press {}".format(temperature, pressure) + ) + conditions = AtmosphericConditions( + qnh=self.random_pressure(pressure), + temperature_celsius=self.random_temperature(temperature), + ) + logging.debug( + "Weather: After random: temp {} press {}".format( + conditions.temperature_celsius, conditions.qnh.pressure_in_inches_hg + ) + ) + return conditions + + @property + def pressure_adjustment(self) -> float: + raise NotImplementedError + + @property + def temperature_adjustment(self) -> float: + raise NotImplementedError + def generate_clouds(self) -> Optional[Clouds]: raise NotImplementedError @@ -83,8 +158,8 @@ class Weather: raise NotImplementedError @staticmethod - def random_wind(minimum: int, maximum) -> WindConditions: - wind_direction = random.randint(0, 360) + def random_wind(minimum: int, maximum: int) -> WindConditions: + wind_direction = Heading.random() at_0m_factor = 1 at_2000m_factor = 2 at_8000m_factor = 3 @@ -92,9 +167,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 @@ -105,8 +180,47 @@ class Weather: def random_cloud_thickness() -> int: return random.randint(100, 400) + @staticmethod + def random_pressure(average_pressure: float) -> Pressure: + # "Safe" constants based roughly on ME and viper altimeter. + # Units are inches of mercury. + SAFE_MIN = 28.4 + SAFE_MAX = 30.9 + # Use normalvariate to get normal distribution, more realistic than uniform + pressure = random.normalvariate(average_pressure, 0.1) + return inches_hg(max(SAFE_MIN, min(SAFE_MAX, pressure))) + + @staticmethod + def random_temperature(average_temperature: float) -> float: + # "Safe" constants based roughly on ME. + # Temperatures are in Celcius. + SAFE_MIN = -12 + SAFE_MAX = 49 + # Use normalvariate to get normal distribution, more realistic than uniform + 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): + @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 @@ -118,6 +232,14 @@ class ClearSkies(Weather): class Cloudy(Weather): + @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) @@ -130,6 +252,14 @@ class Cloudy(Weather): class Raining(Weather): + @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) @@ -142,6 +272,14 @@ class Raining(Weather): class Thunderstorm(Weather): + @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( base=self.random_cloud_base(), @@ -168,12 +306,13 @@ class Conditions: time_of_day: TimeOfDay, settings: Settings, ) -> Conditions: + _start_time = cls.generate_start_time( + theater, day, time_of_day, settings.night_disabled + ) return cls( time_of_day=time_of_day, - start_time=cls.generate_start_time( - theater, day, time_of_day, settings.night_disabled - ), - weather=cls.generate_weather(), + start_time=_start_time, + weather=cls.generate_weather(theater.seasonal_conditions, day, time_of_day), ) @classmethod @@ -199,14 +338,24 @@ 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: + season = determine_season(day) + logging.debug("Weather: Season {}".format(season)) + weather_chances = seasonal_conditions.weather_type_chances[season] chances = { - Thunderstorm: 1, - Raining: 20, - Cloudy: 60, - ClearSkies: 20, + Thunderstorm: weather_chances.thunderstorm, + Raining: weather_chances.raining, + Cloudy: weather_chances.cloudy, + ClearSkies: weather_chances.clear_skies, } + logging.debug("Weather: Chances {}".format(weather_chances)) weather_type = random.choices( list(chances.keys()), weights=list(chances.values()) )[0] - return weather_type() + logging.debug("Weather: Type {}".format(weather_type)) + return weather_type(seasonal_conditions, day, time_of_day) diff --git a/gen/aircraft.py b/gen/aircraft.py index 93befe97..2a64dbd2 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -1,11 +1,12 @@ from __future__ import annotations +import itertools import logging import random -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import timedelta from functools import cached_property -from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union, Iterable +from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union, Iterable, Any from dcs import helicopters from dcs.action import AITaskPush, ActivateGroup @@ -22,7 +23,6 @@ from dcs.planes import ( C_101EB, F_14B, JF_17, - PlaneType, Su_33, Tu_22M3, ) @@ -65,7 +65,7 @@ from dcs.unitgroup import FlyingGroup, ShipGroup, StaticGroup from dcs.unittype import FlyingType from game import db -from game.data.weapons import Pylon +from game.data.weapons import Pylon, WeaponType as WeaponTypeEnum from game.dcs.aircrafttype import AircraftType from game.factions.faction import Faction from game.settings import Settings @@ -81,7 +81,7 @@ from game.theater.missiontarget import MissionTarget from game.theater.theatergroundobject import TheaterGroundObject from game.transfers import MultiGroupTransport from game.unitmap import UnitMap -from game.utils import Distance, meters, nautical_miles +from game.utils import Distance, meters, nautical_miles, pairwise from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit from gen.flights.flight import ( @@ -90,10 +90,11 @@ from gen.flights.flight import ( FlightWaypoint, FlightWaypointType, ) +from gen.lasercoderegistry import LaserCodeRegistry from gen.radios import RadioFrequency, RadioRegistry from gen.runways import RunwayData from gen.tacan import TacanBand, TacanRegistry -from .airsupportgen import AirSupport, AwacsInfo, TankerInfo +from .airsupport import AirSupport, AwacsInfo, TankerInfo from .callsigns import callsign_for_support_unit from .flights.flightplan import ( AwacsFlightPlan, @@ -138,6 +139,8 @@ class FlightData: flight_type: FlightType + aircraft_type: AircraftType + #: All units in the flight. units: List[FlyingUnit] @@ -165,49 +168,24 @@ class FlightData: #: Radio frequency for intra-flight communications. intra_flight_channel: RadioFrequency - #: Map of radio frequencies to their assigned radio and channel, if any. - frequency_to_channel_map: Dict[RadioFrequency, ChannelAssignment] - #: Bingo fuel value in lbs. bingo_fuel: Optional[int] joker_fuel: Optional[int] - def __init__( - self, - package: Package, - aircraft_type: AircraftType, - flight_type: FlightType, - units: List[FlyingUnit], - size: int, - friendly: bool, - departure_delay: timedelta, - departure: RunwayData, - arrival: RunwayData, - divert: Optional[RunwayData], - waypoints: List[FlightWaypoint], - intra_flight_channel: RadioFrequency, - bingo_fuel: Optional[int], - joker_fuel: Optional[int], - custom_name: Optional[str], - ) -> None: - self.package = package - self.aircraft_type = aircraft_type - self.flight_type = flight_type - self.units = units - self.size = size - self.friendly = friendly - self.departure_delay = departure_delay - self.departure = departure - self.arrival = arrival - self.divert = divert - self.waypoints = waypoints - self.intra_flight_channel = intra_flight_channel - self.frequency_to_channel_map = {} - self.bingo_fuel = bingo_fuel - self.joker_fuel = joker_fuel + laser_codes: list[Optional[int]] + + custom_name: Optional[str] + + callsign: str = field(init=False) + + #: Map of radio frequencies to their assigned radio and channel, if any. + frequency_to_channel_map: Dict[RadioFrequency, ChannelAssignment] = field( + init=False, default_factory=dict + ) + + def __post_init__(self) -> None: self.callsign = create_group_callsign_from_unit(self.units[0]) - self.custom_name = custom_name @property def client_units(self) -> List[FlyingUnit]: @@ -247,6 +225,7 @@ class AircraftConflictGenerator: game: Game, radio_registry: RadioRegistry, tacan_registry: TacanRegistry, + laser_code_registry: LaserCodeRegistry, unit_map: UnitMap, air_support: AirSupport, ) -> None: @@ -255,6 +234,7 @@ class AircraftConflictGenerator: self.settings = settings self.radio_registry = radio_registry self.tacan_registy = tacan_registry + self.laser_code_registry = laser_code_registry self.unit_map = unit_map self.flights: List[FlightData] = [] self.air_support = air_support @@ -262,8 +242,8 @@ class AircraftConflictGenerator: @cached_property def use_client(self) -> bool: """True if Client should be used instead of Player.""" - blue_clients = self.client_slots_in_ato(self.game.blue_ato) - red_clients = self.client_slots_in_ato(self.game.red_ato) + blue_clients = self.client_slots_in_ato(self.game.blue.ato) + red_clients = self.client_slots_in_ato(self.game.red.ato) return blue_clients + red_clients > 1 @staticmethod @@ -321,7 +301,7 @@ class AircraftConflictGenerator: @staticmethod def livery_from_db(flight: Flight) -> Optional[str]: - return db.PLANE_LIVERY_OVERRIDES.get(flight.unit_type) + return db.PLANE_LIVERY_OVERRIDES.get(flight.unit_type.dcs_unit_type) def livery_from_faction(self, flight: Flight) -> Optional[str]: faction = self.game.faction_for(player=flight.departure.captured) @@ -342,7 +322,7 @@ class AircraftConflictGenerator: return livery return None - def _setup_livery(self, flight: Flight, group: FlyingGroup) -> None: + def _setup_livery(self, flight: Flight, group: FlyingGroup[Any]) -> None: livery = self.livery_for(flight) if livery is None: return @@ -351,7 +331,7 @@ class AircraftConflictGenerator: def _setup_group( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -361,6 +341,7 @@ class AircraftConflictGenerator: self._setup_payload(flight, group) self._setup_livery(flight, group) + laser_codes = [] for unit, pilot in zip(group.units, flight.roster.pilots): player = pilot is not None and pilot.player self.set_skill(unit, pilot, blue=flight.departure.captured) @@ -368,6 +349,11 @@ class AircraftConflictGenerator: if player and group.late_activation: group.late_activation = False + code: Optional[int] = None + if flight.loadout.has_weapon_of_type(WeaponTypeEnum.TGP) and player: + code = self.laser_code_registry.get_next_laser_code() + laser_codes.append(code) + # Set up F-14 Client to have pre-stored alignment if unit_type is F_14B: unit.set_property(F_14B.Properties.INSAlignmentStored.id, True) @@ -383,7 +369,18 @@ class AircraftConflictGenerator: channel = self.radio_registry.alloc_uhf() else: channel = flight.unit_type.alloc_flight_radio(self.radio_registry) - group.set_frequency(channel.mhz) + + try: + group.set_frequency(channel.mhz) + except TypeError: + # TODO: Remote try/except when pydcs bug is fixed. + # https://github.com/pydcs/dcs/issues/175 + # pydcs now emits an error when attempting to set a preset channel for an + # aircraft that doesn't support them. We're not choosing to set a preset + # here, we're just trying to set the AI's frequency. pydcs automatically + # tries to set channel 1 when it does that and doesn't suppress this new + # error. + pass divert = None if flight.divert is not None: @@ -412,6 +409,7 @@ class AircraftConflictGenerator: bingo_fuel=flight.flight_plan.bingo_fuel, joker_fuel=flight.flight_plan.joker_fuel, custom_name=flight.custom_name, + laser_codes=laser_codes, ) ) @@ -458,8 +456,8 @@ class AircraftConflictGenerator: unit_type: Type[FlyingType], count: int, start_type: str, - airport: Optional[Airport] = None, - ) -> FlyingGroup: + airport: Airport, + ) -> FlyingGroup[Any]: assert count > 0 logging.info("airgen: {} for {} at {}".format(unit_type, side.id, airport)) @@ -476,7 +474,7 @@ class AircraftConflictGenerator: def _generate_inflight( self, name: str, side: Country, flight: Flight, origin: ControlPoint - ) -> FlyingGroup: + ) -> FlyingGroup[Any]: assert flight.count > 0 at = origin.position @@ -521,7 +519,7 @@ class AircraftConflictGenerator: count: int, start_type: str, at: Union[ShipGroup, StaticGroup], - ) -> FlyingGroup: + ) -> FlyingGroup[Any]: assert count > 0 logging.info("airgen: {} for {} at unit {}".format(unit_type, side.id, at)) @@ -536,34 +534,18 @@ class AircraftConflictGenerator: ) def _add_radio_waypoint( - self, group: FlyingGroup, position, altitude: Distance, airspeed: int = 600 + self, + group: FlyingGroup[Any], + position: Point, + altitude: Distance, + airspeed: int = 600, ) -> MovingPoint: point = group.add_waypoint(position, altitude.meters, airspeed) point.alt_type = "RADIO" return point - def _rtb_for( - self, - group: FlyingGroup, - cp: ControlPoint, - at: Optional[db.StartingPosition] = None, - ): - if at is None: - at = cp.at - position = at if isinstance(at, Point) else at.position - - last_waypoint = group.points[-1] - if last_waypoint is not None: - heading = position.heading_between_point(last_waypoint.position) - tod_location = position.point_from_heading(heading, RTB_DISTANCE) - self._add_radio_waypoint(group, tod_location, last_waypoint.alt) - - destination_waypoint = self._add_radio_waypoint(group, position, RTB_ALTITUDE) - if isinstance(at, Airport): - group.land_at(at) - return destination_waypoint - - def _at_position(self, at) -> Point: + @staticmethod + def _at_position(at: Union[Point, ShipGroup, Type[Airport]]) -> Point: if isinstance(at, Point): return at elif isinstance(at, ShipGroup): @@ -573,7 +555,7 @@ class AircraftConflictGenerator: else: assert False - def _setup_payload(self, flight: Flight, group: FlyingGroup) -> None: + def _setup_payload(self, flight: Flight, group: FlyingGroup[Any]) -> None: for p in group.units: p.pylons.clear() @@ -593,7 +575,10 @@ class AircraftConflictGenerator: parking_slot.unit_id = None def generate_flights( - self, country, ato: AirTaskingOrder, dynamic_runways: Dict[str, RunwayData] + self, + country: Country, + ato: AirTaskingOrder, + dynamic_runways: Dict[str, RunwayData], ) -> None: for package in ato.packages: @@ -614,12 +599,11 @@ class AircraftConflictGenerator: if not isinstance(control_point, Airfield): continue + faction = self.game.coalition_for(control_point.captured).faction if control_point.captured: country = player_country - faction = self.game.player_faction else: country = enemy_country - faction = self.game.enemy_faction for aircraft, available in inventory.all_aircraft: try: @@ -672,7 +656,7 @@ class AircraftConflictGenerator: self.unit_map.add_aircraft(group, flight) def set_activation_time( - self, flight: Flight, group: FlyingGroup, delay: timedelta + self, flight: Flight, group: FlyingGroup[Any], delay: timedelta ) -> None: # Note: Late activation causes the waypoint TOTs to look *weird* in the # mission editor. Waypoint times will be relative to the group @@ -691,7 +675,7 @@ class AircraftConflictGenerator: self.m.triggerrules.triggers.append(activation_trigger) def set_startup_time( - self, flight: Flight, group: FlyingGroup, delay: timedelta + self, flight: Flight, group: FlyingGroup[Any], delay: timedelta ) -> None: # Uncontrolled causes the AI unit to spawn, but not begin startup. group.uncontrolled = True @@ -712,14 +696,12 @@ class AircraftConflictGenerator: if flight.from_cp.cptype != ControlPointType.AIRBASE: return - if flight.from_cp.captured: - coalition = self.game.get_player_coalition_id() - else: - coalition = self.game.get_enemy_coalition_id() - + coalition = self.game.coalition_for(flight.departure.captured).coalition_id trigger.add_condition(CoalitionHasAirdrome(coalition, flight.from_cp.id)) - def generate_planned_flight(self, cp, country, flight: Flight): + def generate_planned_flight( + self, cp: ControlPoint, country: Country, flight: Flight + ) -> FlyingGroup[Any]: name = namegen.next_aircraft_name(country, cp.id, flight) try: if flight.start_type == "In Flight": @@ -728,13 +710,19 @@ class AircraftConflictGenerator: ) elif isinstance(cp, NavalControlPoint): group_name = cp.get_carrier_group_name() + carrier_group = self.m.find_group(group_name) + if not isinstance(carrier_group, ShipGroup): + raise RuntimeError( + f"Carrier group {carrier_group} is a " + "{carrier_group.__class__.__name__}, expected a ShipGroup" + ) group = self._generate_at_group( name=name, side=country, unit_type=flight.unit_type.dcs_unit_type, count=flight.count, start_type=flight.start_type, - at=self.m.find_group(group_name), + at=carrier_group, ) else: @@ -796,7 +784,7 @@ class AircraftConflictGenerator: @staticmethod def set_reduced_fuel( - flight: Flight, group: FlyingGroup, unit_type: Type[PlaneType] + flight: Flight, group: FlyingGroup[Any], unit_type: Type[FlyingType] ) -> None: if unit_type is Su_33: for unit in group.units: @@ -822,9 +810,9 @@ class AircraftConflictGenerator: def configure_behavior( self, flight: Flight, - group: FlyingGroup, + group: FlyingGroup[Any], react_on_threat: Optional[OptReactOnThreat.Values] = None, - roe: Optional[OptROE.Values] = None, + roe: Optional[int] = None, rtb_winchester: Optional[OptRTBOnOutOfAmmo.Values] = None, restrict_jettison: Optional[bool] = None, mission_uses_gun: bool = True, @@ -855,13 +843,13 @@ class AircraftConflictGenerator: # https://forums.eagle.ru/forum/english/digital-combat-simulator/dcs-world-2-5/bugs-and-problems-ai/ai-ad/7121294-ai-stuck-at-high-aoa-after-making-sharp-turn-if-afterburner-is-restricted @staticmethod - def configure_eplrs(group: FlyingGroup, flight: Flight) -> None: + def configure_eplrs(group: FlyingGroup[Any], flight: Flight) -> None: if flight.unit_type.eplrs_capable: group.points[0].tasks.append(EPLRS(group.id)) def configure_cap( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -878,7 +866,7 @@ class AircraftConflictGenerator: def configure_sweep( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -895,7 +883,7 @@ class AircraftConflictGenerator: def configure_cas( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -913,7 +901,7 @@ class AircraftConflictGenerator: def configure_dead( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -938,7 +926,7 @@ class AircraftConflictGenerator: def configure_sead( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -962,7 +950,7 @@ class AircraftConflictGenerator: def configure_strike( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -980,7 +968,7 @@ class AircraftConflictGenerator: def configure_anti_ship( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -998,7 +986,7 @@ class AircraftConflictGenerator: def configure_runway_attack( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1016,7 +1004,7 @@ class AircraftConflictGenerator: def configure_oca_strike( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1033,7 +1021,7 @@ class AircraftConflictGenerator: def configure_awacs( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1061,7 +1049,7 @@ class AircraftConflictGenerator: def configure_refueling( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1087,7 +1075,7 @@ class AircraftConflictGenerator: def configure_escort( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1103,7 +1091,7 @@ class AircraftConflictGenerator: def configure_sead_escort( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1126,7 +1114,7 @@ class AircraftConflictGenerator: def configure_transport( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1141,13 +1129,13 @@ class AircraftConflictGenerator: restrict_jettison=True, ) - def configure_unknown_task(self, group: FlyingGroup, flight: Flight) -> None: + def configure_unknown_task(self, group: FlyingGroup[Any], flight: Flight) -> None: logging.error(f"Unhandled flight type: {flight.flight_type}") self.configure_behavior(flight, group) def setup_flight_group( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1191,7 +1179,7 @@ class AircraftConflictGenerator: self.configure_eplrs(group, flight) def create_waypoints( - self, group: FlyingGroup, package: Package, flight: Flight + self, group: FlyingGroup[Any], package: Package, flight: Flight ) -> None: for waypoint in flight.points: @@ -1236,8 +1224,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: @@ -1259,7 +1296,7 @@ class AircraftConflictGenerator: waypoint: FlightWaypoint, package: Package, flight: Flight, - group: FlyingGroup, + group: FlyingGroup[Any], ) -> None: estimator = TotEstimator(package) start_time = estimator.mission_start_time(flight) @@ -1302,7 +1339,7 @@ class PydcsWaypointBuilder: def __init__( self, waypoint: FlightWaypoint, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, mission: Mission, @@ -1345,7 +1382,7 @@ class PydcsWaypointBuilder: def for_waypoint( cls, waypoint: FlightWaypoint, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, mission: Mission, @@ -1459,7 +1496,7 @@ class CasIngressBuilder(PydcsWaypointBuilder): if isinstance(self.flight.flight_plan, CasFlightPlan): waypoint.add_task( EngageTargetsInZone( - position=self.flight.flight_plan.target, + position=self.flight.flight_plan.target.position, radius=int(self.flight.flight_plan.engagement_distance.meters), targets=[ Targets.All.GroundUnits.GroundVehicles, diff --git a/gen/airfields.py b/gen/airfields.py index 7d499cf1..7998cbf4 100644 --- a/gen/airfields.py +++ b/gen/airfields.py @@ -1521,4 +1521,47 @@ AIRFIELD_DATA = { runway_length=3953, atc=AtcData(MHz(3, 850), MHz(118, 200), MHz(38, 600), MHz(250, 200)), ), + "Antonio B. Won Pat Intl": AirfieldData( + theater="MarianaIslands", + icao="PGUM", + elevation=255, + runway_length=9359, + atc=AtcData(MHz(3, 825), MHz(118, 100), MHz(38, 550), MHz(340, 200)), + ils={ + "06": ("IGUM", MHz(110, 30)), + }, + ), + "Andersen AFB": AirfieldData( + theater="MarianaIslands", + icao="PGUA", + elevation=545, + runway_length=10490, + tacan=TacanChannel(54, TacanBand.X), + tacan_callsign="UAM", + atc=AtcData(MHz(3, 850), MHz(126, 200), MHz(38, 600), MHz(250, 100)), + ), + "Rota Intl": AirfieldData( + theater="MarianaIslands", + icao="PGRO", + elevation=568, + runway_length=6105, + atc=AtcData(MHz(3, 750), MHz(123, 600), MHz(38, 400), MHz(250, 0)), + ), + "Tinian Intl": AirfieldData( + theater="MarianaIslands", + icao="PGWT", + elevation=240, + runway_length=7777, + atc=AtcData(MHz(3, 800), MHz(123, 650), MHz(38, 500), MHz(250, 50)), + ), + "Saipan Intl": AirfieldData( + theater="MarianaIslands", + icao="PGSN", + elevation=213, + runway_length=7790, + atc=AtcData(MHz(3, 775), MHz(125, 700), MHz(38, 450), MHz(256, 900)), + ils={ + "07": ("IGSN", MHz(109, 90)), + }, + ), } diff --git a/gen/airsupport.py b/gen/airsupport.py new file mode 100644 index 00000000..1ce520de --- /dev/null +++ b/gen/airsupport.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import timedelta +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from gen import RadioFrequency, TacanChannel + + +@dataclass +class AwacsInfo: + """AWACS information for the kneeboard.""" + + group_name: str + callsign: str + freq: RadioFrequency + depature_location: Optional[str] + start_time: Optional[timedelta] + end_time: Optional[timedelta] + blue: bool + + +@dataclass +class TankerInfo: + """Tanker information for the kneeboard.""" + + group_name: str + callsign: str + variant: str + freq: RadioFrequency + tacan: TacanChannel + start_time: Optional[timedelta] + end_time: Optional[timedelta] + blue: bool + + +@dataclass(frozen=True) +class JtacInfo: + """JTAC information.""" + + group_name: str + unit_name: str + callsign: str + region: str + code: str + blue: bool + freq: RadioFrequency + + +@dataclass +class AirSupport: + awacs: list[AwacsInfo] = field(default_factory=list) + tankers: list[TankerInfo] = field(default_factory=list) + jtacs: list[JtacInfo] = field(default_factory=list) diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 875a0e58..2f20a7c3 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -1,11 +1,10 @@ +from __future__ import annotations + import logging -from dataclasses import dataclass, field -from datetime import timedelta -from typing import List, Type, Tuple, Optional +from typing import List, Type, Tuple, TYPE_CHECKING from dcs.mission import Mission, StartType -from dcs.planes import IL_78M, KC130, KC135MPRS, KC_135 -from dcs.unittype import UnitType +from dcs.planes import IL_78M, KC130, KC135MPRS, KC_135, PlaneType from dcs.task import ( AWACS, ActivateBeaconCommand, @@ -14,15 +13,20 @@ from dcs.task import ( SetImmortalCommand, SetInvisibleCommand, ) +from dcs.unittype import UnitType -from game import db -from .flights.ai_flight_planner_db import AEWC_CAPABLE -from .naming import namegen +from game.utils import Heading +from . import AirSupport +from .airsupport import TankerInfo, AwacsInfo from .callsigns import callsign_for_support_unit from .conflictgen import Conflict -from .radios import RadioFrequency, RadioRegistry -from .tacan import TacanBand, TacanChannel, TacanRegistry +from .flights.ai_flight_planner_db import AEWC_CAPABLE +from .naming import namegen +from .radios import RadioRegistry +from .tacan import TacanBand, TacanRegistry +if TYPE_CHECKING: + from game import Game TANKER_DISTANCE = 15000 TANKER_ALT = 4572 @@ -32,54 +36,22 @@ AWACS_DISTANCE = 150000 AWACS_ALT = 13000 -@dataclass -class AwacsInfo: - """AWACS information for the kneeboard.""" - - group_name: str - callsign: str - freq: RadioFrequency - depature_location: Optional[str] - start_time: Optional[timedelta] - end_time: Optional[timedelta] - blue: bool - - -@dataclass -class TankerInfo: - """Tanker information for the kneeboard.""" - - group_name: str - callsign: str - variant: str - freq: RadioFrequency - tacan: TacanChannel - start_time: Optional[timedelta] - end_time: Optional[timedelta] - blue: bool - - -@dataclass -class AirSupport: - awacs: List[AwacsInfo] = field(default_factory=list) - tankers: List[TankerInfo] = field(default_factory=list) - - class AirSupportConflictGenerator: def __init__( self, mission: Mission, conflict: Conflict, - game, + game: Game, radio_registry: RadioRegistry, tacan_registry: TacanRegistry, + air_support: AirSupport, ) -> None: self.mission = mission self.conflict = conflict self.game = game - self.air_support = AirSupport() self.radio_registry = radio_registry self.tacan_registry = tacan_registry + self.air_support = air_support @classmethod def support_tasks(cls) -> List[Type[MainTask]]: @@ -88,46 +60,51 @@ class AirSupportConflictGenerator: @staticmethod def _get_tanker_params(unit_type: Type[UnitType]) -> Tuple[int, int]: if unit_type is KC130: - return (TANKER_ALT - 500, 596) + return TANKER_ALT - 500, 596 elif unit_type is KC_135: - return (TANKER_ALT, 770) + return TANKER_ALT, 770 elif unit_type is KC135MPRS: - return (TANKER_ALT + 500, 596) - return (TANKER_ALT, 574) + return TANKER_ALT + 500, 596 + return TANKER_ALT, 574 - def generate(self): + def generate(self) -> None: player_cp = ( self.conflict.blue_cp if self.conflict.blue_cp.captured else self.conflict.red_cp ) + country = self.mission.country(self.game.blue.country_name) + if not self.game.settings.disable_legacy_tanker: fallback_tanker_number = 0 for i, tanker_unit_type in enumerate( self.game.faction_for(player=True).tankers ): + unit_type = tanker_unit_type.dcs_unit_type + if not issubclass(unit_type, PlaneType): + logging.warning(f"Refueling aircraft {unit_type} must be a plane") + continue + # TODO: Make loiter altitude a property of the unit type. 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=self.mission.country(self.game.player_country), - name=namegen.next_tanker_name( - self.mission.country(self.game.player_country), tanker_unit_type - ), + country=country, + name=namegen.next_tanker_name(country, tanker_unit_type), airport=None, - plane_type=tanker_unit_type, + plane_type=unit_type, position=tanker_position, altitude=alt, race_distance=58000, @@ -177,6 +154,8 @@ class AirSupportConflictGenerator: tanker_unit_type.name, freq, tacan, + start_time=None, + end_time=None, blue=True, ) ) @@ -195,12 +174,15 @@ class AirSupportConflictGenerator: awacs_unit = possible_awacs[0] freq = self.radio_registry.alloc_uhf() + unit_type = awacs_unit.dcs_unit_type + if not issubclass(unit_type, PlaneType): + logging.warning(f"AWACS aircraft {unit_type} must be a plane") + return + awacs_flight = self.mission.awacs_flight( - country=self.mission.country(self.game.player_country), - name=namegen.next_awacs_name( - self.mission.country(self.game.player_country) - ), - plane_type=awacs_unit, + country=country, + name=namegen.next_awacs_name(country), + plane_type=unit_type, altitude=AWACS_ALT, airport=None, position=self.conflict.position.random_point_within( diff --git a/gen/armor.py b/gen/armor.py index bae64166..e13827a8 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import math import random from dataclasses import dataclass from typing import TYPE_CHECKING, List, Optional, Tuple @@ -12,9 +13,11 @@ from dcs.country import Country from dcs.mapping import Point from dcs.point import PointAction from dcs.task import ( + AFAC, EPLRS, AttackGroup, ControlledTask, + FAC, FireAtPoint, GoToWaypoint, Hold, @@ -23,7 +26,7 @@ from dcs.task import ( SetInvisibleCommand, ) from dcs.triggers import Event, TriggerOnce -from dcs.unit import Vehicle +from dcs.unit import Vehicle, Skill from dcs.unitgroup import VehicleGroup from game.data.groundunitclass import GroundUnitClass @@ -31,16 +34,19 @@ 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, CombatGroupRole, ) +from .airsupport import AirSupport, JtacInfo from .callsigns import callsign_for_support_unit from .conflictgen import Conflict from .ground_forces.combat_stance import CombatStance +from .lasercoderegistry import LaserCodeRegistry from .naming import namegen +from .radios import MHz, RadioFrequency, RadioRegistry if TYPE_CHECKING: from game import Game @@ -63,19 +69,6 @@ RANDOM_OFFSET_ATTACK = 250 INFANTRY_GROUP_SIZE = 5 -@dataclass(frozen=True) -class JtacInfo: - """JTAC information.""" - - group_name: str - unit_name: str - callsign: str - region: str - code: str - blue: bool - # TODO: Radio info? Type? - - class GroundConflictGenerator: def __init__( self, @@ -85,57 +78,29 @@ class GroundConflictGenerator: player_planned_combat_groups: List[CombatGroup], enemy_planned_combat_groups: List[CombatGroup], player_stance: CombatStance, + enemy_stance: CombatStance, unit_map: UnitMap, + radio_registry: RadioRegistry, + air_support: AirSupport, + laser_code_registry: LaserCodeRegistry, ) -> None: self.mission = mission self.conflict = conflict self.enemy_planned_combat_groups = enemy_planned_combat_groups self.player_planned_combat_groups = player_planned_combat_groups - self.player_stance = CombatStance(player_stance) - self.enemy_stance = self._enemy_stance() + self.player_stance = player_stance + self.enemy_stance = enemy_stance self.game = game self.unit_map = unit_map - self.jtacs: List[JtacInfo] = [] + self.radio_registry = radio_registry + self.air_support = air_support + self.laser_code_registry = laser_code_registry - def _enemy_stance(self): - """Picks the enemy stance according to the number of planned groups on the frontline for each side""" - if len(self.enemy_planned_combat_groups) > len( - self.player_planned_combat_groups - ): - return random.choice( - [ - CombatStance.AGGRESSIVE, - CombatStance.AGGRESSIVE, - CombatStance.AGGRESSIVE, - CombatStance.ELIMINATION, - CombatStance.BREAKTHROUGH, - ] - ) - else: - return random.choice( - [ - CombatStance.DEFENSIVE, - CombatStance.DEFENSIVE, - CombatStance.DEFENSIVE, - CombatStance.AMBUSH, - CombatStance.AGGRESSIVE, - ] - ) - - @staticmethod - def _group_point(point: Point, base_distance) -> Point: - distance = random.randint( - int(base_distance * SPREAD_DISTANCE_FACTOR[0]), - int(base_distance * SPREAD_DISTANCE_FACTOR[1]), - ) - return point.random_point_within( - distance, base_distance * SPREAD_DISTANCE_SIZE_FACTOR - ) - - def generate(self): + def generate(self) -> None: position = Conflict.frontline_position( self.conflict.front_line, self.game.theater ) + frontline_vector = Conflict.frontline_vector( self.conflict.front_line, self.game.theater ) @@ -150,12 +115,19 @@ class GroundConflictGenerator: self.enemy_planned_combat_groups, frontline_vector, False ) + # TODO: Differentiate AirConflict and GroundConflict classes. + if self.conflict.heading is None: + raise RuntimeError( + "Cannot generate ground units for non-ground conflict. Ground unit " + "conflicts cannot have the heading `None`." + ) + # Plan combat actions for groups self.plan_action_for_groups( self.player_stance, player_groups, enemy_groups, - self.conflict.heading + 90, + self.conflict.heading.right, self.conflict.blue_cp, self.conflict.red_cp, ) @@ -163,27 +135,32 @@ 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, ) # Add JTAC - if self.game.player_faction.has_jtac: + if self.game.blue.faction.has_jtac: n = "JTAC" + str(self.conflict.blue_cp.id) + str(self.conflict.red_cp.id) - code = 1688 - len(self.jtacs) + code: int = self.laser_code_registry.get_next_laser_code() + freq = self.radio_registry.alloc_uhf() - utype = self.game.player_faction.jtac_unit - if self.game.player_faction.jtac_unit is None: + utype = self.game.blue.faction.jtac_unit + if utype is None: utype = AircraftType.named("MQ-9 Reaper") jtac = self.mission.flight_group( - country=self.mission.country(self.game.player_country), + country=self.mission.country(self.game.blue.country_name), name=n, aircraft_type=utype.dcs_unit_type, position=position[0], airport=None, altitude=5000, + maintask=AFAC, + ) + jtac.points[0].tasks.append( + FAC(callsign=len(self.air_support.jtacs) + 1, frequency=int(freq.mhz)) ) jtac.points[0].tasks.append(SetInvisibleCommand(True)) jtac.points[0].tasks.append(SetImmortalCommand(True)) @@ -195,7 +172,7 @@ class GroundConflictGenerator: ) # Note: Will need to change if we ever add ground based JTAC. callsign = callsign_for_support_unit(jtac) - self.jtacs.append( + self.air_support.jtacs.append( JtacInfo( str(jtac.name), n, @@ -203,11 +180,16 @@ class GroundConflictGenerator: frontline, str(code), blue=True, + freq=freq, ) ) 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( @@ -242,7 +224,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 @@ -269,7 +251,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, ) @@ -281,17 +263,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( @@ -299,7 +283,7 @@ class GroundConflictGenerator: stance: CombatStance, gen_group: CombatGroup, dcs_group: VehicleGroup, - forward_heading: int, + forward_heading: Heading, target: Point, ) -> bool: """ @@ -333,7 +317,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()) @@ -361,8 +345,7 @@ class GroundConflictGenerator: self.mission.triggerrules.triggers.append(artillery_fallback) for u in dcs_group.units: - u.initial = True - u.heading = forward_heading + random.randint(-5, 5) + u.heading = (forward_heading + Heading.random(-5, 5)).degrees return True return False @@ -371,7 +354,7 @@ class GroundConflictGenerator: stance: CombatStance, enemy_groups: List[Tuple[VehicleGroup, CombatGroup]], dcs_group: VehicleGroup, - forward_heading: int, + forward_heading: Heading, to_cp: ControlPoint, ) -> bool: """ @@ -404,9 +387,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 ) @@ -424,9 +405,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 ) @@ -462,7 +441,7 @@ class GroundConflictGenerator: self, stance: CombatStance, dcs_group: VehicleGroup, - forward_heading: int, + forward_heading: Heading, to_cp: ControlPoint, ) -> bool: """ @@ -499,7 +478,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: @@ -540,12 +519,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 """ @@ -558,7 +539,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 @@ -570,10 +551,10 @@ class GroundConflictGenerator: ) # Fallback task - fallback = ControlledTask(GoToWaypoint(to_index=len(dcs_group.points))) - fallback.enabled = False + task = ControlledTask(GoToWaypoint(to_index=len(dcs_group.points))) + task.enabled = False dcs_group.add_trigger_action(Hold()) - dcs_group.add_trigger_action(fallback) + dcs_group.add_trigger_action(task) # Create trigger fallback = TriggerOnce(Event.NoEvent, "Morale manager #" + str(dcs_group.id)) @@ -589,7 +570,7 @@ class GroundConflictGenerator: def find_retreat_point( self, dcs_group: VehicleGroup, - frontline_heading: int, + frontline_heading: Heading, distance: int = RETREAT_DISTANCE, ) -> Point: """ @@ -599,14 +580,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 @@ -616,7 +597,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 @@ -634,7 +615,7 @@ class GroundConflictGenerator: @param enemy_groups Potential enemy groups @param n number of nearby groups to take """ - targets = [] # type: List[Optional[VehicleGroup]] + targets = [] # type: List[VehicleGroup] sorted_list = sorted( enemy_groups, key=lambda group: player_group.points[0].position.distance_to_point( @@ -658,7 +639,7 @@ class GroundConflictGenerator: @param group Group for which we should find the nearest ennemy @param enemy_groups Potential enemy groups """ - min_distance = 99999999 + min_distance = math.inf target = None for dcs_group, _ in enemy_groups: dist = player_group.points[0].position.distance_to_point( @@ -696,7 +677,7 @@ class GroundConflictGenerator: """ For artilery group, decide the distance from frontline with the range of the unit """ - rg = getattr(group.unit_type.dcs_unit_type, "threat_range", 0) - 7500 + rg = group.unit_type.dcs_unit_type.threat_range - 7500 if rg > DISTANCE_FROM_FRONTLINE[CombatGroupRole.ARTILLERY][1]: rg = random.randint( DISTANCE_FROM_FRONTLINE[CombatGroupRole.ARTILLERY][0], @@ -714,14 +695,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 @@ -730,18 +711,14 @@ 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)) - ) - country = self.game.player_country if is_player else self.game.enemy_country + 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: distance_from_frontline = ( @@ -763,12 +740,12 @@ class GroundConflictGenerator: group.unit_type, group.size, final_position, - heading=opposite_heading(spawn_heading), + heading=spawn_heading.opposite, ) if is_player: - g.set_skill(self.game.settings.player_skill) + g.set_skill(Skill(self.game.settings.player_skill)) else: - g.set_skill(self.game.settings.enemy_vehicle_skill) + g.set_skill(Skill(self.game.settings.enemy_vehicle_skill)) positioned_groups.append((g, group)) if group.role in [CombatGroupRole.APC, CombatGroupRole.IFV]: @@ -776,7 +753,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}") @@ -790,7 +767,7 @@ class GroundConflictGenerator: count: int, at: Point, move_formation: PointAction = PointAction.OffRoad, - heading=0, + heading: Heading = Heading.from_degrees(0), ) -> VehicleGroup: if side == self.conflict.attackers_country: @@ -804,7 +781,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/ato.py b/gen/ato.py index da6fbf1c..944cf316 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -40,7 +40,6 @@ class Task: class PackageWaypoints: join: Point ingress: Point - egress: Point split: Point diff --git a/gen/briefinggen.py b/gen/briefinggen.py index 87029d7b..5a6911f0 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -136,6 +136,16 @@ def format_waypoint_time(waypoint: FlightWaypoint, depart_prefix: str) -> str: return "" +def format_intra_flight_channel(flight: FlightData) -> str: + frequency = flight.intra_flight_channel + channel = flight.channel_for(frequency) + if channel is None: + return str(frequency) + + channel_name = flight.aircraft_type.channel_name(channel.radio_id, channel.channel) + return f"{channel_name} ({frequency})" + + class BriefingGenerator(MissionInfoGenerator): def __init__(self, mission: Mission, game: Game): super().__init__(mission, game) @@ -151,6 +161,7 @@ class BriefingGenerator(MissionInfoGenerator): lstrip_blocks=True, ) env.filters["waypoint_timing"] = format_waypoint_time + env.filters["intra_flight_channel"] = format_intra_flight_channel self.template = env.get_template("briefingtemplate_EN.j2") def generate(self) -> None: diff --git a/gen/callsigns.py b/gen/callsigns.py index 8ebda467..a722606f 100644 --- a/gen/callsigns.py +++ b/gen/callsigns.py @@ -1,12 +1,13 @@ """Support for working with DCS group callsigns.""" import logging import re +from typing import Any from dcs.unitgroup import FlyingGroup from dcs.flyingunit import FlyingUnit -def callsign_for_support_unit(group: FlyingGroup) -> str: +def callsign_for_support_unit(group: FlyingGroup[Any]) -> str: # Either something like Overlord11 for Western AWACS, or else just a number. # Convert to either "Overlord" or "Flight 123". lead = group.units[0] diff --git a/gen/cargoshipgen.py b/gen/cargoshipgen.py index 9de370b9..ec7e6577 100644 --- a/gen/cargoshipgen.py +++ b/gen/cargoshipgen.py @@ -24,12 +24,13 @@ class CargoShipGenerator: def generate(self) -> None: # Reset the count to make generation deterministic. - for ship in self.game.transfers.cargo_ships: - self.generate_cargo_ship(ship) + for coalition in self.game.coalitions: + for ship in coalition.transfers.cargo_ships: + self.generate_cargo_ship(ship) def generate_cargo_ship(self, ship: CargoShip) -> ShipGroup: country = self.mission.country( - self.game.player_country if ship.player_owned else self.game.enemy_country + self.game.coalition_for(ship.player_owned).country_name ) waypoints = ship.route group = self.mission.ship_group( diff --git a/gen/coastal/coastal_group_generator.py b/gen/coastal/coastal_group_generator.py index 160712e0..0d263e3b 100644 --- a/gen/coastal/coastal_group_generator.py +++ b/gen/coastal/coastal_group_generator.py @@ -1,6 +1,11 @@ import logging import random -from game import db +from typing import Optional + +from dcs.unitgroup import VehicleGroup + +from game import db, Game +from game.theater.theatergroundobject import CoastalSiteGroundObject from gen.coastal.silkworm import SilkwormGenerator COASTAL_MAP = { @@ -8,10 +13,13 @@ COASTAL_MAP = { } -def generate_coastal_group(game, ground_object, faction_name: str): +def generate_coastal_group( + game: Game, ground_object: CoastalSiteGroundObject, faction_name: str +) -> Optional[VehicleGroup]: """ This generate a coastal defenses group - :return: Nothing, but put the group reference inside the ground object + :return: The generated group, or None if this faction does not support coastal + defenses. """ faction = db.FACTIONS[faction_name] if len(faction.coastal_defenses) > 0: diff --git a/gen/coastal/silkworm.py b/gen/coastal/silkworm.py index 4198e004..b0fb98c5 100644 --- a/gen/coastal/silkworm.py +++ b/gen/coastal/silkworm.py @@ -1,14 +1,20 @@ from dcs.vehicles import MissilesSS, Unarmed, AirDefence -from gen.sam.group_generator import GroupGenerator +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 -class SilkwormGenerator(GroupGenerator): - def __init__(self, game, ground_object, faction): +class SilkwormGenerator(VehicleGroupGenerator[CoastalSiteGroundObject]): + def __init__( + self, game: Game, ground_object: CoastalSiteGroundObject, faction: Faction + ) -> None: super(SilkwormGenerator, self).__init__(game, ground_object) self.faction = faction - def generate(self): + def generate(self) -> None: positions = self.get_circular_position(5, launcher_distance=120, coverage=180) @@ -23,7 +29,7 @@ class SilkwormGenerator(GroupGenerator): # Launchers for i, p in enumerate(positions): self.add_unit( - MissilesSS.Silkworm_SR, + MissilesSS.Hy_launcher, "Missile#" + str(i), p[0], p[1], @@ -54,5 +60,5 @@ class SilkwormGenerator(GroupGenerator): "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 eabf4e4e..6693367e 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import Tuple, Optional @@ -7,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 @@ -23,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, ): @@ -53,26 +55,28 @@ class Conflict: @classmethod def frontline_position( cls, frontline: FrontLine, theater: ConflictTheater - ) -> Tuple[Point, int]: + ) -> 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, ) - return position, opposite_heading(attack_heading) + if position is None: + raise RuntimeError("Could not find front line position") + 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 ) @@ -91,7 +95,7 @@ class Conflict: defender: Country, front_line: FrontLine, theater: ConflictTheater, - ): + ) -> Conflict: assert cls.has_frontline_between(front_line.blue_cp, front_line.red_cp) position, heading, distance = cls.frontline_vector(front_line, theater) conflict = cls( @@ -109,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 @@ -129,16 +137,16 @@ 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=True, + coerce: bool = True, ) -> Optional[Point]: """ Finds the nearest valid ground position along a provided heading and it's inverse up to max_distance. @@ -149,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/convoygen.py b/gen/convoygen.py index 303c286f..b695d144 100644 --- a/gen/convoygen.py +++ b/gen/convoygen.py @@ -27,8 +27,9 @@ class ConvoyGenerator: def generate(self) -> None: # Reset the count to make generation deterministic. - for convoy in self.game.transfers.convoys: - self.generate_convoy(convoy) + for coalition in self.game.coalitions: + for convoy in coalition.transfers.convoys: + self.generate_convoy(convoy) def generate_convoy(self, convoy: Convoy) -> VehicleGroup: group = self._create_mixed_unit_group( @@ -53,9 +54,7 @@ class ConvoyGenerator: units: dict[GroundUnitType, int], for_player: bool, ) -> VehicleGroup: - country = self.mission.country( - self.game.player_country if for_player else self.game.enemy_country - ) + country = self.mission.country(self.game.coalition_for(for_player).country_name) unit_types = list(units.items()) main_unit_type, main_unit_count = unit_types[0] diff --git a/gen/defenses/armor_group_generator.py b/gen/defenses/armor_group_generator.py index fc549ca9..1ed04e06 100644 --- a/gen/defenses/armor_group_generator.py +++ b/gen/defenses/armor_group_generator.py @@ -1,4 +1,5 @@ import random +from typing import Optional from dcs.unitgroup import VehicleGroup @@ -12,7 +13,9 @@ from gen.defenses.armored_group_generator import ( ) -def generate_armor_group(faction: str, game, ground_object): +def generate_armor_group( + faction: str, game: Game, ground_object: VehicleGroupGroundObject +) -> Optional[VehicleGroup]: """ This generate a group of ground units :return: Generated group diff --git a/gen/defenses/armored_group_generator.py b/gen/defenses/armored_group_generator.py index f68b520b..c7404d0d 100644 --- a/gen/defenses/armored_group_generator.py +++ b/gen/defenses/armored_group_generator.py @@ -3,10 +3,10 @@ import random from game import Game from game.dcs.groundunittype import GroundUnitType from game.theater.theatergroundobject import VehicleGroupGroundObject -from gen.sam.group_generator import GroupGenerator +from gen.sam.group_generator import VehicleGroupGenerator -class ArmoredGroupGenerator(GroupGenerator): +class ArmoredGroupGenerator(VehicleGroupGenerator[VehicleGroupGroundObject]): def __init__( self, game: Game, @@ -35,7 +35,7 @@ class ArmoredGroupGenerator(GroupGenerator): ) -class FixedSizeArmorGroupGenerator(GroupGenerator): +class FixedSizeArmorGroupGenerator(VehicleGroupGenerator[VehicleGroupGroundObject]): def __init__( self, game: Game, @@ -47,7 +47,7 @@ class FixedSizeArmorGroupGenerator(GroupGenerator): self.unit_type = unit_type self.size = size - def generate(self): + def generate(self) -> None: spacing = random.randint(20, 70) index = 0 diff --git a/gen/environmentgen.py b/gen/environmentgen.py index 5e393e04..84f5bd59 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -2,7 +2,7 @@ from typing import Optional from dcs.mission import Mission -from game.weather import Clouds, Fog, Conditions, WindConditions +from game.weather import Clouds, Fog, Conditions, WindConditions, AtmosphericConditions class EnvironmentGenerator: @@ -10,6 +10,10 @@ class EnvironmentGenerator: self.mission = mission self.conditions = conditions + def set_atmospheric(self, atmospheric: AtmosphericConditions) -> None: + self.mission.weather.qnh = atmospheric.qnh.mm_hg + self.mission.weather.season_temperature = atmospheric.temperature_celsius + def set_clouds(self, clouds: Optional[Clouds]) -> None: if clouds is None: return @@ -22,7 +26,7 @@ class EnvironmentGenerator: def set_fog(self, fog: Optional[Fog]) -> None: if fog is None: return - self.mission.weather.fog_visibility = fog.visibility.meters + self.mission.weather.fog_visibility = int(fog.visibility.meters) self.mission.weather.fog_thickness = fog.thickness def set_wind(self, wind: WindConditions) -> None: @@ -30,8 +34,9 @@ class EnvironmentGenerator: self.mission.weather.wind_at_2000 = wind.at_2000m self.mission.weather.wind_at_8000 = wind.at_8000m - def generate(self): + def generate(self) -> None: self.mission.start_time = self.conditions.start_time + self.set_atmospheric(self.conditions.weather.atmospheric) self.set_clouds(self.conditions.weather.clouds) self.set_fog(self.conditions.weather.fog) self.set_wind(self.conditions.weather.wind) diff --git a/gen/fleet/carrier_group.py b/gen/fleet/carrier_group.py index 4200caca..74ca4c67 100644 --- a/gen/fleet/carrier_group.py +++ b/gen/fleet/carrier_group.py @@ -1,12 +1,13 @@ import random from gen.sam.group_generator import ShipGroupGenerator +from game.utils import Heading from dcs.ships import USS_Arleigh_Burke_IIa, TICONDEROG class CarrierGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: # Carrier Strike Group 8 if self.faction.carrier_names[0] == "Carrier Strike Group 8": @@ -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/fleet/cn_dd_group.py b/gen/fleet/cn_dd_group.py index 91c710a0..144df4b4 100644 --- a/gen/fleet/cn_dd_group.py +++ b/gen/fleet/cn_dd_group.py @@ -3,7 +3,6 @@ from __future__ import annotations import random from typing import TYPE_CHECKING - from dcs.ships import ( Type_052C, Type_052B, @@ -11,16 +10,16 @@ from dcs.ships import ( ) from game.factions.faction import Faction +from game.theater.theatergroundobject import ShipGroundObject from gen.fleet.dd_group import DDGroupGenerator from gen.sam.group_generator import ShipGroupGenerator -from game.theater.theatergroundobject import TheaterGroundObject if TYPE_CHECKING: from game.game import Game class ChineseNavyGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: include_frigate = random.choice([True, True, False]) include_dd = random.choice([True, False]) @@ -65,9 +64,7 @@ class ChineseNavyGroupGenerator(ShipGroupGenerator): class Type54GroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(Type54GroupGenerator, self).__init__( game, ground_object, faction, Type_054A ) diff --git a/gen/fleet/dd_group.py b/gen/fleet/dd_group.py index db5dd0dd..db766a0d 100644 --- a/gen/fleet/dd_group.py +++ b/gen/fleet/dd_group.py @@ -1,12 +1,13 @@ from __future__ import annotations + from typing import TYPE_CHECKING, Type -from game.factions.faction import Faction -from game.theater.theatergroundobject import TheaterGroundObject - -from gen.sam.group_generator import ShipGroupGenerator -from dcs.unittype import ShipType from dcs.ships import PERRY, USS_Arleigh_Burke_IIa +from dcs.unittype import ShipType + +from game.factions.faction import Faction +from game.theater.theatergroundobject import ShipGroundObject +from gen.sam.group_generator import ShipGroupGenerator if TYPE_CHECKING: from game.game import Game @@ -16,14 +17,14 @@ class DDGroupGenerator(ShipGroupGenerator): def __init__( self, game: Game, - ground_object: TheaterGroundObject, + ground_object: ShipGroundObject, faction: Faction, ddtype: Type[ShipType], ): super(DDGroupGenerator, self).__init__(game, ground_object, faction) self.ddtype = ddtype - def generate(self): + def generate(self) -> None: self.add_unit( self.ddtype, "DD1", @@ -42,18 +43,14 @@ class DDGroupGenerator(ShipGroupGenerator): class OliverHazardPerryGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(OliverHazardPerryGroupGenerator, self).__init__( game, ground_object, faction, PERRY ) class ArleighBurkeGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(ArleighBurkeGroupGenerator, self).__init__( game, ground_object, faction, USS_Arleigh_Burke_IIa ) diff --git a/gen/fleet/lacombattanteII.py b/gen/fleet/lacombattanteII.py index 7de47da1..bd476f45 100644 --- a/gen/fleet/lacombattanteII.py +++ b/gen/fleet/lacombattanteII.py @@ -1,12 +1,13 @@ from dcs.ships import La_Combattante_II +from game import Game from game.factions.faction import Faction -from game.theater import TheaterGroundObject +from game.theater.theatergroundobject import ShipGroundObject from gen.fleet.dd_group import DDGroupGenerator class LaCombattanteIIGroupGenerator(DDGroupGenerator): - def __init__(self, game, ground_object: TheaterGroundObject, faction: Faction): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(LaCombattanteIIGroupGenerator, self).__init__( game, ground_object, faction, La_Combattante_II ) diff --git a/gen/fleet/lha_group.py b/gen/fleet/lha_group.py index a1a78d37..a7a896b9 100644 --- a/gen/fleet/lha_group.py +++ b/gen/fleet/lha_group.py @@ -4,7 +4,7 @@ from gen.sam.group_generator import ShipGroupGenerator class LHAGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: # Add carrier if len(self.faction.helicopter_carrier) > 0: diff --git a/gen/fleet/ru_dd_group.py b/gen/fleet/ru_dd_group.py index 8ec15d26..67f9c923 100644 --- a/gen/fleet/ru_dd_group.py +++ b/gen/fleet/ru_dd_group.py @@ -1,4 +1,5 @@ from __future__ import annotations + import random from typing import TYPE_CHECKING @@ -12,18 +13,17 @@ from dcs.ships import ( SOM, ) +from game.factions.faction import Faction +from game.theater.theatergroundobject import ShipGroundObject from gen.fleet.dd_group import DDGroupGenerator from gen.sam.group_generator import ShipGroupGenerator -from game.factions.faction import Faction -from game.theater.theatergroundobject import TheaterGroundObject - if TYPE_CHECKING: from game.game import Game class RussianNavyGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: include_frigate = random.choice([True, True, False]) include_dd = random.choice([True, False]) @@ -85,32 +85,24 @@ class RussianNavyGroupGenerator(ShipGroupGenerator): class GrishaGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(GrishaGroupGenerator, self).__init__( game, ground_object, faction, ALBATROS ) class MolniyaGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(MolniyaGroupGenerator, self).__init__( game, ground_object, faction, MOLNIYA ) class KiloSubGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(KiloSubGroupGenerator, self).__init__(game, ground_object, faction, KILO) class TangoSubGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(TangoSubGroupGenerator, self).__init__(game, ground_object, faction, SOM) diff --git a/gen/fleet/schnellboot.py b/gen/fleet/schnellboot.py index 83a83fdf..d5fe16a6 100644 --- a/gen/fleet/schnellboot.py +++ b/gen/fleet/schnellboot.py @@ -6,7 +6,7 @@ from gen.sam.group_generator import ShipGroupGenerator class SchnellbootGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: for i in range(random.randint(2, 4)): self.add_unit( diff --git a/gen/fleet/ship_group_generator.py b/gen/fleet/ship_group_generator.py index 03ab852f..1cd8338c 100644 --- a/gen/fleet/ship_group_generator.py +++ b/gen/fleet/ship_group_generator.py @@ -1,7 +1,17 @@ +from __future__ import annotations + import logging import random +from typing import TYPE_CHECKING, Optional + +from dcs.unitgroup import ShipGroup from game import db +from game.theater.theatergroundobject import ( + LhaGroundObject, + CarrierGroundObject, + ShipGroundObject, +) from gen.fleet.carrier_group import CarrierGroupGenerator from gen.fleet.cn_dd_group import ChineseNavyGroupGenerator, Type54GroupGenerator from gen.fleet.dd_group import ( @@ -21,6 +31,9 @@ from gen.fleet.schnellboot import SchnellbootGroupGenerator from gen.fleet.uboat import UBoatGroupGenerator from gen.fleet.ww2lst import WW2LSTGroupGenerator +if TYPE_CHECKING: + from game import Game + SHIP_MAP = { "SchnellbootGroupGenerator": SchnellbootGroupGenerator, @@ -39,10 +52,12 @@ SHIP_MAP = { } -def generate_ship_group(game, ground_object, faction_name: str): +def generate_ship_group( + game: Game, ground_object: ShipGroundObject, faction_name: str +) -> Optional[ShipGroup]: """ This generate a ship group - :return: Nothing, but put the group reference inside the ground object + :return: The generated group, or None if this faction does not support ships. """ faction = db.FACTIONS[faction_name] if len(faction.navy_generators) > 0: @@ -61,26 +76,30 @@ def generate_ship_group(game, ground_object, faction_name: str): return None -def generate_carrier_group(faction: str, game, ground_object): - """ - This generate a carrier group - :param parentCp: The parent control point +def generate_carrier_group( + faction: str, game: Game, ground_object: CarrierGroundObject +) -> ShipGroup: + """Generates a carrier group. + + :param faction: The faction the TGO belongs to. + :param game: The Game the group is being generated for. :param ground_object: The ground object which will own the ship group - :param country: Owner country - :return: Nothing, but put the group reference inside the ground object + :return: The generated group. """ generator = CarrierGroupGenerator(game, ground_object, db.FACTIONS[faction]) generator.generate() return generator.get_generated_group() -def generate_lha_group(faction: str, game, ground_object): - """ - This generate a lha carrier group - :param parentCp: The parent control point +def generate_lha_group( + faction: str, game: Game, ground_object: LhaGroundObject +) -> ShipGroup: + """Generate an LHA group. + + :param faction: The faction the TGO belongs to. + :param game: The Game the group is being generated for. :param ground_object: The ground object which will own the ship group - :param country: Owner country - :return: Nothing, but put the group reference inside the ground object + :return: The generated group. """ generator = LHAGroupGenerator(game, ground_object, db.FACTIONS[faction]) generator.generate() diff --git a/gen/fleet/uboat.py b/gen/fleet/uboat.py index 6333021f..ee8c3114 100644 --- a/gen/fleet/uboat.py +++ b/gen/fleet/uboat.py @@ -6,7 +6,7 @@ from gen.sam.group_generator import ShipGroupGenerator class UBoatGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: for i in range(random.randint(1, 4)): self.add_unit( diff --git a/gen/fleet/ww2lst.py b/gen/fleet/ww2lst.py index 7ed63fbe..e3ac7de6 100644 --- a/gen/fleet/ww2lst.py +++ b/gen/fleet/ww2lst.py @@ -6,7 +6,7 @@ from gen.sam.group_generator import ShipGroupGenerator class WW2LSTGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: # Add LS Samuel Chase self.add_unit( diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py deleted file mode 100644 index 7bbee129..00000000 --- a/gen/flights/ai_flight_planner.py +++ /dev/null @@ -1,1069 +0,0 @@ -from __future__ import annotations - -import logging -import math -import operator -import random -from collections import defaultdict -from dataclasses import dataclass, field -from datetime import timedelta -from enum import Enum, auto -from typing import ( - Dict, - Iterable, - Iterator, - List, - Optional, - Set, - TYPE_CHECKING, - Tuple, - TypeVar, -) - -from game.dcs.aircrafttype import AircraftType -from game.infos.information import Information -from game.procurement import AircraftProcurementRequest -from game.profiling import logged_duration, MultiEventTracer -from game.squadrons import AirWing, Squadron -from game.theater import ( - Airfield, - ControlPoint, - Fob, - FrontLine, - MissionTarget, - OffMapSpawn, - SamGroundObject, - TheaterGroundObject, -) -from game.theater.theatergroundobject import ( - BuildingGroundObject, - EwrGroundObject, - NavalGroundObject, - VehicleGroupGroundObject, -) -from game.transfers import CargoShip, Convoy -from game.utils import Distance, nautical_miles, meters -from gen.ato import Package -from gen.flights.ai_flight_planner_db import aircraft_for_task -from gen.flights.closestairfields import ( - ClosestAirfields, - ObjectiveDistanceCache, -) -from gen.flights.flight import ( - Flight, - FlightType, -) -from gen.flights.flightplan import FlightPlanBuilder -from gen.flights.traveltime import TotEstimator - -# Avoid importing some types that cause circular imports unless type checking. -if TYPE_CHECKING: - from game import Game - from game.inventory import GlobalAircraftInventory - - -class EscortType(Enum): - AirToAir = auto() - Sead = auto() - - -@dataclass(frozen=True) -class ProposedFlight: - """A flight outline proposed by the mission planner. - - Proposed flights haven't been assigned specific aircraft yet. They have only - a task, a required number of aircraft, and a maximum distance allowed - between the objective and the departure airfield. - """ - - #: The flight's role. - task: FlightType - - #: 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 - #: field is None. - escort_type: Optional[EscortType] = field(default=None) - - def __str__(self) -> str: - return f"{self.task} {self.num_aircraft} ship" - - -@dataclass(frozen=True) -class ProposedMission: - """A mission outline proposed by the mission planner. - - Proposed missions haven't been assigned aircraft yet. They have only an - objective location and a list of proposed flights that are required for the - mission. - """ - - #: The mission objective. - location: MissionTarget - - #: The proposed flights that are required for the mission. - flights: List[ProposedFlight] - - asap: bool = field(default=False) - - def __str__(self) -> str: - flights = ", ".join([str(f) for f in self.flights]) - return f"{self.location.name}: {flights}" - - -class AircraftAllocator: - """Finds suitable aircraft for proposed missions.""" - - def __init__( - self, - air_wing: AirWing, - closest_airfields: ClosestAirfields, - global_inventory: GlobalAircraftInventory, - is_player: bool, - ) -> None: - self.air_wing = air_wing - self.closest_airfields = closest_airfields - self.global_inventory = global_inventory - self.is_player = is_player - - def find_squadron_for_flight( - self, flight: ProposedFlight - ) -> Optional[Tuple[ControlPoint, Squadron]]: - """Finds aircraft suitable for the given mission. - - Searches for aircraft capable of performing the given mission within the - maximum allowed range. If insufficient aircraft are available for the - mission, None is returned. - - Airfields are searched ordered nearest to farthest from the target and - searched twice. The first search looks for aircraft which prefer the - mission type, and the second search looks for any aircraft which are - capable of the mission type. For example, an F-14 from a nearby carrier - will be preferred for the CAP of an airfield that has only F-16s, but if - the carrier has only F/A-18s the F-16s will be used for CAP instead. - - Note that aircraft *will* be removed from the global inventory on - success. This is to ensure that the same aircraft are not matched twice - 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) - - def find_aircraft_for_task( - self, 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: - if not airfield.is_friendly(self.is_player): - continue - inventory = self.global_inventory.for_control_point(airfield) - for aircraft in types: - if not airfield.can_operate(aircraft): - continue - if inventory.available(aircraft) < flight.num_aircraft: - 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): - inventory.remove_aircraft(aircraft, flight.num_aircraft) - return airfield, squadron - return None - - -class PackageBuilder: - """Builds a Package for the flights it receives.""" - - def __init__( - self, - location: MissionTarget, - closest_airfields: ClosestAirfields, - global_inventory: GlobalAircraftInventory, - air_wing: AirWing, - is_player: bool, - package_country: str, - start_type: str, - asap: bool, - ) -> None: - self.closest_airfields = closest_airfields - self.is_player = is_player - self.package_country = package_country - self.package = Package(location, auto_asap=asap) - self.allocator = AircraftAllocator( - air_wing, closest_airfields, global_inventory, is_player - ) - self.global_inventory = global_inventory - self.start_type = start_type - - def plan_flight(self, plan: ProposedFlight) -> bool: - """Allocates aircraft for the given flight and adds them to the package. - - If no suitable aircraft are available, False is returned. If the failed - flight was critical and the rest of the mission will be scrubbed, the - caller should return any previously planned flights to the inventory - using release_planned_aircraft. - """ - assignment = self.allocator.find_squadron_for_flight(plan) - if assignment is None: - return False - airfield, squadron = assignment - if isinstance(airfield, OffMapSpawn): - start_type = "In Flight" - else: - start_type = self.start_type - - flight = Flight( - self.package, - self.package_country, - squadron, - plan.num_aircraft, - plan.task, - start_type, - departure=airfield, - arrival=airfield, - divert=self.find_divert_field(squadron.aircraft, airfield), - ) - self.package.add_flight(flight) - return True - - def find_divert_field( - self, aircraft: AircraftType, arrival: ControlPoint - ) -> Optional[ControlPoint]: - divert_limit = nautical_miles(150) - for airfield in self.closest_airfields.operational_airfields_within( - divert_limit - ): - if airfield.captured != self.is_player: - continue - if airfield == arrival: - continue - if not airfield.can_operate(aircraft): - continue - if isinstance(airfield, OffMapSpawn): - continue - return airfield - return None - - def build(self) -> Package: - """Returns the built package.""" - return self.package - - def release_planned_aircraft(self) -> None: - """Returns any planned flights to the inventory.""" - flights = list(self.package.flights) - for flight in flights: - self.global_inventory.return_from_flight(flight) - flight.clear_roster() - self.package.remove_flight(flight) - - -MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget) - - -class ObjectiveFinder: - """Identifies potential objectives for the mission planner.""" - - # TODO: Merge into doctrine. - AIRFIELD_THREAT_RANGE = nautical_miles(150) - SAM_THREAT_RANGE = nautical_miles(100) - - def __init__(self, game: Game, is_player: bool) -> None: - self.game = game - self.is_player = is_player - - def enemy_air_defenses(self) -> Iterator[tuple[TheaterGroundObject, Distance]]: - """Iterates over all enemy SAM sites.""" - doctrine = self.game.faction_for(self.is_player).doctrine - threat_zones = self.game.threat_zone_for(not self.is_player) - for cp in self.enemy_control_points(): - for ground_object in cp.ground_objects: - if ground_object.is_dead: - continue - - if isinstance(ground_object, EwrGroundObject): - if threat_zones.threatened_by_air_defense(ground_object): - # This is a very weak heuristic for determining whether the EWR - # is close enough to be worth targeting before a SAM that is - # covering it. Ingress distance corresponds to the beginning of - # the attack range and is sufficient for most standoff weapons, - # so treating the ingress distance as the threat distance sorts - # these EWRs such that they will be attacked before SAMs that do - # not threaten the ingress point, but after those that do. - target_range = doctrine.ingress_egress_distance - else: - # But if the EWR isn't covered then we should only be worrying - # about its detection range. - target_range = ground_object.max_detection_range() - elif isinstance(ground_object, SamGroundObject): - target_range = ground_object.max_threat_range() - else: - continue - - yield ground_object, target_range - - def threatening_air_defenses(self) -> Iterator[TheaterGroundObject]: - """Iterates over enemy SAMs in threat range of friendly control points. - - SAM sites are sorted by their closest proximity to any friendly control - point (airfield or fleet). - """ - - target_ranges: list[tuple[TheaterGroundObject, Distance]] = [] - for target, threat_range in self.enemy_air_defenses(): - ranges: list[Distance] = [] - for cp in self.friendly_control_points(): - ranges.append(meters(target.distance_to(cp)) - threat_range) - target_ranges.append((target, min(ranges))) - - target_ranges = sorted(target_ranges, key=operator.itemgetter(1)) - for target, _range in target_ranges: - yield target - - def enemy_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]: - """Iterates over all enemy vehicle groups.""" - for cp in self.enemy_control_points(): - for ground_object in cp.ground_objects: - if not isinstance(ground_object, VehicleGroupGroundObject): - continue - - if ground_object.is_dead: - continue - - yield ground_object - - def threatening_vehicle_groups(self) -> Iterator[MissionTarget]: - """Iterates over enemy vehicle groups near friendly control points. - - Groups are sorted by their closest proximity to any friendly control - point (airfield or fleet). - """ - return self._targets_by_range(self.enemy_vehicle_groups()) - - def enemy_ships(self) -> Iterator[NavalGroundObject]: - for cp in self.enemy_control_points(): - for ground_object in cp.ground_objects: - if not isinstance(ground_object, NavalGroundObject): - continue - - if ground_object.is_dead: - continue - - yield ground_object - - def threatening_ships(self) -> Iterator[MissionTarget]: - """Iterates over enemy ships near friendly control points. - - Groups are sorted by their closest proximity to any friendly control - point (airfield or fleet). - """ - return self._targets_by_range(self.enemy_ships()) - - def _targets_by_range( - self, targets: Iterable[MissionTargetType] - ) -> Iterator[MissionTargetType]: - target_ranges: List[Tuple[MissionTargetType, int]] = [] - for target in targets: - ranges: List[int] = [] - for cp in self.friendly_control_points(): - ranges.append(target.distance_to(cp)) - target_ranges.append((target, min(ranges))) - - target_ranges = sorted(target_ranges, key=operator.itemgetter(1)) - for target, _range in target_ranges: - yield target - - def strike_targets(self) -> Iterator[TheaterGroundObject]: - """Iterates over enemy strike targets. - - Targets are sorted by their closest proximity to any friendly control - point (airfield or fleet). - """ - targets: List[Tuple[TheaterGroundObject, int]] = [] - # Building objectives are made of several individual TGOs (one per - # building). - found_targets: Set[str] = set() - for enemy_cp in self.enemy_control_points(): - for ground_object in enemy_cp.ground_objects: - # TODO: Reuse ground_object.mission_types. - # The mission types for ground objects are currently not - # accurate because we include things like strike and BAI for all - # targets since they have different planning behavior (waypoint - # generation is better for players with strike when the targets - # are stationary, AI behavior against weaker air defenses is - # better with BAI), so that's not a useful filter. Once we have - # better control over planning profiles and target dependent - # loadouts we can clean this up. - if isinstance(ground_object, VehicleGroupGroundObject): - # BAI target, not strike target. - continue - - if isinstance(ground_object, NavalGroundObject): - # Anti-ship target, not strike target. - continue - - if isinstance(ground_object, SamGroundObject): - # SAMs are targeted by DEAD. No need to double plan. - continue - - is_building = isinstance(ground_object, BuildingGroundObject) - is_fob = isinstance(enemy_cp, Fob) - if is_building and is_fob and ground_object.is_control_point: - # This is the FOB structure itself. Can't be repaired or - # targeted by the player, so shouldn't be targetable by the - # AI. - continue - - if ground_object.is_dead: - continue - if ground_object.name in found_targets: - continue - ranges: List[int] = [] - for friendly_cp in self.friendly_control_points(): - ranges.append(ground_object.distance_to(friendly_cp)) - targets.append((ground_object, min(ranges))) - found_targets.add(ground_object.name) - targets = sorted(targets, key=operator.itemgetter(1)) - for target, _range in targets: - yield target - - def front_lines(self) -> Iterator[FrontLine]: - """Iterates over all active front lines in the theater.""" - yield from self.game.theater.conflicts() - - def vulnerable_control_points(self) -> Iterator[ControlPoint]: - """Iterates over friendly CPs that are vulnerable to enemy CPs. - - Vulnerability is defined as any enemy CP within threat range of of the - CP. - """ - for cp in self.friendly_control_points(): - if isinstance(cp, OffMapSpawn): - # Off-map spawn locations don't need protection. - continue - airfields_in_proximity = self.closest_airfields_to(cp) - airfields_in_threat_range = ( - airfields_in_proximity.operational_airfields_within( - self.AIRFIELD_THREAT_RANGE - ) - ) - for airfield in airfields_in_threat_range: - if not airfield.is_friendly(self.is_player): - yield cp - break - - def oca_targets(self, min_aircraft: int) -> Iterator[MissionTarget]: - airfields = [] - for control_point in self.enemy_control_points(): - if not isinstance(control_point, Airfield): - continue - if control_point.base.total_aircraft >= min_aircraft: - airfields.append(control_point) - return self._targets_by_range(airfields) - - def convoys(self) -> Iterator[Convoy]: - for front_line in self.front_lines(): - yield from self.game.transfers.convoys.travelling_to( - front_line.control_point_hostile_to(self.is_player) - ) - - def cargo_ships(self) -> Iterator[CargoShip]: - for front_line in self.front_lines(): - yield from self.game.transfers.cargo_ships.travelling_to( - front_line.control_point_hostile_to(self.is_player) - ) - - def friendly_control_points(self) -> Iterator[ControlPoint]: - """Iterates over all friendly control points.""" - return ( - c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player) - ) - - def farthest_friendly_control_point(self) -> ControlPoint: - """Finds the friendly control point that is farthest from any threats.""" - threat_zones = self.game.threat_zone_for(not self.is_player) - - farthest = None - max_distance = meters(0) - for cp in self.friendly_control_points(): - if isinstance(cp, OffMapSpawn): - continue - distance = threat_zones.distance_to_threat(cp.position) - if distance > max_distance: - farthest = cp - max_distance = distance - - if farthest is None: - raise RuntimeError("Found no friendly control points. You probably lost.") - return farthest - - def closest_friendly_control_point(self) -> ControlPoint: - """Finds the friendly control point that is closest to any threats.""" - threat_zones = self.game.threat_zone_for(not self.is_player) - - closest = None - min_distance = meters(math.inf) - for cp in self.friendly_control_points(): - if isinstance(cp, OffMapSpawn): - continue - distance = threat_zones.distance_to_threat(cp.position) - if distance < min_distance: - closest = cp - min_distance = distance - - if closest is None: - raise RuntimeError("Found no friendly control points. You probably lost.") - return closest - - def enemy_control_points(self) -> Iterator[ControlPoint]: - """Iterates over all enemy control points.""" - return ( - c - for c in self.game.theater.controlpoints - if not c.is_friendly(self.is_player) - ) - - def all_possible_targets(self) -> Iterator[MissionTarget]: - """Iterates over all possible mission targets in the theater. - - Valid mission targets are control points (airfields and carriers), front - lines, and ground objects (SAM sites, factories, resource extraction - sites, etc). - """ - for cp in self.game.theater.controlpoints: - yield cp - yield from cp.ground_objects - yield from self.front_lines() - - @staticmethod - def closest_airfields_to(location: MissionTarget) -> ClosestAirfields: - """Returns the closest airfields to the given location.""" - return ObjectiveDistanceCache.get_closest_airfields(location) - - -class CoalitionMissionPlanner: - """Coalition flight planning AI. - - This class is responsible for automatically planning missions for the - coalition at the start of the turn. - - The primary goal of the mission planner is to protect existing friendly - assets. Missions will be planned with the following priorities: - - 1. CAP for airfields/fleets in close proximity to the enemy to prevent heavy - losses of friendly aircraft. - 2. CAP for front line areas to protect ground and CAS units. - 3. DEAD to reduce necessity of SEAD for future missions. - 4. CAS to protect friendly ground units. - 5. Strike missions to reduce the enemy's resources. - - TODO: Anti-ship and airfield strikes to reduce enemy sortie rates. - TODO: BAI to prevent enemy forces from reaching the front line. - TODO: Should fleets always have a CAP? - - TODO: Stance and doctrine-specific planning behavior. - """ - - # TODO: Merge into doctrine, also limit by aircraft. - MAX_CAP_RANGE = nautical_miles(100) - MAX_CAS_RANGE = nautical_miles(50) - MAX_ANTISHIP_RANGE = nautical_miles(150) - MAX_BAI_RANGE = nautical_miles(150) - MAX_OCA_RANGE = nautical_miles(150) - MAX_SEAD_RANGE = nautical_miles(150) - MAX_STRIKE_RANGE = nautical_miles(150) - MAX_AWEC_RANGE = Distance.inf() - MAX_TANKER_RANGE = nautical_miles(200) - - def __init__(self, game: Game, is_player: bool) -> None: - self.game = game - self.is_player = is_player - self.objective_finder = ObjectiveFinder(self.game, self.is_player) - self.ato = self.game.blue_ato if is_player else self.game.red_ato - self.threat_zones = self.game.threat_zone_for(not self.is_player) - self.procurement_requests = self.game.procurement_requests_for(self.is_player) - self.faction = self.game.faction_for(self.is_player) - - def air_wing_can_plan(self, mission_type: FlightType) -> bool: - """Returns True if it is possible for the air wing to plan this mission type. - - Not all mission types can be fulfilled by all air wings. Many factions do not - have AEW&C aircraft, so they will never be able to plan those missions. It's - also possible for the player to exclude mission types from their squadron - designs. - """ - return self.game.air_wing_for(self.is_player).can_auto_plan(mission_type) - - def critical_missions(self) -> Iterator[ProposedMission]: - """Identifies the most important missions to plan this turn. - - Non-critical missions that cannot be fulfilled will create purchase - orders for the next turn. Critical missions will create a purchase order - unless the mission can be doubly fulfilled. In other words, the AI will - attempt to have *double* the aircraft it needs for these missions to - ensure that they can be planned again next turn even if all aircraft are - eliminated this turn. - """ - - # Find farthest, friendly CP for AEWC. - yield ProposedMission( - self.objective_finder.farthest_friendly_control_point(), - [ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)], - # Supports all the early CAP flights, so should be in the air ASAP. - asap=True, - ) - - yield ProposedMission( - self.objective_finder.closest_friendly_control_point(), - [ProposedFlight(FlightType.REFUELING, 1, self.MAX_TANKER_RANGE)], - ) - - # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP. - for cp in self.objective_finder.vulnerable_control_points(): - # Plan CAP in such a way, that it is established during the whole desired mission length - for _ in range( - 0, - int(self.game.settings.desired_player_mission_duration.total_seconds()), - int(self.faction.doctrine.cap_duration.total_seconds()), - ): - yield ProposedMission( - cp, - [ - ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), - ], - ) - - # Find front lines, plan CAS. - for front_line in self.objective_finder.front_lines(): - yield ProposedMission( - front_line, - [ - ProposedFlight(FlightType.CAS, 2, self.MAX_CAS_RANGE), - # This is *not* an escort because front lines don't create a threat - # zone. Generating threat zones from front lines causes the front - # line to push back BARCAPs as it gets closer to the base. While - # front lines do have the same problem of potentially pulling - # BARCAPs off bases to engage a front line TARCAP, that's probably - # the one time where we do want that. - # - # TODO: Use intercepts and extra TARCAPs to cover bases near fronts. - # We don't have intercept missions yet so this isn't something we - # can do today, but we should probably return to having the front - # line project a threat zone (so that strike missions will route - # around it) and instead *not plan* a BARCAP at bases near the - # front, since there isn't a place to put a barrier. Instead, the - # aircraft that would have been a BARCAP could be used as additional - # interceptors and TARCAPs which will defend the base but won't be - # trying to avoid front line contacts. - ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE), - ], - ) - - def propose_missions(self) -> Iterator[ProposedMission]: - """Identifies and iterates over potential mission in priority order.""" - yield from self.critical_missions() - - # Find enemy SAM sites with ranges that cover friendly CPs, front lines, - # or objects, plan DEAD. - # Find enemy SAM sites with ranges that extend to within 50 nmi of - # friendly CPs, front, lines, or objects, plan DEAD. - for sam in self.objective_finder.threatening_air_defenses(): - flights = [ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE)] - - # 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 - # working track radar. - # - # For SAMs without track radars and EWRs, we still want a SEAD escort if - # needed. - # - # Note that there is a quirk here: we should potentially be included a SEAD - # escort *and* SEAD when the target is a radar SAM but the flight path is - # also threatened by SAMs. We don't want to include a SEAD escort if the - # package is *only* threatened by the target though. Could be improved, but - # needs a decent refactor to the escort planning to do so. - if sam.has_live_radar_sam: - flights.append(ProposedFlight(FlightType.SEAD, 2, self.MAX_SEAD_RANGE)) - else: - flights.append( - ProposedFlight( - FlightType.SEAD_ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.Sead - ) - ) - # TODO: Max escort range. - flights.append( - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.AirToAir - ) - ) - yield ProposedMission(sam, flights) - - # These will only rarely get planned. When a convoy is travelling multiple legs, - # they're targetable after the first leg. The reason for this is that - # procurement happens *after* mission planning so that the missions that could - # not be filled will guide the procurement process. Procurement is the stage - # that convoys are created (because they're created to move ground units that - # were just purchased), so we haven't created any yet. Any incomplete transfers - # from the previous turn (multi-leg journeys) will still be present though so - # they can be targeted. - # - # Even after this is fixed, the player's convoys that were created through the - # UI will never be targeted on the first turn of their journey because the AI - # stops planning after the start of the turn. We could potentially fix this by - # moving opfor mission planning until the takeoff button is pushed. - for convoy in self.objective_finder.convoys(): - yield ProposedMission( - convoy, - [ - ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE), - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir - ), - ProposedFlight( - FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead - ), - ], - ) - - for ship in self.objective_finder.cargo_ships(): - yield ProposedMission( - ship, - [ - ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE), - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir - ), - ProposedFlight( - FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead - ), - ], - ) - - for group in self.objective_finder.threatening_ships(): - yield ProposedMission( - group, - [ - ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE), - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, - 2, - self.MAX_ANTISHIP_RANGE, - EscortType.AirToAir, - ), - ], - ) - - for group in self.objective_finder.threatening_vehicle_groups(): - yield ProposedMission( - group, - [ - ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE), - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir - ), - ProposedFlight( - FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead - ), - ], - ) - - for target in self.objective_finder.oca_targets(min_aircraft=20): - flights = [ - ProposedFlight(FlightType.OCA_RUNWAY, 2, self.MAX_OCA_RANGE), - ] - if self.game.settings.default_start_type == "Cold": - # Only schedule if the default start type is Cold. If the player - # has set anything else there are no targets to hit. - flights.append( - ProposedFlight(FlightType.OCA_AIRCRAFT, 2, self.MAX_OCA_RANGE) - ) - flights.extend( - [ - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_OCA_RANGE, EscortType.AirToAir - ), - ProposedFlight( - FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead - ), - ] - ) - yield ProposedMission(target, flights) - - # Plan strike missions. - for target in self.objective_finder.strike_targets(): - yield ProposedMission( - target, - [ - ProposedFlight(FlightType.STRIKE, 2, self.MAX_STRIKE_RANGE), - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE, EscortType.AirToAir - ), - ProposedFlight( - FlightType.SEAD_ESCORT, - 2, - self.MAX_STRIKE_RANGE, - EscortType.Sead, - ), - ], - ) - - def plan_missions(self) -> None: - """Identifies and plans mission for the turn.""" - player = "Blue" if self.is_player else "Red" - with logged_duration(f"{player} mission identification and fulfillment"): - with MultiEventTracer() as tracer: - for proposed_mission in self.propose_missions(): - self.plan_mission(proposed_mission, tracer) - - with logged_duration(f"{player} reserve mission planning"): - with MultiEventTracer() as tracer: - for critical_mission in self.critical_missions(): - self.plan_mission(critical_mission, tracer, reserves=True) - - with logged_duration(f"{player} mission scheduling"): - self.stagger_missions() - - for cp in self.objective_finder.friendly_control_points(): - inventory = self.game.aircraft_inventory.for_control_point(cp) - for aircraft, available in inventory.all_aircraft: - self.message("Unused aircraft", f"{available} {aircraft} from {cp}") - - def plan_flight( - self, - mission: ProposedMission, - flight: ProposedFlight, - builder: PackageBuilder, - missing_types: Set[FlightType], - for_reserves: bool, - ) -> None: - if not builder.plan_flight(flight): - missing_types.add(flight.task) - purchase_order = AircraftProcurementRequest( - near=mission.location, - range=flight.max_distance, - task_capability=flight.task, - number=flight.num_aircraft, - ) - if for_reserves: - # Reserves are planned for critical missions, so prioritize - # those orders over aircraft needed for non-critical missions. - self.procurement_requests.insert(0, purchase_order) - else: - self.procurement_requests.append(purchase_order) - - def scrub_mission_missing_aircraft( - self, - mission: ProposedMission, - builder: PackageBuilder, - missing_types: Set[FlightType], - not_attempted: Iterable[ProposedFlight], - reserves: bool, - ) -> None: - # Try to plan the rest of the mission just so we can count the missing - # types to buy. - for flight in not_attempted: - self.plan_flight(mission, flight, builder, missing_types, reserves) - - missing_types_str = ", ".join(sorted([t.name for t in missing_types])) - builder.release_planned_aircraft() - desc = "reserve aircraft" if reserves else "aircraft" - self.message( - "Insufficient aircraft", - f"Not enough {desc} in range for {mission.location.name} " - f"capable of: {missing_types_str}", - ) - - def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]: - threats = defaultdict(bool) - for flight in builder.package.flights: - if self.threat_zones.waypoints_threatened_by_aircraft( - flight.flight_plan.escorted_waypoints() - ): - threats[EscortType.AirToAir] = True - if self.threat_zones.waypoints_threatened_by_radar_sam( - list(flight.flight_plan.escorted_waypoints()) - ): - threats[EscortType.Sead] = True - return threats - - def plan_mission( - self, mission: ProposedMission, tracer: MultiEventTracer, reserves: bool = False - ) -> None: - """Allocates aircraft for a proposed mission and adds it to the ATO.""" - builder = PackageBuilder( - mission.location, - self.objective_finder.closest_airfields_to(mission.location), - self.game.aircraft_inventory, - self.game.air_wing_for(self.is_player), - self.is_player, - self.game.country_for(self.is_player), - self.game.settings.default_start_type, - mission.asap, - ) - - # Attempt to plan all the main elements of the mission first. Escorts - # will be planned separately so we can prune escorts for packages that - # are not expected to encounter that type of threat. - missing_types: Set[FlightType] = set() - escorts = [] - for proposed_flight in mission.flights: - if not self.air_wing_can_plan(proposed_flight.task): - # This air wing can never plan this mission type because they do not - # have compatible aircraft or squadrons. Skip fulfillment so that we - # don't place the purchase request. - continue - if proposed_flight.escort_type is not None: - # Escorts are planned after the primary elements of the package. - # If the package does not need escorts they may be pruned. - escorts.append(proposed_flight) - continue - with tracer.trace("Flight planning"): - self.plan_flight( - mission, proposed_flight, builder, missing_types, reserves - ) - - if missing_types: - self.scrub_mission_missing_aircraft( - mission, builder, missing_types, escorts, reserves - ) - return - - if not builder.package.flights: - # The non-escort part of this mission is unplannable by this faction. Scrub - # the mission and do not attempt planning escorts because there's no reason - # to buy them because this mission will never be planned. - return - - # Create flight plans for the main flights of the package so we can - # determine threats. This is done *after* creating all of the flights - # rather than as each flight is added because the flight plan for - # flights that will rendezvous with their package will be affected by - # the other flights in the package. Escorts will not be able to - # contribute to this. - flight_plan_builder = FlightPlanBuilder( - self.game, builder.package, self.is_player - ) - for flight in builder.package.flights: - with tracer.trace("Flight plan population"): - flight_plan_builder.populate_flight_plan(flight) - - needed_escorts = self.check_needed_escorts(builder) - for escort in escorts: - # This list was generated from the not None set, so this should be - # impossible. - assert escort.escort_type is not None - if needed_escorts[escort.escort_type]: - with tracer.trace("Flight planning"): - self.plan_flight(mission, escort, builder, missing_types, reserves) - - # Check again for unavailable aircraft. If the escort was required and - # none were found, scrub the mission. - if missing_types: - self.scrub_mission_missing_aircraft( - mission, builder, missing_types, escorts, reserves - ) - return - - if reserves: - # Mission is planned reserves which will not be used this turn. - # Return reserves to the inventory. - builder.release_planned_aircraft() - return - - package = builder.build() - # Add flight plans for escorts. - for flight in package.flights: - if not flight.flight_plan.waypoints: - with tracer.trace("Flight plan population"): - flight_plan_builder.populate_flight_plan(flight) - - if package.has_players and self.game.settings.auto_ato_player_missions_asap: - package.auto_asap = True - package.set_tot_asap() - - self.ato.add_package(package) - - def stagger_missions(self) -> None: - def start_time_generator( - count: int, earliest: int, latest: int, margin: int - ) -> Iterator[timedelta]: - interval = (latest - earliest) // count - for time in range(earliest, latest, interval): - error = random.randint(-margin, margin) - yield timedelta(seconds=max(0, time + error)) - - dca_types = { - FlightType.BARCAP, - FlightType.TARCAP, - } - - previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta) - non_dca_packages = [ - p for p in self.ato.packages if p.primary_task not in dca_types - ] - - start_time = start_time_generator( - count=len(non_dca_packages), - earliest=5 * 60, - latest=int( - self.game.settings.desired_player_mission_duration.total_seconds() - ), - margin=5 * 60, - ) - for package in self.ato.packages: - tot = TotEstimator(package).earliest_tot() - if package.primary_task in dca_types: - previous_end_time = previous_cap_end_time[package.target] - if tot > previous_end_time: - # Can't get there exactly on time, so get there ASAP. This - # will typically only happen for the first CAP at each - # target. - package.time_over_target = tot - else: - package.time_over_target = previous_end_time - - departure_time = package.mission_departure_time - # Should be impossible for CAPs - if departure_time is None: - logging.error(f"Could not determine mission end time for {package}") - continue - previous_cap_end_time[package.target] = departure_time - elif package.auto_asap: - package.set_tot_asap() - else: - # But other packages should be spread out a bit. Note that take - # times are delayed, but all aircraft will become active at - # mission start. This makes it more worthwhile to attack enemy - # airfields to hit grounded aircraft, since they're more likely - # to be present. Runway and air started aircraft will be - # delayed until their takeoff time by AirConflictGenerator. - package.time_over_target = next(start_time) + tot - - def message(self, title, text) -> None: - """Emits a planning message to the player. - - If the mission planner belongs to the players coalition, this emits a - message to the info panel. - """ - if self.is_player: - self.game.informations.append(Information(title, text, self.game.turn)) - else: - logging.info(f"{title}: {text}") diff --git a/gen/flights/ai_flight_planner_db.py b/gen/flights/ai_flight_planner_db.py index 6ed26bd4..59e49406 100644 --- a/gen/flights/ai_flight_planner_db.py +++ b/gen/flights/ai_flight_planner_db.py @@ -1,5 +1,6 @@ import logging -from typing import List, Type +from collections import Sequence +from typing import Type from dcs.helicopters import ( AH_1W, @@ -124,29 +125,30 @@ from pydcs_extensions.su57.su57 import Su_57 CAP_CAPABLE = [ Su_57, F_22A, - MiG_31, + F_15C, F_14B, F_14A_135_GR, - MiG_25PD, Su_33, + Su_34, + J_11A, Su_30, Su_27, - J_11A, - F_15C, MiG_29S, - MiG_29G, - MiG_29A, F_16C_50, FA_18C_hornet, + JF_17, + JAS39Gripen, F_16A, F_4E, - JAS39Gripen, - JF_17, + MiG_31, + MiG_25PD, + MiG_29G, + MiG_29A, MiG_23MLD, MiG_21Bis, Mirage_2000_5, - M_2000C, F_15E, + M_2000C, F_5E_3, MiG_19P, A_4E_C, @@ -173,6 +175,7 @@ CAS_CAPABLE = [ A_10C_2, A_10C, Hercules, + Su_34, Su_25TM, Su_25T, Su_25, @@ -190,17 +193,16 @@ CAS_CAPABLE = [ F_14B, F_14A_135_GR, AJS37, - Su_24MR, Su_24M, Su_17M4, + Su_33, F_4E, S_3B, - Su_34, Su_30, - MiG_19P, MiG_29S, MiG_27K, MiG_29A, + MiG_21Bis, AH_64D, AH_64A, AH_1W, @@ -212,13 +214,14 @@ CAS_CAPABLE = [ Mi_24P, Mi_24V, Mi_8MT, - UH_1H, + MiG_19P, MiG_15bis, M_2000C, F_5E_3, F_86F_Sabre, C_101CC, L_39ZA, + UH_1H, A_20G, Ju_88A4, P_47D_40, @@ -299,13 +302,14 @@ STRIKE_CAPABLE = [ Tornado_GR4, F_16C_50, FA_18C_hornet, + AV8BNA, + JF_17, F_16A, F_14B, F_14A_135_GR, JAS39Gripen_AG, Tornado_IDS, Su_17M4, - Su_24MR, Su_24M, Su_25TM, Su_25T, @@ -317,11 +321,9 @@ STRIKE_CAPABLE = [ MiG_29S, MiG_29G, MiG_29A, - JF_17, F_4E, A_10C_2, A_10C, - AV8BNA, S_3B, A_4E_C, M_2000C, @@ -375,6 +377,7 @@ RUNWAY_ATTACK_CAPABLE = [ Su_34, Su_30, Tornado_IDS, + M_2000C, ] + STRIKE_CAPABLE # For any aircraft that isn't necessarily directly involved in strike @@ -415,7 +418,7 @@ REFUELING_CAPABALE = [ ] -def dcs_types_for_task(task: FlightType) -> list[Type[FlyingType]]: +def dcs_types_for_task(task: FlightType) -> Sequence[Type[FlyingType]]: cap_missions = (FlightType.BARCAP, FlightType.TARCAP, FlightType.SWEEP) if task in cap_missions: return CAP_CAPABLE diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 68a57707..5a7bf855 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -2,14 +2,14 @@ from __future__ import annotations from datetime import timedelta from enum import Enum -from typing import List, Optional, TYPE_CHECKING, Union +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 import db 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 @@ -139,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". @@ -154,11 +154,13 @@ class FlightWaypoint: # Only used in the waypoint list in the flight edit page. No sense # having three names. A short and long form is enough. self.description = "" - self.targets: List[Union[MissionTarget, Unit]] = [] + self.targets: Sequence[Union[MissionTarget, Unit]] = [] self.obj_name = "" 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 @@ -167,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) @@ -325,12 +333,12 @@ class Flight: def clear_roster(self) -> None: self.roster.clear() - def __repr__(self): + def __repr__(self) -> str: if self.custom_name: return f"{self.custom_name} {self.count} x {self.unit_type}" return f"[{self.flight_type}] {self.count} x {self.unit_type}" - def __str__(self): + def __str__(self) -> str: if self.custom_name: return f"{self.custom_name} {self.count} x {self.unit_type}" return f"[{self.flight_type}] {self.count} x {self.unit_type}" diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 80a7f6e1..463be24d 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, @@ -28,9 +30,17 @@ from game.theater import ( SamGroundObject, TheaterGroundObject, NavalControlPoint, + ConflictTheater, ) -from game.theater.theatergroundobject import EwrGroundObject, NavalGroundObject -from game.utils import Distance, Speed, feet, meters, nautical_miles, knots +from game.theater.theatergroundobject import ( + EwrGroundObject, + NavalGroundObject, + BuildingGroundObject, +) + +from game.threatzones import ThreatZones +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 @@ -38,8 +48,8 @@ from .waypointbuilder import StrikeTarget, WaypointBuilder from ..conflictgen import Conflict, FRONTLINE_LENGTH if TYPE_CHECKING: - from game import Game from gen.ato import Package + from game.coalition import Coalition from game.transfers import Convoy INGRESS_TYPES = { @@ -131,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 @@ -219,11 +240,7 @@ class FlightPlan: tot_waypoint = self.tot_waypoint if tot_waypoint is None: return None - - time = self.tot - if time is None: - return None - return time - self._travel_time_to_waypoint(tot_waypoint) + return self.tot - self._travel_time_to_waypoint(tot_waypoint) def startup_time(self) -> Optional[timedelta]: takeoff_time = self.takeoff_time() @@ -540,7 +557,6 @@ class StrikeFlightPlan(FormationFlightPlan): join: FlightWaypoint ingress: FlightWaypoint targets: List[FlightWaypoint] - egress: FlightWaypoint split: FlightWaypoint nav_from: List[FlightWaypoint] land: FlightWaypoint @@ -555,7 +571,6 @@ class StrikeFlightPlan(FormationFlightPlan): yield self.join yield self.ingress yield from self.targets - yield self.egress yield self.split yield from self.nav_from yield self.land @@ -567,7 +582,6 @@ class StrikeFlightPlan(FormationFlightPlan): def package_speed_waypoints(self) -> Set[FlightWaypoint]: return { self.ingress, - self.egress, self.split, } | set(self.targets) @@ -631,8 +645,8 @@ class StrikeFlightPlan(FormationFlightPlan): @property def split_time(self) -> timedelta: - travel_time = self.travel_time_between_waypoints(self.egress, self.split) - return self.egress_time + travel_time + travel_time = self.travel_time_between_waypoints(self.ingress, self.split) + return self.ingress_time + travel_time @property def ingress_time(self) -> timedelta: @@ -642,19 +656,9 @@ class StrikeFlightPlan(FormationFlightPlan): ) return tot - travel_time - @property - def egress_time(self) -> timedelta: - tot = self.tot - travel_time = self.travel_time_between_waypoints( - self.target_area_waypoint, self.egress - ) - return tot + travel_time - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: if waypoint == self.ingress: return self.ingress_time - elif waypoint == self.egress: - return self.egress_time elif waypoint in self.targets: return self.tot return super().tot_for_waypoint(waypoint) @@ -868,7 +872,9 @@ class CustomFlightPlan(FlightPlan): class FlightPlanBuilder: """Generates flight plans for flights.""" - def __init__(self, game: Game, package: Package, is_player: bool) -> None: + def __init__( + self, package: Package, coalition: Coalition, theater: ConflictTheater + ) -> None: # TODO: Plan similar altitudes for the in-country leg of the mission. # Waypoint altitudes for a given flight *shouldn't* differ too much # between the join and split points, so we don't need speeds for each @@ -876,11 +882,21 @@ class FlightPlanBuilder: # hold too well right now since nothing is stopping each waypoint from # jumping 20k feet each time, but that's a huge waste of energy we # should be avoiding anyway. - self.game = game self.package = package - self.is_player = is_player - self.doctrine: Doctrine = self.game.faction_for(self.is_player).doctrine - self.threat_zones = self.game.threat_zone_for(not self.is_player) + self.coalition = coalition + self.theater = theater + + @property + def is_player(self) -> bool: + return self.coalition.player + + @property + def doctrine(self) -> Doctrine: + return self.coalition.doctrine + + @property + def threat_zones(self) -> ThreatZones: + return self.coalition.opponent.threat_zone def populate_flight_plan( self, @@ -945,95 +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 greater than the ingress - # distance into the threat zone and 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 last non-threatened point as - # the join point, and plan the IP inside the threatened area. - # - # When the target is near the edge of the threat zone the IP may need to - # be placed outside the zone. - # - # +--------------+ +---------------+ - # | | | | - # | | IP---+-T | - # | | | | - # | | | | - # +--------------+ +---------------+ - # - # Here we want to place the IP first and route the flight to the IP - # rather than routing to the target and placing the IP based on 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 also need to set up the IP first, - # but depending on the placement of the IP we may need to place the join - # point in a retreating position. - # - # 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() - join_point = self.preferred_join_point() - if join_point is None: - # The whole path from the origin airfield to the target is - # threatened. Need to retreat out of the threat area. - join_point = self.retreat_point(self.package_airfield().position) + # 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) - join_distance = meters(join_point.distance_to_point(target)) - ingress_distance = meters(ingress_point.distance_to_point(target)) - if join_distance < ingress_distance: - # The second case described above. The ingress point is farther from - # the target than the join point. Use the fallback behavior for now. - self.legacy_package_waypoints_impl() - return + join_point = JoinZoneGeometry( + self.package.target.position, + package_airfield.position, + ingress_point, + self.coalition, + ).find_best_join_point() - # The first case described above. The ingress and join points are placed - # reasonably relative to each other. - egress_point = self._egress_point(attack_heading) + # 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, - egress_point, WaypointBuilder.perturb(join_point), ) - def retreat_point(self, origin: Point) -> Point: - return self.threat_zones.closest_boundary(origin) - - def legacy_package_waypoints_impl(self) -> None: - from gen.ato import PackageWaypoints - - ingress_point = self._ingress_point(self._target_heading_to_package_airfield()) - egress_point = self._egress_point(self._target_heading_to_package_airfield()) - join_point = self._rendezvous_point(ingress_point) - split_point = self._rendezvous_point(egress_point) - self.package.waypoints = PackageWaypoints( - join_point, - ingress_point, - egress_point, - split_point, - ) - - def preferred_join_point(self) -> Optional[Point]: - path = self.game.navmesh_for(self.is_player).shortest_path( - self.package_airfield().position, self.package.target.position - ) - for point in reversed(path): - if not self.threat_zones.threatened(point): - return point - return None - def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. @@ -1047,26 +1001,16 @@ class FlightPlanBuilder: raise InvalidObjectiveLocation(flight.flight_type, location) targets: List[StrikeTarget] = [] - if len(location.groups) > 0 and location.dcs_identifier == "AA": + if isinstance(location, BuildingGroundObject): + # A building "group" is implemented as multiple TGOs with the same name. + for building in location.strike_targets: + targets.append(StrikeTarget(building.category, building)) + else: # TODO: Replace with DEAD? # Strike missions on SEAD targets target units. for g in location.groups: for j, u in enumerate(g.units): targets.append(StrikeTarget(f"{u.type} #{j}", u)) - else: - # TODO: Does this actually happen? - # ConflictTheater is built with the belief that multiple ground - # objects have the same name. If that's the case, - # TheaterGroundObject needs some refactoring because it behaves very - # differently for SAM sites than it does for strike targets. - buildings = self.game.theater.find_ground_objects_by_obj_name( - location.obj_name - ) - for building in buildings: - if building.is_dead: - continue - - targets.append(StrikeTarget(building.category, building)) return self.strike_flightplan( flight, location, FlightWaypointType.INGRESS_STRIKE, targets @@ -1087,23 +1031,23 @@ class FlightPlanBuilder: else: patrol_alt = feet(25000) - builder = WaypointBuilder(flight, self.game, self.is_player) - orbit_location = builder.orbit(orbit_location, patrol_alt) + builder = WaypointBuilder(flight, self.coalition) + orbit = builder.orbit(orbit_location, patrol_alt) return AwacsFlightPlan( package=self.package, flight=flight, takeoff=builder.takeoff(flight.departure), nav_to=builder.nav_path( - flight.departure.position, orbit_location.position, patrol_alt + flight.departure.position, orbit.position, patrol_alt ), nav_from=builder.nav_path( - orbit_location.position, flight.arrival.position, patrol_alt + orbit.position, flight.arrival.position, patrol_alt ), land=builder.land(flight.arrival), divert=builder.divert(flight.divert), bullseye=builder.bullseye(), - hold=orbit_location, + hold=orbit, hold_duration=timedelta(hours=4), ) @@ -1134,7 +1078,7 @@ class FlightPlanBuilder: ) @staticmethod - def anti_ship_targets_for_tgo(tgo: TheaterGroundObject) -> List[StrikeTarget]: + def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> List[StrikeTarget]: return [StrikeTarget(f"{g.name} at {tgo.name}", g) for g in tgo.groups] def generate_anti_ship(self, flight: Flight) -> StrikeFlightPlan: @@ -1171,16 +1115,17 @@ class FlightPlanBuilder: if isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) - start, end = self.racetrack_for_objective(location, barcap=True) - patrol_alt = meters( - random.randint( - int(self.doctrine.min_patrol_altitude.meters), - int(self.doctrine.max_patrol_altitude.meters), - ) + start_pos, end_pos = self.racetrack_for_objective(location, barcap=True) + + preferred_alt = flight.unit_type.preferred_patrol_altitude + randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000) + patrol_alt = max( + self.doctrine.min_patrol_altitude, + min(self.doctrine.max_patrol_altitude, randomized_alt), ) - builder = WaypointBuilder(flight, self.game, self.is_player) - start, end = builder.race_track(start, end, patrol_alt) + builder = WaypointBuilder(flight, self.coalition) + start, end = builder.race_track(start_pos, end_pos, patrol_alt) return BarCapFlightPlan( package=self.package, @@ -1209,12 +1154,15 @@ class FlightPlanBuilder: """ assert self.package.waypoints is not None target = self.package.target.position + heading = Heading.from_degrees( + self.package.waypoints.join.heading_between_point(target) + ) + start_pos = target.point_from_heading( + heading.degrees, -self.doctrine.sweep_distance.meters + ) - heading = self.package.waypoints.join.heading_between_point(target) - start = target.point_from_heading(heading, -self.doctrine.sweep_distance.meters) - - builder = WaypointBuilder(flight, self.game, self.is_player) - start, end = builder.sweep(start, target, self.doctrine.ingress_altitude) + builder = WaypointBuilder(flight, self.coalition) + start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude) hold = builder.hold(self._hold_point(flight)) @@ -1253,7 +1201,7 @@ class FlightPlanBuilder: altitude = feet(1500) altitude_is_agl = True - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) pickup = None nav_to_pickup = [] @@ -1305,7 +1253,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 @@ -1341,20 +1291,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) @@ -1368,19 +1318,17 @@ 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( self, origin: Point, front_line: FrontLine ) -> Tuple[Point, Point]: # Find targets waypoints - ingress, heading, distance = Conflict.frontline_vector( - front_line, self.game.theater - ) - center = ingress.point_from_heading(heading, distance / 2) + ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater) + 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) ), @@ -1393,8 +1341,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 @@ -1408,15 +1356,15 @@ class FlightPlanBuilder: """ location = self.package.target - patrol_alt = meters( - random.randint( - int(self.doctrine.min_patrol_altitude.meters), - int(self.doctrine.max_patrol_altitude.meters), - ) + preferred_alt = flight.unit_type.preferred_patrol_altitude + randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000) + patrol_alt = max( + self.doctrine.min_patrol_altitude, + min(self.doctrine.max_patrol_altitude, randomized_alt), ) # Create points - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) if isinstance(location, FrontLine): orbit0p, orbit1p = self.racetrack_for_frontline( @@ -1547,11 +1495,9 @@ class FlightPlanBuilder: def generate_escort(self, flight: Flight) -> StrikeFlightPlan: assert self.package.waypoints is not None - builder = WaypointBuilder(flight, self.game, self.is_player) - ingress, target, egress = builder.escort( - self.package.waypoints.ingress, - self.package.target, - self.package.waypoints.egress, + builder = WaypointBuilder(flight, self.coalition) + ingress, target = builder.escort( + self.package.waypoints.ingress, self.package.target ) hold = builder.hold(self._hold_point(flight)) join = builder.join(self.package.waypoints.join) @@ -1569,7 +1515,6 @@ class FlightPlanBuilder: join=join, ingress=ingress, targets=[target], - egress=egress, split=split, nav_from=builder.nav_path( split.position, flight.arrival.position, self.doctrine.ingress_altitude @@ -1590,18 +1535,16 @@ class FlightPlanBuilder: if not isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) - ingress, heading, distance = Conflict.frontline_vector( - location, self.game.theater - ) - center = ingress.point_from_heading(heading, distance / 2) - egress = ingress.point_from_heading(heading, distance) + ingress, heading, distance = Conflict.frontline_vector(location, self.theater) + 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) if egress_distance < ingress_distance: ingress, egress = egress, ingress - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) return CasFlightPlan( package=self.package, @@ -1629,8 +1572,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) @@ -1645,19 +1588,19 @@ 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.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) tanker_type = flight.unit_type if tanker_type.patrol_altitude is not None: @@ -1724,49 +1667,10 @@ class FlightPlanBuilder: origin = flight.departure.position target = self.package.target.position join = self.package.waypoints.join - origin_to_target = origin.distance_to_point(target) - join_to_target = join.distance_to_point(target) - if origin_to_target < join_to_target: - # If the origin airfield is closer to the target than the join - # point, 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( @@ -1778,7 +1682,7 @@ class FlightPlanBuilder: flight: The flight to generate the landing waypoint for. arrival: Arrival airfield or carrier. """ - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) return builder.land(arrival) def strike_flightplan( @@ -1790,7 +1694,7 @@ class FlightPlanBuilder: lead_time: timedelta = timedelta(), ) -> StrikeFlightPlan: assert self.package.waypoints is not None - builder = WaypointBuilder(flight, self.game, self.is_player, targets) + builder = WaypointBuilder(flight, self.coalition, targets) target_waypoints: List[FlightWaypoint] = [] if targets is not None: @@ -1819,7 +1723,6 @@ class FlightPlanBuilder: ingress_type, self.package.waypoints.ingress, location ), targets=target_waypoints, - egress=builder.egress(self.package.waypoints.egress, location), split=split, nav_from=builder.nav_path( split.position, flight.arrival.position, self.doctrine.ingress_altitude @@ -1830,64 +1733,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 egress 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: int) -> Point: - return self.package.target.position.point_from_heading( - heading - 180 + 15, self.doctrine.ingress_egress_distance.meters - ) - - def _egress_point(self, heading: int) -> Point: - return self.package.target.position.point_from_heading( - heading - 180 - 15, self.doctrine.ingress_egress_distance.meters - ) - - def _target_heading_to_package_airfield(self) -> int: - return self._heading_to_package_airfield(self.package.target.position) - - def _heading_to_package_airfield(self, point: Point) -> int: - return self.package_airfield().position.heading_between_point(point) - - def _distance_to_package_airfield(self, point: Point) -> int: - 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 0a51245a..5e34ec0d 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: @@ -19,16 +20,45 @@ class Loadout: is_custom: bool = False, ) -> None: self.name = name - self.pylons = {k: v for k, v in pylons.items() if v is not None} + # We clear unused pylon entries on initialization, but UI actions can still + # cause a pylon to be emptied, so make the optional type explicit. + self.pylons: Mapping[int, Optional[Weapon]] = { + k: v for k, v in pylons.items() if v is not None + } self.date = date self.is_custom = is_custom def derive_custom(self, name: str) -> Loadout: return Loadout(name, self.pylons, self.date, is_custom=True) + def has_weapon_of_type(self, weapon_type: WeaponType) -> bool: + for weapon in self.pylons.values(): + if weapon is not None and weapon.weapon_group.type is weapon_type: + return True + return False + + @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(): @@ -37,16 +67,39 @@ 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: + if self.has_weapon_of_type(WeaponType.TGP): + 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]: @@ -64,14 +117,10 @@ class Loadout: pylons = payload["pylons"] yield Loadout( name, - {p["num"]: Weapon.from_clsid(p["CLSID"]) for p in pylons.values()}, + {p["num"]: Weapon.with_clsid(p["CLSID"]) for p in pylons.values()}, 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 @@ -92,6 +141,7 @@ class Loadout: FlightType.CAS: ("CAS MAVERICK F", "CAS"), FlightType.STRIKE: ("STRIKE",), FlightType.ANTISHIP: ("ANTISHIP",), + FlightType.DEAD: ("DEAD",), FlightType.SEAD: ("SEAD",), FlightType.BAI: ("BAI",), FlightType.OCA_RUNWAY: ("RUNWAY_ATTACK", "RUNWAY_STRIKE"), @@ -128,9 +178,13 @@ class Loadout: if payload is not None: return Loadout( name, - {i: Weapon.from_clsid(d["clsid"]) for i, d in payload}, + {i: Weapon.with_clsid(d["clsid"]) for i, d in payload}, date=None, ) # TODO: Try group.load_task_default_loadout(loadout_for_task) + return cls.empty_loadout() + + @classmethod + def empty_loadout(cls) -> Loadout: return Loadout("Empty", {}, date=None) diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index f8380897..05ca1d93 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -10,14 +10,15 @@ from typing import ( TYPE_CHECKING, Tuple, Union, + Any, ) from dcs.mapping import Point from dcs.unit import Unit -from dcs.unitgroup import Group, VehicleGroup +from dcs.unitgroup import VehicleGroup, ShipGroup if TYPE_CHECKING: - from game import Game + from game.coalition import Coalition from game.transfers import MultiGroupTransport from game.theater import ( @@ -33,24 +34,24 @@ from .flight import Flight, FlightWaypoint, FlightWaypointType @dataclass(frozen=True) class StrikeTarget: name: str - target: Union[VehicleGroup, TheaterGroundObject, Unit, Group, MultiGroupTransport] + target: Union[ + VehicleGroup, TheaterGroundObject[Any], Unit, ShipGroup, MultiGroupTransport + ] class WaypointBuilder: def __init__( self, flight: Flight, - game: Game, - player: bool, + coalition: Coalition, targets: Optional[List[StrikeTarget]] = None, ) -> None: self.flight = flight - self.conditions = game.conditions - self.doctrine = game.faction_for(player).doctrine - self.threat_zones = game.threat_zone_for(not player) - self.navmesh = game.navmesh_for(player) + self.doctrine = coalition.doctrine + self.threat_zones = coalition.opponent.threat_zone + self.navmesh = coalition.nav_mesh self.targets = targets - self._bullseye = game.bullseye_for(player) + self._bullseye = coalition.bullseye @property def is_helo(self) -> bool: @@ -426,22 +427,19 @@ class WaypointBuilder: self, ingress: Point, target: MissionTarget, - egress: Point, - ) -> Tuple[FlightWaypoint, FlightWaypoint, FlightWaypoint]: + ) -> Tuple[FlightWaypoint, FlightWaypoint]: """Creates the waypoints needed to escort the package. Args: ingress: The package ingress point. target: The mission target. - egress: The package egress point. """ # This would preferably be no points at all, and instead the Escort task # would begin on the join point and end on the split point, however the # escort task does not appear to work properly (see the longer # description in gen.aircraft.JoinPointBuilder), so instead we give - # the escort flights a flight plan including the ingress point, target - # area, and egress point. - ingress = self.ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target) + # the escort flights a flight plan including the ingress point and target area. + ingress_wp = self.ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target) waypoint = FlightWaypoint( FlightWaypointType.TARGET_GROUP_LOC, @@ -454,9 +452,7 @@ class WaypointBuilder: waypoint.name = "TARGET" waypoint.description = "Escort the package" waypoint.pretty_name = "Target area" - - egress = self.egress(egress, target) - return ingress, waypoint, egress + return ingress_wp, waypoint @staticmethod def pickup(control_point: ControlPoint) -> FlightWaypoint: diff --git a/gen/forcedoptionsgen.py b/gen/forcedoptionsgen.py index d18db095..e4025d48 100644 --- a/gen/forcedoptionsgen.py +++ b/gen/forcedoptionsgen.py @@ -38,12 +38,12 @@ class ForcedOptionsGenerator: self.mission.forced_options.labels = ForcedOptions.Labels.None_ def _set_unrestricted_satnav(self) -> None: - blue = self.game.player_faction - red = self.game.enemy_faction + blue = self.game.blue.faction + red = self.game.red.faction if blue.unrestricted_satnav or red.unrestricted_satnav: self.mission.forced_options.unrestricted_satnav = True - def generate(self): + def generate(self) -> None: self._set_options_view() self._set_external_views() self._set_labels() diff --git a/gen/ground_forces/ai_ground_planner.py b/gen/ground_forces/ai_ground_planner.py index 045c4b39..45d98c01 100644 --- a/gen/ground_forces/ai_ground_planner.py +++ b/gen/ground_forces/ai_ground_planner.py @@ -1,13 +1,18 @@ +from __future__ import annotations + import logging import random from enum import Enum -from typing import Dict, List +from typing import Dict, List, TYPE_CHECKING from game.data.groundunitclass import GroundUnitClass from game.dcs.groundunittype import GroundUnitType from game.theater import ControlPoint from gen.ground_forces.combat_stance import CombatStance +if TYPE_CHECKING: + from game import Game + MAX_COMBAT_GROUP_PER_CP = 10 @@ -52,10 +57,9 @@ class CombatGroup: self.unit_type = unit_type self.size = size self.role = role - self.assigned_enemy_cp = None self.start_position = None - def __str__(self): + def __str__(self) -> str: s = f"ROLE : {self.role}\n" if self.size: s += f"UNITS {self.unit_type} * {self.size}" @@ -63,7 +67,7 @@ class CombatGroup: class GroundPlanner: - def __init__(self, cp: ControlPoint, game): + def __init__(self, cp: ControlPoint, game: Game) -> None: self.cp = cp self.game = game self.connected_enemy_cp = [ @@ -83,17 +87,15 @@ class GroundPlanner: self.units_per_cp[cp.id] = [] self.reserve: List[CombatGroup] = [] - def plan_groundwar(self): + def plan_groundwar(self) -> None: ground_unit_limit = self.cp.frontline_unit_count_limit remaining_available_frontline_units = ground_unit_limit - if hasattr(self.cp, "stance"): - group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[self.cp.stance] - else: - self.cp.stance = CombatStance.DEFENSIVE - group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE] + # TODO: Fix to handle the per-front stances. + # https://github.com/dcs-liberation/dcs_liberation/issues/1417 + group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE] # Create combat groups and assign them randomly to each enemy CP for unit_type in self.cp.base.armor: @@ -152,20 +154,9 @@ class GroundPlanner: if len(self.connected_enemy_cp) > 0: enemy_cp = random.choice(self.connected_enemy_cp).id self.units_per_cp[enemy_cp].append(group) - group.assigned_enemy_cp = enemy_cp else: self.reserve.append(group) - group.assigned_enemy_cp = "__reserve__" collection.append(group) if remaining_available_frontline_units == 0: break - - print("------------------") - print("Ground Planner : ") - print(self.cp.name) - print("------------------") - for unit_type in self.units_per_cp.keys(): - print("For : #" + str(unit_type)) - for group in self.units_per_cp[unit_type]: - print(str(group)) diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index 9d307e9c..19907b7e 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -9,7 +9,18 @@ from __future__ import annotations import logging import random -from typing import Dict, Iterator, Optional, TYPE_CHECKING, Type, List +from typing import ( + Dict, + Iterator, + Optional, + TYPE_CHECKING, + Type, + List, + TypeVar, + Any, + Generic, + Union, +) from dcs import Mission, Point, unitgroup from dcs.action import SceneryDestructionZone @@ -25,13 +36,13 @@ from dcs.task import ( ) from dcs.triggers import TriggerStart, TriggerZone from dcs.unit import Ship, Unit, Vehicle, InvisibleFARP -from dcs.unitgroup import Group, ShipGroup, StaticGroup, VehicleGroup -from dcs.unittype import StaticType, UnitType +from dcs.unitgroup import ShipGroup, StaticGroup, VehicleGroup +from dcs.unittype import StaticType, ShipType, VehicleType from dcs.vehicles import vehicle_map from game import db from game.data.building_data import FORTIFICATION_UNITS, FORTIFICATION_UNITS_ID -from game.db import unit_type_from_name +from game.db import unit_type_from_name, ship_type_from_name, vehicle_type_from_name from game.theater import ControlPoint, TheaterGroundObject from game.theater.theatergroundobject import ( BuildingGroundObject, @@ -44,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 @@ -56,7 +67,10 @@ FARP_FRONTLINE_DISTANCE = 10000 AA_CP_MIN_DISTANCE = 40000 -class GenericGroundObjectGenerator: +TgoT = TypeVar("TgoT", bound=TheaterGroundObject[Any]) + + +class GenericGroundObjectGenerator(Generic[TgoT]): """An unspecialized ground object generator. Currently used only for SAM @@ -64,7 +78,7 @@ class GenericGroundObjectGenerator: def __init__( self, - ground_object: TheaterGroundObject, + ground_object: TgoT, country: Country, game: Game, mission: Mission, @@ -89,10 +103,7 @@ class GenericGroundObjectGenerator: logging.warning(f"Found empty group in {self.ground_object}") continue - unit_type = unit_type_from_name(group.units[0].type) - if unit_type is None: - raise RuntimeError(f"Unrecognized unit type: {group.units[0].type}") - + unit_type = vehicle_type_from_name(group.units[0].type) vg = self.m.vehicle_group( self.country, group.name, @@ -116,24 +127,27 @@ class GenericGroundObjectGenerator: self._register_unit_group(group, vg) @staticmethod - def enable_eplrs(group: Group, unit_type: Type[UnitType]) -> None: - if hasattr(unit_type, "eplrs"): - if unit_type.eplrs: - group.points[0].tasks.append(EPLRS(group.id)) + def enable_eplrs(group: VehicleGroup, unit_type: Type[VehicleType]) -> None: + if unit_type.eplrs: + group.points[0].tasks.append(EPLRS(group.id)) - def set_alarm_state(self, group: Group) -> None: + def set_alarm_state(self, group: Union[ShipGroup, VehicleGroup]) -> None: if self.game.settings.perf_red_alert_state: group.points[0].tasks.append(OptAlarmState(2)) else: group.points[0].tasks.append(OptAlarmState(1)) - def _register_unit_group(self, persistence_group: Group, miz_group: Group) -> None: + def _register_unit_group( + self, + persistence_group: Union[ShipGroup, VehicleGroup], + miz_group: Union[ShipGroup, VehicleGroup], + ) -> None: self.unit_map.add_ground_object_units( self.ground_object, persistence_group, miz_group ) -class MissileSiteGenerator(GenericGroundObjectGenerator): +class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject]): @property def culled(self) -> bool: # Don't cull missile sites - their range is long enough to make them easily @@ -148,11 +162,11 @@ class MissileSiteGenerator(GenericGroundObjectGenerator): for group in self.ground_object.groups: vg = self.m.find_group(group.name) if vg is not None: - targets = self.possible_missile_targets(vg) + targets = self.possible_missile_targets() 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.") @@ -165,7 +179,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator): "Couldn't setup missile site to fire, group was not generated." ) - def possible_missile_targets(self, vg: Group) -> List[Point]: + def possible_missile_targets(self) -> List[Point]: """ Find enemy control points in range :param vg: Vehicle group we are searching a target for (There is always only oe group right now) @@ -174,7 +188,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator): targets: List[Point] = [] for cp in self.game.theater.controlpoints: if cp.captured != self.ground_object.control_point.captured: - distance = cp.position.distance_to_point(vg.position) + distance = cp.position.distance_to_point(self.ground_object.position) if distance < self.missile_site_range: targets.append(cp.position) return targets @@ -196,7 +210,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator): return site_range -class BuildingSiteGenerator(GenericGroundObjectGenerator): +class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]): """Generator for building sites. Building sites are the primary type of non-airbase objective locations that @@ -225,14 +239,14 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator): f"{self.ground_object.dcs_identifier} not found in static maps" ) - def generate_vehicle_group(self, unit_type: Type[UnitType]) -> None: + def generate_vehicle_group(self, unit_type: Type[VehicleType]) -> None: if not self.ground_object.is_dead: group = self.m.vehicle_group( country=self.country, 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) @@ -242,7 +256,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator): 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) @@ -324,7 +338,7 @@ class SceneryGenerator(BuildingSiteGenerator): self.unit_map.add_scenery(scenery) -class GenericCarrierGenerator(GenericGroundObjectGenerator): +class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundObject]): """Base type for carrier group generation. Used by both CV(N) groups and LHA groups. @@ -373,16 +387,17 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator): # 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: Group) -> Type[UnitType]: - unit_type = unit_type_from_name(group.units[0].type) - if unit_type is None: - raise RuntimeError(f"Unrecognized carrier name: {group.units[0].type}") - return unit_type + def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]: + return ship_type_from_name(group.units[0].type) - def configure_carrier(self, group: Group, atc_channel: RadioFrequency) -> ShipGroup: + def configure_carrier( + self, group: ShipGroup, atc_channel: RadioFrequency + ) -> ShipGroup: unit_type = self.get_carrier_type(group) ship_group = self.m.ship_group( @@ -409,14 +424,14 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator): 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 @@ -446,7 +461,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator): def add_runway_data( self, - brc: int, + brc: Heading, atc: RadioFrequency, tacan: TacanChannel, callsign: str, @@ -474,7 +489,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator): class CarrierGenerator(GenericCarrierGenerator): """Generator for CV(N) groups.""" - def get_carrier_type(self, group: Group) -> UnitType: + def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]: unit_type = super().get_carrier_type(group) if self.game.settings.supercarrier: unit_type = db.upgrade_to_supercarrier(unit_type, self.control_point.name) @@ -518,7 +533,7 @@ class LhaGenerator(GenericCarrierGenerator): ) -class ShipObjectGenerator(GenericGroundObjectGenerator): +class ShipObjectGenerator(GenericGroundObjectGenerator[ShipGroundObject]): """Generator for non-carrier naval groups.""" def generate(self) -> None: @@ -529,14 +544,11 @@ class ShipObjectGenerator(GenericGroundObjectGenerator): if not group.units: logging.warning(f"Found empty group in {self.ground_object}") continue + self.generate_group(group, ship_type_from_name(group.units[0].type)) - unit_type = unit_type_from_name(group.units[0].type) - if unit_type is None: - raise RuntimeError(f"Unrecognized unit type: {group.units[0].type}") - - self.generate_group(group, unit_type) - - def generate_group(self, group_def: Group, first_unit_type: Type[UnitType]) -> None: + def generate_group( + self, group_def: ShipGroup, first_unit_type: Type[ShipType] + ) -> None: group = self.m.ship_group( self.country, group_def.name, @@ -578,21 +590,15 @@ class HelipadGenerator: def generate(self) -> None: - if self.cp.captured: - country_name = self.game.player_country - else: - country_name = self.game.enemy_country - country = self.m.country(country_name) - # Note : Helipad are generated as neutral object in order not to interfer with capture triggers neutral_country = self.m.country(self.game.neutral_country.name) - + country = self.m.country(self.game.coalition_for(self.cp.captured).country_name) for i, helipad in enumerate(self.cp.helipads): name = self.cp.name + "_helipad_" + str(i) logging.info("Generating helipad : " + name) pad = InvisibleFARP(name=name) pad.position = Point(helipad.x, helipad.y) - pad.heading = helipad.heading + pad.heading = helipad.heading.degrees sg = unitgroup.StaticGroup(self.m.next_group_id(), name) sg.add_unit(pad) sp = StaticPoint() @@ -647,19 +653,15 @@ class GroundObjectsGenerator: self.icls_alloc = iter(range(1, 21)) self.runways: Dict[str, RunwayData] = {} - def generate(self): + def generate(self) -> None: for cp in self.game.theater.controlpoints: - if cp.captured: - country_name = self.game.player_country - else: - country_name = self.game.enemy_country - country = self.m.country(country_name) - + country = self.m.country(self.game.coalition_for(cp.captured).country_name) HelipadGenerator( self.m, cp, self.game, self.radio_registry, self.tacan_registry ).generate() for ground_object in cp.ground_objects: + generator: GenericGroundObjectGenerator[Any] if isinstance(ground_object, FactoryGroundObject): generator = FactoryGenerator( ground_object, country, self.game, self.m, self.unit_map diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 62fd9d25..a2be2ef6 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 @@ -40,6 +41,7 @@ from game.dcs.aircrafttype import AircraftType from game.theater import ConflictTheater, TheaterGroundObject, LatLon from game.theater.bullseye import Bullseye 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 @@ -76,7 +78,7 @@ class KneeboardPageWriter: "arial.ttf", 24, layout_engine=ImageFont.LAYOUT_BASIC ) self.content_font = ImageFont.truetype( - "arial.ttf", 20, layout_engine=ImageFont.LAYOUT_BASIC + "arial.ttf", 16, layout_engine=ImageFont.LAYOUT_BASIC ) self.table_font = ImageFont.truetype( "resources/fonts/Inconsolata.otf", 20, layout_engine=ImageFont.LAYOUT_BASIC @@ -91,10 +93,15 @@ class KneeboardPageWriter: return self.x, self.y def text( - self, text: str, font=None, fill: Tuple[int, int, int] = (0, 0, 0) + self, + text: str, + font: Optional[ImageFont.FreeTypeFont] = None, + fill: Optional[Tuple[int, int, int]] = None, ) -> None: if font is None: font = self.content_font + if fill is None: + fill = self.foreground_fill self.draw.text(self.position, text, font=font, fill=fill) width, height = self.draw.textsize(text, font=font) @@ -107,12 +114,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) @@ -195,6 +207,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 @@ -212,6 +225,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), ] ) @@ -250,6 +264,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 @@ -262,14 +282,21 @@ class BriefingPage(KneeboardPage): flight: FlightData, bullseye: Bullseye, theater: ConflictTheater, + weather: Weather, start_time: datetime.datetime, dark_kneeboard: bool, ) -> None: self.flight = flight self.bullseye = bullseye self.theater = theater + 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) @@ -296,11 +323,29 @@ 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 = 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" + ) + writer.text(f"QNH: {qnh_in_hg} inHg / {qnh_mm_hg} mmHg / {qnh_hpa} hPa") + writer.table( [ [ @@ -311,6 +356,12 @@ class BriefingPage(KneeboardPage): ["Bingo", "Joker"], ) + if any(self.flight.laser_codes): + codes: list[list[str]] = [] + for idx, code in enumerate(self.flight.laser_codes, start=1): + codes.append([str(idx), "" if code is None else str(code)]) + writer.table(codes, ["#", "Laser Code"]) + writer.write(path) def airfield_info_row( @@ -365,6 +416,8 @@ class BriefingPage(KneeboardPage): class SupportPage(KneeboardPage): """A kneeboard page containing information about support units.""" + JTAC_REGION_MAX_LEN = 25 + def __init__( self, flight: FlightData, @@ -408,7 +461,7 @@ class SupportPage(KneeboardPage): aewc_ladder.append( [ str(single_aewc.callsign), - str(single_aewc.freq), + self.format_frequency(single_aewc.freq), str(single_aewc.depature_location), str(dep), str(arr), @@ -444,8 +497,18 @@ class SupportPage(KneeboardPage): writer.heading("JTAC") jtacs = [] for jtac in self.jtacs: - jtacs.append([jtac.callsign, jtac.region, jtac.code]) - writer.table(jtacs, headers=["Callsign", "Region", "Laser Code"]) + jtacs.append( + [ + jtac.callsign, + KneeboardPageWriter.wrap_line( + jtac.region, + self.JTAC_REGION_MAX_LEN, + ), + jtac.code, + self.format_frequency(jtac.freq), + ] + ) + writer.table(jtacs, headers=["Callsign", "Region", "Laser Code", "FREQ"]) writer.write(path) @@ -554,6 +617,24 @@ class StrikeTaskPage(KneeboardPage): ] +class NotesPage(KneeboardPage): + """A kneeboard page containing the campaign owner's notes.""" + + def __init__( + self, + notes: str, + dark_kneeboard: bool, + ) -> None: + 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") + writer.text(self.notes) + writer.write(path) + + class KneeboardGenerator(MissionInfoGenerator): """Creates kneeboard pages for each client flight in the mission.""" @@ -609,6 +690,7 @@ class KneeboardGenerator(MissionInfoGenerator): flight, self.game.bullseye_for(flight.friendly), self.game.theater, + self.game.conditions.weather, self.mission.start_time, self.dark_kneeboard, ), @@ -623,6 +705,10 @@ class KneeboardGenerator(MissionInfoGenerator): ), ] + # 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/lasercoderegistry.py b/gen/lasercoderegistry.py new file mode 100644 index 00000000..6872cb30 --- /dev/null +++ b/gen/lasercoderegistry.py @@ -0,0 +1,37 @@ +from collections import deque +from typing import Iterator + + +class OutOfLaserCodesError(RuntimeError): + def __init__(self) -> None: + super().__init__( + f"All JTAC laser codes have been allocated. No available codes." + ) + + +class LaserCodeRegistry: + def __init__(self) -> None: + self.allocated_codes: set[int] = set() + self.allocator: Iterator[int] = LaserCodeRegistry.__laser_code_generator() + + def get_next_laser_code(self) -> int: + try: + while (code := next(self.allocator)) in self.allocated_codes: + pass + self.allocated_codes.add(code) + return code + except StopIteration: + raise OutOfLaserCodesError + + @staticmethod + def __laser_code_generator() -> Iterator[int]: + # Valid laser codes are as follows + # First digit is always 1 + # Second digit is 5-7 + # Third and fourth digits are 1 - 8 + # We iterate backward (reversed()) so that 1687 follows 1688 + q = deque(int(oct(code)[2:]) + 11 for code in reversed(range(0o1500, 0o2000))) + + # We start with the default of 1688 and wrap around when we reach the end + q.rotate(-q.index(1688)) + return iter(q) diff --git a/gen/locations/preset_control_point_locations.py b/gen/locations/preset_control_point_locations.py deleted file mode 100644 index e4be5136..00000000 --- a/gen/locations/preset_control_point_locations.py +++ /dev/null @@ -1,22 +0,0 @@ -from dataclasses import dataclass, field - -from typing import List - -from gen.locations.preset_locations import PresetLocation - - -@dataclass -class PresetControlPointLocations: - """A repository of preset locations for a given control point""" - - # List of possible ashore locations to generate objects (Represented in miz file by an APC_AAV_7_Amphibious) - ashore_locations: List[PresetLocation] = field(default_factory=list) - - # List of possible offshore locations to generate ship groups (Represented in miz file by an Oliver Hazard Perry) - offshore_locations: List[PresetLocation] = field(default_factory=list) - - # Possible antiship missiles sites locations (Represented in miz file by Iranian Silkworm missiles) - antiship_locations: List[PresetLocation] = field(default_factory=list) - - # List of possible powerplants locations (Represented in miz file by static Workshop A object, USA) - powerplant_locations: List[PresetLocation] = field(default_factory=list) diff --git a/gen/locations/preset_locations.py b/gen/locations/preset_locations.py deleted file mode 100644 index 89bdffbc..00000000 --- a/gen/locations/preset_locations.py +++ /dev/null @@ -1,21 +0,0 @@ -from dataclasses import dataclass - -from dcs import Point - - -@dataclass -class PresetLocation: - """A preset location""" - - position: Point - heading: int - id: str - - def __str__(self): - return ( - "-" * 10 - + "X: {}\n Y: {}\nHdg: {}°\nId: {}".format( - self.position.x, self.position.y, self.heading, self.id - ) - + "-" * 10 - ) diff --git a/gen/missiles/missiles_group_generator.py b/gen/missiles/missiles_group_generator.py index 72251516..63f1bb80 100644 --- a/gen/missiles/missiles_group_generator.py +++ b/gen/missiles/missiles_group_generator.py @@ -1,13 +1,20 @@ import logging import random -from game import db +from typing import Optional + +from dcs.unitgroup import VehicleGroup + +from game import db, Game +from game.theater.theatergroundobject import MissileSiteGroundObject from gen.missiles.scud_site import ScudGenerator from gen.missiles.v1_group import V1GroupGenerator MISSILES_MAP = {"V1GroupGenerator": V1GroupGenerator, "ScudGenerator": ScudGenerator} -def generate_missile_group(game, ground_object, faction_name: str): +def generate_missile_group( + game: Game, ground_object: MissileSiteGroundObject, faction_name: str +) -> Optional[VehicleGroup]: """ This generate a missiles group :return: Nothing, but put the group reference inside the ground object diff --git a/gen/missiles/scud_site.py b/gen/missiles/scud_site.py index 67c9a0ad..c57b43e3 100644 --- a/gen/missiles/scud_site.py +++ b/gen/missiles/scud_site.py @@ -2,15 +2,21 @@ import random from dcs.vehicles import Unarmed, MissilesSS, AirDefence -from gen.sam.group_generator import GroupGenerator +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 -class ScudGenerator(GroupGenerator): - def __init__(self, game, ground_object, faction): +class ScudGenerator(VehicleGroupGenerator[MissileSiteGroundObject]): + def __init__( + self, game: Game, ground_object: MissileSiteGroundObject, faction: Faction + ) -> None: super(ScudGenerator, self).__init__(game, ground_object) self.faction = faction - def generate(self): + def generate(self) -> None: # Scuds self.add_unit( @@ -58,5 +64,5 @@ class ScudGenerator(GroupGenerator): "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 60c94db8..e42a94fe 100644 --- a/gen/missiles/v1_group.py +++ b/gen/missiles/v1_group.py @@ -2,15 +2,21 @@ import random from dcs.vehicles import Unarmed, MissilesSS, AirDefence -from gen.sam.group_generator import GroupGenerator +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 -class V1GroupGenerator(GroupGenerator): - def __init__(self, game, ground_object, faction): +class V1GroupGenerator(VehicleGroupGenerator[MissileSiteGroundObject]): + def __init__( + self, game: Game, ground_object: MissileSiteGroundObject, faction: Faction + ) -> None: super(V1GroupGenerator, self).__init__(game, ground_object) self.faction = faction - def generate(self): + def generate(self) -> None: # Ramps self.add_unit( @@ -60,5 +66,5 @@ class V1GroupGenerator(GroupGenerator): "Blitz#0", self.position.x + 200, self.position.y + 15, - 90, + Heading.from_degrees(90), ) diff --git a/gen/naming.py b/gen/naming.py index df56ab64..e43d629c 100644 --- a/gen/naming.py +++ b/gen/naming.py @@ -1,6 +1,6 @@ import random import time -from typing import List +from typing import List, Any from dcs.country import Country @@ -256,7 +256,7 @@ class NameGenerator: existing_alphas: List[str] = [] @classmethod - def reset(cls): + def reset(cls) -> None: cls.number = 0 cls.infantry_number = 0 cls.convoy_number = 0 @@ -265,7 +265,7 @@ class NameGenerator: cls.existing_alphas = [] @classmethod - def reset_numbers(cls): + def reset_numbers(cls) -> None: cls.number = 0 cls.infantry_number = 0 cls.aircraft_number = 0 @@ -273,7 +273,9 @@ class NameGenerator: cls.cargo_ship_number = 0 @classmethod - def next_aircraft_name(cls, country: Country, parent_base_id: int, flight: Flight): + def next_aircraft_name( + cls, country: Country, parent_base_id: int, flight: Flight + ) -> str: cls.aircraft_number += 1 try: if flight.custom_name: @@ -293,7 +295,9 @@ class NameGenerator: ) @classmethod - def next_unit_name(cls, country: Country, parent_base_id: int, unit_type: UnitType): + def next_unit_name( + cls, country: Country, parent_base_id: int, unit_type: UnitType[Any] + ) -> str: cls.number += 1 return "unit|{}|{}|{}|{}|".format( country.id, cls.number, parent_base_id, unit_type.name @@ -301,8 +305,8 @@ class NameGenerator: @classmethod def next_infantry_name( - cls, country: Country, parent_base_id: int, unit_type: UnitType - ): + cls, country: Country, parent_base_id: int, unit_type: UnitType[Any] + ) -> str: cls.infantry_number += 1 return "infantry|{}|{}|{}|{}|".format( country.id, @@ -312,17 +316,17 @@ class NameGenerator: ) @classmethod - def next_awacs_name(cls, country: Country): + def next_awacs_name(cls, country: Country) -> str: cls.number += 1 return "awacs|{}|{}|0|".format(country.id, cls.number) @classmethod - def next_tanker_name(cls, country: Country, unit_type: AircraftType): + def next_tanker_name(cls, country: Country, unit_type: AircraftType) -> str: cls.number += 1 return "tanker|{}|{}|0|{}".format(country.id, cls.number, unit_type.name) @classmethod - def next_carrier_name(cls, country: Country): + def next_carrier_name(cls, country: Country) -> str: cls.number += 1 return "carrier|{}|{}|0|".format(country.id, cls.number) @@ -337,7 +341,7 @@ class NameGenerator: return f"Cargo Ship {cls.cargo_ship_number:03}" @classmethod - def random_objective_name(cls): + def random_objective_name(cls) -> str: if cls.animals: animal = random.choice(cls.animals) cls.animals.remove(animal) diff --git a/gen/radios.py b/gen/radios.py index 22968397..ced4ac9c 100644 --- a/gen/radios.py +++ b/gen/radios.py @@ -15,7 +15,7 @@ class RadioFrequency: #: The frequency in kilohertz. hertz: int - def __str__(self): + def __str__(self) -> str: if self.hertz >= 1000000: return self.format("MHz", 1000000) return self.format("kHz", 1000) 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_bofors.py b/gen/sam/aaa_bofors.py index 8c76f7f4..f6e21977 100644 --- a/gen/sam/aaa_bofors.py +++ b/gen/sam/aaa_bofors.py @@ -14,25 +14,21 @@ class BoforsGenerator(AirDefenseGroupGenerator): """ name = "Bofors AAA" - price = 75 - def generate(self): - grid_x = random.randint(2, 3) - grid_y = random.randint(2, 3) - - spacing = random.randint(10, 40) + def generate(self) -> None: index = 0 - for i in range(grid_x): - for j in range(grid_y): - index = index + 1 - self.add_unit( - AirDefence.Bofors40, - "AAA#" + str(index), - self.position.x + spacing * i, - self.position.y + spacing * j, - self.heading, - ) + for i in range(4): + spacing_x = random.randint(10, 40) + spacing_y = random.randint(10, 40) + index = index + 1 + self.add_unit( + AirDefence.Bofors40, + "AAA#" + str(index), + self.position.x + spacing_x * i, + self.position.y + spacing_y * i, + self.heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/aaa_flak.py b/gen/sam/aaa_flak.py index f918e48a..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, @@ -23,31 +24,26 @@ class FlakGenerator(AirDefenseGroupGenerator): """ name = "Flak Site" - price = 135 - - def generate(self): - grid_x = random.randint(2, 3) - grid_y = random.randint(2, 3) - - spacing = random.randint(20, 35) + def generate(self) -> None: index = 0 mixed = random.choice([True, False]) unit_type = random.choice(GFLAK) - for i in range(grid_x): - for j in range(grid_y): - index = index + 1 - self.add_unit( - unit_type, - "AAA#" + str(index), - self.position.x + spacing * i + random.randint(1, 5), - self.position.y + spacing * j + random.randint(1, 5), - self.heading, - ) + for i in range(4): + index = index + 1 + spacing_x = random.randint(10, 40) + spacing_y = random.randint(10, 40) + self.add_unit( + unit_type, + "AAA#" + str(index), + self.position.x + spacing_x * i + random.randint(1, 5), + self.position.y + spacing_y * i + random.randint(1, 5), + self.heading, + ) - if mixed: - unit_type = random.choice(GFLAK) + if mixed: + unit_type = random.choice(GFLAK) # Search lights search_pos = self.get_circular_position(random.randint(2, 3), 80) @@ -86,14 +82,14 @@ class FlakGenerator(AirDefenseGroupGenerator): ) # Some Opel Blitz trucks - for i in range(int(max(1, grid_x / 2))): - for j in range(int(max(1, grid_x / 2))): + for i in range(int(max(1, 2))): + for j in range(int(max(1, 2))): self.add_unit( Unarmed.Blitz_36_6700A, "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_flak18.py b/gen/sam/aaa_flak18.py index 91f81f15..17725a33 100644 --- a/gen/sam/aaa_flak18.py +++ b/gen/sam/aaa_flak18.py @@ -14,9 +14,8 @@ class Flak18Generator(AirDefenseGroupGenerator): """ name = "WW2 Flak Site" - price = 40 - def generate(self): + def generate(self) -> None: spacing = random.randint(30, 60) index = 0 diff --git a/gen/sam/aaa_ks19.py b/gen/sam/aaa_ks19.py index 1e3de4ca..7f062bfe 100644 --- a/gen/sam/aaa_ks19.py +++ b/gen/sam/aaa_ks19.py @@ -13,12 +13,8 @@ class KS19Generator(AirDefenseGroupGenerator): """ name = "KS-19 AAA Site" - price = 98 - - def generate(self): - - spacing = random.randint(10, 40) + def generate(self) -> None: self.add_unit( highdigitsams.AAA_SON_9_Fire_Can, "TR", @@ -28,16 +24,17 @@ class KS19Generator(AirDefenseGroupGenerator): ) index = 0 - for i in range(3): - for j in range(3): - index = index + 1 - self.add_unit( - highdigitsams.AAA_100mm_KS_19, - "AAA#" + str(index), - self.position.x + spacing * i, - self.position.y + spacing * j, - self.heading, - ) + for i in range(4): + spacing_x = random.randint(10, 40) + spacing_y = random.randint(10, 40) + index = index + 1 + self.add_unit( + highdigitsams.AAA_100mm_KS_19, + "AAA#" + str(index), + self.position.x + spacing_x * i, + self.position.y + spacing_y * i, + self.heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/aaa_ww2_ally_flak.py b/gen/sam/aaa_ww2_ally_flak.py index 415bdab3..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): @@ -14,9 +15,8 @@ class AllyWW2FlakGenerator(AirDefenseGroupGenerator): """ name = "WW2 Ally Flak Site" - price = 140 - def generate(self): + def generate(self) -> None: positions = self.get_circular_position(4, launcher_distance=30, coverage=360) for i, position in enumerate(positions): @@ -54,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/aaa_zsu57.py b/gen/sam/aaa_zsu57.py index 4648e90b..909ce549 100644 --- a/gen/sam/aaa_zsu57.py +++ b/gen/sam/aaa_zsu57.py @@ -12,10 +12,9 @@ class ZSU57Generator(AirDefenseGroupGenerator): """ name = "ZSU-57-2 Group" - price = 60 - def generate(self): - num_launchers = 5 + def generate(self) -> None: + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=110, coverage=360 ) diff --git a/gen/sam/aaa_zu23_insurgent.py b/gen/sam/aaa_zu23_insurgent.py index 5ca97638..ef2ec419 100644 --- a/gen/sam/aaa_zu23_insurgent.py +++ b/gen/sam/aaa_zu23_insurgent.py @@ -14,25 +14,20 @@ class ZU23InsurgentGenerator(AirDefenseGroupGenerator): """ name = "Zu-23 Site" - price = 56 - - def generate(self): - grid_x = random.randint(2, 3) - grid_y = random.randint(2, 3) - - spacing = random.randint(10, 40) + def generate(self) -> None: index = 0 - for i in range(grid_x): - for j in range(grid_y): - index = index + 1 - self.add_unit( - AirDefence.ZU_23_Closed_Insurgent, - "AAA#" + str(index), - self.position.x + spacing * i, - self.position.y + spacing * j, - self.heading, - ) + for i in range(4): + index = index + 1 + spacing_x = random.randint(10, 40) + spacing_y = random.randint(10, 40) + self.add_unit( + AirDefence.ZU_23_Closed_Insurgent, + "AAA#" + str(index), + self.position.x + spacing_x * i, + self.position.y + spacing_y * i, + self.heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py index a62a5f11..f755cafa 100644 --- a/gen/sam/airdefensegroupgenerator.py +++ b/gen/sam/airdefensegroupgenerator.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from enum import Enum from typing import Iterator, List @@ -6,36 +8,69 @@ from dcs.unitgroup import VehicleGroup from game import Game from game.theater.theatergroundobject import SamGroundObject -from gen.sam.group_generator import GroupGenerator +from gen.sam.group_generator import VehicleGroupGenerator + + +class SkynetRole(Enum): + #: A radar SAM that should be controlled by Skynet. + Sam = "Sam" + + #: A radar SAM that should be controlled and used as an EWR by Skynet. + SamAsEwr = "SamAsEwr" + + #: An air defense unit that should be used as point defense by Skynet. + PointDefense = "PD" + + #: All other types of groups that might be present in a SAM TGO. This includes + #: SHORADS, AAA, supply trucks, etc. Anything that shouldn't be controlled by Skynet + #: should use this role. + NoSkynetBehavior = "NoSkynetBehavior" class AirDefenseRange(Enum): - AAA = "AAA" - Short = "short" - Medium = "medium" - Long = "long" + AAA = ("AAA", SkynetRole.NoSkynetBehavior) + Short = ("short", SkynetRole.NoSkynetBehavior) + Medium = ("medium", SkynetRole.Sam) + Long = ("long", SkynetRole.SamAsEwr) + + def __init__(self, description: str, default_role: SkynetRole) -> None: + self.range_name = description + self.default_role = default_role -class AirDefenseGroupGenerator(GroupGenerator, ABC): +class AirDefenseGroupGenerator(VehicleGroupGenerator[SamGroundObject], ABC): """ This is the base for all SAM group generators """ - price: int - def __init__(self, game: Game, ground_object: SamGroundObject) -> None: - ground_object.skynet_capable = True super().__init__(game, ground_object) + 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, name_suffix: str) -> VehicleGroup: - group = VehicleGroup( - self.game.next_group_id(), "|".join([self.go.group_name, name_suffix]) - ) + def add_auxiliary_group(self, role: SkynetRole) -> VehicleGroup: + gid = self.game.next_group_id() + group = VehicleGroup(gid, self.group_name_for_role(gid, role)) self.auxiliary_groups.append(group) return group + def group_name_for_role(self, gid: int, role: SkynetRole) -> str: + if role is SkynetRole.NoSkynetBehavior: + # No special naming needed for air defense groups that don't participate in + # Skynet. + return f"{self.go.group_name}|{gid}" + + # For those that do, we need a prefix of `$COLOR|SAM| so our Skynet config picks + # the group up at all. To support PDs we need to append the ID of the TGO so + # that the PD will know which group it's protecting. We then append the role so + # our config knows what to do with the group, and finally the GID of *this* + # group to ensure no conflicts. + return "|".join( + [self.go.faction_color, "SAM", str(self.go.group_id), role.value, str(gid)] + ) + def get_generated_group(self) -> VehicleGroup: raise RuntimeError( "Deprecated call to AirDefenseGroupGenerator.get_generated_group " @@ -52,3 +87,7 @@ class AirDefenseGroupGenerator(GroupGenerator, ABC): @abstractmethod def range(cls) -> AirDefenseRange: ... + + @classmethod + def primary_group_role(cls) -> SkynetRole: + return cls.range().default_role diff --git a/gen/sam/cold_war_flak.py b/gen/sam/cold_war_flak.py index 6c0bdf40..bb538434 100644 --- a/gen/sam/cold_war_flak.py +++ b/gen/sam/cold_war_flak.py @@ -17,9 +17,8 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator): """ name = "Early Cold War Flak Site" - price = 74 - def generate(self): + def generate(self) -> None: spacing = random.randint(30, 60) index = 0 @@ -42,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, @@ -58,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, @@ -90,9 +89,8 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator): """ name = "Cold War Flak Site" - price = 72 - def generate(self): + def generate(self) -> None: spacing = random.randint(30, 60) index = 0 @@ -115,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, @@ -131,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/ewr_group_generator.py b/gen/sam/ewr_group_generator.py index 81ede492..32404be4 100644 --- a/gen/sam/ewr_group_generator.py +++ b/gen/sam/ewr_group_generator.py @@ -18,6 +18,7 @@ from gen.sam.ewrs import ( StraightFlushGenerator, TallRackGenerator, EwrGenerator, + TinShieldGenerator, ) EWR_MAP = { @@ -31,6 +32,7 @@ EWR_MAP = { "SnowDriftGenerator": SnowDriftGenerator, "StraightFlushGenerator": StraightFlushGenerator, "HawkEwrGenerator": HawkEwrGenerator, + "TinShieldGenerator": TinShieldGenerator, } diff --git a/gen/sam/ewrs.py b/gen/sam/ewrs.py index df27e6ad..2ffc93da 100644 --- a/gen/sam/ewrs.py +++ b/gen/sam/ewrs.py @@ -1,23 +1,19 @@ from typing import Type -from dcs.vehicles import AirDefence from dcs.unittype import VehicleType +from dcs.vehicles import AirDefence -from gen.sam.group_generator import GroupGenerator +from game.theater.theatergroundobject import EwrGroundObject +from gen.sam.group_generator import VehicleGroupGenerator -class EwrGenerator(GroupGenerator): +class EwrGenerator(VehicleGroupGenerator[EwrGroundObject]): unit_type: Type[VehicleType] @classmethod def name(cls) -> str: return cls.unit_type.name - @staticmethod - def price() -> int: - # TODO: Differentiate sites. - return 20 - def generate(self) -> None: self.add_unit( self.unit_type, "EWR", self.position.x, self.position.y, self.heading @@ -106,3 +102,9 @@ class HawkEwrGenerator(EwrGenerator): """ unit_type = AirDefence.Hawk_sr + + +class TinShieldGenerator(EwrGenerator): + """19ZH6 "Tin Shield" EWR.""" + + unit_type = AirDefence.RLS_19J6 diff --git a/gen/sam/freya_ewr.py b/gen/sam/freya_ewr.py index 917767fb..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): @@ -12,9 +13,8 @@ class FreyaGenerator(AirDefenseGroupGenerator): """ name = "Freya EWR Site" - price = 60 - def generate(self): + def generate(self) -> None: # TODO : would be better with the Concrete structure that is supposed to protect it self.add_unit( @@ -102,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 65eb0b50..bbe6bdb9 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -1,70 +1,141 @@ from __future__ import annotations +import logging import math +import operator import random -from typing import TYPE_CHECKING, Type +from collections import Iterable +from typing import TYPE_CHECKING, Type, TypeVar, Generic, Any from dcs import unitgroup from dcs.mapping import Point from dcs.point import PointAction -from dcs.unit import Ship, Vehicle -from dcs.unittype import VehicleType +from dcs.unit import Ship, Vehicle, Unit +from dcs.unitgroup import ShipGroup, VehicleGroup +from dcs.unittype import VehicleType, UnitType, ShipType +from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction -from game.theater.theatergroundobject import TheaterGroundObject +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 +GroupT = TypeVar("GroupT", VehicleGroup, ShipGroup) +UnitT = TypeVar("UnitT", bound=Unit) +UnitTypeT = TypeVar("UnitTypeT", bound=Type[UnitType]) +TgoT = TypeVar("TgoT", bound=TheaterGroundObject[Any]) + + # TODO: Generate a group description rather than a pydcs group. # It appears that all of this work gets redone at miz generation time (see # groundobjectsgen for an example). We can do less work and include the data we # care about in the format we want if we just generate our own group description # types rather than pydcs groups. -class GroupGenerator: - def __init__(self, game: Game, ground_object: TheaterGroundObject) -> None: +class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): + def __init__(self, game: Game, ground_object: TgoT, group: GroupT) -> None: self.game = game self.go = ground_object self.position = ground_object.position - self.heading = random.randint(0, 359) - self.vg = unitgroup.VehicleGroup(self.game.next_group_id(), self.go.group_name) - wp = self.vg.add_waypoint(self.position, PointAction.OffRoad, 0) - wp.ETA_locked = True + self.heading: Heading = Heading.random() + self.price = 0 + self.vg: GroupT = group - def generate(self): + def generate(self) -> None: raise NotImplementedError - def get_generated_group(self) -> unitgroup.VehicleGroup: + def get_generated_group(self) -> GroupT: return self.vg def add_unit( self, - unit_type: Type[VehicleType], + unit_type: UnitTypeT, name: str, pos_x: float, pos_y: float, - heading: int, - ) -> Vehicle: + heading: Heading, + ) -> UnitT: return self.add_unit_to_group( self.vg, unit_type, name, Point(pos_x, pos_y), heading ) def add_unit_to_group( self, - group: unitgroup.VehicleGroup, + group: GroupT, + unit_type: UnitTypeT, + name: str, + position: Point, + 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] +): + def __init__(self, game: Game, ground_object: TgoT) -> None: + super().__init__( + game, + ground_object, + unitgroup.VehicleGroup(game.next_group_id(), ground_object.group_name), + ) + wp = self.vg.add_waypoint(self.position, PointAction.OffRoad, 0) + wp.ETA_locked = True + + def generate(self) -> None: + raise NotImplementedError + + def add_unit_to_group( + self, + group: VehicleGroup, 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 + try: + ground_unit_type = next(GroundUnitType.for_dcs_type(unit_type)) + self.price += ground_unit_type.price + except StopIteration: + logging.error(f"Cannot get price for unit {unit_type.name}") + return unit - def get_circular_position(self, num_units, launcher_distance, coverage=90): + def get_circular_position( + self, num_units: int, launcher_distance: int, coverage: int = 90 + ) -> 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: @@ -86,43 +157,50 @@ class GroupGenerator: 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 x in range(1, num_units + 1): - positions.append( - ( - self.position.x - + launcher_distance * math.cos(math.radians(current_offset)), - self.position.y - + launcher_distance * math.sin(math.radians(current_offset)), - current_offset, - ) + for _ in range(1, num_units + 1): + x: float = self.position.x + launcher_distance * math.cos( + math.radians(current_offset) ) + y: float = self.position.y + launcher_distance * math.sin( + math.radians(current_offset) + ) + positions.append((x, y, Heading.from_degrees(current_offset))) current_offset += outer_offset return positions -class ShipGroupGenerator(GroupGenerator): +class ShipGroupGenerator( + GroupGenerator[ShipGroup, Ship, Type[ShipType], NavalGroundObject] +): """Abstract class for other ship generator classes""" - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): - self.game = game - self.go = ground_object - self.position = ground_object.position - self.heading = random.randint(0, 359) + def __init__(self, game: Game, ground_object: NavalGroundObject, faction: Faction): + super().__init__( + game, + ground_object, + unitgroup.ShipGroup(game.next_group_id(), ground_object.group_name), + ) self.faction = faction - self.vg = unitgroup.ShipGroup(self.game.next_group_id(), self.go.group_name) wp = self.vg.add_waypoint(self.position, 0) wp.ETA_locked = True - def add_unit(self, unit_type, name, pos_x, pos_y, heading) -> Ship: + def generate(self) -> None: + raise NotImplementedError + + def add_unit_to_group( + self, + group: ShipGroup, + unit_type: Type[ShipType], + name: str, + position: Point, + heading: Heading, + ) -> Ship: unit = Ship(self.game.next_unit_id(), f"{self.go.group_name}|{name}", unit_type) - unit.position.x = pos_x - unit.position.y = pos_y - unit.heading = heading - self.vg.add_unit(unit) + unit.position = position + unit.heading = heading.degrees + group.add_unit(unit) return unit diff --git a/gen/sam/sam_avenger.py b/gen/sam/sam_avenger.py index b778cc62..ac72b709 100644 --- a/gen/sam/sam_avenger.py +++ b/gen/sam/sam_avenger.py @@ -14,10 +14,9 @@ class AvengerGenerator(AirDefenseGroupGenerator): """ name = "Avenger Group" - price = 62 - def generate(self): - num_launchers = random.randint(2, 3) + def generate(self) -> None: + num_launchers = 2 self.add_unit( Unarmed.M_818, diff --git a/gen/sam/sam_chaparral.py b/gen/sam/sam_chaparral.py index 465ba0bd..2a746f95 100644 --- a/gen/sam/sam_chaparral.py +++ b/gen/sam/sam_chaparral.py @@ -14,10 +14,9 @@ class ChaparralGenerator(AirDefenseGroupGenerator): """ name = "Chaparral Group" - price = 66 - def generate(self): - num_launchers = random.randint(2, 4) + def generate(self) -> None: + num_launchers = 2 self.add_unit( Unarmed.M_818, diff --git a/gen/sam/sam_gepard.py b/gen/sam/sam_gepard.py index 669781df..05b04068 100644 --- a/gen/sam/sam_gepard.py +++ b/gen/sam/sam_gepard.py @@ -14,23 +14,20 @@ class GepardGenerator(AirDefenseGroupGenerator): """ name = "Gepard Group" - price = 50 - def generate(self): - self.add_unit( - AirDefence.Gepard, - "SPAAA", - self.position.x, - self.position.y, - self.heading, + def generate(self) -> None: + num_launchers = 2 + + positions = self.get_circular_position( + num_launchers, launcher_distance=120, coverage=180 ) - if random.randint(0, 1) == 1: + for i, position in enumerate(positions): self.add_unit( AirDefence.Gepard, - "SPAAA2", - self.position.x, - self.position.y, - self.heading, + "SPAA#" + str(i), + position[0], + position[1], + position[2], ) self.add_unit( Unarmed.M_818, diff --git a/gen/sam/sam_group_generator.py b/gen/sam/sam_group_generator.py index 3db1b70a..4bebcd27 100644 --- a/gen/sam/sam_group_generator.py +++ b/gen/sam/sam_group_generator.py @@ -28,6 +28,7 @@ from gen.sam.sam_gepard import GepardGenerator from gen.sam.sam_hawk import HawkGenerator from gen.sam.sam_hq7 import HQ7Generator from gen.sam.sam_linebacker import LinebackerGenerator +from gen.sam.sam_nasams import NasamBGenerator, NasamCGenerator from gen.sam.sam_patriot import PatriotGenerator from gen.sam.sam_rapier import RapierGenerator from gen.sam.sam_roland import RolandGenerator @@ -100,6 +101,8 @@ SAM_MAP: Dict[str, Type[AirDefenseGroupGenerator]] = { "SA20Generator": SA20Generator, "SA20BGenerator": SA20BGenerator, "SA23Generator": SA23Generator, + "NasamBGenerator": NasamBGenerator, + "NasamCGenerator": NasamCGenerator, } diff --git a/gen/sam/sam_hawk.py b/gen/sam/sam_hawk.py index 01e463e1..f65faf09 100644 --- a/gen/sam/sam_hawk.py +++ b/gen/sam/sam_hawk.py @@ -6,6 +6,7 @@ from dcs.vehicles import AirDefence from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) @@ -15,9 +16,8 @@ class HawkGenerator(AirDefenseGroupGenerator): """ name = "Hawk Site" - price = 115 - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.Hawk_sr, "SR", @@ -41,7 +41,7 @@ class HawkGenerator(AirDefenseGroupGenerator): ) # Triple A for close range defense - aa_group = self.add_auxiliary_group("AA") + aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) self.add_unit_to_group( aa_group, AirDefence.Vulcan, @@ -50,7 +50,7 @@ class HawkGenerator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(3, 6) + num_launchers = 6 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=180 ) diff --git a/gen/sam/sam_hq7.py b/gen/sam/sam_hq7.py index d05aecd8..89a81097 100644 --- a/gen/sam/sam_hq7.py +++ b/gen/sam/sam_hq7.py @@ -6,6 +6,7 @@ from dcs.vehicles import AirDefence from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) @@ -15,9 +16,8 @@ class HQ7Generator(AirDefenseGroupGenerator): """ name = "HQ-7 Site" - price = 120 - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.HQ_7_STR_SP, "STR", @@ -25,16 +25,9 @@ class HQ7Generator(AirDefenseGroupGenerator): self.position.y, self.heading, ) - self.add_unit( - AirDefence.HQ_7_LN_SP, - "LN", - self.position.x + 20, - self.position.y, - self.heading, - ) # Triple A for close range defense - aa_group = self.add_auxiliary_group("AA") + aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) self.add_unit_to_group( aa_group, AirDefence.Ural_375_ZU_23, @@ -50,7 +43,7 @@ class HQ7Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(0, 3) + num_launchers = 2 if num_launchers > 0: positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=360 diff --git a/gen/sam/sam_linebacker.py b/gen/sam/sam_linebacker.py index b140b138..397c38a7 100644 --- a/gen/sam/sam_linebacker.py +++ b/gen/sam/sam_linebacker.py @@ -14,10 +14,9 @@ class LinebackerGenerator(AirDefenseGroupGenerator): """ name = "Linebacker Group" - price = 75 - def generate(self): - num_launchers = random.randint(2, 4) + def generate(self) -> None: + num_launchers = 2 self.add_unit( Unarmed.M_818, diff --git a/gen/sam/sam_nasams.py b/gen/sam/sam_nasams.py new file mode 100644 index 00000000..62bbf60c --- /dev/null +++ b/gen/sam/sam_nasams.py @@ -0,0 +1,68 @@ +from typing import Type + +from dcs.mapping import Point +from dcs.unittype import VehicleType +from dcs.vehicles import AirDefence + +from game import Game +from game.theater import SamGroundObject +from gen.sam.airdefensegroupgenerator import ( + AirDefenseRange, + AirDefenseGroupGenerator, +) + + +class NasamCGenerator(AirDefenseGroupGenerator): + """ + This generate a Nasams group with AIM-120C missiles + """ + + name = "NASAMS AIM-120C" + + def __init__(self, game: Game, ground_object: SamGroundObject): + super().__init__(game, ground_object) + self.launcherType: Type[VehicleType] = AirDefence.NASAMS_LN_C + + def generate(self) -> None: + # Command Post + self.add_unit( + AirDefence.NASAMS_Command_Post, + "CP", + self.position.x + 30, + self.position.y + 30, + self.heading, + ) + # Radar + self.add_unit( + AirDefence.NASAMS_Radar_MPQ64F1, + "RADAR", + self.position.x - 30, + self.position.y - 30, + self.heading, + ) + + positions = self.get_circular_position(4, launcher_distance=120, coverage=360) + for i, position in enumerate(positions): + self.add_unit( + self.launcherType, + "LN#" + str(i), + position[0], + position[1], + position[2], + ) + + @classmethod + def range(cls) -> AirDefenseRange: + return AirDefenseRange.Medium + + +class NasamBGenerator(NasamCGenerator): + """ + This generate a Nasams group with AIM-120B missiles + """ + + name = "NASAMS AIM-120B" + + def __init__(self, game: Game, ground_object: SamGroundObject): + super().__init__(game, ground_object) + self.launcherType: Type[VehicleType] = AirDefence.NASAMS_LN_B diff --git a/gen/sam/sam_patriot.py b/gen/sam/sam_patriot.py index 21f6cd18..55c4be2b 100644 --- a/gen/sam/sam_patriot.py +++ b/gen/sam/sam_patriot.py @@ -1,11 +1,10 @@ -import random - from dcs.mapping import Point from dcs.vehicles import AirDefence from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) @@ -15,9 +14,8 @@ class PatriotGenerator(AirDefenseGroupGenerator): """ name = "Patriot Battery" - price = 240 - def generate(self): + def generate(self) -> None: # Command Post self.add_unit( AirDefence.Patriot_str, @@ -55,10 +53,7 @@ class PatriotGenerator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(3, 4) - positions = self.get_circular_position( - num_launchers, launcher_distance=120, coverage=360 - ) + positions = self.get_circular_position(8, launcher_distance=120, coverage=360) for i, position in enumerate(positions): self.add_unit( AirDefence.Patriot_ln, @@ -69,11 +64,8 @@ class PatriotGenerator(AirDefenseGroupGenerator): ) # Short range protection for high value site - aa_group = self.add_auxiliary_group("AA") - num_launchers = random.randint(3, 4) - positions = self.get_circular_position( - num_launchers, launcher_distance=200, coverage=360 - ) + aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) + positions = self.get_circular_position(2, launcher_distance=200, coverage=360) for i, (x, y, heading) in enumerate(positions): self.add_unit_to_group( aa_group, @@ -82,6 +74,15 @@ class PatriotGenerator(AirDefenseGroupGenerator): Point(x, y), heading, ) + positions = self.get_circular_position(2, launcher_distance=300, coverage=360) + for i, (x, y, heading) in enumerate(positions): + self.add_unit_to_group( + aa_group, + AirDefence.M1097_Avenger, + f"Avenger#{i}", + Point(x, y), + heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/sam_rapier.py b/gen/sam/sam_rapier.py index 0e361459..aac88d64 100644 --- a/gen/sam/sam_rapier.py +++ b/gen/sam/sam_rapier.py @@ -5,6 +5,7 @@ from dcs.vehicles import AirDefence from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) @@ -14,9 +15,8 @@ class RapierGenerator(AirDefenseGroupGenerator): """ name = "Rapier AA Site" - price = 50 - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.Rapier_fsa_blindfire_radar, "BT", @@ -32,7 +32,7 @@ class RapierGenerator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(3, 6) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=80, coverage=240 ) @@ -49,3 +49,7 @@ class RapierGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: return AirDefenseRange.Short + + @classmethod + def primary_group_role(cls) -> SkynetRole: + return SkynetRole.Sam diff --git a/gen/sam/sam_roland.py b/gen/sam/sam_roland.py index 4a88cfd4..57c3ab0e 100644 --- a/gen/sam/sam_roland.py +++ b/gen/sam/sam_roland.py @@ -3,6 +3,7 @@ from dcs.vehicles import AirDefence, Unarmed from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) @@ -12,9 +13,9 @@ class RolandGenerator(AirDefenseGroupGenerator): """ name = "Roland Site" - price = 40 - def generate(self): + def generate(self) -> None: + num_launchers = 2 self.add_unit( AirDefence.Roland_Radar, "EWR", @@ -22,13 +23,18 @@ class RolandGenerator(AirDefenseGroupGenerator): self.position.y, self.heading, ) - self.add_unit( - AirDefence.Roland_ADS, - "ADS", - self.position.x, - self.position.y, - self.heading, + positions = self.get_circular_position( + num_launchers, launcher_distance=80, coverage=240 ) + + for i, position in enumerate(positions): + self.add_unit( + AirDefence.Roland_ADS, + "ADS#" + str(i), + position[0], + position[1], + position[2], + ) self.add_unit( Unarmed.M_818, "TRUCK", @@ -40,3 +46,7 @@ class RolandGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: return AirDefenseRange.Short + + @classmethod + def primary_group_role(cls) -> SkynetRole: + return SkynetRole.Sam diff --git a/gen/sam/sam_sa10.py b/gen/sam/sam_sa10.py index 6daf8bfb..6b277bfa 100644 --- a/gen/sam/sam_sa10.py +++ b/gen/sam/sam_sa10.py @@ -1,6 +1,7 @@ -import random +from typing import Type from dcs.mapping import Point +from dcs.unittype import VehicleType from dcs.vehicles import AirDefence from game import Game @@ -8,6 +9,7 @@ from game.theater import SamGroundObject from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) from pydcs_extensions.highdigitsams import highdigitsams @@ -18,19 +20,18 @@ class SA10Generator(AirDefenseGroupGenerator): """ name = "SA-10/S-300PS Battery - With ZSU-23" - price = 550 def __init__(self, game: Game, ground_object: SamGroundObject): super().__init__(game, ground_object) - self.sr1 = AirDefence.S_300PS_40B6MD_sr - self.sr2 = AirDefence.S_300PS_64H6E_sr - self.cp = AirDefence.S_300PS_54K6_cp - self.tr1 = AirDefence.S_300PS_40B6M_tr - self.tr2 = AirDefence.S_300PS_40B6M_tr - self.ln1 = AirDefence.S_300PS_5P85C_ln - self.ln2 = AirDefence.S_300PS_5P85D_ln + self.sr1: Type[VehicleType] = AirDefence.S_300PS_40B6MD_sr + self.sr2: Type[VehicleType] = AirDefence.S_300PS_64H6E_sr + self.cp: Type[VehicleType] = AirDefence.S_300PS_54K6_cp + self.tr1: Type[VehicleType] = AirDefence.S_300PS_40B6M_tr + self.tr2: Type[VehicleType] = AirDefence.S_300PS_40B6M_tr + self.ln1: Type[VehicleType] = AirDefence.S_300PS_5P85C_ln + self.ln2: Type[VehicleType] = AirDefence.S_300PS_5P85D_ln - def generate(self): + def generate(self) -> None: # Search Radar self.add_unit( self.sr1, "SR1", self.position.x, self.position.y + 40, self.heading @@ -44,17 +45,13 @@ class SA10Generator(AirDefenseGroupGenerator): # Command Post self.add_unit(self.cp, "CP", self.position.x, self.position.y, self.heading) - # 2 Tracking radars + # 1 Tracking radar self.add_unit( self.tr1, "TR1", self.position.x - 40, self.position.y - 40, self.heading ) - self.add_unit( - self.tr2, "TR2", self.position.x + 40, self.position.y - 40, self.heading - ) - # 2 different launcher type (C & D) - num_launchers = random.randint(6, 8) + num_launchers = 6 positions = self.get_circular_position( num_launchers, launcher_distance=100, coverage=360 ) @@ -76,8 +73,8 @@ class SA10Generator(AirDefenseGroupGenerator): def generate_defensive_groups(self) -> None: # AAA for defending against close targets. - aa_group = self.add_auxiliary_group("AA") - num_launchers = random.randint(6, 8) + aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=210, coverage=360 ) @@ -94,15 +91,14 @@ class SA10Generator(AirDefenseGroupGenerator): class Tier2SA10Generator(SA10Generator): name = "SA-10/S-300PS Battery - With SA-15 PD" - price = 650 def generate_defensive_groups(self) -> None: # Create AAA the way the main group does. super().generate_defensive_groups() # SA-15 for both shorter range targets and point defense. - pd_group = self.add_auxiliary_group("PD") - num_launchers = random.randint(2, 4) + pd_group = self.add_auxiliary_group(SkynetRole.PointDefense) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=140, coverage=360 ) @@ -119,12 +115,11 @@ class Tier2SA10Generator(SA10Generator): class Tier3SA10Generator(SA10Generator): name = "SA-10/S-300PS Battery - With SA-15 PD & SA-19 SHORAD" - price = 750 def generate_defensive_groups(self) -> None: # AAA for defending against close targets. - aa_group = self.add_auxiliary_group("AA") - num_launchers = random.randint(6, 8) + aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=210, coverage=360 ) @@ -138,8 +133,8 @@ class Tier3SA10Generator(SA10Generator): ) # SA-15 for both shorter range targets and point defense. - pd_group = self.add_auxiliary_group("PD") - num_launchers = random.randint(2, 4) + pd_group = self.add_auxiliary_group(SkynetRole.PointDefense) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=140, coverage=360 ) @@ -155,7 +150,6 @@ class Tier3SA10Generator(SA10Generator): class SA10BGenerator(Tier3SA10Generator): - price = 700 name = "SA-10B/S-300PS Battery" def __init__(self, game: Game, ground_object: SamGroundObject): @@ -171,7 +165,6 @@ class SA10BGenerator(Tier3SA10Generator): class SA12Generator(Tier3SA10Generator): - price = 750 name = "SA-12/S-300V Battery" def __init__(self, game: Game, ground_object: SamGroundObject): @@ -187,7 +180,6 @@ class SA12Generator(Tier3SA10Generator): class SA20Generator(Tier3SA10Generator): - price = 800 name = "SA-20/S-300PMU-1 Battery" def __init__(self, game: Game, ground_object: SamGroundObject): @@ -203,7 +195,6 @@ class SA20Generator(Tier3SA10Generator): class SA20BGenerator(Tier3SA10Generator): - price = 850 name = "SA-20B/S-300PMU-2 Battery" def __init__(self, game: Game, ground_object: SamGroundObject): @@ -219,7 +210,6 @@ class SA20BGenerator(Tier3SA10Generator): class SA23Generator(Tier3SA10Generator): - price = 950 name = "SA-23/S-300VM Battery" def __init__(self, game: Game, ground_object: SamGroundObject): diff --git a/gen/sam/sam_sa11.py b/gen/sam/sam_sa11.py index 7fec37c2..873ee0d5 100644 --- a/gen/sam/sam_sa11.py +++ b/gen/sam/sam_sa11.py @@ -14,9 +14,8 @@ class SA11Generator(AirDefenseGroupGenerator): """ name = "SA-11 Buk Battery" - price = 180 - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.SA_11_Buk_SR_9S18M1, "SR", @@ -32,7 +31,7 @@ class SA11Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(2, 4) + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=140, coverage=180 ) diff --git a/gen/sam/sam_sa13.py b/gen/sam/sam_sa13.py index 0fbe1af0..0c81e042 100644 --- a/gen/sam/sam_sa13.py +++ b/gen/sam/sam_sa13.py @@ -14,9 +14,8 @@ class SA13Generator(AirDefenseGroupGenerator): """ name = "SA-13 Strela Group" - price = 50 - def generate(self): + def generate(self) -> None: self.add_unit( Unarmed.UAZ_469, "UAZ", @@ -32,7 +31,7 @@ class SA13Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(2, 3) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=360 ) diff --git a/gen/sam/sam_sa15.py b/gen/sam/sam_sa15.py index 3dcb881a..c0a6d852 100644 --- a/gen/sam/sam_sa15.py +++ b/gen/sam/sam_sa15.py @@ -12,16 +12,20 @@ class SA15Generator(AirDefenseGroupGenerator): """ name = "SA-15 Tor Group" - price = 55 - def generate(self): - self.add_unit( - AirDefence.Tor_9A331, - "ADS", - self.position.x, - self.position.y, - self.heading, + def generate(self) -> None: + num_launchers = 2 + positions = self.get_circular_position( + num_launchers, launcher_distance=120, coverage=360 ) + for i, position in enumerate(positions): + self.add_unit( + AirDefence.Tor_9A331, + "ADS#" + str(i), + position[0], + position[1], + position[2], + ) self.add_unit( Unarmed.UAZ_469, "EWR", diff --git a/gen/sam/sam_sa17.py b/gen/sam/sam_sa17.py index 093044b8..1544a043 100644 --- a/gen/sam/sam_sa17.py +++ b/gen/sam/sam_sa17.py @@ -13,9 +13,8 @@ class SA17Generator(AirDefenseGroupGenerator): """ name = "SA-17 Grizzly Battery" - price = 180 - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.SA_11_Buk_SR_9S18M1, "SR", diff --git a/gen/sam/sam_sa19.py b/gen/sam/sam_sa19.py index caac1f7c..8611a310 100644 --- a/gen/sam/sam_sa19.py +++ b/gen/sam/sam_sa19.py @@ -14,10 +14,9 @@ class SA19Generator(AirDefenseGroupGenerator): """ name = "SA-19 Tunguska Group" - price = 90 - def generate(self): - num_launchers = random.randint(1, 3) + def generate(self) -> None: + num_launchers = 2 if num_launchers == 1: self.add_unit( diff --git a/gen/sam/sam_sa2.py b/gen/sam/sam_sa2.py index 4b7341df..0d2546c5 100644 --- a/gen/sam/sam_sa2.py +++ b/gen/sam/sam_sa2.py @@ -14,9 +14,8 @@ class SA2Generator(AirDefenseGroupGenerator): """ name = "SA-2/S-75 Site" - price = 74 - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.P_19_s_125_sr, "SR", @@ -32,7 +31,7 @@ class SA2Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(3, 6) + num_launchers = 6 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=180 ) diff --git a/gen/sam/sam_sa3.py b/gen/sam/sam_sa3.py index 1a95de12..b75555d1 100644 --- a/gen/sam/sam_sa3.py +++ b/gen/sam/sam_sa3.py @@ -14,9 +14,8 @@ class SA3Generator(AirDefenseGroupGenerator): """ name = "SA-3/S-125 Site" - price = 80 - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.P_19_s_125_sr, "SR", @@ -32,7 +31,7 @@ class SA3Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(3, 6) + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=180 ) diff --git a/gen/sam/sam_sa6.py b/gen/sam/sam_sa6.py index fa72b24a..af9a6ffc 100644 --- a/gen/sam/sam_sa6.py +++ b/gen/sam/sam_sa6.py @@ -14,9 +14,8 @@ class SA6Generator(AirDefenseGroupGenerator): """ name = "SA-6 Kub Site" - price = 102 - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.Kub_1S91_str, "STR", @@ -25,7 +24,7 @@ class SA6Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(2, 4) + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=360 ) diff --git a/gen/sam/sam_sa8.py b/gen/sam/sam_sa8.py index 3ab28dfc..35afab86 100644 --- a/gen/sam/sam_sa8.py +++ b/gen/sam/sam_sa8.py @@ -12,16 +12,21 @@ class SA8Generator(AirDefenseGroupGenerator): """ name = "SA-8 OSA Site" - price = 55 - def generate(self): - self.add_unit( - AirDefence.Osa_9A33_ln, - "OSA", - self.position.x, - self.position.y, - self.heading, + def generate(self) -> None: + num_launchers = 2 + positions = self.get_circular_position( + num_launchers, launcher_distance=120, coverage=180 ) + + for i, position in enumerate(positions): + self.add_unit( + AirDefence.Osa_9A33_ln, + "OSA" + str(i), + position[0], + position[1], + position[2], + ) self.add_unit( AirDefence.SA_8_Osa_LD_9T217, "LD", diff --git a/gen/sam/sam_sa9.py b/gen/sam/sam_sa9.py index fccc7973..6ee35518 100644 --- a/gen/sam/sam_sa9.py +++ b/gen/sam/sam_sa9.py @@ -14,9 +14,8 @@ class SA9Generator(AirDefenseGroupGenerator): """ name = "SA-9 Group" - price = 40 - def generate(self): + def generate(self) -> None: self.add_unit( Unarmed.UAZ_469, "UAZ", @@ -32,7 +31,7 @@ class SA9Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(2, 3) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=360 ) diff --git a/gen/sam/sam_vulcan.py b/gen/sam/sam_vulcan.py index 2d057dc0..9a458db0 100644 --- a/gen/sam/sam_vulcan.py +++ b/gen/sam/sam_vulcan.py @@ -14,23 +14,20 @@ class VulcanGenerator(AirDefenseGroupGenerator): """ name = "Vulcan Group" - price = 25 - def generate(self): - self.add_unit( - AirDefence.Vulcan, - "SPAAA", - self.position.x, - self.position.y, - self.heading, + def generate(self) -> None: + num_launchers = 2 + + positions = self.get_circular_position( + num_launchers, launcher_distance=120, coverage=180 ) - if random.randint(0, 1) == 1: + for i, position in enumerate(positions): self.add_unit( AirDefence.Vulcan, - "SPAAA2", - self.position.x, - self.position.y, - self.heading, + "SPAA#" + str(i), + position[0], + position[1], + position[2], ) self.add_unit( Unarmed.M_818, diff --git a/gen/sam/sam_zsu23.py b/gen/sam/sam_zsu23.py index 708ae5c6..5e64d5df 100644 --- a/gen/sam/sam_zsu23.py +++ b/gen/sam/sam_zsu23.py @@ -1,6 +1,6 @@ import random -from dcs.vehicles import AirDefence +from dcs.vehicles import AirDefence, Unarmed from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, @@ -14,10 +14,9 @@ class ZSU23Generator(AirDefenseGroupGenerator): """ name = "ZSU-23 Group" - price = 50 - def generate(self): - num_launchers = random.randint(4, 5) + def generate(self) -> None: + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=180 @@ -30,6 +29,13 @@ class ZSU23Generator(AirDefenseGroupGenerator): position[1], position[2], ) + self.add_unit( + Unarmed.M_818, + "TRUCK", + self.position.x + 80, + self.position.y, + self.heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/sam_zu23.py b/gen/sam/sam_zu23.py index 6a1b41cb..2a2e2f4b 100644 --- a/gen/sam/sam_zu23.py +++ b/gen/sam/sam_zu23.py @@ -1,6 +1,6 @@ import random -from dcs.vehicles import AirDefence +from dcs.vehicles import AirDefence, Unarmed from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, @@ -14,25 +14,27 @@ class ZU23Generator(AirDefenseGroupGenerator): """ name = "ZU-23 Group" - price = 54 - - def generate(self): - grid_x = random.randint(2, 3) - grid_y = random.randint(2, 3) - - spacing = random.randint(10, 40) + def generate(self) -> None: index = 0 - for i in range(grid_x): - for j in range(grid_y): - index = index + 1 - self.add_unit( - AirDefence.ZU_23_Emplacement_Closed, - "AAA#" + str(index), - self.position.x + spacing * i, - self.position.y + spacing * j, - self.heading, - ) + for i in range(4): + index = index + 1 + spacing_x = random.randint(10, 40) + spacing_y = random.randint(10, 40) + self.add_unit( + AirDefence.ZU_23_Emplacement_Closed, + "AAA#" + str(index), + self.position.x + spacing_x * i, + self.position.y + spacing_y * i, + self.heading, + ) + self.add_unit( + Unarmed.M_818, + "TRUCK", + self.position.x + 80, + self.position.y, + self.heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/sam_zu23_ural.py b/gen/sam/sam_zu23_ural.py index 4f97d6f3..85ca1d20 100644 --- a/gen/sam/sam_zu23_ural.py +++ b/gen/sam/sam_zu23_ural.py @@ -14,10 +14,9 @@ class ZU23UralGenerator(AirDefenseGroupGenerator): """ name = "ZU-23 Ural Group" - price = 64 - def generate(self): - num_launchers = random.randint(2, 8) + def generate(self) -> None: + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=80, coverage=360 diff --git a/gen/sam/sam_zu23_ural_insurgent.py b/gen/sam/sam_zu23_ural_insurgent.py index d0ab8405..7d70300a 100644 --- a/gen/sam/sam_zu23_ural_insurgent.py +++ b/gen/sam/sam_zu23_ural_insurgent.py @@ -14,10 +14,13 @@ class ZU23UralInsurgentGenerator(AirDefenseGroupGenerator): """ name = "ZU-23 Ural Insurgent Group" - price = 64 - def generate(self): - num_launchers = random.randint(2, 8) + @classmethod + def range(cls) -> AirDefenseRange: + return AirDefenseRange.AAA + + def generate(self) -> None: + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=80, coverage=360 @@ -30,7 +33,3 @@ class ZU23UralInsurgentGenerator(AirDefenseGroupGenerator): position[1], position[2], ) - - @classmethod - def range(cls) -> AirDefenseRange: - return AirDefenseRange.AAA diff --git a/gen/triggergen.py b/gen/triggergen.py index a8e29a42..2a70d204 100644 --- a/gen/triggergen.py +++ b/gen/triggergen.py @@ -51,11 +51,11 @@ class TriggersGenerator: capture_zone_types = (Fob,) capture_zone_flag = 600 - def __init__(self, mission: Mission, game: Game): + def __init__(self, mission: Mission, game: Game) -> None: self.mission = mission self.game = game - def _set_allegiances(self, player_coalition: str, enemy_coalition: str): + def _set_allegiances(self, player_coalition: str, enemy_coalition: str) -> None: """ Set airbase initial coalition """ @@ -83,11 +83,16 @@ class TriggersGenerator: for cp in self.game.theater.controlpoints: if isinstance(cp, Airfield): - self.mission.terrain.airport_by_id(cp.at.id).set_coalition( + cp_airport = self.mission.terrain.airport_by_id(cp.airport.id) + if cp_airport is None: + raise RuntimeError( + f"Could not find {cp.airport.name} in the mission" + ) + cp_airport.set_coalition( cp.captured and player_coalition or enemy_coalition ) - def _set_skill(self, player_coalition: str, enemy_coalition: str): + def _set_skill(self, player_coalition: str, enemy_coalition: str) -> None: """ Set skill level for all aircraft in the mission """ @@ -103,7 +108,7 @@ class TriggersGenerator: for vehicle_group in country.vehicle_group: vehicle_group.set_skill(skill_level) - def _gen_markers(self): + def _gen_markers(self) -> None: """ Generate markers on F10 map for each existing objective """ @@ -188,7 +193,7 @@ class TriggersGenerator: recapture_trigger.add_action(ClearFlag(flag=flag)) self.mission.triggerrules.triggers.append(recapture_trigger) - def generate(self): + def generate(self) -> None: player_coalition = "blue" enemy_coalition = "red" @@ -198,7 +203,7 @@ class TriggersGenerator: self._generate_capture_triggers(player_coalition, enemy_coalition) @classmethod - def get_capture_zone_flag(cls): + def get_capture_zone_flag(cls) -> int: flag = cls.capture_zone_flag cls.capture_zone_flag += 1 return flag diff --git a/gen/units.py b/gen/units.py deleted file mode 100644 index cfd16ab8..00000000 --- a/gen/units.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Unit conversions.""" - - -def meters_to_feet(meters: float) -> float: - """Convers meters to feet.""" - return meters * 3.28084 diff --git a/gen/visualgen.py b/gen/visualgen.py index 0fa9c335..3a11652e 100644 --- a/gen/visualgen.py +++ b/gen/visualgen.py @@ -1,9 +1,8 @@ from __future__ import annotations import random -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from dcs.mapping import Point from dcs.mission import Mission from dcs.unit import Static from dcs.unittype import StaticType @@ -11,22 +10,22 @@ from dcs.unittype import StaticType if TYPE_CHECKING: from game import Game -from .conflictgen import Conflict, FRONTLINE_LENGTH +from .conflictgen import Conflict class MarkerSmoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" - shape_name = 5 - rate = 0.1 + shape_name = 5 # type: ignore + rate = 0.1 # type: ignore class Smoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" - shape_name = 2 + shape_name = 2 # type: ignore rate = 1 @@ -34,7 +33,7 @@ class BigSmoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" - shape_name = 3 + shape_name = 3 # type: ignore rate = 1 @@ -42,17 +41,11 @@ class MassiveSmoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" - shape_name = 4 + shape_name = 4 # type: ignore rate = 1 -class Outpost(StaticType): - id = "outpost" - name = "outpost" - category = "Fortifications" - - -def __monkey_static_dict(self: Static): +def __monkey_static_dict(self: Static) -> dict[str, Any]: global __original_static_dict d = __original_static_dict(self) @@ -63,9 +56,8 @@ def __monkey_static_dict(self: Static): __original_static_dict = Static.dict -Static.dict = __monkey_static_dict +Static.dict = __monkey_static_dict # type: ignore -FRONT_SMOKE_SPACING = 800 FRONT_SMOKE_RANDOM_SPREAD = 4000 FRONT_SMOKE_TYPE_CHANCES = { 2: MassiveSmoke, @@ -74,29 +66,13 @@ FRONT_SMOKE_TYPE_CHANCES = { 100: Smoke, } -DESTINATION_SMOKE_AMOUNT_FACTOR = 0.03 -DESTINATION_SMOKE_DISTANCE_FACTOR = 1 -DESTINATION_SMOKE_TYPE_CHANCES = { - 5: BigSmoke, - 100: Smoke, -} - - -def turn_heading(heading, fac): - heading += fac - if heading > 359: - heading = heading - 359 - if heading < 0: - heading = 359 + heading - return heading - class VisualGenerator: - def __init__(self, mission: Mission, game: Game): + def __init__(self, mission: Mission, game: Game) -> None: self.mission = mission self.game = game - def _generate_frontline_smokes(self): + def _generate_frontline_smokes(self) -> None: for front_line in self.game.theater.conflicts(): from_cp = front_line.blue_cp to_cp = front_line.red_cp @@ -110,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: @@ -121,68 +97,12 @@ class VisualGenerator: break self.mission.static_group( - self.mission.country(self.game.enemy_country), + self.mission.country(self.game.red.country_name), "", _type=v, position=pos, ) break - def _generate_stub_planes(self): - pass - """ - mission_units = set() - for coalition_name, coalition in self.mission.coalition.items(): - for country in coalition.countries.values(): - for group in country.plane_group + country.helicopter_group + country.vehicle_group: - for unit in group.units: - mission_units.add(db.unit_type_of(unit)) - - for unit_type in mission_units: - self.mission.static_group(self.mission.country(self.game.player_country), "a", unit_type, Point(0, 300000), hidden=True)""" - - def generate_target_smokes(self, target): - spread = target.size * DESTINATION_SMOKE_DISTANCE_FACTOR - for _ in range( - 0, - int( - target.size - * DESTINATION_SMOKE_AMOUNT_FACTOR - * (1.1 - target.base.strength) - ), - ): - for k, v in DESTINATION_SMOKE_TYPE_CHANCES.items(): - if random.randint(0, 100) <= k: - position = target.position.random_point_within(0, spread) - if not self.game.theater.is_on_land(position): - break - - self.mission.static_group( - self.mission.country(self.game.enemy_country), - "", - _type=v, - position=position, - hidden=True, - ) - break - - def generate_transportation_marker(self, at: Point): - self.mission.static_group( - self.mission.country(self.game.player_country), - "", - _type=MarkerSmoke, - position=at, - ) - - def generate_transportation_destination(self, at: Point): - self.generate_transportation_marker(at.point_from_heading(0, 20)) - self.mission.static_group( - self.mission.country(self.game.player_country), - "", - _type=Outpost, - position=at, - ) - - def generate(self): + def generate(self) -> None: self._generate_frontline_smokes() - self._generate_stub_planes() diff --git a/mypy.ini b/mypy.ini index b8bb3a89..da81307c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,18 +1,24 @@ [mypy] +# TODO: Cleanup so we can enable the checks commented out here. +check_untyped_defs = True +# disallow_any_decorated = True +# disallow_any_expr = True +disallow_any_generics = True +# disallow_any_unimported = True +disallow_untyped_decorators = True +disallow_untyped_defs = True +follow_imports = silent +# implicit_reexport = False namespace_packages = True - -[mypy-dcs.*] -follow_imports=silent -ignore_missing_imports = True +no_implicit_optional = True +warn_redundant_casts = True +# warn_return_any = True +warn_unreachable = True +warn_unused_ignores = True [mypy-faker.*] ignore_missing_imports = True -[mypy-PIL.*] -ignore_missing_imports = True - -[mypy-winreg.*] -ignore_missing_imports = True - [mypy-shapely.*] +# https://github.com/Toblerity/Shapely/issues/721 ignore_missing_imports = True \ No newline at end of file diff --git a/pydcs_extensions/jas39/jas39.py b/pydcs_extensions/jas39/jas39.py index a9940b2d..8ce7d2c9 100644 --- a/pydcs_extensions/jas39/jas39.py +++ b/pydcs_extensions/jas39/jas39.py @@ -1,3 +1,5 @@ +from typing import Set + from dcs import task from dcs.planes import PlaneType from dcs.weapons_data import Weapons @@ -6,111 +8,177 @@ from pydcs_extensions.weapon_injector import inject_weapons class JAS39GripenWeapons: - JAS_ARAKM70BAP = { - "clsid": "JAS_ARAKM70BAP", - "name": "ARAK M70B AP", - "weight": 372.2, + EWS_39_Integrated_ECM = { + "clsid": "{JAS39_EWS39}", + "name": "EWS 39 Integrated ECM", + "weight": 1, } - JAS_ARAKM70BHE = { - "clsid": "JAS_ARAKM70BHE", - "name": "ARAK M70B HE", - "weight": 372.2, + Integrated_ELINT = { + "clsid": "{JAS39_ELINT}", + "name": "Integrated ELINT", + "weight": 1, } - JAS_BK90 = { - "clsid": "JAS_BK90", - "name": "BK-90 Unguided Cluster Munition", - "weight": 605, + JAS39_AIM120B = { + "clsid": "JAS39_AIM120B", + "name": "AIM-120B AMRAAM Active Rdr AAM", + "weight": 157, } - JAS_BRIMSTONE = { - "clsid": "JAS_BRIMSTONE", + JAS39_AIM120C5 = { + "clsid": "JAS39_AIM120C5", + "name": "AIM-120C-5 AMRAAM Active Rdr AAM", + "weight": 162.5, + } + JAS39_AIM120C7 = { + "clsid": "JAS39_AIM120C7", + "name": "AIM-120C-7 AMRAAM Active Rdr AAM", + "weight": 162.5, + } + JAS39_AIM_9L = { + "clsid": "JAS39_AIM-9L", + "name": "AIM-9L Sidewinder IR AAM", + "weight": 86, + } + JAS39_AIM_9M = { + "clsid": "JAS39_AIM-9M", + "name": "AIM-9M Sidewinder IR AAM", + "weight": 86, + } + JAS39_AIM_9X = { + "clsid": "JAS39_AIM-9X", + "name": "AIM-9X Sidewinder IR AAM", + "weight": 86.5, + } + JAS39_ASRAAM = { + "clsid": "JAS39_ASRAAM", + "name": "AIM-132 ASRAAM IR AAM", + "weight": 89, + } + JAS39_A_DARTER = { + "clsid": "JAS39_A-DARTER", + "name": "A-Darter IR AAM", + "weight": 90, + } + JAS39_BRIMSTONE = { + "clsid": "JAS39_BRIMSTONE", "name": "Brimstone Laser Guided Missile", "weight": 195.5, } - JAS_GBU10_TV = { - "clsid": "JAS_GBU10_TV", - "name": "GBU-10 2000 lb TV-guided Bomb", + JAS39_Derby = { + "clsid": "JAS39_Derby", + "name": "I-Derby ER BVRAAM Active Rdr AAM", + "weight": 119, + } + JAS39_DWS39 = { + "clsid": "JAS39_DWS39", + "name": "DWS39 Unguided Cluster Munition", + "weight": 605, + } + JAS39_GBU10 = { + "clsid": "JAS39_GBU10", + "name": "GBU-10 2000 lb Laser-guided Bomb", "weight": 934, } - JAS_GBU12 = { - "clsid": "JAS_GBU12", + JAS39_GBU12 = { + "clsid": "JAS39_GBU12", "name": "GBU-12 500 lb Laser-guided Bomb", "weight": 275, } - JAS_GBU16_TV = { - "clsid": "JAS_GBU16_TV", - "name": "GBU-16 1000lb TV Guided Bomb", - "weight": 934, + JAS39_GBU16 = { + "clsid": "JAS39_GBU16", + "name": "GBU-16 1000 lb Laser-guided Bomb", + "weight": 454, } - JAS_GBU31 = { - "clsid": "JAS_GBU31", + JAS39_GBU31 = { + "clsid": "JAS39_GBU31", "name": "GBU-31 2000lb TV Guided Glide-Bomb", "weight": 934, } - JAS_GBU49_TV = { - "clsid": "JAS_GBU49_TV", + JAS39_GBU32 = { + "clsid": "JAS39_GBU32", + "name": "GBU-32 1000lb TV Guided Glide-Bomb", + "weight": 454, + } + JAS39_GBU38 = { + "clsid": "JAS39_GBU38", + "name": "GBU-38 500lb TV Guided Glide-Bomb", + "weight": 241, + } + JAS39_GBU49 = { + "clsid": "JAS39_GBU49", "name": "GBU-49 500lb TV Guided Bomb", - "weight": 275, + "weight": 241, } - JAS_IRIS_T = { - "clsid": "JAS_IRIS-T", - "name": "Rb98 IRIS-T Sidewinder IR AAM", - "weight": 88.4, + JAS39_IRIS_T = {"clsid": "JAS39_IRIS-T", "name": "IRIS-T IR AAM", "weight": 88.4} + JAS39_Litening = { + "clsid": "JAS39_Litening", + "name": "Litening III Targeting Pod", + "weight": 208, } - JAS_Litening = { - "clsid": "JAS_Litening", - "name": "Litening III POD (LLTV)", - "weight": 295, + JAS39_M70BAP = { + "clsid": "JAS39_M70BAP", + "name": "M70B AP Unguided rocket", + "weight": 372.2, } - JAS_MAR_1 = { - "clsid": "JAS_MAR-1", + JAS39_M70BHE = { + "clsid": "JAS39_M70BHE", + "name": "M70B HE Unguided rocket", + "weight": 372.2, + } + JAS39_M71LD = { + "clsid": "JAS39_M71LD", + "name": "4x M/71 120kg GP Bomb Low-drag", + "weight": 605, + } + JAS39_MAR_1 = { + "clsid": "JAS39_MAR-1", "name": "MAR-1 High Speed Anti-Radiation Missile", "weight": 350, } - JAS_Meteor = { - "clsid": "JAS_Meteor", - "name": "Rb101 Meteor BVRAAM Active Rdr AAM", + JAS39_Meteor = { + "clsid": "JAS39_Meteor", + "name": "Meteor BVRAAM Active Rdr AAM", "weight": 191, } - JAS_RB15F = { - "clsid": "JAS_RB15F", - "name": "RBS-15 Mk. IV Gungnir Radiation Seeking Anti-ship Missile ", - "weight": None, + JAS39_PYTHON_5 = { + "clsid": "JAS39_PYTHON-5", + "name": "Python-5 IR AAM", + "weight": 106, } - JAS_RB75T = { - "clsid": "JAS_RB75T", - "name": "Rb-75T (AGM-65E Maverick) (Laser ASM Lg Whd)", - "weight": 210, + JAS39_RBS15 = { + "clsid": "JAS39_RBS15", + "name": "RBS-15 Mk4 Gungnir Anti-ship Missile", + "weight": 650, } - JAS_Rb74 = { - "clsid": "JAS_Rb74", - "name": "Rb74 AIM-9L Sidewinder IR AAM", - "weight": 90, + JAS39_RBS15AI = { + "clsid": "JAS39_RBS15AI", + "name": "RBS-15 Mk4 Gungnir Anti-ship Missile (AI)", + "weight": 650, } - JAS_Rb99 = { - "clsid": "JAS_Rb99", - "name": "Rb99 AIM-120B AMRAAM Active Rdr AAM", - "weight": 157, + JAS39_SDB = { + "clsid": "JAS39_SDB", + "name": "GBU-39 SDB 285lb TV Guided Glide-Bomb", + "weight": 661, } - JAS_Rb99_DUAL = { - "clsid": "JAS_Rb99_DUAL", - "name": "Rb99 AIM-120B AMRAAM Active Rdr AAM x 2", - "weight": 313, - } - JAS_Stormshadow = { - "clsid": "JAS_Stormshadow", + JAS39_STORMSHADOW = { + "clsid": "JAS39_STORMSHADOW", "name": "Storm Shadow Long Range Anti-Radiation Cruise-missile", - "weight": None, + "weight": 1300, } - JAS_TANK1100 = { - "clsid": "JAS_TANK1100", - "name": "External drop tank 1100 litre", + JAS39_TANK1100 = { + "clsid": "JAS39_TANK1100", + "name": "Drop tank 1100 litre", "weight": 1019, } - JAS_TANK1700 = { - "clsid": "JAS_TANK1700", - "name": "External drop tank 1700 litre", + JAS39_TANK1700 = { + "clsid": "JAS39_TANK1700", + "name": "Drop tank 1700 litre", "weight": 1533, } + Litening_III_Targeting_Pod_FLIR = { + "clsid": "{JAS39_FLIR}", + "name": "Litening III Targeting Pod FLIR", + "weight": 2, + } inject_weapons(JAS39GripenWeapons) @@ -124,17 +192,22 @@ class JAS39Gripen(PlaneType): length = 14.1 fuel_max = 2550 max_speed = 2649.996 - chaff = 90 - flare = 45 - charge_total = 180 + chaff = 80 + flare = 40 + charge_total = 120 chaff_charge_size = 1 - flare_charge_size = 2 + flare_charge_size = 1 category = "Interceptor" # {78EFB7A2-FD52-4b57-A6A6-3BF0E1D6555F} radio_frequency = 127.5 class Pylon1: - JAS_IRIS_T = (1, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (1, JAS39GripenWeapons.JAS_Rb74) + JAS39_IRIS_T = (1, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (1, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (1, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (1, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (1, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (1, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (1, JAS39GripenWeapons.JAS39_ASRAAM) AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (1, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) Smokewinder___red = (1, Weapons.Smokewinder___red) Smokewinder___green = (1, Weapons.Smokewinder___green) @@ -144,92 +217,100 @@ class JAS39Gripen(PlaneType): Smokewinder___orange = (1, Weapons.Smokewinder___orange) class Pylon2: - JAS_IRIS_T = (2, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (2, JAS39GripenWeapons.JAS_Rb74) - JAS_Meteor = (2, JAS39GripenWeapons.JAS_Meteor) - JAS_Rb99 = (2, JAS39GripenWeapons.JAS_Rb99) - JAS_Rb99_DUAL = (2, JAS39GripenWeapons.JAS_Rb99_DUAL) - LAU_115_2_LAU_127_AIM_120C = (2, Weapons.LAU_115_2_LAU_127_AIM_120C) - AIM_120C_5_AMRAAM___Active_Rdr_AAM = ( - 2, - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, - ) - - # ERRR + JAS39_IRIS_T = (2, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (2, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (2, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (2, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (2, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (2, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (2, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_Meteor = (2, JAS39GripenWeapons.JAS39_Meteor) + JAS39_AIM120B = (2, JAS39GripenWeapons.JAS39_AIM120B) + JAS39_AIM120C5 = (2, JAS39GripenWeapons.JAS39_AIM120C5) + JAS39_AIM120C7 = (2, JAS39GripenWeapons.JAS39_AIM120C7) + JAS39_Derby = (2, JAS39GripenWeapons.JAS39_Derby) class Pylon3: - JAS_Meteor = (3, JAS39GripenWeapons.JAS_Meteor) - JAS_Rb99 = (3, JAS39GripenWeapons.JAS_Rb99) - AIM_120C_5_AMRAAM___Active_Rdr_AAM = ( - 3, - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, - ) - JAS_TANK1100 = (3, JAS39GripenWeapons.JAS_TANK1100) - JAS_TANK1700 = (3, JAS39GripenWeapons.JAS_TANK1700) - - # ERRR + JAS39_AIM_9L = (3, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_IRIS_T = (3, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_A_DARTER = (3, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (3, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (3, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (3, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (3, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_Meteor = (3, JAS39GripenWeapons.JAS39_Meteor) + JAS39_AIM120B = (3, JAS39GripenWeapons.JAS39_AIM120B) + JAS39_AIM120C5 = (3, JAS39GripenWeapons.JAS39_AIM120C5) + JAS39_AIM120C7 = (3, JAS39GripenWeapons.JAS39_AIM120C7) + JAS39_Derby = (3, JAS39GripenWeapons.JAS39_Derby) + JAS39_TANK1100 = (3, JAS39GripenWeapons.JAS39_TANK1100) + JAS39_TANK1700 = (3, JAS39GripenWeapons.JAS39_TANK1700) class Pylon4: - L_081_Fantasmagoria_ELINT_pod = (4, Weapons.L_081_Fantasmagoria_ELINT_pod) + JAS39_TANK1100 = (4, JAS39GripenWeapons.JAS39_TANK1100) class Pylon5: - JAS_TANK1100 = (5, JAS39GripenWeapons.JAS_TANK1100) - JAS_Meteor = (5, JAS39GripenWeapons.JAS_Meteor) - AIM_120C_5_AMRAAM___Active_Rdr_AAM = ( - 5, - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, - ) - JAS_Rb99 = (5, JAS39GripenWeapons.JAS_Rb99) - JAS_Rb99_DUAL = (5, JAS39GripenWeapons.JAS_Rb99_DUAL) - - # ERRR + JAS39_Litening = (5, JAS39GripenWeapons.JAS39_Litening) class Pylon6: - L005_Sorbtsiya_ECM_pod__left_ = (6, Weapons.L005_Sorbtsiya_ECM_pod__left_) + JAS39_AIM_9L = (6, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_IRIS_T = (6, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_A_DARTER = (6, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (6, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (6, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (6, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (6, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_Meteor = (6, JAS39GripenWeapons.JAS39_Meteor) + JAS39_AIM120B = (6, JAS39GripenWeapons.JAS39_AIM120B) + JAS39_AIM120C5 = (6, JAS39GripenWeapons.JAS39_AIM120C5) + JAS39_AIM120C7 = (6, JAS39GripenWeapons.JAS39_AIM120C7) + JAS39_Derby = (6, JAS39GripenWeapons.JAS39_Derby) + JAS39_TANK1100 = (6, JAS39GripenWeapons.JAS39_TANK1100) + JAS39_TANK1700 = (6, JAS39GripenWeapons.JAS39_TANK1700) class Pylon7: - JAS_Litening = (7, JAS39GripenWeapons.JAS_Litening) - - # ERRR + JAS39_IRIS_T = (7, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (7, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (7, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (7, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (7, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (7, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (7, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_Meteor = (7, JAS39GripenWeapons.JAS39_Meteor) + JAS39_AIM120B = (7, JAS39GripenWeapons.JAS39_AIM120B) + JAS39_AIM120C5 = (7, JAS39GripenWeapons.JAS39_AIM120C5) + JAS39_AIM120C7 = (7, JAS39GripenWeapons.JAS39_AIM120C7) + JAS39_Derby = (7, JAS39GripenWeapons.JAS39_Derby) class Pylon8: - JAS_Meteor = (8, JAS39GripenWeapons.JAS_Meteor) - JAS_Rb99 = (8, JAS39GripenWeapons.JAS_Rb99) - AIM_120C_5_AMRAAM___Active_Rdr_AAM = ( - 8, - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, - ) - JAS_TANK1100 = (8, JAS39GripenWeapons.JAS_TANK1100) - JAS_TANK1700 = (8, JAS39GripenWeapons.JAS_TANK1700) - - # ERRR + JAS39_IRIS_T = (8, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (8, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (8, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (8, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (8, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (8, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (8, JAS39GripenWeapons.JAS39_ASRAAM) + AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (8, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) + Smokewinder___red = (8, Weapons.Smokewinder___red) + Smokewinder___green = (8, Weapons.Smokewinder___green) + Smokewinder___blue = (8, Weapons.Smokewinder___blue) + Smokewinder___white = (8, Weapons.Smokewinder___white) + Smokewinder___yellow = (8, Weapons.Smokewinder___yellow) + Smokewinder___orange = (8, Weapons.Smokewinder___orange) class Pylon9: - JAS_IRIS_T = (9, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (9, JAS39GripenWeapons.JAS_Rb74) - JAS_Meteor = (9, JAS39GripenWeapons.JAS_Meteor) - JAS_Rb99 = (9, JAS39GripenWeapons.JAS_Rb99) - JAS_Rb99_DUAL = (9, JAS39GripenWeapons.JAS_Rb99_DUAL) - LAU_115_2_LAU_127_AIM_120C = (9, Weapons.LAU_115_2_LAU_127_AIM_120C) - AIM_120C_5_AMRAAM___Active_Rdr_AAM = ( + Litening_III_Targeting_Pod_FLIR = ( 9, - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, + JAS39GripenWeapons.Litening_III_Targeting_Pod_FLIR, ) - # ERRR - class Pylon10: - JAS_IRIS_T = (10, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (10, JAS39GripenWeapons.JAS_Rb74) - AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (10, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) - Smokewinder___red = (10, Weapons.Smokewinder___red) - Smokewinder___green = (10, Weapons.Smokewinder___green) - Smokewinder___blue = (10, Weapons.Smokewinder___blue) - Smokewinder___white = (10, Weapons.Smokewinder___white) - Smokewinder___yellow = (10, Weapons.Smokewinder___yellow) - Smokewinder___orange = (10, Weapons.Smokewinder___orange) + Integrated_ELINT = (10, JAS39GripenWeapons.Integrated_ELINT) - pylons = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + class Pylon11: + EWS_39_Integrated_ECM = (11, JAS39GripenWeapons.EWS_39_Integrated_ECM) + + pylons: Set[int] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} tasks = [ task.Intercept, @@ -249,17 +330,22 @@ class JAS39Gripen_AG(PlaneType): length = 14.1 fuel_max = 2550 max_speed = 2649.996 - chaff = 90 - flare = 45 - charge_total = 180 + chaff = 80 + flare = 40 + charge_total = 120 chaff_charge_size = 1 flare_charge_size = 1 category = "Interceptor" # {78EFB7A2-FD52-4b57-A6A6-3BF0E1D6555F} radio_frequency = 127.5 class Pylon1: - JAS_IRIS_T = (1, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (1, JAS39GripenWeapons.JAS_Rb74) + JAS39_IRIS_T = (1, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (1, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (1, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (1, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (1, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (1, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (1, JAS39GripenWeapons.JAS39_ASRAAM) AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (1, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) Smokewinder___red = (1, Weapons.Smokewinder___red) Smokewinder___green = (1, Weapons.Smokewinder___green) @@ -269,56 +355,65 @@ class JAS39Gripen_AG(PlaneType): Smokewinder___orange = (1, Weapons.Smokewinder___orange) class Pylon2: - JAS_IRIS_T = (2, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (2, JAS39GripenWeapons.JAS_Rb74) - JAS_RB75T = (2, JAS39GripenWeapons.JAS_RB75T) - AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( - 2, - Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_, - ) - JAS_BK90 = (2, JAS39GripenWeapons.JAS_BK90) - JAS_RB15F = (2, JAS39GripenWeapons.JAS_RB15F) - JAS_MAR_1 = (2, JAS39GripenWeapons.JAS_MAR_1) - JAS_GBU12 = (2, JAS39GripenWeapons.JAS_GBU12) - JAS_GBU49_TV = (2, JAS39GripenWeapons.JAS_GBU49_TV) - # ERRR JAS_GBU16 - JAS_GBU16_TV = (2, JAS39GripenWeapons.JAS_GBU16_TV) - # ERRR GBU12_TEST + JAS39_IRIS_T = (2, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (2, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (2, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (2, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (2, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (2, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (2, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_RBS15 = (2, JAS39GripenWeapons.JAS39_RBS15) + JAS39_RBS15AI = (2, JAS39GripenWeapons.JAS39_RBS15AI) + JAS39_MAR_1 = (2, JAS39GripenWeapons.JAS39_MAR_1) + JAS39_GBU49 = (2, JAS39GripenWeapons.JAS39_GBU49) + JAS39_GBU32 = (2, JAS39GripenWeapons.JAS39_GBU32) + JAS39_GBU38 = (2, JAS39GripenWeapons.JAS39_GBU38) + JAS39_SDB = (2, JAS39GripenWeapons.JAS39_SDB) + JAS39_GBU12 = (2, JAS39GripenWeapons.JAS39_GBU12) + JAS39_GBU16 = (2, JAS39GripenWeapons.JAS39_GBU16) + JAS39_DWS39 = (2, JAS39GripenWeapons.JAS39_DWS39) Mk_82___500lb_GP_Bomb_LD = (2, Weapons.Mk_82___500lb_GP_Bomb_LD) Mk_83___1000lb_GP_Bomb_LD = (2, Weapons.Mk_83___1000lb_GP_Bomb_LD) BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( 2, Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, ) - _4x_SB_M_71_120kg_GP_Bomb_Low_drag = ( + JAS39_M71LD = (2, JAS39GripenWeapons.JAS39_M71LD) + JAS39_M70BHE = (2, JAS39GripenWeapons.JAS39_M70BHE) + JAS39_M70BAP = (2, JAS39GripenWeapons.JAS39_M70BAP) + JAS39_BRIMSTONE = (2, JAS39GripenWeapons.JAS39_BRIMSTONE) + LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( 2, - Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, + Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_, ) - JAS_ARAKM70BHE = (2, JAS39GripenWeapons.JAS_ARAKM70BHE) - JAS_ARAKM70BAP = (2, JAS39GripenWeapons.JAS_ARAKM70BAP) - JAS_BRIMSTONE = (2, JAS39GripenWeapons.JAS_BRIMSTONE) - - # ERRR + LAU_117_AGM_65H = (2, Weapons.LAU_117_AGM_65H) class Pylon3: - JAS_RB75T = (3, JAS39GripenWeapons.JAS_RB75T) - AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( + JAS39_AIM_9L = (3, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_IRIS_T = (3, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_A_DARTER = (3, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (3, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (3, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (3, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (3, JAS39GripenWeapons.JAS39_ASRAAM) + LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( 3, - Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_, - ) - JAS_Stormshadow = (3, JAS39GripenWeapons.JAS_Stormshadow) - JAS_BK90 = (3, JAS39GripenWeapons.JAS_BK90) - JAS_GBU31 = (3, JAS39GripenWeapons.JAS_GBU31) - JAS_RB15F = (3, JAS39GripenWeapons.JAS_RB15F) - JAS_MAR_1 = (3, JAS39GripenWeapons.JAS_MAR_1) - JAS_GBU12 = (3, JAS39GripenWeapons.JAS_GBU12) - JAS_GBU49_TV = (3, JAS39GripenWeapons.JAS_GBU49_TV) - # ERRR JAS_GBU16 - JAS_GBU16_TV = (3, JAS39GripenWeapons.JAS_GBU16_TV) - GBU_10___2000lb_Laser_Guided_Bomb = ( - 3, - Weapons.GBU_10___2000lb_Laser_Guided_Bomb, + Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_, ) + LAU_117_AGM_65H = (3, Weapons.LAU_117_AGM_65H) + JAS39_BRIMSTONE = (3, JAS39GripenWeapons.JAS39_BRIMSTONE) + JAS39_RBS15 = (3, JAS39GripenWeapons.JAS39_RBS15) + JAS39_RBS15AI = (3, JAS39GripenWeapons.JAS39_RBS15AI) + JAS39_MAR_1 = (3, JAS39GripenWeapons.JAS39_MAR_1) + JAS39_GBU49 = (3, JAS39GripenWeapons.JAS39_GBU49) + JAS39_GBU31 = (3, JAS39GripenWeapons.JAS39_GBU31) + JAS39_GBU32 = (3, JAS39GripenWeapons.JAS39_GBU32) + JAS39_GBU38 = (3, JAS39GripenWeapons.JAS39_GBU38) + JAS39_SDB = (3, JAS39GripenWeapons.JAS39_SDB) + JAS39_GBU12 = (3, JAS39GripenWeapons.JAS39_GBU12) + JAS39_GBU10 = (3, JAS39GripenWeapons.JAS39_GBU10) + JAS39_GBU16 = (3, JAS39GripenWeapons.JAS39_GBU16) + JAS39_DWS39 = (3, JAS39GripenWeapons.JAS39_DWS39) Mk_82___500lb_GP_Bomb_LD = (3, Weapons.Mk_82___500lb_GP_Bomb_LD) Mk_83___1000lb_GP_Bomb_LD = (3, Weapons.Mk_83___1000lb_GP_Bomb_LD) Mk_84___2000lb_GP_Bomb_LD = (3, Weapons.Mk_84___2000lb_GP_Bomb_LD) @@ -326,144 +421,140 @@ class JAS39Gripen_AG(PlaneType): 3, Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, ) - _4x_SB_M_71_120kg_GP_Bomb_Low_drag = ( - 3, - Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, - ) - JAS_TANK1100 = (3, JAS39GripenWeapons.JAS_TANK1100) - JAS_TANK1700 = (3, JAS39GripenWeapons.JAS_TANK1700) - JAS_ARAKM70BHE = (3, JAS39GripenWeapons.JAS_ARAKM70BHE) - JAS_ARAKM70BAP = (3, JAS39GripenWeapons.JAS_ARAKM70BAP) - JAS_BRIMSTONE = (3, JAS39GripenWeapons.JAS_BRIMSTONE) - - # ERRR + JAS39_M71LD = (3, JAS39GripenWeapons.JAS39_M71LD) + JAS39_TANK1100 = (3, JAS39GripenWeapons.JAS39_TANK1100) + JAS39_TANK1700 = (3, JAS39GripenWeapons.JAS39_TANK1700) + JAS39_M70BHE = (3, JAS39GripenWeapons.JAS39_M70BHE) + JAS39_M70BAP = (3, JAS39GripenWeapons.JAS39_M70BAP) + JAS39_STORMSHADOW = (3, JAS39GripenWeapons.JAS39_STORMSHADOW) class Pylon4: - L_081_Fantasmagoria_ELINT_pod = (4, Weapons.L_081_Fantasmagoria_ELINT_pod) + JAS39_BRIMSTONE = (4, JAS39GripenWeapons.JAS39_BRIMSTONE) + JAS39_STORMSHADOW = (4, JAS39GripenWeapons.JAS39_STORMSHADOW) + JAS39_GBU49 = (4, JAS39GripenWeapons.JAS39_GBU49) + JAS39_GBU31 = (4, JAS39GripenWeapons.JAS39_GBU31) + JAS39_GBU32 = (4, JAS39GripenWeapons.JAS39_GBU32) + JAS39_GBU38 = (4, JAS39GripenWeapons.JAS39_GBU38) + JAS39_SDB = (4, JAS39GripenWeapons.JAS39_SDB) + JAS39_GBU10 = (4, JAS39GripenWeapons.JAS39_GBU10) + JAS39_GBU12 = (4, JAS39GripenWeapons.JAS39_GBU12) + JAS39_GBU16 = (4, JAS39GripenWeapons.JAS39_GBU16) + Mk_82___500lb_GP_Bomb_LD = (4, Weapons.Mk_82___500lb_GP_Bomb_LD) + Mk_83___1000lb_GP_Bomb_LD = (4, Weapons.Mk_83___1000lb_GP_Bomb_LD) + Mk_84___2000lb_GP_Bomb_LD = (4, Weapons.Mk_84___2000lb_GP_Bomb_LD) + BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( + 4, + Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, + ) + JAS39_M71LD = (4, JAS39GripenWeapons.JAS39_M71LD) + JAS39_TANK1100 = (4, JAS39GripenWeapons.JAS39_TANK1100) class Pylon5: - JAS_Stormshadow = (5, JAS39GripenWeapons.JAS_Stormshadow) - JAS_GBU12 = (5, JAS39GripenWeapons.JAS_GBU12) - JAS_GBU49_TV = (5, JAS39GripenWeapons.JAS_GBU49_TV) - # ERRR JAS_GBU16 - JAS_GBU16_TV = (5, JAS39GripenWeapons.JAS_GBU16_TV) - GBU_10___2000lb_Laser_Guided_Bomb = ( - 5, - Weapons.GBU_10___2000lb_Laser_Guided_Bomb, - ) - Mk_82___500lb_GP_Bomb_LD = (5, Weapons.Mk_82___500lb_GP_Bomb_LD) - Mk_83___1000lb_GP_Bomb_LD = (5, Weapons.Mk_83___1000lb_GP_Bomb_LD) - Mk_84___2000lb_GP_Bomb_LD = (5, Weapons.Mk_84___2000lb_GP_Bomb_LD) - BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( - 5, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, - ) - _4x_SB_M_71_120kg_GP_Bomb_Low_drag = ( - 5, - Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, - ) - JAS_TANK1100 = (5, JAS39GripenWeapons.JAS_TANK1100) - # ERRR JAS_WMD7 - JAS_BRIMSTONE = (5, JAS39GripenWeapons.JAS_BRIMSTONE) - - # ERRR {INV-SMOKE-RED} - # ERRR {INV-SMOKE-GREEN} - # ERRR {INV-SMOKE-BLUE} - # ERRR {INV-SMOKE-WHITE} - # ERRR {INV-SMOKE-YELLOW} - # ERRR {INV-SMOKE-ORANGE} - # ERRR + JAS39_Litening = (5, JAS39GripenWeapons.JAS39_Litening) class Pylon6: - L005_Sorbtsiya_ECM_pod__left_ = (6, Weapons.L005_Sorbtsiya_ECM_pod__left_) + JAS39_AIM_9L = (6, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_IRIS_T = (6, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_A_DARTER = (6, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (6, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (6, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (6, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (6, JAS39GripenWeapons.JAS39_ASRAAM) + LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( + 6, + Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_, + ) + LAU_117_AGM_65H = (6, Weapons.LAU_117_AGM_65H) + JAS39_BRIMSTONE = (6, JAS39GripenWeapons.JAS39_BRIMSTONE) + JAS39_RBS15 = (6, JAS39GripenWeapons.JAS39_RBS15) + JAS39_RBS15AI = (6, JAS39GripenWeapons.JAS39_RBS15AI) + JAS39_MAR_1 = (6, JAS39GripenWeapons.JAS39_MAR_1) + JAS39_GBU49 = (6, JAS39GripenWeapons.JAS39_GBU49) + JAS39_GBU31 = (6, JAS39GripenWeapons.JAS39_GBU31) + JAS39_GBU32 = (6, JAS39GripenWeapons.JAS39_GBU32) + JAS39_GBU38 = (6, JAS39GripenWeapons.JAS39_GBU38) + JAS39_SDB = (6, JAS39GripenWeapons.JAS39_SDB) + JAS39_GBU12 = (6, JAS39GripenWeapons.JAS39_GBU12) + JAS39_GBU10 = (6, JAS39GripenWeapons.JAS39_GBU10) + JAS39_GBU16 = (6, JAS39GripenWeapons.JAS39_GBU16) + JAS39_DWS39 = (6, JAS39GripenWeapons.JAS39_DWS39) + Mk_82___500lb_GP_Bomb_LD = (6, Weapons.Mk_82___500lb_GP_Bomb_LD) + Mk_83___1000lb_GP_Bomb_LD = (6, Weapons.Mk_83___1000lb_GP_Bomb_LD) + Mk_84___2000lb_GP_Bomb_LD = (6, Weapons.Mk_84___2000lb_GP_Bomb_LD) + BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( + 6, + Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, + ) + JAS39_M71LD = (6, JAS39GripenWeapons.JAS39_M71LD) + JAS39_TANK1100 = (6, JAS39GripenWeapons.JAS39_TANK1100) + JAS39_TANK1700 = (6, JAS39GripenWeapons.JAS39_TANK1700) + JAS39_M70BHE = (6, JAS39GripenWeapons.JAS39_M70BHE) + JAS39_M70BAP = (6, JAS39GripenWeapons.JAS39_M70BAP) + JAS39_STORMSHADOW = (6, JAS39GripenWeapons.JAS39_STORMSHADOW) class Pylon7: - JAS_Litening = (7, JAS39GripenWeapons.JAS_Litening) - - # ERRR + JAS39_IRIS_T = (7, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (7, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (7, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (7, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (7, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (7, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (7, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_RBS15 = (7, JAS39GripenWeapons.JAS39_RBS15) + JAS39_RBS15AI = (7, JAS39GripenWeapons.JAS39_RBS15AI) + JAS39_MAR_1 = (7, JAS39GripenWeapons.JAS39_MAR_1) + JAS39_GBU49 = (7, JAS39GripenWeapons.JAS39_GBU49) + JAS39_GBU32 = (7, JAS39GripenWeapons.JAS39_GBU32) + JAS39_GBU38 = (7, JAS39GripenWeapons.JAS39_GBU38) + JAS39_SDB = (7, JAS39GripenWeapons.JAS39_SDB) + JAS39_GBU12 = (7, JAS39GripenWeapons.JAS39_GBU12) + JAS39_GBU16 = (7, JAS39GripenWeapons.JAS39_GBU16) + JAS39_DWS39 = (7, JAS39GripenWeapons.JAS39_DWS39) + Mk_82___500lb_GP_Bomb_LD = (7, Weapons.Mk_82___500lb_GP_Bomb_LD) + Mk_83___1000lb_GP_Bomb_LD = (7, Weapons.Mk_83___1000lb_GP_Bomb_LD) + BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( + 7, + Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, + ) + JAS39_M71LD = (7, JAS39GripenWeapons.JAS39_M71LD) + JAS39_M70BHE = (7, JAS39GripenWeapons.JAS39_M70BHE) + JAS39_M70BAP = (7, JAS39GripenWeapons.JAS39_M70BAP) + JAS39_BRIMSTONE = (7, JAS39GripenWeapons.JAS39_BRIMSTONE) + LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( + 7, + Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_, + ) + LAU_117_AGM_65H = (7, Weapons.LAU_117_AGM_65H) class Pylon8: - JAS_RB75T = (8, JAS39GripenWeapons.JAS_RB75T) - AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( - 8, - Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_, - ) - JAS_Stormshadow = (8, JAS39GripenWeapons.JAS_Stormshadow) - JAS_BK90 = (8, JAS39GripenWeapons.JAS_BK90) - JAS_GBU31 = (8, JAS39GripenWeapons.JAS_GBU31) - JAS_RB15F = (8, JAS39GripenWeapons.JAS_RB15F) - JAS_MAR_1 = (8, JAS39GripenWeapons.JAS_MAR_1) - JAS_GBU12 = (8, JAS39GripenWeapons.JAS_GBU12) - JAS_GBU49_TV = (8, JAS39GripenWeapons.JAS_GBU49_TV) - # ERRR JAS_GBU16 - JAS_GBU16_TV = (8, JAS39GripenWeapons.JAS_GBU16_TV) - GBU_10___2000lb_Laser_Guided_Bomb = ( - 8, - Weapons.GBU_10___2000lb_Laser_Guided_Bomb, - ) - Mk_82___500lb_GP_Bomb_LD = (8, Weapons.Mk_82___500lb_GP_Bomb_LD) - Mk_83___1000lb_GP_Bomb_LD = (8, Weapons.Mk_83___1000lb_GP_Bomb_LD) - Mk_84___2000lb_GP_Bomb_LD = (8, Weapons.Mk_84___2000lb_GP_Bomb_LD) - BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( - 8, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, - ) - _4x_SB_M_71_120kg_GP_Bomb_Low_drag = ( - 8, - Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, - ) - JAS_TANK1100 = (8, JAS39GripenWeapons.JAS_TANK1100) - JAS_TANK1700 = (8, JAS39GripenWeapons.JAS_TANK1700) - JAS_ARAKM70BHE = (8, JAS39GripenWeapons.JAS_ARAKM70BHE) - JAS_ARAKM70BAP = (8, JAS39GripenWeapons.JAS_ARAKM70BAP) - JAS_BRIMSTONE = (8, JAS39GripenWeapons.JAS_BRIMSTONE) - - # ERRR + JAS39_IRIS_T = (8, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (8, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (8, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (8, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (8, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (8, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (8, JAS39GripenWeapons.JAS39_ASRAAM) + AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (8, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) + Smokewinder___red = (8, Weapons.Smokewinder___red) + Smokewinder___green = (8, Weapons.Smokewinder___green) + Smokewinder___blue = (8, Weapons.Smokewinder___blue) + Smokewinder___white = (8, Weapons.Smokewinder___white) + Smokewinder___yellow = (8, Weapons.Smokewinder___yellow) + Smokewinder___orange = (8, Weapons.Smokewinder___orange) class Pylon9: - JAS_IRIS_T = (9, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (9, JAS39GripenWeapons.JAS_Rb74) - JAS_RB75T = (9, JAS39GripenWeapons.JAS_RB75T) - AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( + Litening_III_Targeting_Pod_FLIR = ( 9, - Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_, + JAS39GripenWeapons.Litening_III_Targeting_Pod_FLIR, ) - JAS_BK90 = (9, JAS39GripenWeapons.JAS_BK90) - JAS_RB15F = (9, JAS39GripenWeapons.JAS_RB15F) - JAS_MAR_1 = (9, JAS39GripenWeapons.JAS_MAR_1) - JAS_GBU12 = (9, JAS39GripenWeapons.JAS_GBU12) - JAS_GBU49_TV = (9, JAS39GripenWeapons.JAS_GBU49_TV) - # ERRR JAS_GBU16 - JAS_GBU16_TV = (9, JAS39GripenWeapons.JAS_GBU16_TV) - # ERRR GBU12_TEST - Mk_82___500lb_GP_Bomb_LD = (9, Weapons.Mk_82___500lb_GP_Bomb_LD) - Mk_83___1000lb_GP_Bomb_LD = (9, Weapons.Mk_83___1000lb_GP_Bomb_LD) - BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( - 9, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, - ) - _4x_SB_M_71_120kg_GP_Bomb_Low_drag = ( - 9, - Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, - ) - JAS_ARAKM70BHE = (9, JAS39GripenWeapons.JAS_ARAKM70BHE) - JAS_ARAKM70BAP = (9, JAS39GripenWeapons.JAS_ARAKM70BAP) - JAS_BRIMSTONE = (9, JAS39GripenWeapons.JAS_BRIMSTONE) - - # ERRR class Pylon10: - JAS_IRIS_T = (10, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (10, JAS39GripenWeapons.JAS_Rb74) - AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (10, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) - Smokewinder___red = (10, Weapons.Smokewinder___red) - Smokewinder___green = (10, Weapons.Smokewinder___green) - Smokewinder___blue = (10, Weapons.Smokewinder___blue) - Smokewinder___white = (10, Weapons.Smokewinder___white) - Smokewinder___yellow = (10, Weapons.Smokewinder___yellow) - Smokewinder___orange = (10, Weapons.Smokewinder___orange) + Integrated_ELINT = (10, JAS39GripenWeapons.Integrated_ELINT) - pylons = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + class Pylon11: + EWS_39_Integrated_ECM = (11, JAS39GripenWeapons.EWS_39_Integrated_ECM) + + pylons: Set[int] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} tasks = [ task.SEAD, diff --git a/pyinstaller.spec b/pyinstaller.spec index 839c641f..88436110 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -36,7 +36,7 @@ exe = EXE( bootloader_ignore_signals=False, strip=False, upx=True, - console=True, + console=False, ) coll = COLLECT( exe, diff --git a/qt_ui/liberation_install.py b/qt_ui/liberation_install.py index 0bbcb0b0..341cd45a 100644 --- a/qt_ui/liberation_install.py +++ b/qt_ui/liberation_install.py @@ -112,7 +112,7 @@ def replace_mission_scripting_file(): ) liberation_scripting_path = "./resources/scripts/MissionScripting.lua" backup_scripting_path = "./resources/scripts/MissionScripting.original.lua" - if os.path.isfile(mission_scripting_path): + if install_dir != "" and os.path.isfile(mission_scripting_path): with open(mission_scripting_path, "r") as ms: current_file_content = ms.read() with open(liberation_scripting_path, "r") as libe_ms: @@ -133,5 +133,9 @@ def restore_original_mission_scripting(): ) backup_scripting_path = "./resources/scripts/MissionScripting.original.lua" - if os.path.isfile(backup_scripting_path) and os.path.isfile(mission_scripting_path): + if ( + install_dir != "" + and os.path.isfile(backup_scripting_path) + and os.path.isfile(mission_scripting_path) + ): copyfile(backup_scripting_path, mission_scripting_path) diff --git a/qt_ui/logging_config.py b/qt_ui/logging_config.py index ea08efe3..8d7a26af 100644 --- a/qt_ui/logging_config.py +++ b/qt_ui/logging_config.py @@ -3,6 +3,8 @@ import logging import os from logging.handlers import RotatingFileHandler +from qt_ui.logging_handler import HookableInMemoryHandler + def init_logging(version: str) -> None: """Initializes the logging configuration.""" @@ -10,13 +12,22 @@ def init_logging(version: str) -> None: os.mkdir("logs") fmt = "%(asctime)s :: %(levelname)s :: %(message)s" + formatter = logging.Formatter(fmt) + logging.basicConfig(level=logging.DEBUG, format=fmt) logger = logging.getLogger() - handler = RotatingFileHandler("./logs/liberation.log", "a", 5000000, 1) - handler.setLevel(logging.DEBUG) - handler.setFormatter(logging.Formatter(fmt)) + rotating_file_handler = RotatingFileHandler( + "./logs/liberation.log", "a", 5000000, 1 + ) + rotating_file_handler.setLevel(logging.DEBUG) + rotating_file_handler.setFormatter(formatter) - logger.addHandler(handler) + hookable_in_memory_handler = HookableInMemoryHandler() + hookable_in_memory_handler.setLevel(logging.DEBUG) + hookable_in_memory_handler.setFormatter(formatter) + + logger.addHandler(rotating_file_handler) + logger.addHandler(hookable_in_memory_handler) logger.info(f"DCS Liberation {version}") diff --git a/qt_ui/logging_handler.py b/qt_ui/logging_handler.py new file mode 100644 index 00000000..465e4cb1 --- /dev/null +++ b/qt_ui/logging_handler.py @@ -0,0 +1,38 @@ +import logging +import typing + +LogHook = typing.Callable[[str], None] + + +class HookableInMemoryHandler(logging.Handler): + """Hookable in-memory logging handler for logs window""" + + _log: str + _hook: typing.Optional[typing.Callable[[str], None]] + + def __init__(self, *args, **kwargs): + super(HookableInMemoryHandler, self).__init__(*args, **kwargs) + self._log = "" + self._hook = None + + @property + def log(self) -> str: + return self._log + + def emit(self, record): + msg = self.format(record) + self._log += msg + "\n" + if self._hook is not None: + self._hook(msg) + + def write(self, m): + pass + + def clearLog(self) -> None: + self._log = "" + + def setHook(self, hook: typing.Callable[[str], None]) -> None: + self._hook = hook + + def clearHook(self) -> None: + self._hook = None diff --git a/qt_ui/main.py b/qt_ui/main.py index 744f90de..70d4dd5b 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -7,18 +7,15 @@ from pathlib import Path from typing import Optional from PySide2 import QtWidgets +from PySide2.QtCore import Qt from PySide2.QtGui import QPixmap from PySide2.QtWidgets import QApplication, QSplashScreen from dcs.payloads import PayloadDirectories -from dcs.weapons_data import weapon_ids from game import Game, VERSION, persistency -from game.data.weapons import ( - WEAPON_FALLBACK_MAP, - WEAPON_INTRODUCTION_YEARS, - Weapon, -) +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 @@ -62,6 +59,8 @@ def run_ui(game: Optional[Game]) -> None: os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens app = QApplication(sys.argv) + app.setAttribute(Qt.AA_DisableWindowContextHelpButton) + # init the theme and load the stylesheet based on the theme index liberation_theme.init() with open( @@ -97,6 +96,22 @@ def run_ui(game: Optional[Game]) -> None: uiconstants.load_aircraft_banners() uiconstants.load_vehicle_banners() + # Show warning if no DCS Installation directory was set + if liberation_install.get_dcs_install_directory() == "": + QtWidgets.QMessageBox.warning( + splash, + "No DCS installation directory.", + "The DCS Installation directory is not set correctly. " + "This will prevent DCS Liberation to work properly as the MissionScripting " + "file will not be modified." + "

To solve this problem, you can set the Installation directory " + "within the preferences menu. You can also manually edit or replace the " + "following file:" + "

<dcs_installation_directory>/Scripts/MissionScripting.lua" + "

The easiest way to do it is to replace the original file with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua)." + "

You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub.

", + QtWidgets.QMessageBox.StandardButton.Ok, + ) # Replace DCS Mission scripting file to allow DCS Liberation to work try: liberation_install.replace_mission_scripting_file() @@ -169,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() @@ -182,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: @@ -210,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, @@ -232,16 +266,24 @@ def create_game( high_digit_sams=False, ), ) - return generator.generate() + game = generator.generate() + game.begin_turn_0() + return game -def lint_weapon_data() -> None: - for clsid in weapon_ids: - weapon = Weapon.from_clsid(clsid) - if weapon not in WEAPON_INTRODUCTION_YEARS: - logging.warning(f"{weapon} has no introduction date") - if weapon not in WEAPON_FALLBACK_MAP: - logging.warning(f"{weapon} has no fallback") +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(): @@ -255,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"): @@ -267,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/models.py b/qt_ui/models.py index 4e5f46b3..ee842de5 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -12,11 +12,10 @@ from PySide2.QtCore import ( ) from PySide2.QtGui import QIcon -from game import db from game.game import Game from game.squadrons import Squadron, Pilot from game.theater.missiontarget import MissionTarget -from game.transfers import TransferOrder +from game.transfers import TransferOrder, PendingTransfers from gen.ato import AirTaskingOrder, Package from gen.flights.flight import Flight, FlightType from gen.flights.traveltime import TotEstimator @@ -281,9 +280,9 @@ class AtoModel(QAbstractListModel): self.package_models.clear() if self.game is not None: if player: - self.ato = self.game.blue_ato + self.ato = self.game.blue.ato else: - self.ato = self.game.red_ato + self.ato = self.game.red.ato else: self.ato = AirTaskingOrder() self.endResetModel() @@ -316,8 +315,12 @@ class TransferModel(QAbstractListModel): super().__init__() self.game_model = game_model + @property + def transfers(self) -> PendingTransfers: + return self.game_model.game.coalition_for(player=True).transfers + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - return self.game_model.game.transfers.pending_transfer_count + return self.transfers.pending_transfer_count def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: if not index.isValid(): @@ -345,7 +348,7 @@ class TransferModel(QAbstractListModel): """Updates the game with the new unit transfer.""" self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) # TODO: Needs to regenerate base inventory tab. - self.game_model.game.transfers.new_transfer(transfer) + self.transfers.new_transfer(transfer) self.endInsertRows() def cancel_transfer_at_index(self, index: QModelIndex) -> None: @@ -354,15 +357,15 @@ class TransferModel(QAbstractListModel): def cancel_transfer(self, transfer: TransferOrder) -> None: """Cancels the planned unit transfer at the given index.""" - index = self.game_model.game.transfers.index_of_transfer(transfer) + index = self.transfers.index_of_transfer(transfer) self.beginRemoveRows(QModelIndex(), index, index) # TODO: Needs to regenerate base inventory tab. - self.game_model.game.transfers.cancel_transfer(transfer) + self.transfers.cancel_transfer(transfer) self.endRemoveRows() def transfer_at_index(self, index: QModelIndex) -> TransferOrder: """Returns the transfer located at the given index.""" - return self.game_model.game.transfers.transfer_at_index(index.row()) + return self.transfers.transfer_at_index(index.row()) class AirWingModel(QAbstractListModel): @@ -488,8 +491,8 @@ class GameModel: self.ato_model = AtoModel(self, AirTaskingOrder()) self.red_ato_model = AtoModel(self, AirTaskingOrder()) else: - self.ato_model = AtoModel(self, self.game.blue_ato) - self.red_ato_model = AtoModel(self, self.game.red_ato) + self.ato_model = AtoModel(self, self.game.blue.ato) + self.red_ato_model = AtoModel(self, self.game.red.ato) def ato_model_for(self, player: bool) -> AtoModel: if player: diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index 30ee7adf..761cbc19 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -68,6 +68,7 @@ def load_icons(): ICONS["Terrain_Normandy"] = QPixmap("./resources/ui/terrain_normandy.gif") ICONS["Terrain_TheChannel"] = QPixmap("./resources/ui/terrain_channel.gif") ICONS["Terrain_Syria"] = QPixmap("./resources/ui/terrain_syria.gif") + ICONS["Terrain_MarianaIslands"] = QPixmap("./resources/ui/terrain_marianas.gif") ICONS["Dawn"] = QPixmap("./resources/ui/conditions/timeofday/dawn.png") ICONS["Day"] = QPixmap("./resources/ui/conditions/timeofday/day.png") @@ -106,6 +107,7 @@ def load_icons(): ICONS["PluginsOptions"] = QPixmap( "./resources/ui/misc/" + get_theme_icons() + "/pluginsoptions.png" ) + ICONS["Notes"] = QPixmap("./resources/ui/misc/" + get_theme_icons() + "/notes.png") ICONS["TaskCAS"] = QPixmap("./resources/ui/tasks/cas.png") ICONS["TaskCAP"] = QPixmap("./resources/ui/tasks/cap.png") diff --git a/qt_ui/widgets/QBudgetBox.py b/qt_ui/widgets/QBudgetBox.py index e44713a8..30713c92 100644 --- a/qt_ui/widgets/QBudgetBox.py +++ b/qt_ui/widgets/QBudgetBox.py @@ -1,6 +1,7 @@ from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QPushButton import qt_ui.uiconstants as CONST +from game import Game from game.income import Income from qt_ui.windows.finances.QFinancesMenu import QFinancesMenu @@ -10,7 +11,7 @@ class QBudgetBox(QGroupBox): UI Component to display current budget and player's money """ - def __init__(self, game): + def __init__(self, game: Game): super(QBudgetBox, self).__init__("Budget") self.game = game @@ -40,7 +41,7 @@ class QBudgetBox(QGroupBox): return self.game = game - self.setBudget(self.game.budget, Income(self.game, player=True).total) + self.setBudget(self.game.blue.budget, Income(self.game, player=True).total) self.finances.setEnabled(True) def openFinances(self): diff --git a/qt_ui/widgets/QFactionsInfos.py b/qt_ui/widgets/QFactionsInfos.py index 935f4af9..c0ca25cd 100644 --- a/qt_ui/widgets/QFactionsInfos.py +++ b/qt_ui/widgets/QFactionsInfos.py @@ -24,8 +24,8 @@ class QFactionsInfos(QGroupBox): def setGame(self, game: Game): if game is not None: - self.player_name.setText(game.player_faction.name) - self.enemy_name.setText(game.enemy_faction.name) + self.player_name.setText(game.blue.faction.name) + self.enemy_name.setText(game.red.faction.name) else: self.player_name.setText("") self.enemy_name.setText("") diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index a00b6044..d94f2911 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -13,6 +13,7 @@ import qt_ui.uiconstants as CONST from game import Game from game.event.airwar import AirWarEvent from game.profiling import logged_duration +from game.utils import meters from gen.ato import Package from gen.flights.traveltime import TotEstimator from qt_ui.models import GameModel @@ -112,6 +113,14 @@ class QTopPanel(QFrame): self.transfers.setEnabled(True) self.conditionsWidget.setCurrentTurn(game.turn, game.conditions) + + if game.conditions.weather.clouds: + base_m = game.conditions.weather.clouds.base + base_ft = int(meters(base_m).feet) + self.conditionsWidget.setToolTip(f"Cloud Base: {base_m}m / {base_ft}ft") + else: + self.conditionsWidget.setToolTip("") + self.intel_box.set_game(game) self.budgetBox.setGame(game) self.factionsInfos.setGame(game) @@ -159,7 +168,7 @@ class QTopPanel(QFrame): package.time_over_target = estimator.earliest_tot() def ato_has_clients(self) -> bool: - for package in self.game.blue_ato.packages: + for package in self.game.blue.ato.packages: for flight in package.flights: if flight.client_count > 0: return True @@ -227,7 +236,7 @@ class QTopPanel(QFrame): def check_no_missing_pilots(self) -> bool: missing_pilots = [] - for package in self.game.blue_ato.packages: + for package in self.game.blue.ato.packages: for flight in package.flights: if flight.missing_pilots > 0: missing_pilots.append((package, flight)) @@ -273,8 +282,8 @@ class QTopPanel(QFrame): closest_cps[0], closest_cps[1], self.game.theater.controlpoints[0].position, - self.game.player_faction.name, - self.game.enemy_faction.name, + self.game.blue.faction.name, + self.game.red.faction.name, ) unit_map = self.game.initiate_event(game_event) diff --git a/qt_ui/widgets/combos/QAircraftTypeSelector.py b/qt_ui/widgets/combos/QAircraftTypeSelector.py index 80dfa5b0..12f913db 100644 --- a/qt_ui/widgets/combos/QAircraftTypeSelector.py +++ b/qt_ui/widgets/combos/QAircraftTypeSelector.py @@ -4,7 +4,6 @@ from typing import Iterable, Type from PySide2.QtWidgets import QComboBox from dcs.unittype import FlyingType -from game import db from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.flight import FlightType @@ -13,16 +12,12 @@ class QAircraftTypeSelector(QComboBox): """Combo box for selecting among the given aircraft types.""" def __init__( - self, - aircraft_types: Iterable[Type[FlyingType]], - country: str, - mission_type: FlightType, + self, aircraft_types: Iterable[Type[FlyingType]], mission_type: FlightType ) -> None: super().__init__() self.model().sort(0) self.setSizeAdjustPolicy(self.AdjustToContents) - self.country = country self.update_items(mission_type, aircraft_types) def update_items(self, mission_type: FlightType, aircraft_types): diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 3d56b5d8..24024bc1 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -8,11 +8,18 @@ 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.navmesh import NavMesh +from game.flightplan import JoinZoneGeometry, HoldZoneGeometry +from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( ConflictTheater, @@ -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() @@ -336,8 +364,12 @@ class SupplyRouteJs(QObject): def find_transports(self) -> List[MultiGroupTransport]: if self.sea_route: - return self.find_in_transport_map(self.game.transfers.cargo_ships) - return self.find_in_transport_map(self.game.transfers.convoys) + return self.find_in_transport_map( + self.game.blue.transfers.cargo_ships + ) + self.find_in_transport_map(self.game.red.transfers.cargo_ships) + return self.find_in_transport_map( + self.game.blue.transfers.convoys + ) + self.find_in_transport_map(self.game.red.transfers.convoys) @Property(list, notify=activeTransportsChanged) def activeTransports(self) -> List[str]: @@ -385,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]] @@ -508,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, @@ -642,11 +687,35 @@ class ThreatZoneContainerJs(QObject): return self._red +class NavMeshPolyJs(QObject): + polyChanged = Signal() + threatenedChanged = Signal() + + def __init__(self, poly: LeafletPoly, threatened: bool) -> None: + super().__init__() + self._poly = poly + self._threatened = threatened + + @Property(list, notify=polyChanged) + def poly(self) -> LeafletPoly: + return self._poly + + @Property(bool, notify=threatenedChanged) + def threatened(self) -> bool: + return self._threatened + + @classmethod + def from_navmesh(cls, poly: NavMeshPoly, theater: ConflictTheater) -> NavMeshPolyJs: + return NavMeshPolyJs( + shapely_poly_to_leaflet_points(poly.poly, theater), poly.threatened + ) + + class NavMeshJs(QObject): blueChanged = Signal() redChanged = Signal() - def __init__(self, blue: list[LeafletPoly], red: list[LeafletPoly]) -> None: + def __init__(self, blue: list[NavMeshPolyJs], red: list[NavMeshPolyJs]) -> None: super().__init__() self._blue = blue self._red = red @@ -663,17 +732,17 @@ class NavMeshJs(QObject): return self._red @staticmethod - def to_polys(navmesh: NavMesh, theater: ConflictTheater) -> list[LeafletPoly]: + def to_polys(navmesh: NavMesh, theater: ConflictTheater) -> list[NavMeshPolyJs]: polys = [] for poly in navmesh.polys: - polys.append(shapely_poly_to_leaflet_points(poly.poly, theater)) + polys.append(NavMeshPolyJs.from_navmesh(poly, theater)) return polys @classmethod def from_game(cls, game: Game) -> NavMeshJs: return NavMeshJs( - cls.to_polys(game.blue_navmesh, game.theater), - cls.to_polys(game.red_navmesh, game.theater), + cls.to_polys(game.blue.nav_mesh, game.theater), + cls.to_polys(game.red.nav_mesh, game.theater), ) @@ -741,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() @@ -754,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__() @@ -770,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) @@ -793,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: @@ -868,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.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]: @@ -1001,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/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index ac666e0e..df0cf81c 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -3,12 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Optional, Iterator -from PySide2.QtCore import ( - QItemSelectionModel, - QModelIndex, - Qt, - QSize, -) +from PySide2.QtCore import QItemSelectionModel, QModelIndex, QSize from PySide2.QtWidgets import ( QAbstractItemView, QCheckBox, @@ -183,7 +178,7 @@ class AirInventoryView(QWidget): self.table.setSortingEnabled(True) def iter_allocated_aircraft(self) -> Iterator[AircraftInventoryData]: - for package in self.game_model.game.blue_ato.packages: + for package in self.game_model.game.blue.ato.packages: for flight in package.flights: yield from AircraftInventoryData.from_flight(flight) diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index e4d7f403..a2c984d2 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -36,6 +36,8 @@ from qt_ui.windows.preferences.QLiberationPreferencesWindow import ( ) from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow from qt_ui.windows.stats.QStatsWindow import QStatsWindow +from qt_ui.windows.notes.QNotesWindow import QNotesWindow +from qt_ui.windows.logs.QLogsWindow import QLogsWindow class QLiberationWindow(QMainWindow): @@ -150,6 +152,9 @@ class QLiberationWindow(QMainWindow): ) ) + self.openLogsAction = QAction("Show &logs", self) + self.openLogsAction.triggered.connect(self.showLogsDialog) + self.openSettingsAction = QAction("Settings", self) self.openSettingsAction.setIcon(CONST.ICONS["Settings"]) self.openSettingsAction.triggered.connect(self.showSettingsDialog) @@ -158,6 +163,10 @@ class QLiberationWindow(QMainWindow): self.openStatsAction.setIcon(CONST.ICONS["Statistics"]) self.openStatsAction.triggered.connect(self.showStatsDialog) + self.openNotesAction = QAction("Notes", self) + self.openNotesAction.setIcon(CONST.ICONS["Notes"]) + self.openNotesAction.triggered.connect(self.showNotesDialog) + def initToolbar(self): self.tool_bar = self.addToolBar("File") self.tool_bar.addAction(self.newGameAction) @@ -171,6 +180,7 @@ class QLiberationWindow(QMainWindow): self.actions_bar = self.addToolBar("Actions") self.actions_bar.addAction(self.openSettingsAction) self.actions_bar.addAction(self.openStatsAction) + self.actions_bar.addAction(self.openNotesAction) def initMenuBar(self): self.menu = self.menuBar() @@ -204,6 +214,7 @@ class QLiberationWindow(QMainWindow): help_menu.addAction( "Report an &issue", lambda: webbrowser.open_new_tab(URLS["Issues"]) ) + help_menu.addAction(self.openLogsAction) help_menu.addSeparator() help_menu.addAction(self.showAboutDialogAction) @@ -351,6 +362,14 @@ class QLiberationWindow(QMainWindow): self.dialog = QStatsWindow(self.game) self.dialog.show() + def showNotesDialog(self): + self.dialog = QNotesWindow(self.game) + self.dialog.show() + + def showLogsDialog(self): + self.dialog = QLogsWindow() + self.dialog.show() + def onDebriefing(self, debrief: Debriefing): logging.info("On Debriefing") self.debriefing = QDebriefingWindow(debrief) diff --git a/qt_ui/windows/QUnitInfoWindow.py b/qt_ui/windows/QUnitInfoWindow.py index a87ce597..e5503544 100644 --- a/qt_ui/windows/QUnitInfoWindow.py +++ b/qt_ui/windows/QUnitInfoWindow.py @@ -94,6 +94,9 @@ class QUnitInfoWindow(QDialog): self.details_text = QTextBrowser() self.details_text.setProperty("style", "info-desc") self.details_text.setText(unit_type.description) + self.details_text.setOpenExternalLinks( + True + ) # in aircrafttype.py and groundunittype.py, for the descriptions, if No Data. including a google search link self.gridLayout.addWidget(self.details_text, 3, 0) self.layout.addLayout(self.gridLayout, 1, 0) diff --git a/qt_ui/windows/basemenu/DepartingConvoysMenu.py b/qt_ui/windows/basemenu/DepartingConvoysMenu.py index d858539e..c334f0bb 100644 --- a/qt_ui/windows/basemenu/DepartingConvoysMenu.py +++ b/qt_ui/windows/basemenu/DepartingConvoysMenu.py @@ -73,11 +73,15 @@ class DepartingConvoysList(QFrame): task_box_layout = QGridLayout() scroll_content.setLayout(task_box_layout) - for convoy in game_model.game.transfers.convoys.departing_from(cp): + for convoy in game_model.game.coalition_for( + cp.captured + ).transfers.convoys.departing_from(cp): group_info = DepartingConvoyInfo(convoy) task_box_layout.addWidget(group_info) - for cargo_ship in game_model.game.transfers.cargo_ships.departing_from(cp): + for cargo_ship in game_model.game.coalition_for( + cp.captured + ).transfers.cargo_ships.departing_from(cp): group_info = DepartingConvoyInfo(cargo_ship) task_box_layout.addWidget(group_info) diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index 8a913280..d10e5bc7 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -108,7 +108,7 @@ class QBaseMenu2(QDialog): capture_button.clicked.connect(self.cheat_capture) self.budget_display = QLabel( - QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.budget) + QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.blue.budget) ) self.budget_display.setAlignment(Qt.AlignRight | Qt.AlignBottom) self.budget_display.setProperty("style", "budget-label") @@ -124,7 +124,6 @@ class QBaseMenu2(QDialog): self.cp.capture(self.game_model.game, for_player=not self.cp.captured) # Reinitialized ground planners and the like. The ATO needs to be reset because # missions planned against the flipped base are no longer valid. - self.game_model.game.reset_ato() self.game_model.game.initialize_turn() GameUpdateSignal.get_instance().updateGame(self.game_model.game) @@ -140,7 +139,7 @@ class QBaseMenu2(QDialog): @property def can_afford_runway_repair(self) -> bool: - return self.game_model.game.budget >= db.RUNWAY_REPAIR_COST + return self.game_model.game.blue.budget >= db.RUNWAY_REPAIR_COST def begin_runway_repair(self) -> None: if not self.can_afford_runway_repair: @@ -148,7 +147,7 @@ class QBaseMenu2(QDialog): self, "Cannot repair runway", f"Runway repair costs ${db.RUNWAY_REPAIR_COST}M but you have " - f"only ${self.game_model.game.budget}M available.", + f"only ${self.game_model.game.blue.budget}M available.", QMessageBox.Ok, ) return @@ -162,7 +161,7 @@ class QBaseMenu2(QDialog): return self.cp.begin_runway_repair() - self.game_model.game.budget -= db.RUNWAY_REPAIR_COST + self.game_model.game.blue.budget -= db.RUNWAY_REPAIR_COST self.update_repair_button() self.update_intel_summary() GameUpdateSignal.get_instance().updateGame(self.game_model.game) @@ -196,7 +195,9 @@ class QBaseMenu2(QDialog): ground_unit_limit = self.cp.frontline_unit_count_limit deployable_unit_info = "" - allocated = self.cp.allocated_ground_units(self.game_model.game.transfers) + allocated = self.cp.allocated_ground_units( + self.game_model.game.coalition_for(self.cp.captured).transfers + ) unit_overage = max( allocated.total_present - self.cp.frontline_unit_count_limit, 0 ) @@ -256,4 +257,6 @@ class QBaseMenu2(QDialog): NewUnitTransferDialog(self.game_model, self.cp, parent=self.window()).show() def update_budget(self, game: Game) -> None: - self.budget_display.setText(QRecruitBehaviour.BUDGET_FORMAT.format(game.budget)) + self.budget_display.setText( + QRecruitBehaviour.BUDGET_FORMAT.format(game.blue.budget) + ) diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py index 5eb7534a..77b0258b 100644 --- a/qt_ui/windows/basemenu/QRecruitBehaviour.py +++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py @@ -103,11 +103,11 @@ class QRecruitBehaviour: @property def budget(self) -> float: - return self.game_model.game.budget + return self.game_model.game.blue.budget @budget.setter def budget(self, value: int) -> None: - self.game_model.game.budget = value + self.game_model.game.blue.budget = value def add_purchase_row( self, @@ -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/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index 2df51537..9e24e082 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -45,7 +45,7 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): row = 0 unit_types: Set[AircraftType] = set() - for unit_type in self.game_model.game.player_faction.aircrafts: + for unit_type in self.game_model.game.blue.faction.aircrafts: if self.cp.is_carrier and not unit_type.carrier_capable: continue if self.cp.is_lha and not unit_type.lha_capable: diff --git a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py index ec467b92..166f7b4b 100644 --- a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py +++ b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py @@ -56,6 +56,5 @@ class QGroundForcesStrategy(QGroupBox): self.cp.base.affect_strength(amount) enemy_point.base.affect_strength(-amount) # Clear the ATO to replan missions affected by the front line. - self.game.reset_ato() self.game.initialize_turn() GameUpdateSignal.get_instance().updateGame(self.game) diff --git a/qt_ui/windows/finances/QFinancesMenu.py b/qt_ui/windows/finances/QFinancesMenu.py index 4ef8b281..c1eec23e 100644 --- a/qt_ui/windows/finances/QFinancesMenu.py +++ b/qt_ui/windows/finances/QFinancesMenu.py @@ -57,10 +57,7 @@ class FinancesLayout(QGridLayout): middle=f"Income multiplier: {income.multiplier:.1f}", right=f"{income.total}M", ) - if player: - budget = game.budget - else: - budget = game.enemy_budget + budget = game.coalition_for(player).budget self.add_row(middle="Balance", right=f"{budget}M") self.setRowStretch(next(self.row), 1) diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py index 96debe14..5622682f 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -1,7 +1,6 @@ import logging from typing import List, Optional -from PySide2 import QtCore from PySide2.QtGui import Qt from PySide2.QtWidgets import ( QComboBox, @@ -238,8 +237,8 @@ class QGroundObjectMenu(QDialog): self.total_value = total_value def repair_unit(self, group, unit, price): - if self.game.budget > price: - self.game.budget -= price + if self.game.blue.budget > price: + self.game.blue.budget -= price group.units_losts = [u for u in group.units_losts if u.id != unit.id] group.units.append(unit) GameUpdateSignal.get_instance().updateGame(self.game) @@ -257,8 +256,16 @@ class QGroundObjectMenu(QDialog): def sell_all(self): self.update_total_value() - self.game.budget = self.game.budget + self.total_value + self.game.blue.budget = self.game.blue.budget + self.total_value self.ground_object.groups = [] + + # Replan if the tgo was a target of the redfor + if any( + package.target == self.ground_object + for package in self.game.ato_for(player=False).packages + ): + self.game.initialize_turn(for_red=True, for_blue=False) + self.do_refresh_layout() GameUpdateSignal.get_instance().updateGame(self.game) @@ -299,14 +306,17 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.buySamBox = QGroupBox("Buy SAM site :") self.buyArmorBox = QGroupBox("Buy defensive position :") - faction = self.game.player_faction + faction = self.game.blue.faction # Sams possible_sams = get_faction_possible_sams_generator(faction) for sam in possible_sams: + # Pre Generate SAM to get the real price + generator = sam(self.game, self.ground_object) + generator.generate() self.samCombo.addItem( - sam.name + " [$" + str(sam.price) + "M]", userData=sam + generator.name + " [$" + str(generator.price) + "M]", userData=generator ) self.samCombo.currentIndexChanged.connect(self.samComboChanged) @@ -331,8 +341,12 @@ class QBuyGroupForGroundObjectDialog(QDialog): buy_ewr_layout.addWidget(self.ewr_selector, 0, 1, alignment=Qt.AlignRight) ewr_types = get_faction_possible_ewrs_generator(faction) for ewr_type in ewr_types: + # Pre Generate to get the real price + generator = ewr_type(self.game, self.ground_object) + generator.generate() self.ewr_selector.addItem( - f"{ewr_type.name()} [${ewr_type.price()}M]", ewr_type + generator.name() + " [$" + str(generator.price) + "M]", + userData=generator, ) self.ewr_selector.currentIndexChanged.connect(self.on_ewr_selection_changed) @@ -402,7 +416,7 @@ class QBuyGroupForGroundObjectDialog(QDialog): def on_ewr_selection_changed(self, index): ewr = self.ewr_selector.itemData(index) self.buy_ewr_button.setText( - f"Buy [${ewr.price()}M][-${self.current_group_value}M]" + f"Buy [${ewr.price}M][-${self.current_group_value}M]" ) def armorComboChanged(self, index): @@ -419,12 +433,12 @@ class QBuyGroupForGroundObjectDialog(QDialog): logging.info("Buying Armor ") utype = self.buyArmorCombo.itemData(self.buyArmorCombo.currentIndex()) price = utype.price * self.amount.value() - self.current_group_value - if price > self.game.budget: + if price > self.game.blue.budget: self.error_money() self.close() return else: - self.game.budget -= price + self.game.blue.budget -= price # Generate Armor group = generate_armor_group_of_type_and_size( @@ -432,36 +446,40 @@ class QBuyGroupForGroundObjectDialog(QDialog): ) self.ground_object.groups = [group] + # Replan redfor missions + self.game.initialize_turn(for_red=True, for_blue=False) + GameUpdateSignal.get_instance().updateGame(self.game) def buySam(self): sam_generator = self.samCombo.itemData(self.samCombo.currentIndex()) price = sam_generator.price - self.current_group_value - if price > self.game.budget: + if price > self.game.blue.budget: self.error_money() return else: - self.game.budget -= price + self.game.blue.budget -= price - # Generate SAM - generator = sam_generator(self.game, self.ground_object) - generator.generate() - self.ground_object.groups = list(generator.groups) + self.ground_object.groups = list(sam_generator.groups) + + # Replan redfor missions + self.game.initialize_turn(for_red=True, for_blue=False) GameUpdateSignal.get_instance().updateGame(self.game) def buy_ewr(self): ewr_generator = self.ewr_selector.itemData(self.ewr_selector.currentIndex()) - price = ewr_generator.price() - self.current_group_value - if price > self.game.budget: + price = ewr_generator.price - self.current_group_value + if price > self.game.blue.budget: self.error_money() return else: - self.game.budget -= price + self.game.blue.budget -= price - generator = ewr_generator(self.game, self.ground_object) - generator.generate() - self.ground_object.groups = [generator.vg] + self.ground_object.groups = [ewr_generator.vg] + + # Replan redfor missions + self.game.initialize_turn(for_red=True, for_blue=False) GameUpdateSignal.get_instance().updateGame(self.game) diff --git a/qt_ui/windows/logs/QLogsWindow.py b/qt_ui/windows/logs/QLogsWindow.py new file mode 100644 index 00000000..936261a1 --- /dev/null +++ b/qt_ui/windows/logs/QLogsWindow.py @@ -0,0 +1,67 @@ +import logging +import typing + +from PySide2.QtWidgets import ( + QDialog, + QPlainTextEdit, + QVBoxLayout, + QPushButton, +) +from PySide2.QtGui import QTextCursor + +from qt_ui.logging_handler import HookableInMemoryHandler + + +class QLogsWindow(QDialog): + vbox: QVBoxLayout + textbox: QPlainTextEdit + clear_button: QPushButton + _logging_handler: typing.Optional[HookableInMemoryHandler] + + def __init__(self): + super().__init__() + + self.setWindowTitle("Logs") + self.setMinimumSize(400, 100) + self.resize(1000, 450) + + self.vbox = QVBoxLayout() + self.setLayout(self.vbox) + + self.textbox = QPlainTextEdit(self) + self.textbox.setReadOnly(True) + self.textbox.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + self.textbox.move(10, 10) + self.textbox.resize(1000, 450) + self.textbox.setStyleSheet( + "font-family: 'Courier New', monospace; background: #1D2731;" + ) + self.vbox.addWidget(self.textbox) + + self.clear_button = QPushButton(self) + self.clear_button.setText("CLEAR") + self.clear_button.setProperty("style", "btn-primary") + self.clear_button.clicked.connect(self.clearLogs) + self.vbox.addWidget(self.clear_button) + + self._logging_handler = None + logger = logging.getLogger() + for handler in logger.handlers: + if isinstance(handler, HookableInMemoryHandler): + self._logging_handler = handler + break + if self._logging_handler is not None: + self.textbox.setPlainText(self._logging_handler.log) + self.textbox.moveCursor(QTextCursor.End) + self._logging_handler.setHook(self.appendLog) + else: + self.textbox.setPlainText("WARNING: logging not initialized!") + + def clearLogs(self) -> None: + if self._logging_handler is not None: + self._logging_handler.clearLog() + self.textbox.setPlainText("") + + def appendLog(self, msg: str): + self.textbox.appendPlainText(msg) + self.textbox.moveCursor(QTextCursor.End) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 19634847..c86987ae 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -180,7 +180,7 @@ class QPackageDialog(QDialog): self.game.aircraft_inventory.claim_for_flight(flight) self.package_model.add_flight(flight) planner = FlightPlanBuilder( - self.game, self.package_model.package, is_player=True + self.package_model.package, self.game.blue, self.game.theater ) try: planner.populate_flight_plan(flight) diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 7f5c6cc4..3c0a1e74 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -38,7 +38,7 @@ class QFlightCreator(QDialog): self.game = game self.package = package self.custom_name_text = None - self.country = self.game.player_country + self.country = self.game.blue.country_name self.setWindowTitle("Create flight") self.setWindowIcon(EVENT_ICONS["strike"]) @@ -52,7 +52,6 @@ class QFlightCreator(QDialog): self.aircraft_selector = QAircraftTypeSelector( self.game.aircraft_inventory.available_types_for_player, - self.game.player_country, self.task_selector.currentData(), ) self.aircraft_selector.setCurrentIndex(0) diff --git a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py index 5cf5b370..b1eb809e 100644 --- a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py +++ b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py @@ -47,8 +47,20 @@ class QFlightPayloadTab(QFrame): def reload_from_flight(self) -> None: self.loadout_selector.setCurrentText(self.flight.loadout.name) + def loadout_at(self, index: int) -> Loadout: + loadout = self.loadout_selector.itemData(index) + if loadout is None: + return Loadout.empty_loadout() + return loadout + + def current_loadout(self) -> Loadout: + loadout = self.loadout_selector.currentData() + if loadout is None: + return Loadout.empty_loadout() + return loadout + def on_new_loadout(self, index: int) -> None: - self.flight.loadout = self.loadout_selector.itemData(index) + self.flight.loadout = self.loadout_at(index) self.payload_editor.reset_pylons() def on_custom_toggled(self, use_custom: bool) -> None: @@ -56,5 +68,5 @@ class QFlightPayloadTab(QFrame): if use_custom: self.flight.loadout = self.flight.loadout.derive_custom("Custom") else: - self.flight.loadout = self.loadout_selector.currentData() + self.flight.loadout = self.current_loadout() self.payload_editor.reset_pylons() diff --git a/qt_ui/windows/mission/flight/payload/QPylonEditor.py b/qt_ui/windows/mission/flight/payload/QPylonEditor.py index 3cb22c19..e6eeaa24 100644 --- a/qt_ui/windows/mission/flight/payload/QPylonEditor.py +++ b/qt_ui/windows/mission/flight/payload/QPylonEditor.py @@ -56,7 +56,7 @@ class QPylonEditor(QComboBox): # # A similar hack exists in Pylon to support forcibly equipping this even when # it's not known to be compatible. - if weapon.cls_id == "": + if weapon.clsid == "": if not self.has_added_clean_item: self.addItem("Clean", weapon) self.has_added_clean_item = True diff --git a/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py b/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py index 282df1ce..2cca2425 100644 --- a/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py +++ b/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py @@ -100,6 +100,6 @@ class FlightAirfieldDisplay(QGroupBox): def update_flight_plan(self) -> None: planner = FlightPlanBuilder( - self.game, self.package_model.package, is_player=True + self.package_model.package, self.game.blue, self.game.theater ) planner.populate_flight_plan(self.flight) diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 440d3f9b..bc4a7d51 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -37,7 +37,7 @@ class QFlightWaypointTab(QFrame): self.game = game self.package = package self.flight = flight - self.planner = FlightPlanBuilder(self.game, package, is_player=True) + self.planner = FlightPlanBuilder(package, game.blue, game.theater) self.flight_waypoint_list: Optional[QFlightWaypointList] = None self.rtb_waypoint: Optional[QPushButton] = None 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/qt_ui/windows/notes/QNotesWindow.py b/qt_ui/windows/notes/QNotesWindow.py new file mode 100644 index 00000000..cb1419b5 --- /dev/null +++ b/qt_ui/windows/notes/QNotesWindow.py @@ -0,0 +1,67 @@ +from PySide2.QtWidgets import ( + QDialog, + QPlainTextEdit, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QLabel, +) +from PySide2.QtGui import QTextCursor +from PySide2.QtCore import QTimer + +import qt_ui.uiconstants as CONST +from game.game import Game + +from time import sleep + + +class QNotesWindow(QDialog): + def __init__(self, game: Game): + super(QNotesWindow, self).__init__() + + self.game = game + self.setWindowTitle("Notes") + self.setWindowIcon(CONST.ICONS["Notes"]) + self.setMinimumSize(400, 100) + self.resize(600, 450) + + self.vbox = QVBoxLayout() + self.setLayout(self.vbox) + + self.vbox.addWidget( + QLabel("Saved notes are available as a page in your kneeboard.") + ) + + self.textbox = QPlainTextEdit(self) + try: + self.textbox.setPlainText(self.game.notes) + self.textbox.moveCursor(QTextCursor.End) + except AttributeError: # old save may not have game.notes + pass + self.textbox.move(10, 10) + self.textbox.resize(600, 450) + self.textbox.setStyleSheet("background: #1D2731;") + self.vbox.addWidget(self.textbox) + + self.button_row = QHBoxLayout() + self.vbox.addLayout(self.button_row) + + self.clear_button = QPushButton(self) + self.clear_button.setText("CLEAR") + self.clear_button.setProperty("style", "btn-primary") + self.clear_button.clicked.connect(self.clearNotes) + self.button_row.addWidget(self.clear_button) + + self.save_button = QPushButton(self) + self.save_button.setText("SAVE") + self.save_button.setProperty("style", "btn-success") + self.save_button.clicked.connect(self.saveNotes) + self.button_row.addWidget(self.save_button) + + def clearNotes(self) -> None: + self.textbox.setPlainText("") + + def saveNotes(self) -> None: + self.game.notes = self.textbox.toPlainText() + self.save_button.setText("SAVED") + QTimer.singleShot(5000, lambda: self.save_button.setText("SAVE")) diff --git a/qt_ui/windows/preferences/QLiberationFirstStartWindow.py b/qt_ui/windows/preferences/QLiberationFirstStartWindow.py index 4a300f35..78b898a1 100644 --- a/qt_ui/windows/preferences/QLiberationFirstStartWindow.py +++ b/qt_ui/windows/preferences/QLiberationFirstStartWindow.py @@ -58,6 +58,12 @@ class QLiberationFirstStartWindow(QDialog):

As you click on the button below, the file will be replaced in your DCS installation directory.

+
+

If you leave the DCS Installation Directory empty, DCS Liberation can not automatically replace the MissionScripting.lua and will therefore not work correctly! + In this case, you need to edit the file yourself. The easiest way to do it is to replace the original file with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua). +

You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub.

+ +

Thank you for reading ! diff --git a/qt_ui/windows/preferences/QLiberationPreferences.py b/qt_ui/windows/preferences/QLiberationPreferences.py index 0d41b298..fbfa6770 100644 --- a/qt_ui/windows/preferences/QLiberationPreferences.py +++ b/qt_ui/windows/preferences/QLiberationPreferences.py @@ -22,6 +22,7 @@ class QLiberationPreferences(QFrame): super(QLiberationPreferences, self).__init__() self.saved_game_dir = "" self.dcs_install_dir = "" + self.install_dir_ignore_warning = False self.dcs_install_dir = liberation_install.get_dcs_install_directory() self.saved_game_dir = liberation_install.get_saved_game_dir() @@ -102,17 +103,38 @@ class QLiberationPreferences(QFrame): error_dialog.exec_() return False - if not os.path.isdir(self.dcs_install_dir): + if self.install_dir_ignore_warning and self.dcs_install_dir == "": + warning_dialog = QMessageBox.warning( + self, + "The DCS Installation directory was not set", + "You set an empty DCS Installation directory! " + "

Without this directory, DCS Liberation can not replace the MissionScripting.lua for you and will not work properly. " + "In this case, you need to edit the MissionScripting.lua yourself. The easiest way to do it is to replace the original file (<dcs_installation_directory>/Scripts/MissionScripting.lua) with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua)." + "

You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub.

" + "

Are you sure that you want to leave the installation directory empty?" + "

This is only recommended for expert users!", + QMessageBox.StandardButton.Yes, + QMessageBox.StandardButton.No, + ) + if warning_dialog == QMessageBox.No: + return False + elif not os.path.isdir(self.dcs_install_dir): error_dialog = QMessageBox.critical( self, "Wrong DCS installation directory.", - self.dcs_install_dir + " is not a valid directory", + self.dcs_install_dir + + " is not a valid directory. DCS Liberation requires the installation directory to replace the MissionScripting.lua" + "

If you ignore this Error, DCS Liberation can not work properly and needs your attention. " + "In this case, you need to edit the MissionScripting.lua yourself. The easiest way to do it is to replace the original file (<dcs_installation_directory>/Scripts/MissionScripting.lua) with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua)." + "

You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub.

" + "

This is only recommended for expert users!", + QMessageBox.StandardButton.Ignore, QMessageBox.StandardButton.Ok, ) - error_dialog.exec_() + if error_dialog == QMessageBox.Ignore: + self.install_dir_ignore_warning = True return False - - if not os.path.isdir( + elif not os.path.isdir( os.path.join(self.dcs_install_dir, "Scripts") ) and os.path.isfile(os.path.join(self.dcs_install_dir, "bin", "DCS.exe")): error_dialog = QMessageBox.critical( diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 67cc0e3b..188963d7 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -101,7 +101,7 @@ class HqAutomationSettingsBox(QGroupBox): front_line = QCheckBox() front_line.setChecked(self.game.settings.automate_front_line_reinforcements) - front_line.toggled.connect(self.set_front_line_automation) + front_line.toggled.connect(self.set_front_line_reinforcement_automation) layout.addWidget(QLabel("Automate front-line purchases"), 1, 0) layout.addWidget(front_line, 1, 1, Qt.AlignRight) @@ -147,12 +147,30 @@ class HqAutomationSettingsBox(QGroupBox): ) layout.addWidget(self.auto_ato_player_missions_asap, 4, 1, Qt.AlignRight) + self.automate_front_line_stance = QCheckBox() + self.automate_front_line_stance.setChecked( + self.game.settings.automate_front_line_stance + ) + self.automate_front_line_stance.toggled.connect( + self.set_front_line_stance_automation + ) + + layout.addWidget( + QLabel("Automatically manage front line stances"), + 5, + 0, + ) + layout.addWidget(self.automate_front_line_stance, 5, 1, Qt.AlignRight) + def set_runway_automation(self, value: bool) -> None: self.game.settings.automate_runway_repair = value - def set_front_line_automation(self, value: bool) -> None: + def set_front_line_reinforcement_automation(self, value: bool) -> None: self.game.settings.automate_front_line_reinforcements = value + def set_front_line_stance_automation(self, value: bool) -> None: + self.game.settings.automate_front_line_stance = value + def set_aircraft_automation(self, value: bool) -> None: self.game.settings.automate_aircraft_reinforcements = value @@ -855,7 +873,7 @@ class QSettingsWindow(QDialog): def cheatMoney(self, amount): logging.info("CHEATING FOR AMOUNT : " + str(amount) + "M") - self.game.budget += amount + self.game.blue.budget += amount if amount > 0: self.game.informations.append( Information( diff --git a/qt_ui/windows/stats/QAircraftChart.py b/qt_ui/windows/stats/QAircraftChart.py index 6c8d1db9..6516ec58 100644 --- a/qt_ui/windows/stats/QAircraftChart.py +++ b/qt_ui/windows/stats/QAircraftChart.py @@ -42,10 +42,16 @@ class QAircraftChart(QFrame): self.chart.setTitle("Aircraft forces over time") self.chart.createDefaultAxes() + self.chart.axisX().setTitleText("Turn") + self.chart.axisX().setLabelFormat("%i") self.chart.axisX().setRange(0, len(self.alliedAircraft)) + self.chart.axisX().applyNiceNumbers() + + self.chart.axisY().setLabelFormat("%i") self.chart.axisY().setRange( 0, max(max(self.alliedAircraft), max(self.enemyAircraft)) + 10 ) + self.chart.axisY().applyNiceNumbers() self.chartView = QtCharts.QChartView(self.chart) self.chartView.setRenderHint(QPainter.Antialiasing) diff --git a/qt_ui/windows/stats/QArmorChart.py b/qt_ui/windows/stats/QArmorChart.py index 09c272fa..e952c717 100644 --- a/qt_ui/windows/stats/QArmorChart.py +++ b/qt_ui/windows/stats/QArmorChart.py @@ -42,10 +42,16 @@ class QArmorChart(QFrame): self.chart.setTitle("Combat vehicles over time") self.chart.createDefaultAxes() + self.chart.axisX().setTitleText("Turn") + self.chart.axisX().setLabelFormat("%i") self.chart.axisX().setRange(0, len(self.alliedArmor)) + self.chart.axisX().applyNiceNumbers() + + self.chart.axisY().setLabelFormat("%i") self.chart.axisY().setRange( 0, max(max(self.alliedArmor), max(self.enemyArmor)) + 10 ) + self.chart.axisY().applyNiceNumbers() self.chartView = QtCharts.QChartView(self.chart) self.chartView.setRenderHint(QPainter.Antialiasing) diff --git a/qt_ui/windows/stats/QStatsWindow.py b/qt_ui/windows/stats/QStatsWindow.py index 7d4fda07..14817d18 100644 --- a/qt_ui/windows/stats/QStatsWindow.py +++ b/qt_ui/windows/stats/QStatsWindow.py @@ -14,7 +14,7 @@ class QStatsWindow(QDialog): self.setModal(True) self.setWindowTitle("Stats") self.setWindowIcon(CONST.ICONS["Statistics"]) - self.setMinimumSize(600, 250) + self.setMinimumSize(600, 300) self.layout = QGridLayout() self.aircraft_charts = QAircraftChart(self.game) diff --git a/requirements.txt b/requirements.txt index 75bf846a..e42f5a80 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@7dea4f516d943c1f48454a46043b5f38d42a35f0#egg=pydcs +-e git://github.com/pydcs/dcs@eb0b9f2de660393ccd6ba17b2d82371d44e0d27b#egg=pydcs pyinstaller==4.3 pyinstaller-hooks-contrib==2021.1 pyparsing==2.4.7 @@ -36,5 +36,6 @@ tabulate==0.8.7 text-unidecode==1.3 toml==0.10.2 typed-ast==1.4.2 +types-Pillow==8.3.1 typing-extensions==3.7.4.3 virtualenv==20.4.2 diff --git a/resources/briefing/templates/briefingtemplate_CN.j2 b/resources/briefing/templates/briefingtemplate_CN.j2 index 5b4e38cc..a5a0dc01 100644 --- a/resources/briefing/templates/briefingtemplate_CN.j2 +++ b/resources/briefing/templates/briefingtemplate_CN.j2 @@ -67,6 +67,7 @@ DCS Liberation 第 {{ game.turn }} 回合 {% for flight in flights if flight.client_units %} -------------------------------------------------- {{ flight.flight_type }} {{ flight.units[0].type }} x {{flight.size}}, departing in {{ flight.departure_delay }}, {{ flight.package.target.name}} +频率 : {{ flight|intra_flight_channel }} {% for waypoint in flight.waypoints %} {{ loop.index0 }} {{waypoint|waypoint_timing("Depart ")}}-- {{waypoint.name}} : {{ waypoint.description}} {% endfor %} @@ -108,6 +109,6 @@ AWACS: {%- if jtacs|length > 0 %} JTACS [F-10 菜单] : ==================== -{% for jtac in jtacs %}前线 {{ jtac.region }} -- 激光编码 : {{ jtac.code }} +{% for jtac in jtacs %}前线 {{ jtac.region }} -- 激光编码 : {{ jtac.code }}, 频率 : {{ jtac.freq.mhz }} {% endfor %} {% endif %} diff --git a/resources/briefing/templates/briefingtemplate_EN.j2 b/resources/briefing/templates/briefingtemplate_EN.j2 index ce5b7220..67fc74eb 100644 --- a/resources/briefing/templates/briefingtemplate_EN.j2 +++ b/resources/briefing/templates/briefingtemplate_EN.j2 @@ -67,6 +67,7 @@ Your flights: {% for flight in flights if flight.client_units %} -------------------------------------------------- {{ flight.flight_type }} {{ flight.units[0].type }} x {{ flight.size }}, departing in {{ flight.departure_delay }}, {{ flight.package.target.name}} +Freq : {{ flight|intra_flight_channel }} {% for waypoint in flight.waypoints %} {{ loop.index0 }} {{waypoint|waypoint_timing("Depart ")}}-- {{waypoint.name}} : {{ waypoint.description}} {% endfor %} @@ -108,6 +109,6 @@ AWACS: {%- if jtacs|length > 0 %} JTACS [F-10 Menu] : ==================== -{% for jtac in jtacs %}Frontline {{ jtac.region }} -- Code : {{ jtac.code }} +{% for jtac in jtacs %}Frontline {{ jtac.region }} -- Code : {{ jtac.code }}, Freq : {{ jtac.freq.mhz }} {% endfor %} {% endif %} \ No newline at end of file diff --git a/resources/briefing/templates/briefingtemplate_FR.j2 b/resources/briefing/templates/briefingtemplate_FR.j2 index 61cf6b2e..2672cb44 100644 --- a/resources/briefing/templates/briefingtemplate_FR.j2 +++ b/resources/briefing/templates/briefingtemplate_FR.j2 @@ -67,6 +67,7 @@ Vols : {% for flight in flights if flight.client_units %} -------------------------------------------------- {{ flight.flight_type }} {{ flight.units[0].type }} x {{flight.size}}, départ dans {{ flight.departure_delay }}, {{ flight.package.target.name}} +Fréq : {{ flight|intra_flight_channel }} {% for waypoint in flight.waypoints %} {{ loop.index0 }} {{waypoint|waypoint_timing("Départ dans ")}}-- {{waypoint.name}} : {{ waypoint.description}} {% endfor %} @@ -108,6 +109,6 @@ AWACS: {%- if jtacs|length > 0 %} JTACS [Menu F-10] : ==================== -{% for jtac in jtacs %}Ligne de front {{ jtac.region }} -- Code : {{ jtac.code }} +{% for jtac in jtacs %}Ligne de front {{ jtac.region }} -- Code : {{ jtac.code }}, Fréq : {{ jtac.freq.mhz }} {% endfor %} {% endif %} \ No newline at end of file diff --git a/resources/campaigns/Battle_for_the_UAE.json b/resources/campaigns/Battle_for_the_UAE.json index 0c5086e2..2eabc899 100644 --- a/resources/campaigns/Battle_for_the_UAE.json +++ b/resources/campaigns/Battle_for_the_UAE.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Bluefor Modern", "recommended_enemy_faction": "Iran 2015", "description": "

Following the Battle of Abu Dhabi, Iran's invasion of the UAE has been halted approximately 20 miles Northeast of Liwa Airbase by coalition forces.

After weeks of stalemate, coalition forces have consolidated their position and are ready to launch their counterattack to push Iranian forces off the peninsula.

", - "version": "7.0", + "version": "8.0", "miz": "Battle_for_the_UAE_v3.0.2.miz", "performance": 2 } diff --git a/resources/campaigns/Caucasus_Multi_Full.json b/resources/campaigns/Caucasus_Multi_Full.json index cf41d395..04a42a69 100644 --- a/resources/campaigns/Caucasus_Multi_Full.json +++ b/resources/campaigns/Caucasus_Multi_Full.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Bluefor Modern", "recommended_enemy_faction": "Russia 2010", "description": "

This is a complete map of every airbase in the Caucasus Region, all bases are fully defended by Air, Land and/or Sea. The player starts by invading southern Georgia and works their way through Russia. The Strike and SAM targets are limited for performance reasons. If this Scenario is too taxing for your computer you may use the Multi-Part Scenarios. They are copied from this Campaign and are catered toward less powerful machines.

", - "version": "7.0", + "version": "8.0", "miz": "Caucasus_Multi_Full.miz", "performance": 3 } \ No newline at end of file diff --git a/resources/campaigns/Caucasus_Multi_Georgia.json b/resources/campaigns/Caucasus_Multi_Georgia.json index 63c1dba0..c0e55f2d 100644 --- a/resources/campaigns/Caucasus_Multi_Georgia.json +++ b/resources/campaigns/Caucasus_Multi_Georgia.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Bluefor Modern", "recommended_enemy_faction": "Georgia 2008", "description": "

This is Part 1 of the Caucasus Multi-Part Campaign. This is the invasion of Georgia starting from the southwest (Batumi) and ending in both Gudauta and Tiblisi. This is a straightforward campaign that is smaller and simpler than most. However, it acts great as either a stand alone campaign for beginners, or as a lead into the Caucasus Multi-Part Russia campaign.

", - "version": "7.0", + "version": "8.0", "miz": "Caucasus_Multi_Georgia.miz", "performance": 1 } \ No newline at end of file diff --git a/resources/campaigns/Caucasus_Multi_Russia.json b/resources/campaigns/Caucasus_Multi_Russia.json index e9df1997..a35edea4 100644 --- a/resources/campaigns/Caucasus_Multi_Russia.json +++ b/resources/campaigns/Caucasus_Multi_Russia.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Bluefor Modern", "recommended_enemy_faction": "Russia 2010", "description": "

This is part 2 of the Caucasus Multi-part campaign. After completing Multi-Part Georgia, play this campaign to invade Russia and finish the theater. As this is now Russia the recommended enemy faction has changed. To simulate still owning Georgia the player income has been supplemented through an increased number of blue strike targets at the starting bases. This is a more difficult scenario with a higher concentration of Redfor SAMs and Strike targets than usual.

", - "version": "7.0", + "version": "8.0", "miz": "Caucasus_Multi_Russia.miz", "performance": 2 } \ No newline at end of file diff --git a/resources/campaigns/First_Lebanon_War.json b/resources/campaigns/First_Lebanon_War.json index ecb56691..6534643a 100644 --- a/resources/campaigns/First_Lebanon_War.json +++ b/resources/campaigns/First_Lebanon_War.json @@ -6,6 +6,6 @@ "recommended_enemy_faction": "Syria 1982", "description": "

1100HRS, 06 June 1982: H-hour for Operation Peace for Galilee.

Objective: Push North towards Beirut and into the Bekaa Valley, eliminating or displacing any PLO and Syrian resistance. Airbases and their surrounding infrastructure in Syria are not the main objective but are still viable strategic targets.

Background: Years of PLO encroachment into the UN neutral zone and their resulting terror attacks against Israelis have pushed tension along the border to a breaking point. On June 3, the attempted assassination of Israeli Ambassador, Shlomo Argov by gunmen with ties to the PLO have finally pushed the Israelis to action.

Recommended Starting Budget:

$1500m for recommended factions, $$2000m for modern scenarios

Income Multiplier:

Blue: 1.0x

Red: 0.7x-1.0x

", "miz": "First_Lebanon_War_v3.0.2.miz", - "version": "7.0", + "version": "8.0", "performance": 2 } diff --git a/resources/campaigns/Operation_Mole_Cricket_2010.json b/resources/campaigns/Operation_Mole_Cricket_2010.json index fad2810b..36c753ff 100644 --- a/resources/campaigns/Operation_Mole_Cricket_2010.json +++ b/resources/campaigns/Operation_Mole_Cricket_2010.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Bluefor Modern", "recommended_enemy_faction": "Syria 2011", "description": "

In a scenario reminescent of the First Lebanon War, hostile Syrian-backed forces have flooded into the Bekaa Valley.

The objective of this operation is twofold: drive the enemy out of the Bekaa Valley and push past the Golan Heights into Syrian territory to capture Tiyas Airbase.

", - "version": "7.0", + "version": "8.0", "miz": "Operation_Mole_Cricket_2010_v3.0.2.miz", "performance": 2 } diff --git a/resources/campaigns/Road_to_Dubai.json b/resources/campaigns/Road_to_Dubai.json index 4d90403d..ab524690 100644 --- a/resources/campaigns/Road_to_Dubai.json +++ b/resources/campaigns/Road_to_Dubai.json @@ -3,7 +3,7 @@ "theater": "Persian Gulf", "authors": "Doc_of_Mur", "description": "

Small beginner friendly map

Note: This scenario is based around Iran invading the UAE and you are trying to take it back. It is small and beginner friendly.

", - "version": "7.0", + "version": "8.0", "recommended_player_faction": "USA 2005", "recommended_enemy_faction": "Iran 2015", "miz": "Road_to_Dubai.miz", diff --git a/resources/campaigns/around_the_mountain.json b/resources/campaigns/around_the_mountain.json index 422cee26..4cc81e8d 100644 --- a/resources/campaigns/around_the_mountain.json +++ b/resources/campaigns/around_the_mountain.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Russia 2010", "recommended_enemy_faction": "USA 1990", "description": "

Scenario from Russia to Georgia in two Frontlines.

", - "version": "7.0", + "version": "8.0", "miz": "around_the_mountain.miz", "performance": 2 } \ No newline at end of file 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/campaigns/caen_to_evreux.json b/resources/campaigns/caen_to_evreux.json index d630767e..c6e3fea3 100644 --- a/resources/campaigns/caen_to_evreux.json +++ b/resources/campaigns/caen_to_evreux.json @@ -7,5 +7,5 @@ "description": "

This is a light scenario on the Normandy map.

August 1944, allied forces are pushing from Caen/Carpiquet to the cities of Lisieux and Evreux.

Lisieux is an important logistic hub for the Werhmacht, and Evreux airbase is hosting most of the Luftwaffe forces in the region.

", "miz": "caen_to_evreux.miz", "performance": 1, - "version": "7.0" + "version": "8.0" } diff --git a/resources/campaigns/caen_to_evreux.miz b/resources/campaigns/caen_to_evreux.miz index bd119430..7c5e2020 100644 Binary files a/resources/campaigns/caen_to_evreux.miz and b/resources/campaigns/caen_to_evreux.miz differ diff --git a/resources/campaigns/exercise_vegas_nerve.json b/resources/campaigns/exercise_vegas_nerve.json index 2925c5a9..ac08327d 100644 --- a/resources/campaigns/exercise_vegas_nerve.json +++ b/resources/campaigns/exercise_vegas_nerve.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Bluefor Modern", "recommended_enemy_faction": "Redfor (China) 2010", "description": "

This is an asymmetrical Red Flag Exercise scenario for the NTTR comprising 4 control points. You start off in control of the two Tonopah airports, and will push south to capture Groom Lake and Nellis AFBs. Taking down Nellis AFB's IADS and striking their resource sites ASAP once Groom Lake has been captured is recommended to offset their resource advantage.

", - "version": "7.0", + "version": "8.0", "miz": "exercise_vegas_nerve.miz", "performance": 0 } \ No newline at end of file diff --git a/resources/campaigns/exercise_vegas_nerve.miz b/resources/campaigns/exercise_vegas_nerve.miz index 4cc74abd..7d280d6e 100644 Binary files a/resources/campaigns/exercise_vegas_nerve.miz and b/resources/campaigns/exercise_vegas_nerve.miz differ diff --git a/resources/campaigns/golan_heights_lite.json b/resources/campaigns/golan_heights_lite.json index 051205da..ba618c9b 100644 --- a/resources/campaigns/golan_heights_lite.json +++ b/resources/campaigns/golan_heights_lite.json @@ -7,5 +7,5 @@ "description": "

In this scenario, you start in Israel and the conflict is focused around the golan heights, an historically disputed territory.

This scenario is designed to be performance friendly.

", "miz": "golan_heights_lite.miz", "performance": 1, - "version": "7.0" + "version": "8.0" } diff --git a/resources/campaigns/golan_heights_lite.miz b/resources/campaigns/golan_heights_lite.miz index 3aeaa695..ee6e371e 100644 Binary files a/resources/campaigns/golan_heights_lite.miz and b/resources/campaigns/golan_heights_lite.miz differ diff --git a/resources/campaigns/guam.json b/resources/campaigns/guam.json new file mode 100644 index 00000000..17b312a1 --- /dev/null +++ b/resources/campaigns/guam.json @@ -0,0 +1,11 @@ +{ + "name": "Mariana Islands - Battle for Guam", + "theater": "MarianaIslands", + "authors": "Khopa", + "recommended_player_faction": "USA 2005", + "recommended_enemy_faction": "China 2010", + "description": "

As USA, repel a Chinese invasion of Guam Island.

", + "miz": "guam.miz", + "performance": 1, + "version": "7.0" +} diff --git a/resources/campaigns/guam.miz b/resources/campaigns/guam.miz new file mode 100644 index 00000000..b40ec246 Binary files /dev/null and b/resources/campaigns/guam.miz differ diff --git a/resources/campaigns/mozdok_to_maykop.json b/resources/campaigns/mozdok_to_maykop.json index 5cb9c642..490f1510 100644 --- a/resources/campaigns/mozdok_to_maykop.json +++ b/resources/campaigns/mozdok_to_maykop.json @@ -7,5 +7,5 @@ "description": "

A small theater in Russia, progress from Mozdok to Maykop.

This scenario is pretty simple, and is ideal if you want to run a short campaign to try liberation. If your PC is not powerful, this is also the less performance heavy scenario.

", "miz": "mozdok_to_maykop.miz", "performance": 0, - "version": "7.0" + "version": "8.0" } diff --git a/resources/campaigns/nevada_Limited_Air.json b/resources/campaigns/nevada_Limited_Air.json index d042461d..09e9aad7 100644 --- a/resources/campaigns/nevada_Limited_Air.json +++ b/resources/campaigns/nevada_Limited_Air.json @@ -5,7 +5,7 @@ "recommended_player_faction": "USA 2005", "recommended_enemy_faction": "Russia 1975", "description": "

This campaign is designed to be beginner friendly in that the number of aircraft slot have been limited. Other than the starting point and the 'boss' base the max slots in each of the airbases have a mere 3-5 slots.

This should prevent the airpower rush escperienced in most of the other larger campaign.

", - "version": "7.0", + "version": "8.0", "miz": "nevada_limited_air.miz", "performance": 1 } \ No newline at end of file diff --git a/resources/campaigns/northern_russia.json b/resources/campaigns/northern_russia.json index f76276c1..a2c66c50 100644 --- a/resources/campaigns/northern_russia.json +++ b/resources/campaigns/northern_russia.json @@ -7,5 +7,5 @@ "description": "

A medium campaign through the north eastern part of the Caucasus map. Play vs 1975 Russia for an low-medium difficulty campaign, play vs russia 1990 for a hard difficulty campaign.

Russia has invaded Georgia through the eastern mountains. Mount a counter offense and push them back!", "miz": "northern_russia.miz", "performance": 2, - "version": "7.0" + "version": "8.0" } \ No newline at end of file diff --git a/resources/campaigns/operation_allied_sword.json b/resources/campaigns/operation_allied_sword.json index 3f26c95a..027864e7 100644 --- a/resources/campaigns/operation_allied_sword.json +++ b/resources/campaigns/operation_allied_sword.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Israel-USN 2005 (Allied Sword)", "recommended_enemy_faction": "Syria-Lebanon 2005 (Allied Sword)", "description": "

In this fictional scenario, a US/Israeli coalition must push north from the Israeli border, through Syria and Lebanon to Aleppo.

Backstory: A Syrian-Lebanese joint force (with Russian materiel support) has attacked Israel, attmepting to cross the northern border. With the arrival of a US carrier group, Israel prepares its counterattack. The US Navy will handle the Beirut region's coastal arena, while the IAF will push through Damascus and the inland mountain ranges.

", - "version": "7.0", + "version": "8.0", "miz": "operation_allied_sword.miz", "performance": 2 -} \ No newline at end of file +} diff --git a/resources/campaigns/operation_blackball.json b/resources/campaigns/operation_blackball.json index 14ee2863..9b0483f3 100644 --- a/resources/campaigns/operation_blackball.json +++ b/resources/campaigns/operation_blackball.json @@ -5,7 +5,7 @@ "recommended_player_faction": "US Navy 2005", "recommended_enemy_faction": "Russia 2010", "description": "

Warning: This campaign will not work if the attacking faction does not have a carrier.

A lightweight, fictional showcase of Cyprus for the Syria terrain. A US Navy force must deploy from a FOB and carrier group to push from the north-east down through the island.

Backstory: The world is at war. With the help of her eastern allies, Russia has taken the Suez Canal and deployed a large naval force to the Mediterranean, trapping a US carrier group near the Turkish-Syrian border. Now, they must break out by taking Cyprus back.

", - "version": "7.0", + "version": "8.0", "miz": "operation_blackball.miz", "performance": 1 -} \ No newline at end of file +} diff --git a/resources/campaigns/operation_dynamo.json b/resources/campaigns/operation_dynamo.json index 63c147a3..e0445cc3 100644 --- a/resources/campaigns/operation_dynamo.json +++ b/resources/campaigns/operation_dynamo.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Allies 1940", "recommended_enemy_faction": "Germany 1940", "description": "

The Battle of Dunkirk (French: Bataille de Dunkerque) was fought around the French port of Dunkirk (Dunkerque) during the Second World War, between the Allies and Nazi Germany. As the Allies were losing the Battle of France on the Western Front, the Battle of Dunkirk was the defence and evacuation of British and other Allied forces to Britain from 26 May to 4 June 1940..

", - "version": 7.0, + "version": 8.0, "miz": "operation_dynamo.miz", "performance": 1 } diff --git a/resources/campaigns/operation_dynamo.miz b/resources/campaigns/operation_dynamo.miz index 1aadc86f..cfd68ffb 100644 Binary files a/resources/campaigns/operation_dynamo.miz and b/resources/campaigns/operation_dynamo.miz differ diff --git a/resources/campaigns/operation_peace_spring.json b/resources/campaigns/operation_peace_spring.json index 2e6eb3b5..91dce6bd 100644 --- a/resources/campaigns/operation_peace_spring.json +++ b/resources/campaigns/operation_peace_spring.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Bluefor Modern", "recommended_enemy_faction": "Turkey 2005", "description": "

This is a semi-fictional what-if scenario for Operation Peace Spring, during which Turkish forces that crossed into Syria on an offensive against Kurdish militias were emboldened by early successes to continue pushing further southward. Attempts to broker a ceasefire have failed. Members of Operation Inherent Resolve have gathered at Ramat David Airbase in Israel to launch a counter-offensive. Campaign inversion is available if you wish to play as Turkey.

", - "version": "7.0", + "version": "8.0", "miz": "operation_peace_spring.miz", "performance": 1 } \ No newline at end of file diff --git a/resources/campaigns/operation_peace_spring.miz b/resources/campaigns/operation_peace_spring.miz index 7d1b5c7c..251e9aa4 100644 Binary files a/resources/campaigns/operation_peace_spring.miz and b/resources/campaigns/operation_peace_spring.miz differ diff --git a/resources/campaigns/operation_vectrons_claw.json b/resources/campaigns/operation_vectrons_claw.json index ac0e176f..51d5cb60 100644 --- a/resources/campaigns/operation_vectrons_claw.json +++ b/resources/campaigns/operation_vectrons_claw.json @@ -5,7 +5,7 @@ "recommended_player_faction": "USA 2005", "recommended_enemy_faction": "Russia 1990", "description": "

United Nations Observer Mission in Georgia (UNOMIG) observers stationed in Georgia to monitor the ceasefire between Georgia and Abkhazia have been cut off from friendly forces by Russian troops backing the separatist state. The UNOMIG HQ at Sukhumi has been taken, and a small contingent of observers and troops at the Zugdidi Sector HQ will have to make a run for the coast, supported by offshore US naval aircraft. The contingent is aware that their best shot at survival is to swiftly retake Sukhumi before Russian forces have a chance to dig in, so that friendly ground forces can land and reinforce them.

Note: Ground unit purchase will not be available past Turn 0 until Sukhumi is retaken, so it is imperative you reach Sukhumi with at least one surviving ground unit to capture it. The player can either play the first leg of the scenario as an evacuation with a couple of light vehicles (e.g. Humvees) set on breakthrough (modifying waypoints in the mission editor so they are not charging head-on into enemy ground forces is suggested), or purchase heavier ground units if they wish to experience a more traditional ground war.

", - "version": "7.0", + "version": "8.0", "miz": "operation_vectrons_claw.miz", "performance": 1 } \ No newline at end of file diff --git a/resources/campaigns/operation_vectrons_claw.miz b/resources/campaigns/operation_vectrons_claw.miz index 5862883f..a415ba8b 100644 Binary files a/resources/campaigns/operation_vectrons_claw.miz and b/resources/campaigns/operation_vectrons_claw.miz differ diff --git a/resources/campaigns/scenic_route.json b/resources/campaigns/scenic_route.json index d79ae493..b6dc194c 100644 --- a/resources/campaigns/scenic_route.json +++ b/resources/campaigns/scenic_route.json @@ -5,7 +5,7 @@ "recommended_player_faction": "US Navy 2005", "recommended_enemy_faction": "Iran 2015", "description": "

A lightweight naval campaign involving a US Navy carrier group pushing across the coast of Iran. Note that the ground units purchased on turn zero must sustain you until you've taken the first hostile FOB. The starting point does not have a factory to simulate a Marine Expeditionary Force deploying from the carrier group.

Backstory: Iran has declared war on all US forces in the Gulf, resulting in all local allies withdrawing their support for American troops. A lone carrier group must pacify the southern coast of Iran and hold out until backup can arrive, lest the US and her interests be ejected from the region permanently.

", - "version": "7.0", + "version": "8.0", "miz": "scenic_route.miz", "performance": 1 -} \ No newline at end of file +} diff --git a/resources/customized_payloads/F-16C_50.lua b/resources/customized_payloads/F-16C_50.lua index 61f30508..447354e8 100644 --- a/resources/customized_payloads/F-16C_50.lua +++ b/resources/customized_payloads/F-16C_50.lua @@ -242,7 +242,7 @@ local unitPayloads = { ["num"] = 8, }, [5] = { - ["CLSID"] = "{5335D97A-35A5-4643-9D9B-026C75961E52}", + ["CLSID"] = "{AGM-154A}", ["num"] = 7, }, [6] = { @@ -250,7 +250,7 @@ local unitPayloads = { ["num"] = 6, }, [7] = { - ["CLSID"] = "{5335D97A-35A5-4643-9D9B-026C75961E52}", + ["CLSID"] = "{AGM-154A}", ["num"] = 3, }, [8] = { diff --git a/resources/customized_payloads/JAS39Gripen.lua b/resources/customized_payloads/JAS39Gripen.lua index 8f963be7..4b4f6dfd 100644 --- a/resources/customized_payloads/JAS39Gripen.lua +++ b/resources/customized_payloads/JAS39Gripen.lua @@ -6,39 +6,39 @@ local unitPayloads = { ["name"] = "CAP", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 1, - }, - [3] = { - ["CLSID"] = "JAS_Meteor", - ["num"] = 2, - }, - [4] = { - ["CLSID"] = "JAS_Meteor", - ["num"] = 9, - }, - [5] = { - ["CLSID"] = "JAS_Meteor", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, + [2] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [3] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [4] = { + ["CLSID"] = "JAS39_IRIS-T", + ["num"] = 1, + }, + [5] = { + ["CLSID"] = "JAS39_Meteor", + ["num"] = 7, + }, [6] = { - ["CLSID"] = "JAS_Meteor", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [8] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", + ["CLSID"] = "JAS39_Meteor", ["num"] = 6, }, - [9] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", + [7] = { + ["CLSID"] = "JAS39_Meteor", + ["num"] = 2, + }, + [8] = { + ["CLSID"] = "JAS39_Meteor", + ["num"] = 3, + }, + [9] = { + ["CLSID"] = "JAS39_TANK1100", ["num"] = 4, }, }, diff --git a/resources/customized_payloads/JAS39Gripen_AG.lua b/resources/customized_payloads/JAS39Gripen_AG.lua index db687a8e..43b23b1a 100644 --- a/resources/customized_payloads/JAS39Gripen_AG.lua +++ b/resources/customized_payloads/JAS39Gripen_AG.lua @@ -6,40 +6,40 @@ local unitPayloads = { ["name"] = "ANTISHIP", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "JAS_RB15F", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_RB15F", + [2] = { + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, + [3] = { + ["CLSID"] = "JAS39_TANK1100", + ["num"] = 4, + }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [5] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [6] = { + ["CLSID"] = "JAS39_RBS15AI", + ["num"] = 6, + }, + [7] = { + ["CLSID"] = "JAS39_RBS15AI", + ["num"] = 7, + }, [8] = { - ["CLSID"] = "JAS_RB15F", + ["CLSID"] = "JAS39_RBS15AI", ["num"] = 2, }, [9] = { - ["CLSID"] = "JAS_RB15F", - ["num"] = 9, + ["CLSID"] = "JAS39_RBS15AI", + ["num"] = 3, }, }, ["tasks"] = { @@ -51,40 +51,40 @@ local unitPayloads = { ["name"] = "SEAD", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "JAS_MAR-1", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_MAR-1", + [2] = { + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, - [8] = { - ["CLSID"] = "JAS_MAR-1", + [3] = { + ["CLSID"] = "JAS39_TANK1100", + ["num"] = 4, + }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [5] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [6] = { + ["CLSID"] = "JAS39_MAR-1", ["num"] = 2, }, + [7] = { + ["CLSID"] = "JAS39_MAR-1", + ["num"] = 3, + }, + [8] = { + ["CLSID"] = "JAS39_MAR-1", + ["num"] = 6, + }, [9] = { - ["CLSID"] = "JAS_MAR-1", - ["num"] = 9, + ["CLSID"] = "JAS39_MAR-1", + ["num"] = 7, }, }, ["tasks"] = { @@ -96,40 +96,40 @@ local unitPayloads = { ["name"] = "DEAD", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, + [2] = { + ["CLSID"] = "JAS39_IRIS-T", + ["num"] = 8, }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", + [3] = { + ["CLSID"] = "JAS39_TANK1100", ["num"] = 4, }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, }, [6] = { - ["CLSID"] = "JAS_Stormshadow", + ["CLSID"] = "JAS39_STORMSHADOW", ["num"] = 3, }, [7] = { - ["CLSID"] = "JAS_Stormshadow", - ["num"] = 8, + ["CLSID"] = "JAS39_STORMSHADOW", + ["num"] = 6, }, [8] = { - ["CLSID"] = "JAS_MAR-1", + ["CLSID"] = "JAS39_MAR-1", ["num"] = 2, }, [9] = { - ["CLSID"] = "JAS_MAR-1", - ["num"] = 9, + ["CLSID"] = "JAS39_MAR-1", + ["num"] = 7, }, }, ["tasks"] = { @@ -141,44 +141,48 @@ local unitPayloads = { ["name"] = "CAS", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "JAS_BRIMSTONE", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_BRIMSTONE", + [2] = { + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, - [8] = { - ["CLSID"] = "JAS_BRIMSTONE", + [3] = { + ["CLSID"] = "JAS39_TANK1100", + ["num"] = 4, + }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [5] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [6] = { + ["CLSID"] = "JAS39_BRIMSTONE", ["num"] = 2, }, + [7] = { + ["CLSID"] = "JAS39_BRIMSTONE", + ["num"] = 3, + }, + [8] = { + ["CLSID"] = "JAS39_BRIMSTONE", + ["num"] = 6, + }, [9] = { - ["CLSID"] = "JAS_BRIMSTONE", - ["num"] = 9, + ["CLSID"] = "JAS39_BRIMSTONE", + ["num"] = 7, }, [10] = { - ["CLSID"] = "JAS_Litening", - ["num"] = 7, + ["CLSID"] = "JAS39_Litening", + ["num"] = 5, + }, + [11] = { + ["CLSID"] = "{JAS39_FLIR}", + ["num"] = 9 }, }, ["tasks"] = { @@ -190,44 +194,48 @@ local unitPayloads = { ["name"] = "STRIKE", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "JAS_GBU31", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_GBU31", + [2] = { + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, - [8] = { - ["CLSID"] = "JAS_GBU49_TV", + [3] = { + ["CLSID"] = "JAS39_TANK1100", + ["num"] = 4, + }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [5] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [6] = { + ["CLSID"] = "JAS39_GBU31", ["num"] = 2, }, + [7] = { + ["CLSID"] = "JAS39_GBU31", + ["num"] = 7, + }, + [8] = { + ["CLSID"] = "JAS39_GBU49", + ["num"] = 3, + }, [9] = { - ["CLSID"] = "JAS_GBU49_TV", - ["num"] = 9, + ["CLSID"] = "JAS39_GBU49", + ["num"] = 6, }, [10] = { - ["CLSID"] = "JAS_Litening", - ["num"] = 7, + ["CLSID"] = "JAS39_Litening", + ["num"] = 5, + }, + [11] = { + ["CLSID"] = "{JAS39_FLIR}", + ["num"] = 9 }, }, ["tasks"] = { @@ -239,44 +247,48 @@ local unitPayloads = { ["name"] = "OCA", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "JAS_BK90", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_BK90", + [2] = { + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, - [8] = { - ["CLSID"] = "JAS_ARAKM70BHE", + [3] = { + ["CLSID"] = "JAS39_TANK1100", + ["num"] = 4, + }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [5] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [6] = { + ["CLSID"] = "JAS39_DWS39", ["num"] = 2, }, + [7] = { + ["CLSID"] = "JAS39_DWS39", + ["num"] = 7, + }, + [8] = { + ["CLSID"] = "JAS39_M70BHE", + ["num"] = 3, + }, [9] = { - ["CLSID"] = "JAS_ARAKM70BHE", - ["num"] = 9, + ["CLSID"] = "JAS39_M70BHE", + ["num"] = 6, }, [10] = { - ["CLSID"] = "JAS_Litening", - ["num"] = 7, + ["CLSID"] = "JAS39_Litening", + ["num"] = 5, + }, + [11] = { + ["CLSID"] = "{JAS39_FLIR}", + ["num"] = 9 }, }, ["tasks"] = { diff --git a/resources/customized_payloads/JF-17.lua b/resources/customized_payloads/JF-17.lua index 8e135655..b4c0e4b5 100644 --- a/resources/customized_payloads/JF-17.lua +++ b/resources/customized_payloads/JF-17.lua @@ -77,7 +77,7 @@ local unitPayloads = { ["num"] = 3, }, [3] = { - ["CLSID"] = "DIS_WMD7", + ["CLSID"] = "DIS_SPJ_POD", ["num"] = 4, }, [4] = { @@ -107,7 +107,7 @@ local unitPayloads = { ["name"] = "SEAD", ["pylons"] = { [1] = { - ["CLSID"] = "DIS_WMD7", + ["CLSID"] = "DIS_SPJ_POD", ["num"] = 4, }, [2] = { diff --git a/resources/dcs/beacons/marianaislands.json b/resources/dcs/beacons/marianaislands.json new file mode 100644 index 00000000..da67641b --- /dev/null +++ b/resources/dcs/beacons/marianaislands.json @@ -0,0 +1,135 @@ +[ + { + "name": "MTMACAJNA", + "callsign": "AJA", + "beacon_type": 9, + "hertz": 385000, + "channel": null + }, + { + "name": "Nimitz", + "callsign": "UNZ", + "beacon_type": 6, + "hertz": 115800000, + "channel": 105 + }, + { + "name": "SAIPAN", + "callsign": "SN", + "beacon_type": 9, + "hertz": 312000, + "channel": null + }, + { + "name": "ANDERSEN", + "callsign": "UAM", + "beacon_type": 5, + "hertz": null, + "channel": 54 + }, + { + "name": "", + "callsign": "IPMY", + "beacon_type": 15, + "hertz": 110150000, + "channel": null + }, + { + "name": "", + "callsign": "IUAM", + "beacon_type": 15, + "hertz": 110100000, + "channel": null + }, + { + "name": "", + "callsign": "IYIG", + "beacon_type": 15, + "hertz": 109350000, + "channel": null + }, + { + "name": "", + "callsign": "IAND", + "beacon_type": 15, + "hertz": 109300000, + "channel": null + }, + { + "name": "", + "callsign": "IUAM", + "beacon_type": 14, + "hertz": 110100000, + "channel": null + }, + { + "name": "", + "callsign": "IAND", + "beacon_type": 14, + "hertz": 109300000, + "channel": null + }, + { + "name": "", + "callsign": "IYIG", + "beacon_type": 14, + "hertz": 109350000, + "channel": null + }, + { + "name": "", + "callsign": "IPMY", + "beacon_type": 14, + "hertz": 110150000, + "channel": null + }, + { + "name": "", + "callsign": "IGUM", + "beacon_type": 14, + "hertz": 110300000, + "channel": null + }, + { + "name": "", + "callsign": "PGUM", + "beacon_type": 15, + "hertz": 110300000, + "channel": null + }, + { + "name": "", + "callsign": "IAWD", + "beacon_type": 14, + "hertz": 110900000, + "channel": null + }, + { + "name": "", + "callsign": "PGUM", + "beacon_type": 15, + "hertz": 110900000, + "channel": null + }, + { + "name": "ROTA", + "callsign": "GRO", + "beacon_type": 9, + "hertz": 332000, + "channel": null + }, + { + "name": "", + "callsign": "IGSN", + "beacon_type": 14, + "hertz": 109900000, + "channel": null + }, + { + "name": "", + "callsign": "PGSN", + "beacon_type": 15, + "hertz": 109900000, + "channel": null + } +] \ No newline at end of file diff --git a/resources/factions/gdr_1985.json b/resources/factions/gdr_1985.json index 45eacc60..638bb8e1 100644 --- a/resources/factions/gdr_1985.json +++ b/resources/factions/gdr_1985.json @@ -52,7 +52,8 @@ "ZU23Generator" ], "ewrs": [ - "FlatFaceGenerator" + "FlatFaceGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -66,4 +67,4 @@ "navy_generators": [], "has_jtac": true, "jtac_unit": "Mi-8MTV2 Hip" -} \ No newline at end of file +} diff --git a/resources/factions/india_2010.json b/resources/factions/india_2010.json index 5deef839..accf47bc 100644 --- a/resources/factions/india_2010.json +++ b/resources/factions/india_2010.json @@ -57,7 +57,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [ "KUZNECOW" @@ -78,4 +79,4 @@ ], "has_jtac": true, "jtac_unit": "MQ-9 Reaper" -} \ No newline at end of file +} diff --git a/resources/factions/iran_1988.json b/resources/factions/iran_1988.json index 67edce5b..b6d729c8 100644 --- a/resources/factions/iran_1988.json +++ b/resources/factions/iran_1988.json @@ -53,7 +53,8 @@ "ColdWarFlakGenerator" ], "ewrs": [ - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -81,4 +82,4 @@ "has_jtac": true, "jtac_unit": "MQ-9 Reaper", "doctrine": "coldwar" -} \ No newline at end of file +} diff --git a/resources/factions/iran_2015.json b/resources/factions/iran_2015.json index 76f1d23a..6b426d29 100644 --- a/resources/factions/iran_2015.json +++ b/resources/factions/iran_2015.json @@ -65,7 +65,8 @@ "ZU23UralGenerator" ], "ewrs": [ - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -93,4 +94,4 @@ ], "has_jtac": true, "jtac_unit": "MQ-9 Reaper" -} \ No newline at end of file +} diff --git a/resources/factions/libya_2011.json b/resources/factions/libya_2011.json index 58454e54..6c77d0e6 100644 --- a/resources/factions/libya_2011.json +++ b/resources/factions/libya_2011.json @@ -53,7 +53,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -75,4 +76,4 @@ "MolniyaGroupGenerator", "LaCombattanteIIGroupGenerator" ] -} \ No newline at end of file +} diff --git a/resources/factions/north_korea_2000.json b/resources/factions/north_korea_2000.json index 3c5033bc..fe8411d0 100644 --- a/resources/factions/north_korea_2000.json +++ b/resources/factions/north_korea_2000.json @@ -57,7 +57,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -75,4 +76,4 @@ "MolniyaGroupGenerator" ], "has_jtac": false -} \ No newline at end of file +} diff --git a/resources/factions/poland_2010.json b/resources/factions/poland_2010.json index 7b092d9e..57585c3e 100644 --- a/resources/factions/poland_2010.json +++ b/resources/factions/poland_2010.json @@ -8,8 +8,11 @@ ], "aircrafts": [ "F-16CM Fighting Falcon (Block 50)", + "Mi-24V Hind-E", + "Mi-24P Hind-F", "Mi-8MTV2 Hip", "MiG-29A Fulcrum-A", + "MiG-29G Fulcrum-A", "Su-22M4 Fitter-K" ], "tankers": [ @@ -56,4 +59,4 @@ "MolniyaGroupGenerator", "OliverHazardPerryGroupGenerator" ] -} \ No newline at end of file +} diff --git a/resources/factions/russia_1970_limited_air.json b/resources/factions/russia_1970_limited_air.json index 9bab53dc..45f8c938 100644 --- a/resources/factions/russia_1970_limited_air.json +++ b/resources/factions/russia_1970_limited_air.json @@ -48,7 +48,8 @@ "ZU23UralGenerator" ], "ewrs": [ - "FlatFaceGenerator" + "FlatFaceGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -66,4 +67,4 @@ ], "has_jtac": false, "doctrine": "coldwar" -} \ No newline at end of file +} diff --git a/resources/factions/russia_1975 (Mi-24P).json b/resources/factions/russia_1975 (Mi-24P).json index 5294604c..5272b589 100644 --- a/resources/factions/russia_1975 (Mi-24P).json +++ b/resources/factions/russia_1975 (Mi-24P).json @@ -63,7 +63,8 @@ "ZU23UralGenerator" ], "ewrs": [ - "FlatFaceGenerator" + "FlatFaceGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], diff --git a/resources/factions/russia_1975.json b/resources/factions/russia_1975.json index 9c4dbd3a..5c672293 100644 --- a/resources/factions/russia_1975.json +++ b/resources/factions/russia_1975.json @@ -63,7 +63,8 @@ "ZU23UralGenerator" ], "ewrs": [ - "FlatFaceGenerator" + "FlatFaceGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], diff --git a/resources/factions/russia_1990.json b/resources/factions/russia_1990.json index a833f71b..0f3ead54 100644 --- a/resources/factions/russia_1990.json +++ b/resources/factions/russia_1990.json @@ -73,7 +73,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [ "KUZNECOW" @@ -96,4 +97,4 @@ ], "has_jtac": true, "jtac_unit": "MQ-9 Reaper" -} \ No newline at end of file +} diff --git a/resources/factions/syria_1982.json b/resources/factions/syria_1982.json index 04267623..0c782bc2 100644 --- a/resources/factions/syria_1982.json +++ b/resources/factions/syria_1982.json @@ -54,7 +54,8 @@ "ZSU57Generator" ], "ewrs": [ - "BoxSpringGenerator" + "BoxSpringGenerator", + "TinShieldGenerator" ], "missiles": [ "ScudGenerator" @@ -70,4 +71,4 @@ "navy_generators": [ "GrishaGroupGenerator" ] -} \ No newline at end of file +} diff --git a/resources/factions/syria_2011.json b/resources/factions/syria_2011.json index 07e77af1..a089d47e 100644 --- a/resources/factions/syria_2011.json +++ b/resources/factions/syria_2011.json @@ -61,7 +61,6 @@ "SA8Generator", "SA8Generator", "SA9Generator", - "SA10Generator", "SA11Generator", "SA13Generator", "SA17Generator", @@ -72,7 +71,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "missiles": [ "ScudGenerator" @@ -89,4 +89,4 @@ "GrishaGroupGenerator", "MolniyaGroupGenerator" ] -} \ No newline at end of file +} diff --git a/resources/factions/syria_2012.json b/resources/factions/syria_2012.json index 1b8c4719..ec4190d9 100644 --- a/resources/factions/syria_2012.json +++ b/resources/factions/syria_2012.json @@ -73,7 +73,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "missiles": [ "ScudGenerator" @@ -90,4 +91,4 @@ "GrishaGroupGenerator", "MolniyaGroupGenerator" ] -} \ No newline at end of file +} diff --git a/resources/factions/usa_2005.json b/resources/factions/usa_2005.json index a05ede7a..4206d357 100644 --- a/resources/factions/usa_2005.json +++ b/resources/factions/usa_2005.json @@ -68,7 +68,8 @@ "air_defenses": [ "AvengerGenerator", "LinebackerGenerator", - "PatriotGenerator" + "PatriotGenerator", + "NasamCGenerator" ], "ewrs": [ "PatriotEwrGenerator" @@ -126,4 +127,4 @@ "VMFA-323" ] } -} \ No newline at end of file +} diff --git a/resources/factions/usn_2005.json b/resources/factions/usn_2005.json index 1ba50c00..4f3fbb03 100644 --- a/resources/factions/usn_2005.json +++ b/resources/factions/usn_2005.json @@ -22,12 +22,12 @@ "S-3B Tanker" ], "frontline_units": [ - "M113", + "AAVP-7A1 'Amtrac'", "M1043 HMMWV (M2 HMG)", "M1045 HMMWV (BGM-71 TOW)", + "M1097 Heavy HMMWV Avenger", "M1A2 Abrams", - "LAV-25", - "M163 Vulcan Air Defense System" + "LAV-25" ], "artillery_units": [ "M270 Multiple Launch Rocket System" diff --git a/resources/marianaislandslandmap.p b/resources/marianaislandslandmap.p new file mode 100644 index 00000000..d11e0368 Binary files /dev/null and b/resources/marianaislandslandmap.p differ diff --git a/resources/marianasislands.gif b/resources/marianasislands.gif new file mode 100644 index 00000000..f9217dd4 Binary files /dev/null and b/resources/marianasislands.gif differ diff --git a/resources/plugins/jtacautolase/JTACAutoLase.lua b/resources/plugins/jtacautolase/JTACAutoLase.lua index 05315574..01cd8649 100644 --- a/resources/plugins/jtacautolase/JTACAutoLase.lua +++ b/resources/plugins/jtacautolase/JTACAutoLase.lua @@ -161,9 +161,9 @@ function JTACAutoLase(jtacGroupName, laserCode,smoke,lock,colour) local tempUnit = Unit.getByName(tempUnitInfo.name) if tempUnit ~= nil and tempUnit:getLife() > 0 and tempUnit:isActive() == true then - notify(jtacGroupName .. " target " .. tempUnitInfo.unitType .. " lost. Scanning for Targets. ", 10) + notify(jtacGroupName .. " target " .. tempUnitInfo.unitType .. " lost. Scanning for Targets. ", 20) else - notify(jtacGroupName .. " target " .. tempUnitInfo.unitType .. " KIA. Good Job! Scanning for Targets. ", 10) + notify(jtacGroupName .. " target " .. tempUnitInfo.unitType .. " KIA. Good Job! Scanning for Targets. ", 20) end --remove from smoke list @@ -186,7 +186,7 @@ function JTACAutoLase(jtacGroupName, laserCode,smoke,lock,colour) -- store current target for easy lookup GLOBAL_JTAC_CURRENT_TARGETS[jtacGroupName] = { name = enemyUnit:getName(), unitType = enemyUnit:getTypeName(), unitId = enemyUnit:getID() } - notify(jtacGroupName .. " lasing new target " .. enemyUnit:getTypeName() .. '. CODE: ' .. laserCode ..getPositionString(enemyUnit) , 10) + notify(jtacGroupName .. " lasing new target " .. enemyUnit:getTypeName() .. '. CODE: ' .. laserCode ..getPositionString(enemyUnit) , 30) -- create smoke if smoke == true then @@ -554,7 +554,7 @@ function getJTACStatus() end end - notify(message, 25) + notify(message, 60) end diff --git a/resources/plugins/skynetiads/skynet-iads-compiled.lua b/resources/plugins/skynetiads/skynet-iads-compiled.lua index b13f1bb5..0108cd52 100644 --- a/resources/plugins/skynetiads/skynet-iads-compiled.lua +++ b/resources/plugins/skynetiads/skynet-iads-compiled.lua @@ -1,4 +1,4 @@ -env.info("--- SKYNET VERSION: 2.1.0 | BUILD TIME: 27.03.2021 2125Z ---") +env.info("--- SKYNET VERSION: 2.2.0 | BUILD TIME: 28.07.2021 1023Z ---") do --this file contains the required units per sam type samTypesDB = { @@ -194,7 +194,31 @@ samTypesDB = { ['NATO'] = 'Roland ADS', }, ['harm_detection_chance'] = 60 - }, + }, + + ['NASAM'] = { + ['type'] = 'complex', + ['searchRadar'] = { + ['NASAMS_Radar_MPQ64F1'] = { + }, + }, + ['launchers'] = { + ['NASAMS_LN_B'] = { + }, + ['NASAMS_LN_C'] = { + }, + }, + + ['name'] = { + ['NATO'] = 'NASAM', + }, + ['misc'] = { + ['NASAMS_Command_Post'] = { + ['required'] = false, + }, + }, + }, + ['2S6 Tunguska'] = { ['type'] = 'single', ['searchRadar'] = { diff --git a/resources/plugins/skynetiads/skynetiads-config.lua b/resources/plugins/skynetiads/skynetiads-config.lua index aa0ce992..f083c6f9 100644 --- a/resources/plugins/skynetiads/skynetiads-config.lua +++ b/resources/plugins/skynetiads/skynetiads-config.lua @@ -93,9 +93,28 @@ if dcsLiberation and SkynetIADS then for i = 1, #sites do local site = sites[i] local name = site:getDCSName() + + if string.match(name, "|SamAsEwr|") then + env.info(string.format("DCSLiberation|Skynet-IADS plugin - %s now acting as EWR", name)) + site:setActAsEW(true) + end + if not string.match(name, "|PD") then - env.info(string.format("DCSLiberation|Skynet-IADS plugin - Checking %s for PD", name)) - local pds = iads:getSAMSitesByPrefix(name .. "|PD") + -- Name is prefixed with `$color|SAM|$tgoid`. For pre-4.1 generated + -- campaigns that's the full name of the primary SAM and any PD are just + -- that name suffixed with |PD. + -- + -- For 4.1+ generated campaigns the name will be + -- `$color|SAM|$tgoid|$role|$gid`, so we need to replace the content + -- beginning with the third pipe with `|PD` to find our PDs. + local first_pipe = string.find(name, "|") + local second_pipe = string.find(name, "|", first_pipe + 1) + local third_pipe = string.find(name, "|", second_pipe + 1) + local pd_prefix = name .. "|PD" + if third_pipe ~= nil then + pd_prefix = string.sub(name, 1, third_pipe) .. "PD" + end + local pds = iads:getSAMSitesByPrefix(pd_prefix) for j = 1, #pds do pd = pds[j] env.info(string.format("DCSLiberation|Skynet-IADS plugin - Adding %s as PD for %s", pd:getDCSName(), name)) diff --git a/resources/tools/export_coordinates.py b/resources/tools/export_coordinates.py index d3605238..e59c90d0 100644 --- a/resources/tools/export_coordinates.py +++ b/resources/tools/export_coordinates.py @@ -39,6 +39,7 @@ from dcs.terrain.persiangulf import PersianGulf from dcs.terrain.syria import Syria from dcs.terrain.terrain import Terrain from dcs.terrain.thechannel import TheChannel +from dcs.terrain.marianaislands import MarianaIslands from dcs.triggers import TriggerStart from pyproj import CRS, Transformer @@ -59,6 +60,7 @@ ARG_TO_TERRAIN_MAP = { "persiangulf": PersianGulf(), "thechannel": TheChannel(), "syria": Syria(), + "marianaislands": MarianaIslands(), } # https://gisgeography.com/central-meridian/ @@ -71,6 +73,7 @@ CENTRAL_MERIDIANS = { "persiangulf": 57, "thechannel": 3, "syria": 39, + "marianaislands": 147, } @@ -238,6 +241,8 @@ def main() -> None: out_file.write_text( textwrap.dedent( f"""\ + # DO NOT EDIT: + # This file is generated by resources/tools/export_coordinates.py. from game.theater.projections import TransverseMercator PARAMETERS = TransverseMercator( diff --git a/resources/tools/generate_frontlines.py b/resources/tools/generate_frontlines.py index 46f429fe..ced861c6 100644 --- a/resources/tools/generate_frontlines.py +++ b/resources/tools/generate_frontlines.py @@ -5,10 +5,20 @@ import argparse from pathlib import Path from typing import List, Tuple, Union, Dict -from dcs.terrain import Caucasus, PersianGulf, Syria, Nevada, Normandy, TheChannel +from dcs.terrain import ( + Caucasus, + PersianGulf, + Syria, + Nevada, + Normandy, + TheChannel, + MarianaIslands, +) from dcs import Mission -Terrain = Union[Caucasus, PersianGulf, Syria, Nevada, Normandy, TheChannel] +Terrain = Union[ + Caucasus, PersianGulf, Syria, Nevada, Normandy, TheChannel, MarianaIslands +] SAVE_PATH = Path("resources/frontlines") diff --git a/resources/tools/generate_landmap.py b/resources/tools/generate_landmap.py index b61bcc80..60f17ad4 100644 --- a/resources/tools/generate_landmap.py +++ b/resources/tools/generate_landmap.py @@ -32,7 +32,7 @@ def _geometry_collection_to_multipoly(obj: GeometryCollection) -> MultiPolygon: raise RuntimeError(f"Not sure how to convert collection to multipoly: {obj.wkt}") -for terrain in ["cau", "nev", "syria", "channel", "normandy", "gulf"]: +for terrain in ["cau", "nev", "syria", "channel", "normandy", "gulf", "marianaislands"]: print("Terrain " + terrain) m = Mission() m.load_file("./{}_terrain.miz".format(terrain)) diff --git a/resources/tools/marianaislands_terrain.miz b/resources/tools/marianaislands_terrain.miz new file mode 100644 index 00000000..69690191 Binary files /dev/null and b/resources/tools/marianaislands_terrain.miz differ diff --git a/resources/ui/map/canvas.html b/resources/ui/map/canvas.html index ff99669a..0efc3ebf 100644 --- a/resources/ui/map/canvas.html +++ b/resources/ui/map/canvas.html @@ -25,12 +25,8 @@ crossorigin=""> - + href="lib/leaflet-ruler/leaflet-ruler.css"> +