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}"