diff --git a/changelog.md b/changelog.md index 4864dfd3..9be996fd 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,10 @@ Saves from 3.x are not compatible with 5.0. ## Features/Improvements +* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. +* **[Campaign 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. + ## Fixes # 4.1.0 @@ -13,17 +17,27 @@ 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. * **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR * **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). * **[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. ## Fixes -* **[UI]** Statistics window tick marks are now always integers. -* **[Mission Generation]** The lua data for other plugins is now generated correctly + +* **[Campaign]** Fixed the Silkworm generator to include launchers and not all radars. +* **[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]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed. +* **[UI]** Statistics window tick marks are now always integers. +* **[UI]** Statistics window now shows the correct info for the turn +* **[UI]** Toggling custom loadout for an aircraft with no preset loadouts no longer breaks the flight. # 4.0.0 diff --git a/game/coalition.py b/game/coalition.py new file mode 100644 index 00000000..1922f3ce --- /dev/null +++ b/game/coalition.py @@ -0,0 +1,220 @@ +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.profiling import logged_duration, MultiEventTracer +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: list[AircraftProcurementRequest] = [] + 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 + + def __setstate__(self, state: dict[str, Any]) -> None: + 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 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) 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..16ea678a --- /dev/null +++ b/game/commander/aircraftallocator.py @@ -0,0 +1,76 @@ +from typing import Optional, Tuple + +from game.commander.missionproposals import ProposedFlight +from game.inventory import GlobalAircraftInventory +from game.squadrons import AirWing, Squadron +from game.theater import ControlPoint +from 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, 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 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..2b8fc074 --- /dev/null +++ b/game/commander/missionproposals.py @@ -0,0 +1,62 @@ +from dataclasses import field, dataclass +from enum import Enum, auto +from typing import Optional + +from game.theater import MissionTarget +from game.utils import Distance +from gen.flights.flight import FlightType + + +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}" 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..490e0286 --- /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(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..d4d8352b --- /dev/null +++ b/game/commander/packagefulfiller.py @@ -0,0 +1,214 @@ +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.procurement_requests.append(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], + ) -> 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, + ) + # 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], + ) -> 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) + + 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, 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) + + if missing_types: + self.scrub_mission_missing_aircraft( + mission, builder, missing_types, escorts + ) + 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) + + # 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 + ) + 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..67407010 --- /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)] 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..fb50af23 --- /dev/null +++ b/game/commander/tasks/packageplanningtask.py @@ -0,0 +1,171 @@ +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, doctrine: Doctrine) -> None: + ... + + def propose_flight( + self, + task: FlightType, + num_aircraft: int, + max_distance: Optional[Distance], + escort_type: Optional[EscortType] = None, + ) -> None: + if max_distance is None: + max_distance = Distance.inf() + self.flights.append( + ProposedFlight(task, num_aircraft, max_distance, escort_type) + ) + + @property + def asap(self) -> bool: + return False + + def fulfill_mission(self, state: TheaterState) -> bool: + self.propose_flights(state.context.coalition.doctrine) + fulfiller = PackageFulfiller( + state.context.coalition, + state.context.theater, + state.available_aircraft, + state.context.settings, + ) + self.package = fulfiller.plan_mission( + ProposedMission(self.target, self.flights), state.context.tracer + ) + return self.package is not None + + def propose_common_escorts(self, doctrine: Doctrine) -> None: + self.propose_flight( + FlightType.SEAD_ESCORT, + 2, + doctrine.mission_ranges.offensive, + EscortType.Sead, + ) + + self.propose_flight( + FlightType.ESCORT, + 2, + doctrine.mission_ranges.offensive, + EscortType.AirToAir, + ) + + def 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..8153aac6 --- /dev/null +++ b/game/commander/tasks/primitive/aewc.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.data.doctrine import Doctrine +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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.AEWC, 1, doctrine.mission_ranges.aewc) + + @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..3f85c74c --- /dev/null +++ b/game/commander/tasks/primitive/antiship.py @@ -0,0 +1,32 @@ +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.data.doctrine import Doctrine +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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive) + self.propose_flight( + FlightType.ESCORT, + 2, + doctrine.mission_ranges.offensive, + EscortType.AirToAir, + ) diff --git a/game/commander/tasks/primitive/antishipping.py b/game/commander/tasks/primitive/antishipping.py new file mode 100644 index 00000000..303a9af1 --- /dev/null +++ b/game/commander/tasks/primitive/antishipping.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 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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive) + self.propose_common_escorts(doctrine) diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py new file mode 100644 index 00000000..f9d61818 --- /dev/null +++ b/game/commander/tasks/primitive/bai.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.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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive) + self.propose_common_escorts(doctrine) diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py new file mode 100644 index 00000000..77302adf --- /dev/null +++ b/game/commander/tasks/primitive/barcap.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.data.doctrine import Doctrine +from game.theater import ControlPoint +from gen.flights.flight import FlightType + + +@dataclass +class PlanBarcap(PackagePlanningTask[ControlPoint]): + 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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.BARCAP, 2, doctrine.mission_ranges.cap) 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..7a9997ff --- /dev/null +++ b/game/commander/tasks/primitive/cas.py @@ -0,0 +1,24 @@ +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.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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.CAS, 2, doctrine.mission_ranges.cas) + self.propose_flight(FlightType.TARCAP, 2, doctrine.mission_ranges.cap) diff --git a/game/commander/tasks/primitive/convoyinterdiction.py b/game/commander/tasks/primitive/convoyinterdiction.py new file mode 100644 index 00000000..11ed4ee4 --- /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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive) + self.propose_common_escorts(doctrine) diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py new file mode 100644 index 00000000..3861908c --- /dev/null +++ b/game/commander/tasks/primitive/dead.py @@ -0,0 +1,58 @@ +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.data.doctrine import Doctrine +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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.DEAD, 2, doctrine.mission_ranges.offensive) + + # 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, doctrine.mission_ranges.offensive) + else: + self.propose_flight( + FlightType.SEAD_ESCORT, + 2, + doctrine.mission_ranges.offensive, + EscortType.Sead, + ) + + self.propose_flight( + FlightType.ESCORT, + 2, + doctrine.mission_ranges.offensive, + EscortType.AirToAir, + ) 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..4c995f75 --- /dev/null +++ b/game/commander/tasks/primitive/oca.py @@ -0,0 +1,32 @@ +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.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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.OCA_RUNWAY, 2, doctrine.mission_ranges.offensive) + if self.aircraft_cold_start: + self.propose_flight( + FlightType.OCA_AIRCRAFT, 2, doctrine.mission_ranges.offensive + ) + self.propose_common_escorts(doctrine) diff --git a/game/commander/tasks/primitive/refueling.py b/game/commander/tasks/primitive/refueling.py new file mode 100644 index 00000000..005cbc3a --- /dev/null +++ b/game/commander/tasks/primitive/refueling.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.data.doctrine import Doctrine +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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.REFUELING, 1, doctrine.mission_ranges.refueling) 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..ce322dad --- /dev/null +++ b/game/commander/tasks/primitive/strike.py @@ -0,0 +1,27 @@ +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.data.doctrine import Doctrine +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, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.STRIKE, 2, doctrine.mission_ranges.offensive) + self.propose_common_escorts(doctrine) 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/doctrine.py b/game/data/doctrine.py index 262d5fa5..4f944833 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,9 +1,10 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field 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 @@ -17,6 +18,15 @@ class GroundUnitProcurementRatios: return 0.0 +@dataclass(frozen=True) +class MissionPlannerMaxRanges: + cap: Distance = field(default=nautical_miles(100)) + cas: Distance = field(default=nautical_miles(50)) + offensive: Distance = field(default=nautical_miles(150)) + aewc: Distance = field(default=Distance.inf()) + refueling: Distance = field(default=nautical_miles(200)) + + @dataclass(frozen=True) class Doctrine: cas: bool @@ -26,13 +36,21 @@ 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 distance between the ingress point (beginning of the attack) and target. + ingress_distance: Distance + ingress_altitude: Distance - egress_altitude: Distance min_patrol_altitude: Distance max_patrol_altitude: Distance @@ -65,6 +83,15 @@ class Doctrine: ground_unit_procurement_ratios: GroundUnitProcurementRatios + mission_ranges: MissionPlannerMaxRanges = field(default=MissionPlannerMaxRanges()) + + @has_save_compat_for(5) + def __setstate__(self, state: dict[str, Any]) -> None: + if "ingress_distance" not in state: + state["ingress_distance"] = state["ingress_egress_distance"] + del state["ingress_egress_distance"] + self.__dict__.update(state) + MODERN_DOCTRINE = Doctrine( cap=True, @@ -76,10 +103,8 @@ MODERN_DOCTRINE = Doctrine( hold_distance=nautical_miles(15), push_distance=nautical_miles(20), join_distance=nautical_miles(20), - split_distance=nautical_miles(20), - ingress_egress_distance=nautical_miles(45), + ingress_distance=nautical_miles(45), ingress_altitude=feet(20000), - egress_altitude=feet(20000), min_patrol_altitude=feet(15000), max_patrol_altitude=feet(33000), pattern_altitude=feet(5000), @@ -114,10 +139,8 @@ COLDWAR_DOCTRINE = Doctrine( hold_distance=nautical_miles(10), push_distance=nautical_miles(10), join_distance=nautical_miles(10), - split_distance=nautical_miles(10), - ingress_egress_distance=nautical_miles(30), + ingress_distance=nautical_miles(30), ingress_altitude=feet(18000), - egress_altitude=feet(18000), min_patrol_altitude=feet(10000), max_patrol_altitude=feet(24000), pattern_altitude=feet(5000), @@ -151,11 +174,9 @@ WWII_DOCTRINE = Doctrine( hold_distance=nautical_miles(5), 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), + ingress_distance=nautical_miles(7), 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 6b9164a7..5d0b0dd1 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -3,74 +3,197 @@ 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 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}" + ) + 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: +@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 = 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"] + year = data.get("year") + fallback_name = data.get("fallback") + group = WeaponGroup(name, 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", 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", 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: + 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 +204,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 +239,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 +247,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): 1999, - Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod_): 1999, - Weapon.from_pydcs(Weapons.AN_ASQ_228_ATFLIR___Targeting_Pod): 2003, - 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): 1990, - Weapon.from_pydcs(Weapons.Lantirn_F_16): 1990, - Weapon.from_pydcs(Weapons.Lantirn_Target_Pod): 1990, - 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 0a63c056..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 } @@ -328,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 @@ -361,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 @@ -374,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 9b5fedae..dd9b5282 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -105,8 +105,9 @@ class PatrolConfig: ) +# TODO: Split into PlaneType and HelicopterType? @dataclass(frozen=True) -class AircraftType(UnitType[FlyingType]): +class AircraftType(UnitType[Type[FlyingType]]): carrier_capable: bool lha_capable: bool always_keeps_gun: bool @@ -144,12 +145,23 @@ class AircraftType(UnitType[FlyingType]): return kph(self.dcs_unit_type.max_speed) 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: diff --git a/game/dcs/groundunittype.py b/game/dcs/groundunittype.py index 908e0e18..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 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..2176220c 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) 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/game.py b/game/game.py index 6d2aa329..6ce7b178 100644 --- a/game/game.py +++ b/game/game.py @@ -1,47 +1,41 @@ -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.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 @@ -99,156 +93,106 @@ 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() + @property + def coalitions(self) -> Iterator[Coalition]: + yield self.blue + yield self.red + def ato_for(self, player: bool) -> AirTaskingOrder: - if player: - return self.blue_ato - return self.red_ato + return self.coalition_for(player).ato def procurement_requests_for( self, player: bool - ) -> List[AircraftProcurementRequest]: - if player: - return self.blue_procurement_requests - return self.red_procurement_requests + ) -> list[AircraftProcurementRequest]: + return self.coalition_for(player).procurement_requests 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, ) ) - def _generate_events(self): + def _generate_events(self) -> None: for front_line in self.theater.conflicts(): self._generate_player_event( FrontlineAttackEvent, @@ -256,27 +200,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) @@ -285,16 +223,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 @@ -309,36 +237,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) @@ -349,14 +291,19 @@ 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.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) @@ -366,7 +313,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() } @@ -383,24 +330,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): @@ -411,59 +384,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)) @@ -476,7 +416,7 @@ 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 """ @@ -490,34 +430,22 @@ class Game: 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 @@ -540,7 +468,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) @@ -558,7 +486,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, @@ -576,15 +504,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 @@ -597,38 +525,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/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 a44dd5aa..290076db 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 @@ -62,7 +62,7 @@ class Operation: plugin_scripts: List[str] = [] @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 +70,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 +81,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,16 +93,16 @@ 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() ) - 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)]() ) @@ -163,7 +149,7 @@ class Operation: airsupportgen: AirSupportConflictGenerator, jtacs: List[JtacInfo], airgen: AircraftConflictGenerator, - ): + ) -> None: """Generates subscribed MissionInfoGenerator objects (currently kneeboards and briefings)""" gens: List[MissionInfoGenerator] = [ @@ -251,7 +237,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, @@ -266,18 +252,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, @@ -367,18 +358,18 @@ 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 @@ -389,10 +380,10 @@ class Operation: 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, ) @@ -406,6 +397,7 @@ class Operation: player_gp, enemy_gp, player_cp.stances[enemy_cp.id], + enemy_cp.stances[player_cp.id], cls.unit_map, ) ground_conflict_gen.generate() @@ -418,7 +410,7 @@ 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 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..a87914a1 100644 --- a/game/point_with_heading.py +++ b/game/point_with_heading.py @@ -1,13 +1,15 @@ +from __future__ import annotations + from dcs import Point class PointWithHeading(Point): - def __init__(self): + def __init__(self) -> None: super(PointWithHeading, self).__init__(0, 0) self.heading = 0 @staticmethod - def from_point(point: Point, heading: int): + def from_point(point: Point, heading: int) -> 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..8820453c 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -72,7 +72,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 @@ -316,7 +318,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 +347,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 +362,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/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..3a23d4ea 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -13,16 +13,18 @@ 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 @@ -95,16 +97,13 @@ 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._recruit_pilots(self.settings.squadron_pilot_limit) self.auto_assignable_mission_types = set(self.mission_types) def __str__(self) -> str: @@ -112,9 +111,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 +133,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: @@ -183,7 +186,7 @@ class Squadron: 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 +199,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 +208,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 +230,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: @@ -251,7 +254,7 @@ class Squadron: 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 @@ -286,11 +289,11 @@ class Squadron: livery=data.get("livery"), mission_types=tuple(mission_types), 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 +301,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 +314,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 +349,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 +358,28 @@ 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)), pilot_pool=[], - game=game, - player=player, + coalition=coalition, + settings=game.settings, ) ] 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/conflicttheater.py b/game/theater/conflicttheater.py index 0d49b1e6..e0f4d69a 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 @@ -56,10 +56,14 @@ from .landmap import Landmap, load_landmap, poly_contains from .latlon import LatLon from .projections import TransverseMercator 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 +if TYPE_CHECKING: + from . import TheaterGroundObject + SIZE_TINY = 150 SIZE_SMALL = 600 SIZE_REGULAR = 1000 @@ -181,7 +185,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 @@ -305,26 +309,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 @@ -385,22 +389,22 @@ 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) -> Tuple[ControlPoint, Distance]: + closest = self.theater.closest_control_point(near.position) + 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, 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) closest.preset_locations.ships.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point(ship.position, ship.units[0].heading) ) for group in self.missile_sites: @@ -451,33 +455,33 @@ class MizCampaignLoader: PointWithHeading.from_point(group.position, 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( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point(static.position, 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, 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, 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, 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(): @@ -504,7 +508,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") @@ -536,10 +540,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: @@ -581,12 +587,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") @@ -698,6 +704,7 @@ class ConflictTheater: "Normandy": NormandyTheater, "The Channel": TheChannelTheater, "Syria": SyriaTheater, + "MarianaIslands": MarianaIslandsTheater, } theater = theaters[data["theater"]] t = theater() @@ -856,3 +863,22 @@ class SyriaTheater(ConflictTheater): 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 projection_parameters(self) -> TransverseMercator: + from .marianaislands import PARAMETERS + + return PARAMETERS diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 7631b3eb..075f4f5e 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -43,6 +43,7 @@ from .missiontarget import MissionTarget from .theatergroundobject import ( GenericCarrierGroundObject, TheaterGroundObject, + BuildingGroundObject, ) from ..dcs.aircrafttype import AircraftType from ..dcs.groundunittype import GroundUnitType @@ -270,6 +271,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 @@ -290,15 +294,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[PointWithHeading] = [] @@ -322,11 +326,11 @@ 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 @@ -334,13 +338,17 @@ class ControlPoint(MissionTarget, ABC): def heading(self) -> int: ... - 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]: @@ -405,21 +413,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 """ @@ -439,7 +447,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 @@ -471,7 +479,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 @@ -497,10 +505,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: @@ -522,7 +532,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 @@ -596,7 +606,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() @@ -613,11 +623,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: @@ -725,30 +731,51 @@ 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 strike_targets(self) -> List[Union[MissionTarget, Unit]]: + def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: return [] @property @@ -764,8 +791,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, @@ -879,9 +906,12 @@ class NavalControlPoint(ControlPoint, ABC): def heading(self) -> int: return 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}") @@ -960,7 +990,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: diff --git a/game/theater/frontline.py b/game/theater/frontline.py index 8d46327c..2f1b6067 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 @@ -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) -> float: """The heading of the active attack segment from player to enemy control point""" return self.active_segment.attack_heading @@ -149,6 +160,9 @@ class FrontLine(MissionTarget): ) 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..b5ddd5e1 --- /dev/null +++ b/game/theater/marianaislands.py @@ -0,0 +1,8 @@ +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/start_generator.py b/game/theater/start_generator.py index 4ec827ec..0bf85391 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 @@ -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 = [] diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index 49fb8fd9..f063a1ea 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 ( @@ -47,7 +48,10 @@ NAME_BY_CATEGORY = { } -class TheaterGroundObject(MissionTarget): +GroupT = TypeVar("GroupT", ShipGroup, VehicleGroup) + + +class TheaterGroundObject(MissionTarget, Generic[GroupT]): def __init__( self, name: str, @@ -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, @@ -217,7 +225,7 @@ class BuildingGroundObject(TheaterGroundObject): heading: int, 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 @@ -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 @@ -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: @@ -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: int, ) -> None: super().__init__( name=name, @@ -460,10 +472,19 @@ class CoastalSiteGroundObject(TheaterGroundObject): return False +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(TheaterGroundObject): +class SamGroundObject(IadsGroundObject): def __init__( self, name: str, @@ -488,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: @@ -535,7 +552,7 @@ class SamGroundObject(TheaterGroundObject): return True -class VehicleGroupGroundObject(TheaterGroundObject): +class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, @@ -563,7 +580,7 @@ class VehicleGroupGroundObject(TheaterGroundObject): return True -class EwrGroundObject(TheaterGroundObject): +class EwrGroundObject(IadsGroundObject): def __init__( self, name: str, @@ -588,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 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..7401b03d 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: @@ -313,7 +316,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 +340,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 +523,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 +538,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 +597,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 +614,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() @@ -638,7 +652,7 @@ class PendingTransfers: self.arrange_transport(transfer) 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,7 +687,7 @@ class PendingTransfers: # aesthetic. gap += 1 - self.game.procurement_requests_for(player=control_point.captured).append( + self.game.procurement_requests_for(self.player).append( AircraftProcurementRequest( control_point, nautical_miles(200), FlightType.TRANSPORT, gap ) diff --git a/game/unitdelivery.py b/game/unitdelivery.py index a6de6a71..7dbfb0a0 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,61 @@ 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 - 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 +98,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 +111,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 68e38d31..2370c56f 100644 --- a/game/utils.py +++ b/game/utils.py @@ -2,8 +2,9 @@ from __future__ import annotations import itertools import math +from collections import Iterable from dataclasses import dataclass -from typing import Union +from typing import Union, Any METERS_TO_FEET = 3.28084 FEET_TO_METERS = 1 / METERS_TO_FEET @@ -16,12 +17,12 @@ MS_TO_KPH = 3.6 KPH_TO_MS = 1 / MS_TO_KPH -def heading_sum(h, a) -> int: +def heading_sum(h: int, a: int) -> int: h += a return h % 360 -def opposite_heading(h): +def opposite_heading(h: int) -> int: return heading_sum(h, 180) @@ -180,7 +181,7 @@ def mach(value: float, altitude: Distance) -> Speed: SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5) -def pairwise(iterable): +def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]: """ itertools recipe s -> (s0,s1), (s1,s2), (s2, s3), ... diff --git a/game/version.py b/game/version.py index bcb8b8cf..7c989e2a 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,7 @@ 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 +CAMPAIGN_FORMAT_VERSION = (7, 1) diff --git a/game/weather.py b/game/weather.py index fc077634..fae1d5a0 100644 --- a/game/weather.py +++ b/game/weather.py @@ -24,6 +24,14 @@ class TimeOfDay(Enum): Night = "night" +@dataclass(frozen=True) +class AtmosphericConditions: + #: Pressure at sea level in inches of mercury. + qnh_inches_mercury: float + #: Temperature at sea level in Celcius. + temperature_celsius: float + + @dataclass(frozen=True) class WindConditions: at_0m: Wind @@ -64,10 +72,16 @@ class Fog: class Weather: def __init__(self) -> None: + # Future improvement: Use theater, day and time of day + # to get a more realistic conditions + self.atmospheric = self.generate_atmospheric() self.clouds = self.generate_clouds() self.fog = self.generate_fog() self.wind = self.generate_wind() + def generate_atmospheric(self) -> AtmosphericConditions: + raise NotImplementedError + def generate_clouds(self) -> Optional[Clouds]: raise NotImplementedError @@ -83,7 +97,7 @@ class Weather: raise NotImplementedError @staticmethod - def random_wind(minimum: int, maximum) -> WindConditions: + def random_wind(minimum: int, maximum: int) -> WindConditions: wind_direction = random.randint(0, 360) at_0m_factor = 1 at_2000m_factor = 2 @@ -105,8 +119,35 @@ class Weather: def random_cloud_thickness() -> int: return random.randint(100, 400) + @staticmethod + def random_pressure(average_pressure: float) -> float: + # "Safe" constants based roughly on ME and viper altimeter. + # Units are inches of mercury. + SAFE_MIN = 28.4 + SAFE_MAX = 30.9 + # Use normalvariate to get normal distribution, more realistic than uniform + pressure = random.normalvariate(average_pressure, 0.2) + return max(SAFE_MIN, min(SAFE_MAX, pressure)) + + @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, 4) + temperature = round(temperature) + return max(SAFE_MIN, min(SAFE_MAX, temperature)) + class ClearSkies(Weather): + def generate_atmospheric(self) -> AtmosphericConditions: + return AtmosphericConditions( + qnh_inches_mercury=self.random_pressure(29.96), + temperature_celsius=self.random_temperature(22), + ) + def generate_clouds(self) -> Optional[Clouds]: return None @@ -118,6 +159,12 @@ class ClearSkies(Weather): class Cloudy(Weather): + def generate_atmospheric(self) -> AtmosphericConditions: + return AtmosphericConditions( + qnh_inches_mercury=self.random_pressure(29.90), + temperature_celsius=self.random_temperature(20), + ) + def generate_clouds(self) -> Optional[Clouds]: return Clouds.random_preset(rain=False) @@ -130,6 +177,12 @@ class Cloudy(Weather): class Raining(Weather): + def generate_atmospheric(self) -> AtmosphericConditions: + return AtmosphericConditions( + qnh_inches_mercury=self.random_pressure(29.70), + temperature_celsius=self.random_temperature(16), + ) + def generate_clouds(self) -> Optional[Clouds]: return Clouds.random_preset(rain=True) @@ -142,6 +195,12 @@ class Raining(Weather): class Thunderstorm(Weather): + def generate_atmospheric(self) -> AtmosphericConditions: + return AtmosphericConditions( + qnh_inches_mercury=self.random_pressure(29.60), + temperature_celsius=self.random_temperature(15), + ) + def generate_clouds(self) -> Optional[Clouds]: return Clouds( base=self.random_cloud_base(), @@ -168,11 +227,12 @@ 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 - ), + start_time=_start_time, weather=cls.generate_weather(), ) diff --git a/gen/aircraft.py b/gen/aircraft.py index 949e5e98..392cd70c 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -5,7 +5,7 @@ import random from dataclasses import dataclass 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 +22,6 @@ from dcs.planes import ( C_101EB, F_14B, JF_17, - PlaneType, Su_33, Tu_22M3, ) @@ -262,8 +261,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 +320,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 +341,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 +350,7 @@ class AircraftConflictGenerator: def _setup_group( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -458,8 +457,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 +475,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 +520,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 +535,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 +556,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 +576,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 +600,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 +657,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 +676,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 +697,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 +711,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: if not isinstance(cp, Airfield): @@ -765,7 +754,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: @@ -791,9 +780,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, @@ -824,13 +813,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], @@ -847,7 +836,7 @@ class AircraftConflictGenerator: def configure_sweep( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -864,7 +853,7 @@ class AircraftConflictGenerator: def configure_cas( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -882,7 +871,7 @@ class AircraftConflictGenerator: def configure_dead( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -907,7 +896,7 @@ class AircraftConflictGenerator: def configure_sead( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -931,7 +920,7 @@ class AircraftConflictGenerator: def configure_strike( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -949,7 +938,7 @@ class AircraftConflictGenerator: def configure_anti_ship( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -967,7 +956,7 @@ class AircraftConflictGenerator: def configure_runway_attack( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -985,7 +974,7 @@ class AircraftConflictGenerator: def configure_oca_strike( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1002,7 +991,7 @@ class AircraftConflictGenerator: def configure_awacs( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1030,7 +1019,7 @@ class AircraftConflictGenerator: def configure_refueling( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1056,7 +1045,7 @@ class AircraftConflictGenerator: def configure_escort( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1072,7 +1061,7 @@ class AircraftConflictGenerator: def configure_sead_escort( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1095,7 +1084,7 @@ class AircraftConflictGenerator: def configure_transport( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1110,13 +1099,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], @@ -1160,7 +1149,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: @@ -1228,7 +1217,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) @@ -1271,7 +1260,7 @@ class PydcsWaypointBuilder: def __init__( self, waypoint: FlightWaypoint, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, mission: Mission, @@ -1314,7 +1303,7 @@ class PydcsWaypointBuilder: def for_waypoint( cls, waypoint: FlightWaypoint, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, mission: Mission, @@ -1428,7 +1417,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/airsupportgen.py b/gen/airsupportgen.py index 875a0e58..409a0959 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -1,11 +1,12 @@ +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, Optional, 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 +15,17 @@ 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 .callsigns import callsign_for_support_unit from .conflictgen import Conflict +from .flights.ai_flight_planner_db import AEWC_CAPABLE +from .naming import namegen from .radios import RadioFrequency, RadioRegistry from .tacan import TacanBand, TacanChannel, TacanRegistry +if TYPE_CHECKING: + from game import Game TANKER_DISTANCE = 15000 TANKER_ALT = 4572 @@ -70,7 +73,7 @@ class AirSupportConflictGenerator: self, mission: Mission, conflict: Conflict, - game, + game: Game, radio_registry: RadioRegistry, tacan_registry: TacanRegistry, ) -> None: @@ -95,19 +98,26 @@ class AirSupportConflictGenerator: 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() @@ -122,12 +132,10 @@ class AirSupportConflictGenerator: tanker_heading, 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 +185,8 @@ class AirSupportConflictGenerator: tanker_unit_type.name, freq, tacan, + start_time=None, + end_time=None, blue=True, ) ) @@ -195,12 +205,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..7e92169b 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 @@ -23,7 +24,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 @@ -85,57 +86,24 @@ class GroundConflictGenerator: player_planned_combat_groups: List[CombatGroup], enemy_planned_combat_groups: List[CombatGroup], player_stance: CombatStance, + enemy_stance: CombatStance, unit_map: UnitMap, ) -> 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] = [] - 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,6 +118,13 @@ 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, @@ -169,16 +144,16 @@ class GroundConflictGenerator: ) # 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) - 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], @@ -361,7 +336,6 @@ 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) return True return False @@ -570,10 +544,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)) @@ -634,7 +608,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 +632,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 +670,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], @@ -716,7 +690,7 @@ class GroundConflictGenerator: distance_from_frontline: int, heading: int, spawn_heading: int, - ): + ) -> Optional[Point]: shifted = conflict_position.point_from_heading( heading, random.randint(0, combat_width) ) @@ -741,7 +715,7 @@ class GroundConflictGenerator: if is_player else int(heading_sum(heading, 90)) ) - country = self.game.player_country if is_player else self.game.enemy_country + country = self.game.coalition_for(is_player).country_name for group in groups: if group.role == CombatGroupRole.ARTILLERY: distance_from_frontline = ( @@ -766,9 +740,9 @@ class GroundConflictGenerator: heading=opposite_heading(spawn_heading), ) 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]: @@ -790,7 +764,7 @@ class GroundConflictGenerator: count: int, at: Point, move_formation: PointAction = PointAction.OffRoad, - heading=0, + heading: int = 0, ) -> VehicleGroup: if side == self.conflict.attackers_country: 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/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 1ffe6b04..6712762a 100644 --- a/gen/coastal/silkworm.py +++ b/gen/coastal/silkworm.py @@ -1,14 +1,19 @@ 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 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) diff --git a/gen/conflictgen.py b/gen/conflictgen.py index eabf4e4e..5576805a 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import Tuple, Optional @@ -54,13 +56,15 @@ class Conflict: def frontline_position( cls, frontline: FrontLine, theater: ConflictTheater ) -> Tuple[Point, int]: - attack_heading = frontline.attack_heading + attack_heading = int(frontline.attack_heading) position = cls.find_ground_position( frontline.position, FRONTLINE_LENGTH, heading_sum(attack_heading, 90), theater, ) + if position is None: + raise RuntimeError("Could not find front line position") return position, opposite_heading(attack_heading) @classmethod @@ -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( @@ -138,7 +142,7 @@ class Conflict: max_distance: int, heading: int, 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. 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..2bc9da84 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -2,7 +2,8 @@ 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 +from .units import inches_hg_to_mm_hg class EnvironmentGenerator: @@ -10,6 +11,10 @@ class EnvironmentGenerator: self.mission = mission self.conditions = conditions + def set_atmospheric(self, atmospheric: AtmosphericConditions) -> None: + self.mission.weather.qnh = inches_hg_to_mm_hg(atmospheric.qnh_inches_mercury) + self.mission.weather.season_temperature = atmospheric.temperature_celsius + def set_clouds(self, clouds: Optional[Clouds]) -> None: if clouds is None: return @@ -22,7 +27,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 +35,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..b25902a9 100644 --- a/gen/fleet/carrier_group.py +++ b/gen/fleet/carrier_group.py @@ -6,7 +6,7 @@ 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": 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..9569cfaa 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, @@ -415,7 +416,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 cf5c011f..05ee4c19 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -2,13 +2,12 @@ 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 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.squadrons import Pilot, Squadron from game.theater.controlpoint import ControlPoint, MissionTarget @@ -154,7 +153,7 @@ 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 @@ -323,12 +322,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..0927b968 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -28,8 +28,14 @@ from game.theater import ( SamGroundObject, TheaterGroundObject, NavalControlPoint, + ConflictTheater, ) -from game.theater.theatergroundobject import EwrGroundObject, NavalGroundObject +from game.theater.theatergroundobject import ( + EwrGroundObject, + NavalGroundObject, + BuildingGroundObject, +) +from game.threatzones import ThreatZones from game.utils import Distance, Speed, feet, meters, nautical_miles, knots from .closestairfields import ObjectiveDistanceCache from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType @@ -38,8 +44,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 = { @@ -219,11 +225,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 +542,6 @@ class StrikeFlightPlan(FormationFlightPlan): join: FlightWaypoint ingress: FlightWaypoint targets: List[FlightWaypoint] - egress: FlightWaypoint split: FlightWaypoint nav_from: List[FlightWaypoint] land: FlightWaypoint @@ -555,7 +556,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 +567,6 @@ class StrikeFlightPlan(FormationFlightPlan): def package_speed_waypoints(self) -> Set[FlightWaypoint]: return { self.ingress, - self.egress, self.split, } | set(self.targets) @@ -631,8 +630,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 +641,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 +857,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 +867,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,30 +946,14 @@ 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 simple case is where the target is not near the departure airfield. In + # this case, we can plan the shortest route from the departure airfield to the + # target, use the nearest non-threatened point *that's farther from the target + # than the ingress point to avoid backtracking) as the join point. # # The other case that we need to handle is when the target is close to - # the origin airfield. In this case we 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. + # the origin airfield. In this case we currently fall back to the old planning + # behavior. # # A messy (and very unlikely) case that we can't do much about: # @@ -982,30 +967,27 @@ class FlightPlanBuilder: target = self.package.target.position - 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) - - 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. + for join_point in self.preferred_join_points(): + join_distance = meters(join_point.distance_to_point(target)) + if join_distance > self.doctrine.ingress_distance: + break + else: + # The entire path to the target is threatened. Use the fallback behavior for + # now. self.legacy_package_waypoints_impl() return + attack_heading = join_point.heading_between_point(target) + ingress_point = self._ingress_point(attack_heading) + # The first case described above. The ingress and join points are placed # reasonably relative to each other. - egress_point = self._egress_point(attack_heading) self.package.waypoints = PackageWaypoints( WaypointBuilder.perturb(join_point), ingress_point, - egress_point, - WaypointBuilder.perturb(join_point), + WaypointBuilder.perturb( + self.preferred_split_point(ingress_point, join_point) + ), ) def retreat_point(self, origin: Point) -> Point: @@ -1015,24 +997,35 @@ class FlightPlanBuilder: 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, + WaypointBuilder.perturb(join_point), ingress_point, - egress_point, - split_point, + WaypointBuilder.perturb(join_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): + def safe_points_between(self, a: Point, b: Point) -> Iterator[Point]: + for point in self.coalition.nav_mesh.shortest_path(a, b)[1:-1]: if not self.threat_zones.threatened(point): - return point - return None + yield point + + def preferred_join_points(self) -> Iterator[Point]: + # Use non-threatened points along the path to the target as the join point. We + # may need to try more than one in the event that the close non-threatened + # points are closer than the ingress point itself. + return self.safe_points_between( + self.package.target.position, self.package_airfield().position + ) + + def preferred_split_point(self, ingress_point: Point, join_point: Point) -> Point: + # Use non-threatened points along the path to the target as the join point. We + # may need to try more than one in the event that the close non-threatened + # points are closer than the ingress point itself. + for point in self.safe_points_between( + ingress_point, self.package_airfield().position + ): + return point + return join_point def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. @@ -1047,26 +1040,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 +1070,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 +1117,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,7 +1154,7 @@ class FlightPlanBuilder: if isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) - start, end = self.racetrack_for_objective(location, barcap=True) + start_pos, end_pos = self.racetrack_for_objective(location, barcap=True) patrol_alt = meters( random.randint( int(self.doctrine.min_patrol_altitude.meters), @@ -1179,8 +1162,8 @@ class FlightPlanBuilder: ) ) - 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, @@ -1211,10 +1194,12 @@ class FlightPlanBuilder: target = self.package.target.position heading = self.package.waypoints.join.heading_between_point(target) - start = target.point_from_heading(heading, -self.doctrine.sweep_distance.meters) + start_pos = 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 +1238,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 = [] @@ -1375,9 +1360,7 @@ class FlightPlanBuilder: self, origin: Point, front_line: FrontLine ) -> Tuple[Point, Point]: # Find targets waypoints - ingress, heading, distance = Conflict.frontline_vector( - front_line, self.game.theater - ) + ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater) center = ingress.point_from_heading(heading, distance / 2) orbit_center = center.point_from_heading( heading - 90, @@ -1416,7 +1399,7 @@ class FlightPlanBuilder: ) # 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 +1530,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 +1550,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,9 +1570,7 @@ class FlightPlanBuilder: if not isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) - ingress, heading, distance = Conflict.frontline_vector( - location, self.game.theater - ) + ingress, heading, distance = Conflict.frontline_vector(location, self.theater) center = ingress.point_from_heading(heading, distance / 2) egress = ingress.point_from_heading(heading, distance) @@ -1601,7 +1579,7 @@ class FlightPlanBuilder: 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, @@ -1657,7 +1635,7 @@ class FlightPlanBuilder: orbit_heading - 90, 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,11 +1702,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 + origin_to_join = origin.distance_to_point(join) + if meters(origin_to_join) < self.doctrine.push_distance: + # If the origin airfield is closer to the join point, than the minimum push + # distance. Plan the hold point such that it retreats from the origin # airfield. return join.point_from_heading( target.heading_between_point(origin), self.doctrine.push_distance.meters @@ -1778,7 +1755,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 +1767,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 +1796,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 @@ -1863,29 +1839,24 @@ class FlightPlanBuilder: """Returns the position of the rendezvous point. Args: - attack_transition: The ingress or egress point for this rendezvous. + attack_transition: The ingress or target point for this rendezvous. """ if self._rendezvous_should_retreat(attack_transition): return self._retreating_rendezvous_point(attack_transition) return self._advancing_rendezvous_point(attack_transition) - def _ingress_point(self, heading: int) -> Point: + def _ingress_point(self, heading: float) -> Point: return self.package.target.position.point_from_heading( - heading - 180 + 15, self.doctrine.ingress_egress_distance.meters + heading - 180, self.doctrine.ingress_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: + def _target_heading_to_package_airfield(self) -> float: return self._heading_to_package_airfield(self.package.target.position) - def _heading_to_package_airfield(self, point: Point) -> int: + def _heading_to_package_airfield(self, point: Point) -> float: return self.package_airfield().position.heading_between_point(point) - def _distance_to_package_airfield(self, point: Point) -> int: + def _distance_to_package_airfield(self, point: Point) -> float: return self.package_airfield().position.distance_to_point(point) def package_airfield(self) -> ControlPoint: diff --git a/gen/flights/loadouts.py b/gen/flights/loadouts.py index 0a51245a..826cc01a 100644 --- a/gen/flights/loadouts.py +++ b/gen/flights/loadouts.py @@ -19,7 +19,11 @@ 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 @@ -64,7 +68,7 @@ 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, ) @@ -128,9 +132,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 f3cf94f3..c7b7ca53 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, SingleHeliPad -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, @@ -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,7 +162,7 @@ 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( @@ -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,7 +239,7 @@ 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, @@ -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. @@ -376,13 +390,12 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator): self.add_runway_data(brc or 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( @@ -474,7 +487,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 +531,7 @@ class LhaGenerator(GenericCarrierGenerator): ) -class ShipObjectGenerator(GenericGroundObjectGenerator): +class ShipObjectGenerator(GenericGroundObjectGenerator[ShipGroundObject]): """Generator for non-carrier naval groups.""" def generate(self) -> None: @@ -529,14 +542,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, @@ -577,13 +587,7 @@ class HelipadGenerator: self.tacan_registry = tacan_registry 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) - + 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) @@ -624,19 +628,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 9a074940..35aac4e3 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -39,6 +39,7 @@ from game.db import unit_type_from_name from game.dcs.aircrafttype import AircraftType from game.theater import ConflictTheater, TheaterGroundObject, LatLon from game.theater.bullseye import Bullseye +from game.weather import Weather from game.utils import meters from .aircraft import FlightData from .airsupportgen import AwacsInfo, TankerInfo @@ -46,6 +47,7 @@ from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator from .flights.flight import FlightWaypoint, FlightWaypointType, FlightType from .radios import RadioFrequency from .runways import RunwayData +from .units import inches_hg_to_mm_hg, inches_hg_to_hpa if TYPE_CHECKING: from game import Game @@ -91,7 +93,10 @@ 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: Tuple[int, int, int] = (0, 0, 0), ) -> None: if font is None: font = self.content_font @@ -262,12 +267,14 @@ 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 @@ -301,6 +308,18 @@ class BriefingPage(KneeboardPage): writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}") + qnh_in_hg = "{:.2f}".format(self.weather.atmospheric.qnh_inches_mercury) + qnh_mm_hg = "{:.1f}".format( + inches_hg_to_mm_hg(self.weather.atmospheric.qnh_inches_mercury) + ) + qnh_hpa = "{:.1f}".format( + inches_hg_to_hpa(self.weather.atmospheric.qnh_inches_mercury) + ) + 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( [ [ @@ -631,6 +650,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, ), 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..ca7f9b94 100644 --- a/gen/missiles/scud_site.py +++ b/gen/missiles/scud_site.py @@ -2,15 +2,20 @@ 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 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( diff --git a/gen/missiles/v1_group.py b/gen/missiles/v1_group.py index 60c94db8..9d377754 100644 --- a/gen/missiles/v1_group.py +++ b/gen/missiles/v1_group.py @@ -2,15 +2,20 @@ 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 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( 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/sam/aaa_bofors.py b/gen/sam/aaa_bofors.py index 283c1d59..f6e21977 100644 --- a/gen/sam/aaa_bofors.py +++ b/gen/sam/aaa_bofors.py @@ -14,9 +14,8 @@ class BoforsGenerator(AirDefenseGroupGenerator): """ name = "Bofors AAA" - price = 75 - def generate(self): + def generate(self) -> None: index = 0 for i in range(4): diff --git a/gen/sam/aaa_flak.py b/gen/sam/aaa_flak.py index 141e5b7d..68dee391 100644 --- a/gen/sam/aaa_flak.py +++ b/gen/sam/aaa_flak.py @@ -23,9 +23,8 @@ class FlakGenerator(AirDefenseGroupGenerator): """ name = "Flak Site" - price = 135 - def generate(self): + def generate(self) -> None: index = 0 mixed = random.choice([True, False]) unit_type = random.choice(GFLAK) 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 bb51e92a..7f062bfe 100644 --- a/gen/sam/aaa_ks19.py +++ b/gen/sam/aaa_ks19.py @@ -13,9 +13,8 @@ class KS19Generator(AirDefenseGroupGenerator): """ name = "KS-19 AAA Site" - price = 98 - def generate(self): + def generate(self) -> None: self.add_unit( highdigitsams.AAA_SON_9_Fire_Can, "TR", diff --git a/gen/sam/aaa_ww2_ally_flak.py b/gen/sam/aaa_ww2_ally_flak.py index 415bdab3..5fc18ddc 100644 --- a/gen/sam/aaa_ww2_ally_flak.py +++ b/gen/sam/aaa_ww2_ally_flak.py @@ -14,9 +14,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): diff --git a/gen/sam/aaa_zsu57.py b/gen/sam/aaa_zsu57.py index 422693d5..909ce549 100644 --- a/gen/sam/aaa_zsu57.py +++ b/gen/sam/aaa_zsu57.py @@ -12,9 +12,8 @@ class ZSU57Generator(AirDefenseGroupGenerator): """ name = "ZSU-57-2 Group" - price = 60 - def generate(self): + 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 49113668..ef2ec419 100644 --- a/gen/sam/aaa_zu23_insurgent.py +++ b/gen/sam/aaa_zu23_insurgent.py @@ -14,9 +14,8 @@ class ZU23InsurgentGenerator(AirDefenseGroupGenerator): """ name = "Zu-23 Site" - price = 56 - def generate(self): + def generate(self) -> None: index = 0 for i in range(4): index = index + 1 diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py index 7d269ece..36755036 100644 --- a/gen/sam/airdefensegroupgenerator.py +++ b/gen/sam/airdefensegroupgenerator.py @@ -8,7 +8,7 @@ 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): @@ -38,13 +38,11 @@ class AirDefenseRange(Enum): 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: super().__init__(game, ground_object) diff --git a/gen/sam/cold_war_flak.py b/gen/sam/cold_war_flak.py index 6c0bdf40..788482ec 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 @@ -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 diff --git a/gen/sam/ewrs.py b/gen/sam/ewrs.py index df27e6ad..fdcdf061 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 diff --git a/gen/sam/freya_ewr.py b/gen/sam/freya_ewr.py index 917767fb..7c61a25c 100644 --- a/gen/sam/freya_ewr.py +++ b/gen/sam/freya_ewr.py @@ -12,9 +12,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( diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index 65eb0b50..2fb800f8 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -1,58 +1,93 @@ from __future__ import annotations +import logging import math 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.theatergroundobject import TheaterGroundObject, NavalGroundObject 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.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: + ) -> 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: int, + ) -> UnitT: + raise NotImplementedError + + +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, @@ -62,9 +97,19 @@ class GroupGenerator: unit.position = position unit.heading = heading 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, int]]: """ Given a position on the map, array a group of units in a circle a uniform distance from the unit :param num_units: @@ -90,39 +135,47 @@ class GroupGenerator: else: current_offset = self.heading 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) + ) + heading = current_offset + positions.append((x, y, int(heading))) 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: int, + ) -> 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.position = position unit.heading = heading - self.vg.add_unit(unit) + group.add_unit(unit) return unit diff --git a/gen/sam/sam_avenger.py b/gen/sam/sam_avenger.py index b3f63354..ac72b709 100644 --- a/gen/sam/sam_avenger.py +++ b/gen/sam/sam_avenger.py @@ -14,9 +14,8 @@ class AvengerGenerator(AirDefenseGroupGenerator): """ name = "Avenger Group" - price = 62 - def generate(self): + def generate(self) -> None: num_launchers = 2 self.add_unit( diff --git a/gen/sam/sam_chaparral.py b/gen/sam/sam_chaparral.py index ea239746..2a746f95 100644 --- a/gen/sam/sam_chaparral.py +++ b/gen/sam/sam_chaparral.py @@ -14,9 +14,8 @@ class ChaparralGenerator(AirDefenseGroupGenerator): """ name = "Chaparral Group" - price = 66 - def generate(self): + def generate(self) -> None: num_launchers = 2 self.add_unit( diff --git a/gen/sam/sam_gepard.py b/gen/sam/sam_gepard.py index 6128efab..05b04068 100644 --- a/gen/sam/sam_gepard.py +++ b/gen/sam/sam_gepard.py @@ -14,9 +14,8 @@ class GepardGenerator(AirDefenseGroupGenerator): """ name = "Gepard Group" - price = 50 - def generate(self): + def generate(self) -> None: num_launchers = 2 positions = self.get_circular_position( diff --git a/gen/sam/sam_hawk.py b/gen/sam/sam_hawk.py index efec60a2..f65faf09 100644 --- a/gen/sam/sam_hawk.py +++ b/gen/sam/sam_hawk.py @@ -16,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", diff --git a/gen/sam/sam_hq7.py b/gen/sam/sam_hq7.py index 0143fc63..89a81097 100644 --- a/gen/sam/sam_hq7.py +++ b/gen/sam/sam_hq7.py @@ -16,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", diff --git a/gen/sam/sam_linebacker.py b/gen/sam/sam_linebacker.py index 09c57117..397c38a7 100644 --- a/gen/sam/sam_linebacker.py +++ b/gen/sam/sam_linebacker.py @@ -14,9 +14,8 @@ class LinebackerGenerator(AirDefenseGroupGenerator): """ name = "Linebacker Group" - price = 75 - def generate(self): + def generate(self) -> None: num_launchers = 2 self.add_unit( diff --git a/gen/sam/sam_patriot.py b/gen/sam/sam_patriot.py index fcd82417..55c4be2b 100644 --- a/gen/sam/sam_patriot.py +++ b/gen/sam/sam_patriot.py @@ -14,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, diff --git a/gen/sam/sam_rapier.py b/gen/sam/sam_rapier.py index 538dd7c4..aac88d64 100644 --- a/gen/sam/sam_rapier.py +++ b/gen/sam/sam_rapier.py @@ -15,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", diff --git a/gen/sam/sam_roland.py b/gen/sam/sam_roland.py index 64ee154d..57c3ab0e 100644 --- a/gen/sam/sam_roland.py +++ b/gen/sam/sam_roland.py @@ -13,9 +13,8 @@ class RolandGenerator(AirDefenseGroupGenerator): """ name = "Roland Site" - price = 40 - def generate(self): + def generate(self) -> None: num_launchers = 2 self.add_unit( AirDefence.Roland_Radar, diff --git a/gen/sam/sam_sa10.py b/gen/sam/sam_sa10.py index 8d4d4e2c..6b277bfa 100644 --- a/gen/sam/sam_sa10.py +++ b/gen/sam/sam_sa10.py @@ -1,4 +1,7 @@ +from typing import Type + from dcs.mapping import Point +from dcs.unittype import VehicleType from dcs.vehicles import AirDefence from game import Game @@ -17,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 @@ -89,7 +91,6 @@ 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. @@ -114,7 +115,6 @@ 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. @@ -150,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): @@ -166,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): @@ -182,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): @@ -198,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): @@ -214,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 3611aff5..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", diff --git a/gen/sam/sam_sa13.py b/gen/sam/sam_sa13.py index 24cd75a0..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", diff --git a/gen/sam/sam_sa15.py b/gen/sam/sam_sa15.py index d5d74b9c..c0a6d852 100644 --- a/gen/sam/sam_sa15.py +++ b/gen/sam/sam_sa15.py @@ -12,9 +12,8 @@ class SA15Generator(AirDefenseGroupGenerator): """ name = "SA-15 Tor Group" - price = 55 - def generate(self): + def generate(self) -> None: num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=360 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 0ff23158..8611a310 100644 --- a/gen/sam/sam_sa19.py +++ b/gen/sam/sam_sa19.py @@ -14,9 +14,8 @@ class SA19Generator(AirDefenseGroupGenerator): """ name = "SA-19 Tunguska Group" - price = 90 - def generate(self): + def generate(self) -> None: num_launchers = 2 if num_launchers == 1: diff --git a/gen/sam/sam_sa2.py b/gen/sam/sam_sa2.py index 3cfae81e..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", diff --git a/gen/sam/sam_sa3.py b/gen/sam/sam_sa3.py index 400bda82..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", diff --git a/gen/sam/sam_sa6.py b/gen/sam/sam_sa6.py index 6706f250..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", diff --git a/gen/sam/sam_sa8.py b/gen/sam/sam_sa8.py index 7f3f72b3..35afab86 100644 --- a/gen/sam/sam_sa8.py +++ b/gen/sam/sam_sa8.py @@ -12,9 +12,8 @@ class SA8Generator(AirDefenseGroupGenerator): """ name = "SA-8 OSA Site" - price = 55 - def generate(self): + def generate(self) -> None: num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=180 diff --git a/gen/sam/sam_sa9.py b/gen/sam/sam_sa9.py index ed7883b5..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", diff --git a/gen/sam/sam_vulcan.py b/gen/sam/sam_vulcan.py index 0c869afc..9a458db0 100644 --- a/gen/sam/sam_vulcan.py +++ b/gen/sam/sam_vulcan.py @@ -14,9 +14,8 @@ class VulcanGenerator(AirDefenseGroupGenerator): """ name = "Vulcan Group" - price = 25 - def generate(self): + def generate(self) -> None: num_launchers = 2 positions = self.get_circular_position( diff --git a/gen/sam/sam_zsu23.py b/gen/sam/sam_zsu23.py index 0e638b62..5e64d5df 100644 --- a/gen/sam/sam_zsu23.py +++ b/gen/sam/sam_zsu23.py @@ -14,9 +14,8 @@ class ZSU23Generator(AirDefenseGroupGenerator): """ name = "ZSU-23 Group" - price = 50 - def generate(self): + def generate(self) -> None: num_launchers = 4 positions = self.get_circular_position( diff --git a/gen/sam/sam_zu23.py b/gen/sam/sam_zu23.py index 58c55ad5..2a2e2f4b 100644 --- a/gen/sam/sam_zu23.py +++ b/gen/sam/sam_zu23.py @@ -14,9 +14,8 @@ class ZU23Generator(AirDefenseGroupGenerator): """ name = "ZU-23 Group" - price = 54 - def generate(self): + def generate(self) -> None: index = 0 for i in range(4): index = index + 1 diff --git a/gen/sam/sam_zu23_ural.py b/gen/sam/sam_zu23_ural.py index 47b7ac6a..85ca1d20 100644 --- a/gen/sam/sam_zu23_ural.py +++ b/gen/sam/sam_zu23_ural.py @@ -14,9 +14,8 @@ class ZU23UralGenerator(AirDefenseGroupGenerator): """ name = "ZU-23 Ural Group" - price = 64 - def generate(self): + def generate(self) -> None: num_launchers = 4 positions = self.get_circular_position( diff --git a/gen/sam/sam_zu23_ural_insurgent.py b/gen/sam/sam_zu23_ural_insurgent.py index 0e277599..7d70300a 100644 --- a/gen/sam/sam_zu23_ural_insurgent.py +++ b/gen/sam/sam_zu23_ural_insurgent.py @@ -14,9 +14,12 @@ class ZU23UralInsurgentGenerator(AirDefenseGroupGenerator): """ name = "ZU-23 Ural Insurgent Group" - price = 64 - def generate(self): + @classmethod + def range(cls) -> AirDefenseRange: + return AirDefenseRange.AAA + + def generate(self) -> None: num_launchers = 4 positions = self.get_circular_position( @@ -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 index cfd16ab8..9aec8348 100644 --- a/gen/units.py +++ b/gen/units.py @@ -2,5 +2,15 @@ def meters_to_feet(meters: float) -> float: - """Convers meters to feet.""" + """Converts meters to feet.""" return meters * 3.28084 + + +def inches_hg_to_mm_hg(inches_hg: float) -> float: + """Converts inches mercury to millimeters mercury.""" + return inches_hg * 25.400002776728 + + +def inches_hg_to_hpa(inches_hg: float) -> float: + """Converts inches mercury to hectopascal.""" + return inches_hg * 33.86389 diff --git a/gen/visualgen.py b/gen/visualgen.py index 0fa9c335..83be4859 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 @@ -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/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/main.py b/qt_ui/main.py index d1aacdff..26c5cb48 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -11,14 +11,9 @@ 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 from game.db import FACTIONS from game.profiling import logged_duration from game.settings import Settings @@ -100,6 +95,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() @@ -239,12 +250,8 @@ def create_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") + for weapon in WeaponGroup.named("Unknown").weapons: + logging.warning(f"No weapon data for {weapon}: {weapon.clsid}") def main(): 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 e36ffb7e..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") 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 5f295a6a..d94f2911 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -168,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 @@ -236,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)) @@ -282,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..02eadd9f 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -12,7 +12,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPo from game import Game from game.dcs.groundunittype import GroundUnitType -from game.navmesh import NavMesh +from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( ConflictTheater, @@ -336,8 +336,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]: @@ -642,11 +646,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 +691,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), ) @@ -870,8 +898,8 @@ class MapModel(QObject): 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() @Property(list, notify=flightsChanged) 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/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..b3ab3d8f 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, diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index c5edcdbc..e435fd75 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/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/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/requirements.txt b/requirements.txt index 75bf846a..54073289 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@2baba37e32bc55fed59ef977c43dad275c9821eb#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/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/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/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/tools/export_coordinates.py b/resources/tools/export_coordinates.py index d3605238..34006640 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, } 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/map.js b/resources/ui/map/map.js index 141fe2ef..6cae9ae2 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -904,10 +904,13 @@ function drawThreatZones() { function drawNavmesh(zones, layer) { for (const zone of zones) { - L.polyline(zone, { + L.polyline(zone.poly, { color: "#000000", weight: 1, - fill: false, + fillColor: zone.threatened ? "#ff0000" : "#00ff00", + fill: true, + fillOpacity: 0.1, + noClip: true, interactive: false, }).addTo(layer); } diff --git a/resources/ui/terrain_marianas.gif b/resources/ui/terrain_marianas.gif new file mode 100644 index 00000000..2546cb88 Binary files /dev/null and b/resources/ui/terrain_marianas.gif differ diff --git a/resources/weapons/a2a-missiles/AIM-120B-2X.yaml b/resources/weapons/a2a-missiles/AIM-120B-2X.yaml new file mode 100644 index 00000000..562fcb0f --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-120B-2X.yaml @@ -0,0 +1,5 @@ +name: 2xAIM-120B +year: 1994 +fallback: AIM-7MH +clsids: + - "LAU-115_2*LAU-127_AIM-120B" diff --git a/resources/weapons/a2a-missiles/AIM-120B.yaml b/resources/weapons/a2a-missiles/AIM-120B.yaml new file mode 100644 index 00000000..01301534 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-120B.yaml @@ -0,0 +1,7 @@ +name: AIM-120B +year: 1994 +fallback: AIM-7MH +clsids: + - "{C8E06185-7CD6-4C90-959F-044679E90751}" + - "{LAU-115 - AIM-120B}" + - "{LAU-115 - AIM-120B_R}" diff --git a/resources/weapons/a2a-missiles/AIM-120C-2X.yaml b/resources/weapons/a2a-missiles/AIM-120C-2X.yaml new file mode 100644 index 00000000..95d4a089 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-120C-2X.yaml @@ -0,0 +1,5 @@ +name: 2xAIM-120C +year: 1996 +fallback: 2xAIM-120B +clsids: + - "LAU-115_2*LAU-127_AIM-120C" diff --git a/resources/weapons/a2a-missiles/AIM-120C.yaml b/resources/weapons/a2a-missiles/AIM-120C.yaml new file mode 100644 index 00000000..2098e2b8 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-120C.yaml @@ -0,0 +1,7 @@ +name: AIM-120C +year: 1996 +fallback: AIM-120B +clsids: + - "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}" + - "{LAU-115 - AIM-120C}" + - "{LAU-115 - AIM-120C_R}" diff --git a/resources/weapons/a2a-missiles/AIM-7E.yaml b/resources/weapons/a2a-missiles/AIM-7E.yaml new file mode 100644 index 00000000..16e60733 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-7E.yaml @@ -0,0 +1,5 @@ +name: AIM-7E +year: 1963 +clsids: + - "{AIM-7E}" + - "{LAU-115 - AIM-7E}" diff --git a/resources/weapons/a2a-missiles/AIM-7F.yaml b/resources/weapons/a2a-missiles/AIM-7F.yaml new file mode 100644 index 00000000..d66fd3b8 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-7F.yaml @@ -0,0 +1,8 @@ +name: AIM-7F +year: 1976 +fallback: AIM-7E +clsids: + - "{SHOULDER AIM-7F}" + - "{BELLY AIM-7F}" + - "{AIM-7F}" + - "{LAU-115 - AIM-7F}" diff --git a/resources/weapons/a2a-missiles/AIM-7M.yaml b/resources/weapons/a2a-missiles/AIM-7M.yaml new file mode 100644 index 00000000..9128c8ed --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-7M.yaml @@ -0,0 +1,8 @@ +name: AIM-7M +year: 1982 +fallback: AIM-7F +clsids: + - "{SHOULDER AIM-7M}" + - "{BELLY AIM-7M}" + - "{8D399DDA-FF81-4F14-904D-099B34FE7918}" + - "{LAU-115 - AIM-7M}" diff --git a/resources/weapons/a2a-missiles/AIM-7MH.yaml b/resources/weapons/a2a-missiles/AIM-7MH.yaml new file mode 100644 index 00000000..e2d15b34 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-7MH.yaml @@ -0,0 +1,8 @@ +name: AIM-7MH +year: 1987 +fallback: AIM-7M +clsids: + - "{SHOULDER AIM-7MH}" + - "{BELLY AIM-7MH}" + - "{AIM-7H}" + - "{LAU-115 - AIM-7H}" diff --git a/resources/weapons/bombs/Mk-84.yaml b/resources/weapons/bombs/Mk-84.yaml new file mode 100644 index 00000000..aa246026 --- /dev/null +++ b/resources/weapons/bombs/Mk-84.yaml @@ -0,0 +1,6 @@ +name: Mk 84 +clsids: + - "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}" + - "{BRU-32 MK-84}" + - "{Mk_84P}" + - "{Mk_84T}" diff --git a/resources/weapons/pods/atflir.yaml b/resources/weapons/pods/atflir.yaml new file mode 100644 index 00000000..64ef6833 --- /dev/null +++ b/resources/weapons/pods/atflir.yaml @@ -0,0 +1,4 @@ +name: AN/ASQ-228 ATFLIR +year: 2003 +clsids: + - "{AN_ASQ_228}" diff --git a/resources/weapons/pods/litening.yaml b/resources/weapons/pods/litening.yaml new file mode 100644 index 00000000..0ea9db08 --- /dev/null +++ b/resources/weapons/pods/litening.yaml @@ -0,0 +1,5 @@ +name: AN/AAQ-28 LITENING +year: 1999 +clsids: + - "{A111396E-D3E8-4b9c-8AC9-2432489304D5}" + - "{AAQ-28_LEFT}" diff --git a/resources/weapons/standoff/AGM-154A-2X.yaml b/resources/weapons/standoff/AGM-154A-2X.yaml new file mode 100644 index 00000000..efa5e150 --- /dev/null +++ b/resources/weapons/standoff/AGM-154A-2X.yaml @@ -0,0 +1,6 @@ +name: 2xAGM-154A JSOW +year: 1998 +fallback: AGM-62 Walleye II +clsids: + - "{BRU55_2*AGM-154A}" + - "{BRU57_2*AGM-154A}" diff --git a/resources/weapons/standoff/AGM-154A.yaml b/resources/weapons/standoff/AGM-154A.yaml new file mode 100644 index 00000000..40bcc727 --- /dev/null +++ b/resources/weapons/standoff/AGM-154A.yaml @@ -0,0 +1,5 @@ +name: AGM-154A JSOW +year: 1998 +fallback: AGM-62 Walleye II +clsids: + - "{AGM-154A}" diff --git a/resources/weapons/standoff/AGM-154C-2X.yaml b/resources/weapons/standoff/AGM-154C-2X.yaml new file mode 100644 index 00000000..0959a9d3 --- /dev/null +++ b/resources/weapons/standoff/AGM-154C-2X.yaml @@ -0,0 +1,5 @@ +name: 2xAGM-154C JSOW +year: 2005 +fallback: AGM-62 Walleye II +clsids: + - "{BRU55_2*AGM-154C}" diff --git a/resources/weapons/standoff/AGM-154C.yaml b/resources/weapons/standoff/AGM-154C.yaml new file mode 100644 index 00000000..4e7f4770 --- /dev/null +++ b/resources/weapons/standoff/AGM-154C.yaml @@ -0,0 +1,5 @@ +name: AGM-154C JSOW +year: 2005 +fallback: AGM-62 Walleye II +clsids: + - "{9BCC2A2B-5708-4860-B1F1-053A18442067}" diff --git a/resources/weapons/standoff/AGM-62.yaml b/resources/weapons/standoff/AGM-62.yaml new file mode 100644 index 00000000..d1f72a12 --- /dev/null +++ b/resources/weapons/standoff/AGM-62.yaml @@ -0,0 +1,5 @@ +name: AGM-62 Walleye II +year: 1972 +fallback: Mk 84 +clsids: + - "{C40A1E3A-DD05-40D9-85A4-217729E37FAE}"