diff --git a/.gitignore b/.gitignore
index b9205033..7d556efc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,7 +18,7 @@ env/
/liberation_preferences.json
/state.json
-logs/
+/logs/
qt_ui/logs/liberation.log
diff --git a/changelog.md b/changelog.md
index d8f11ab9..f1486a61 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,19 +1,65 @@
# 5.0.0
-Saves from 3.x are not compatible with 5.0.
+Saves from 4.x are not compatible with 5.0.
## Features/Improvements
+* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated.
+* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts.
+* **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions.
+* **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI.
+* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points.
+* **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken.
+* **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron.
+* **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet).
+* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons.
+
## Fixes
-# 4.0.1
+* **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning.
-Saves from 4.0.0 are compatible with 4.0.1.
+# 4.1.0
+
+Saves from 4.0.0 are compatible with 4.1.0.
## Features/Improvements
+* **[Campaign]** Air defense sites now generate a fixed number of launchers per type.
+* **[Campaign]** Added support for Mariana Islands map.
+* **[Campaign AI]** Adjustments to aircraft selection priorities for most mission types.
+* **[Engine]** Support for DCS 2.7.4.9632 and newer, including the Marianas map, F-16 JSOWs, NASAMS, and Tin Shield EWR.
+* **[Flight Planning]** CAP patrol altitudes are now set per-aircraft. By default the altitude will be set based on the aircraft's maximum speed.
+* **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR
+* **[Mission Generation]** SAM sites are now headed towards the center of the conflict
+* **[Mods]** Support for latest version of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts.
+* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI.
+* **[Plugins]** Updated SkynetIADS to 2.2.0 (adds NASAMS support).
+* **[UI]** Added ability to take notes and have those notes appear as a kneeboard page.
+* **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet).
+* **[UI]** Google search link added to unit information when there is no information provided.
+* **[UI]** Control point name displayed with ground object group name on map.
+* **[UI]** Buy or Replace will now show the correct price for generated ground objects like sams.
+* **[UI]** Improved logging for frontline movement to be more descriptive about what happened and why.
+* **[UI]** Brought ruler map module into source, which should fix file integrity issues with the module.
+
## Fixes
+* **[Campaign]** Fixed the Silkworm generator to include launchers and not all radars.
+* **[Data]** Fixed Introduction dates for targeting pods (ATFLIR and LITENING were both a few years too early).
+* **[Data]** Removed SA-10 from Syria 2011 faction.
+* **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money
+* **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles.
+* **[Mission Generation]** The lua data for other plugins is now generated correctly
+* **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs
+* **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation.
+* **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured.
+* **[Mission Generation]** Planned transfers which will be impossible after a base capture will no longer prevent the mission result submit.
+* **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed.
+* **[Mission Generation]** Fixed a potential bug with laser code generation where it would generate invalid codes.
+* **[UI]** Statistics window tick marks are now always integers.
+* **[UI]** Statistics window now shows the correct info for the turn
+* **[UI]** Toggling custom loadout for an aircraft with no preset loadouts no longer breaks the flight.
+
# 4.0.0
Saves from 3.x are not compatible with 4.0.
diff --git a/doc/fuel-consumption-measurement.md b/doc/fuel-consumption-measurement.md
new file mode 100644
index 00000000..62dd0a1b
--- /dev/null
+++ b/doc/fuel-consumption-measurement.md
@@ -0,0 +1,80 @@
+# Measuring estimated fuel consumption
+
+To estimate fuel consumption numbers for an aircraft, create a mission with a
+typical heavy load for the aircraft. For example, to measure for the F/A-18C, a
+loadout with two bags, two GBU-31s, two sidewinders, an AMRAAM, and an ATFLIR.
+Do **not** drop bags or weapons during the test flight.
+
+Start the aircraft on the ground at a large airport (for example, Akrotiri) at a
+parking space at the opposite end of the takeoff runway so you can estimate long
+taxi fuel consumption.
+
+When you enter the jet, note the amount of fuel below, then taxi to the far end
+of the runway. Hold short and note the remaining fuel below.
+
+Follow a typical takeoff pattern for the aircraft. For the F/A-18C, this might
+be AB takeoff, reduce to MIL at 350KIAS, and maintian 350KIAS/0.85 mach until
+cruise altitude (angles 25).
+
+Once you reach angels 25, pause the game. Note your remaining fuel below and
+measure the distance traveled from takeoff. Mark your location on the map.
+
+Level out and increase to cruise speed if needed. Liberation assumes 0.85 mach
+for supersonic aircraft, for subsonic aircraft it depends so pick something
+reasonable and note your descision in a comment in the file when done. Maintain
+speed, heading, and altitude for a long distance (the longer the distance, the
+more accurate the result, but be careful to leave enough fuel for the final
+section). Once complete, note the distance traveled and the remaining fuel.
+
+Finally, increase speed as you would for an attack. At least MIL power,
+potentially use AB sparingly, etc. The goal is to measure fuel consumption per
+mile traveled during an attack run.
+
+```
+start:
+taxi end:
+to 25k distance:
+at 25k fuel:
+cruise (.85 mach) distance:
+cruise (.85 mach) end fuel:
+combat distance:
+combat end fuel:
+```
+
+Finally, fill out the data in the aircraft data. Below is an example for the
+F/A-18C:
+
+```
+start: 15290
+taxi end: 15120
+climb distance: 40NM
+at 25k fuel: 13350
+cruise (.85 mach) distance: 100NM
+cruise (.85 mach) end fuel: 11140
+combat distance: 100NM
+combat end fuel: 8390
+
+taxi = start - taxi end = 15290 - 15120 = 170
+climb fuel = taxi end - at 25k fuel = 15120 - 13350 = 1770
+climb ppm = climb fuel / climb distance = 1770 / 40 = 44.25
+cruise fuel = at 25k fuel - cruise end fuel = 13350 - 11140 = 2210
+cruise ppm = cruise fuel / cruise distance = 2210 / 100 = 22.1
+combat fuel = cruise end fuel - combat end fuel = 11140 - 8390 = 2750
+combat ppm = combat fuel / combat distance = 2750 / 100 = 27.5
+```
+
+```yaml
+fuel:
+ # Parking A1 to RWY 32 at Akrotiri.
+ taxi: 170
+ # AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft.
+ climb_ppm: 44.25
+ # 0.85 mach for 100NM.
+ cruise_ppm: 22.1
+ # ~0.9 mach for 100NM. Occasional AB use.
+ combat_ppm: 27.5
+ min_safe: 2000
+```
+
+The last entry (`min_safe`) is the minimum amount of fuel that the aircraft
+should land with.
diff --git a/game/coalition.py b/game/coalition.py
new file mode 100644
index 00000000..b6e681f9
--- /dev/null
+++ b/game/coalition.py
@@ -0,0 +1,239 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Optional
+
+from dcs import Point
+from faker import Faker
+
+from game.commander import TheaterCommander
+from game.commander.missionscheduler import MissionScheduler
+from game.income import Income
+from game.inventory import GlobalAircraftInventory
+from game.navmesh import NavMesh
+from game.orderedset import OrderedSet
+from game.profiling import logged_duration, MultiEventTracer
+from game.savecompat import has_save_compat_for
+from game.threatzones import ThreatZones
+from game.transfers import PendingTransfers
+
+if TYPE_CHECKING:
+ from game import Game
+from game.data.doctrine import Doctrine
+from game.factions.faction import Faction
+from game.procurement import AircraftProcurementRequest, ProcurementAi
+from game.squadrons import AirWing
+from game.theater.bullseye import Bullseye
+from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
+from gen import AirTaskingOrder
+
+
+class Coalition:
+ def __init__(
+ self, game: Game, faction: Faction, budget: float, player: bool
+ ) -> None:
+ self.game = game
+ self.player = player
+ self.faction = faction
+ self.budget = budget
+ self.ato = AirTaskingOrder()
+ self.transit_network = TransitNetwork()
+ self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet()
+ self.bullseye = Bullseye(Point(0, 0))
+ self.faker = Faker(self.faction.locales)
+ self.air_wing = AirWing(game, self)
+ self.transfers = PendingTransfers(game, player)
+
+ # Late initialized because the two coalitions in the game are mutually
+ # dependent, so must be both constructed before this property can be set.
+ self._opponent: Optional[Coalition] = None
+
+ # Volatile properties that are not persisted to the save file since they can be
+ # recomputed on load. Keeping this data out of the save file makes save compat
+ # breaks less frequent. Each of these properties has a non-underscore-prefixed
+ # @property that should be used for non-Optional access.
+ #
+ # All of these are late-initialized (whether via on_load or called later), but
+ # will be non-None after the game has finished loading.
+ self._threat_zone: Optional[ThreatZones] = None
+ self._navmesh: Optional[NavMesh] = None
+ self.on_load()
+
+ @property
+ def doctrine(self) -> Doctrine:
+ return self.faction.doctrine
+
+ @property
+ def coalition_id(self) -> int:
+ if self.player:
+ return 2
+ return 1
+
+ @property
+ def country_name(self) -> str:
+ return self.faction.country
+
+ @property
+ def opponent(self) -> Coalition:
+ assert self._opponent is not None
+ return self._opponent
+
+ @property
+ def threat_zone(self) -> ThreatZones:
+ assert self._threat_zone is not None
+ return self._threat_zone
+
+ @property
+ def nav_mesh(self) -> NavMesh:
+ assert self._navmesh is not None
+ return self._navmesh
+
+ @property
+ def aircraft_inventory(self) -> GlobalAircraftInventory:
+ return self.game.aircraft_inventory
+
+ def __getstate__(self) -> dict[str, Any]:
+ state = self.__dict__.copy()
+ # Avoid persisting any volatile types that can be deterministically
+ # recomputed on load for the sake of save compatibility.
+ del state["_threat_zone"]
+ del state["_navmesh"]
+ del state["faker"]
+ return state
+
+ @has_save_compat_for(5)
+ def __setstate__(self, state: dict[str, Any]) -> None:
+ # Begin save compat
+ old_procurement_requests = state["procurement_requests"]
+ if isinstance(old_procurement_requests, list):
+ state["procurement_requests"] = OrderedSet(old_procurement_requests)
+ # End save compat
+
+ self.__dict__.update(state)
+ # Regenerate any state that was not persisted.
+ self.on_load()
+
+ def on_load(self) -> None:
+ self.faker = Faker(self.faction.locales)
+
+ def set_opponent(self, opponent: Coalition) -> None:
+ if self._opponent is not None:
+ raise RuntimeError("Double-initialization of Coalition.opponent")
+ self._opponent = opponent
+
+ def adjust_budget(self, amount: float) -> None:
+ self.budget += amount
+
+ def compute_threat_zones(self) -> None:
+ self._threat_zone = ThreatZones.for_faction(self.game, self.player)
+
+ def compute_nav_meshes(self) -> None:
+ self._navmesh = NavMesh.from_threat_zones(
+ self.opponent.threat_zone, self.game.theater
+ )
+
+ def update_transit_network(self) -> None:
+ self.transit_network = TransitNetworkBuilder(
+ self.game.theater, self.player
+ ).build()
+
+ def set_bullseye(self, bullseye: Bullseye) -> None:
+ self.bullseye = bullseye
+
+ def end_turn(self) -> None:
+ """Processes coalition-specific turn finalization.
+
+ For more information on turn finalization in general, see the documentation for
+ `Game.finish_turn`.
+ """
+ self.air_wing.replenish()
+ self.budget += Income(self.game, self.player).total
+
+ # Need to recompute before transfers and deliveries to account for captures.
+ # This happens in in initialize_turn as well, because cheating doesn't advance a
+ # turn but can capture bases so we need to recompute there as well.
+ self.update_transit_network()
+
+ # Must happen *before* unit deliveries are handled, or else new units will spawn
+ # one hop ahead. ControlPoint.process_turn handles unit deliveries. The
+ # coalition-specific turn-end happens before the theater-wide turn-end, so this
+ # is handled correctly.
+ self.transfers.perform_transfers()
+
+ def preinit_turn_0(self) -> None:
+ """Runs final Coalition initialization.
+
+ Final initialization occurs before Game.initialize_turn runs for turn 0.
+ """
+ self.air_wing.populate_for_turn_0()
+
+ def initialize_turn(self) -> None:
+ """Processes coalition-specific turn initialization.
+
+ For more information on turn initialization in general, see the documentation
+ for `Game.initialize_turn`.
+ """
+ # Needs to happen *before* planning transfers so we don't cancel them.
+ self.ato.clear()
+ self.air_wing.reset()
+ self.refund_outstanding_orders()
+ self.procurement_requests.clear()
+
+ with logged_duration("Transit network identification"):
+ self.update_transit_network()
+ with logged_duration("Procurement of airlift assets"):
+ self.transfers.order_airlift_assets()
+ with logged_duration("Transport planning"):
+ self.transfers.plan_transports()
+
+ self.plan_missions()
+ self.plan_procurement()
+
+ def refund_outstanding_orders(self) -> None:
+ # TODO: Split orders between air and ground units.
+ # This isn't quite right. If the player has ground purchases automated we should
+ # be refunding the ground units, and if they have air automated but not ground
+ # we should be refunding air units.
+ if self.player and not self.game.settings.automate_aircraft_reinforcements:
+ return
+
+ for cp in self.game.theater.control_points_for(self.player):
+ cp.pending_unit_deliveries.refund_all(self)
+
+ def plan_missions(self) -> None:
+ color = "Blue" if self.player else "Red"
+ with MultiEventTracer() as tracer:
+ with tracer.trace(f"{color} mission planning"):
+ with tracer.trace(f"{color} mission identification"):
+ TheaterCommander(self.game, self.player).plan_missions(tracer)
+ with tracer.trace(f"{color} mission scheduling"):
+ MissionScheduler(
+ self, self.game.settings.desired_player_mission_duration
+ ).schedule_missions()
+
+ def plan_procurement(self) -> None:
+ # The first turn needs to buy a *lot* of aircraft to fill CAPs, so it gets much
+ # more of the budget that turn. Otherwise budget (after repairs) is split evenly
+ # between air and ground. For the default starting budget of 2000 this gives 600
+ # to ground forces and 1400 to aircraft. After that the budget will be spent
+ # proportionally based on how much is already invested.
+
+ if self.player:
+ manage_runways = self.game.settings.automate_runway_repair
+ manage_front_line = self.game.settings.automate_front_line_reinforcements
+ manage_aircraft = self.game.settings.automate_aircraft_reinforcements
+ else:
+ manage_runways = True
+ manage_front_line = True
+ manage_aircraft = True
+
+ self.budget = ProcurementAi(
+ self.game,
+ self.player,
+ self.faction,
+ manage_runways,
+ manage_front_line,
+ manage_aircraft,
+ ).spend_budget(self.budget)
+
+ def add_procurement_request(self, request: AircraftProcurementRequest) -> None:
+ self.procurement_requests.add(request)
diff --git a/game/commander/__init__.py b/game/commander/__init__.py
new file mode 100644
index 00000000..ac46c5ef
--- /dev/null
+++ b/game/commander/__init__.py
@@ -0,0 +1 @@
+from .theatercommander import TheaterCommander
diff --git a/game/commander/aircraftallocator.py b/game/commander/aircraftallocator.py
new file mode 100644
index 00000000..a50dbd22
--- /dev/null
+++ b/game/commander/aircraftallocator.py
@@ -0,0 +1,78 @@
+from typing import Optional, Tuple
+
+from game.commander.missionproposals import ProposedFlight
+from game.inventory import GlobalAircraftInventory
+from game.squadrons import AirWing, Squadron
+from game.theater import ControlPoint, MissionTarget
+from game.utils import meters
+from gen.flights.ai_flight_planner_db import aircraft_for_task
+from gen.flights.closestairfields import ClosestAirfields
+from gen.flights.flight import FlightType
+
+
+class AircraftAllocator:
+ """Finds suitable aircraft for proposed missions."""
+
+ def __init__(
+ self,
+ air_wing: AirWing,
+ closest_airfields: ClosestAirfields,
+ global_inventory: GlobalAircraftInventory,
+ is_player: bool,
+ ) -> None:
+ self.air_wing = air_wing
+ self.closest_airfields = closest_airfields
+ self.global_inventory = global_inventory
+ self.is_player = is_player
+
+ def find_squadron_for_flight(
+ self, target: MissionTarget, flight: ProposedFlight
+ ) -> Optional[Tuple[ControlPoint, Squadron]]:
+ """Finds aircraft suitable for the given mission.
+
+ Searches for aircraft capable of performing the given mission within the
+ maximum allowed range. If insufficient aircraft are available for the
+ mission, None is returned.
+
+ Airfields are searched ordered nearest to farthest from the target and
+ searched twice. The first search looks for aircraft which prefer the
+ mission type, and the second search looks for any aircraft which are
+ capable of the mission type. For example, an F-14 from a nearby carrier
+ will be preferred for the CAP of an airfield that has only F-16s, but if
+ the carrier has only F/A-18s the F-16s will be used for CAP instead.
+
+ Note that aircraft *will* be removed from the global inventory on
+ success. This is to ensure that the same aircraft are not matched twice
+ on subsequent calls. If the found aircraft are not used, the caller is
+ responsible for returning them to the inventory.
+ """
+ return self.find_aircraft_for_task(target, flight, flight.task)
+
+ def find_aircraft_for_task(
+ self, target: MissionTarget, flight: ProposedFlight, task: FlightType
+ ) -> Optional[Tuple[ControlPoint, Squadron]]:
+ types = aircraft_for_task(task)
+ for airfield in self.closest_airfields.operational_airfields:
+ if not airfield.is_friendly(self.is_player):
+ continue
+ inventory = self.global_inventory.for_control_point(airfield)
+ for aircraft in types:
+ if not airfield.can_operate(aircraft):
+ continue
+ if inventory.available(aircraft) < flight.num_aircraft:
+ continue
+ distance_to_target = meters(target.distance_to(airfield))
+ if distance_to_target > aircraft.max_mission_range:
+ continue
+ # Valid location with enough aircraft available. Find a squadron to fit
+ # the role.
+ squadrons = self.air_wing.auto_assignable_for_task_with_type(
+ aircraft, task
+ )
+ for squadron in squadrons:
+ if squadron.operates_from(airfield) and squadron.can_provide_pilots(
+ flight.num_aircraft
+ ):
+ inventory.remove_aircraft(aircraft, flight.num_aircraft)
+ return airfield, squadron
+ return None
diff --git a/game/commander/garrisons.py b/game/commander/garrisons.py
new file mode 100644
index 00000000..8ed43843
--- /dev/null
+++ b/game/commander/garrisons.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from collections import Iterator
+from dataclasses import dataclass
+
+from game.theater import ControlPoint
+from game.theater.theatergroundobject import VehicleGroupGroundObject
+from game.utils import meters
+
+
+@dataclass
+class Garrisons:
+ blocking_capture: list[VehicleGroupGroundObject]
+ defending_front_line: list[VehicleGroupGroundObject]
+
+ @property
+ def in_priority_order(self) -> Iterator[VehicleGroupGroundObject]:
+ yield from self.blocking_capture
+ yield from self.defending_front_line
+
+ def eliminate(self, garrison: VehicleGroupGroundObject) -> None:
+ if garrison in self.blocking_capture:
+ self.blocking_capture.remove(garrison)
+ if garrison in self.defending_front_line:
+ self.defending_front_line.remove(garrison)
+
+ def __contains__(self, item: VehicleGroupGroundObject) -> bool:
+ return item in self.in_priority_order
+
+ @classmethod
+ def for_control_point(cls, control_point: ControlPoint) -> Garrisons:
+ """Categorize garrison groups based on target priority.
+
+ Any garrisons blocking base capture are the highest priority.
+ """
+ blocking = []
+ defending = []
+ garrisons = [
+ tgo
+ for tgo in control_point.ground_objects
+ if isinstance(tgo, VehicleGroupGroundObject) and not tgo.is_dead
+ ]
+ for garrison in garrisons:
+ if (
+ meters(garrison.distance_to(control_point))
+ < ControlPoint.CAPTURE_DISTANCE
+ ):
+ blocking.append(garrison)
+ else:
+ defending.append(garrison)
+
+ return Garrisons(blocking, defending)
diff --git a/game/commander/missionproposals.py b/game/commander/missionproposals.py
new file mode 100644
index 00000000..a13802b8
--- /dev/null
+++ b/game/commander/missionproposals.py
@@ -0,0 +1,58 @@
+from dataclasses import field, dataclass
+from enum import Enum, auto
+from typing import Optional
+
+from game.theater import MissionTarget
+from gen.flights.flight import FlightType
+
+
+class EscortType(Enum):
+ AirToAir = auto()
+ Sead = auto()
+
+
+@dataclass(frozen=True)
+class ProposedFlight:
+ """A flight outline proposed by the mission planner.
+
+ Proposed flights haven't been assigned specific aircraft yet. They have only
+ a task, a required number of aircraft, and a maximum distance allowed
+ between the objective and the departure airfield.
+ """
+
+ #: The flight's role.
+ task: FlightType
+
+ #: The number of aircraft required.
+ num_aircraft: int
+
+ #: The type of threat this flight defends against if it is an escort. Escort
+ #: flights will be pruned if the rest of the package is not threatened by
+ #: the threat they defend against. If this flight is not an escort, this
+ #: field is None.
+ escort_type: Optional[EscortType] = field(default=None)
+
+ def __str__(self) -> str:
+ return f"{self.task} {self.num_aircraft} ship"
+
+
+@dataclass(frozen=True)
+class ProposedMission:
+ """A mission outline proposed by the mission planner.
+
+ Proposed missions haven't been assigned aircraft yet. They have only an
+ objective location and a list of proposed flights that are required for the
+ mission.
+ """
+
+ #: The mission objective.
+ location: MissionTarget
+
+ #: The proposed flights that are required for the mission.
+ flights: list[ProposedFlight]
+
+ asap: bool = field(default=False)
+
+ def __str__(self) -> str:
+ flights = ", ".join([str(f) for f in self.flights])
+ return f"{self.location.name}: {flights}"
diff --git a/game/commander/missionscheduler.py b/game/commander/missionscheduler.py
new file mode 100644
index 00000000..26889a97
--- /dev/null
+++ b/game/commander/missionscheduler.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import logging
+import random
+from collections import defaultdict
+from datetime import timedelta
+from typing import Iterator, Dict, TYPE_CHECKING
+
+from game.theater import MissionTarget
+from gen.flights.flight import FlightType
+from gen.flights.traveltime import TotEstimator
+
+if TYPE_CHECKING:
+ from game.coalition import Coalition
+
+
+class MissionScheduler:
+ def __init__(self, coalition: Coalition, desired_mission_length: timedelta) -> None:
+ self.coalition = coalition
+ self.desired_mission_length = desired_mission_length
+
+ def schedule_missions(self) -> None:
+ """Identifies and plans mission for the turn."""
+
+ def start_time_generator(
+ count: int, earliest: int, latest: int, margin: int
+ ) -> Iterator[timedelta]:
+ interval = (latest - earliest) // count
+ for time in range(earliest, latest, interval):
+ error = random.randint(-margin, margin)
+ yield timedelta(seconds=max(0, time + error))
+
+ dca_types = {
+ FlightType.BARCAP,
+ FlightType.TARCAP,
+ }
+
+ previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta)
+ non_dca_packages = [
+ p for p in self.coalition.ato.packages if p.primary_task not in dca_types
+ ]
+
+ start_time = start_time_generator(
+ count=len(non_dca_packages),
+ earliest=5 * 60,
+ latest=int(self.desired_mission_length.total_seconds()),
+ margin=5 * 60,
+ )
+ for package in self.coalition.ato.packages:
+ tot = TotEstimator(package).earliest_tot()
+ if package.primary_task in dca_types:
+ previous_end_time = previous_cap_end_time[package.target]
+ if tot > previous_end_time:
+ # Can't get there exactly on time, so get there ASAP. This
+ # will typically only happen for the first CAP at each
+ # target.
+ package.time_over_target = tot
+ else:
+ package.time_over_target = previous_end_time
+
+ departure_time = package.mission_departure_time
+ # Should be impossible for CAPs
+ if departure_time is None:
+ logging.error(f"Could not determine mission end time for {package}")
+ continue
+ previous_cap_end_time[package.target] = departure_time
+ elif package.auto_asap:
+ package.set_tot_asap()
+ else:
+ # But other packages should be spread out a bit. Note that take
+ # times are delayed, but all aircraft will become active at
+ # mission start. This makes it more worthwhile to attack enemy
+ # airfields to hit grounded aircraft, since they're more likely
+ # to be present. Runway and air started aircraft will be
+ # delayed until their takeoff time by AirConflictGenerator.
+ package.time_over_target = next(start_time) + tot
diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py
new file mode 100644
index 00000000..cf5c6102
--- /dev/null
+++ b/game/commander/objectivefinder.py
@@ -0,0 +1,246 @@
+from __future__ import annotations
+
+import math
+import operator
+from collections import Iterator, Iterable
+from typing import TypeVar, TYPE_CHECKING
+
+from game.theater import (
+ ControlPoint,
+ OffMapSpawn,
+ MissionTarget,
+ Fob,
+ FrontLine,
+ Airfield,
+)
+from game.theater.theatergroundobject import (
+ BuildingGroundObject,
+ IadsGroundObject,
+ NavalGroundObject,
+)
+from game.utils import meters, nautical_miles
+from gen.flights.closestairfields import ObjectiveDistanceCache, ClosestAirfields
+
+if TYPE_CHECKING:
+ from game import Game
+ from game.transfers import CargoShip, Convoy
+
+MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget)
+
+
+class ObjectiveFinder:
+ """Identifies potential objectives for the mission planner."""
+
+ # TODO: Merge into doctrine.
+ AIRFIELD_THREAT_RANGE = nautical_miles(150)
+ SAM_THREAT_RANGE = nautical_miles(100)
+
+ def __init__(self, game: Game, is_player: bool) -> None:
+ self.game = game
+ self.is_player = is_player
+
+ def enemy_air_defenses(self) -> Iterator[IadsGroundObject]:
+ """Iterates over all enemy SAM sites."""
+ for cp in self.enemy_control_points():
+ for ground_object in cp.ground_objects:
+ if ground_object.is_dead:
+ continue
+
+ if isinstance(ground_object, IadsGroundObject):
+ yield ground_object
+
+ def enemy_ships(self) -> Iterator[NavalGroundObject]:
+ for cp in self.enemy_control_points():
+ for ground_object in cp.ground_objects:
+ if not isinstance(ground_object, NavalGroundObject):
+ continue
+
+ if ground_object.is_dead:
+ continue
+
+ yield ground_object
+
+ def threatening_ships(self) -> Iterator[NavalGroundObject]:
+ """Iterates over enemy ships near friendly control points.
+
+ Groups are sorted by their closest proximity to any friendly control
+ point (airfield or fleet).
+ """
+ return self._targets_by_range(self.enemy_ships())
+
+ def _targets_by_range(
+ self, targets: Iterable[MissionTargetType]
+ ) -> Iterator[MissionTargetType]:
+ target_ranges: list[tuple[MissionTargetType, float]] = []
+ for target in targets:
+ ranges: list[float] = []
+ for cp in self.friendly_control_points():
+ ranges.append(target.distance_to(cp))
+ target_ranges.append((target, min(ranges)))
+
+ target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
+ for target, _range in target_ranges:
+ yield target
+
+ def strike_targets(self) -> Iterator[BuildingGroundObject]:
+ """Iterates over enemy strike targets.
+
+ Targets are sorted by their closest proximity to any friendly control
+ point (airfield or fleet).
+ """
+ targets: list[tuple[BuildingGroundObject, float]] = []
+ # Building objectives are made of several individual TGOs (one per
+ # building).
+ found_targets: set[str] = set()
+ for enemy_cp in self.enemy_control_points():
+ for ground_object in enemy_cp.ground_objects:
+ # TODO: Reuse ground_object.mission_types.
+ # The mission types for ground objects are currently not
+ # accurate because we include things like strike and BAI for all
+ # targets since they have different planning behavior (waypoint
+ # generation is better for players with strike when the targets
+ # are stationary, AI behavior against weaker air defenses is
+ # better with BAI), so that's not a useful filter. Once we have
+ # better control over planning profiles and target dependent
+ # loadouts we can clean this up.
+ if not isinstance(ground_object, BuildingGroundObject):
+ # Other group types (like ships, SAMs, garrisons, etc) have better
+ # suited mission types like anti-ship, DEAD, and BAI.
+ continue
+
+ if isinstance(enemy_cp, Fob) and ground_object.is_control_point:
+ # This is the FOB structure itself. Can't be repaired or
+ # targeted by the player, so shouldn't be targetable by the
+ # AI.
+ continue
+
+ if ground_object.is_dead:
+ continue
+ if ground_object.name in found_targets:
+ continue
+ ranges: list[float] = []
+ for friendly_cp in self.friendly_control_points():
+ ranges.append(ground_object.distance_to(friendly_cp))
+ targets.append((ground_object, min(ranges)))
+ found_targets.add(ground_object.name)
+ targets = sorted(targets, key=operator.itemgetter(1))
+ for target, _range in targets:
+ yield target
+
+ def front_lines(self) -> Iterator[FrontLine]:
+ """Iterates over all active front lines in the theater."""
+ yield from self.game.theater.conflicts()
+
+ def vulnerable_control_points(self) -> Iterator[ControlPoint]:
+ """Iterates over friendly CPs that are vulnerable to enemy CPs.
+
+ Vulnerability is defined as any enemy CP within threat range of of the
+ CP.
+ """
+ for cp in self.friendly_control_points():
+ if isinstance(cp, OffMapSpawn):
+ # Off-map spawn locations don't need protection.
+ continue
+ airfields_in_proximity = self.closest_airfields_to(cp)
+ airfields_in_threat_range = (
+ airfields_in_proximity.operational_airfields_within(
+ self.AIRFIELD_THREAT_RANGE
+ )
+ )
+ for airfield in airfields_in_threat_range:
+ if not airfield.is_friendly(self.is_player):
+ yield cp
+ break
+
+ def oca_targets(self, min_aircraft: int) -> Iterator[ControlPoint]:
+ airfields = []
+ for control_point in self.enemy_control_points():
+ if not isinstance(control_point, Airfield):
+ continue
+ if control_point.base.total_aircraft >= min_aircraft:
+ airfields.append(control_point)
+ return self._targets_by_range(airfields)
+
+ def convoys(self) -> Iterator[Convoy]:
+ for front_line in self.front_lines():
+ yield from self.game.coalition_for(
+ self.is_player
+ ).transfers.convoys.travelling_to(
+ front_line.control_point_hostile_to(self.is_player)
+ )
+
+ def cargo_ships(self) -> Iterator[CargoShip]:
+ for front_line in self.front_lines():
+ yield from self.game.coalition_for(
+ self.is_player
+ ).transfers.cargo_ships.travelling_to(
+ front_line.control_point_hostile_to(self.is_player)
+ )
+
+ def friendly_control_points(self) -> Iterator[ControlPoint]:
+ """Iterates over all friendly control points."""
+ return (
+ c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player)
+ )
+
+ def farthest_friendly_control_point(self) -> ControlPoint:
+ """Finds the friendly control point that is farthest from any threats."""
+ threat_zones = self.game.threat_zone_for(not self.is_player)
+
+ farthest = None
+ max_distance = meters(0)
+ for cp in self.friendly_control_points():
+ if isinstance(cp, OffMapSpawn):
+ continue
+ distance = threat_zones.distance_to_threat(cp.position)
+ if distance > max_distance:
+ farthest = cp
+ max_distance = distance
+
+ if farthest is None:
+ raise RuntimeError("Found no friendly control points. You probably lost.")
+ return farthest
+
+ def closest_friendly_control_point(self) -> ControlPoint:
+ """Finds the friendly control point that is closest to any threats."""
+ threat_zones = self.game.threat_zone_for(not self.is_player)
+
+ closest = None
+ min_distance = meters(math.inf)
+ for cp in self.friendly_control_points():
+ if isinstance(cp, OffMapSpawn):
+ continue
+ distance = threat_zones.distance_to_threat(cp.position)
+ if distance < min_distance:
+ closest = cp
+ min_distance = distance
+
+ if closest is None:
+ raise RuntimeError("Found no friendly control points. You probably lost.")
+ return closest
+
+ def enemy_control_points(self) -> Iterator[ControlPoint]:
+ """Iterates over all enemy control points."""
+ return (
+ c
+ for c in self.game.theater.controlpoints
+ if not c.is_friendly(self.is_player)
+ )
+
+ def prioritized_unisolated_points(self) -> list[ControlPoint]:
+ prioritized = []
+ capturable_later = []
+ for cp in self.game.theater.control_points_for(not self.is_player):
+ if cp.is_isolated:
+ continue
+ if cp.has_active_frontline:
+ prioritized.append(cp)
+ else:
+ capturable_later.append(cp)
+ prioritized.extend(self._targets_by_range(capturable_later))
+ return prioritized
+
+ @staticmethod
+ def closest_airfields_to(location: MissionTarget) -> ClosestAirfields:
+ """Returns the closest airfields to the given location."""
+ return ObjectiveDistanceCache.get_closest_airfields(location)
diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py
new file mode 100644
index 00000000..da96a8e2
--- /dev/null
+++ b/game/commander/packagebuilder.py
@@ -0,0 +1,98 @@
+from typing import Optional
+
+from game.commander.missionproposals import ProposedFlight
+from game.dcs.aircrafttype import AircraftType
+from game.inventory import GlobalAircraftInventory
+from game.squadrons import AirWing
+from game.theater import MissionTarget, OffMapSpawn, ControlPoint
+from game.utils import nautical_miles
+from gen import Package
+from game.commander.aircraftallocator import AircraftAllocator
+from gen.flights.closestairfields import ClosestAirfields
+from gen.flights.flight import Flight
+
+
+class PackageBuilder:
+ """Builds a Package for the flights it receives."""
+
+ def __init__(
+ self,
+ location: MissionTarget,
+ closest_airfields: ClosestAirfields,
+ global_inventory: GlobalAircraftInventory,
+ air_wing: AirWing,
+ is_player: bool,
+ package_country: str,
+ start_type: str,
+ asap: bool,
+ ) -> None:
+ self.closest_airfields = closest_airfields
+ self.is_player = is_player
+ self.package_country = package_country
+ self.package = Package(location, auto_asap=asap)
+ self.allocator = AircraftAllocator(
+ air_wing, closest_airfields, global_inventory, is_player
+ )
+ self.global_inventory = global_inventory
+ self.start_type = start_type
+
+ def plan_flight(self, plan: ProposedFlight) -> bool:
+ """Allocates aircraft for the given flight and adds them to the package.
+
+ If no suitable aircraft are available, False is returned. If the failed
+ flight was critical and the rest of the mission will be scrubbed, the
+ caller should return any previously planned flights to the inventory
+ using release_planned_aircraft.
+ """
+ assignment = self.allocator.find_squadron_for_flight(self.package.target, plan)
+ if assignment is None:
+ return False
+ airfield, squadron = assignment
+ if isinstance(airfield, OffMapSpawn):
+ start_type = "In Flight"
+ else:
+ start_type = self.start_type
+
+ flight = Flight(
+ self.package,
+ self.package_country,
+ squadron,
+ plan.num_aircraft,
+ plan.task,
+ start_type,
+ departure=airfield,
+ arrival=airfield,
+ divert=self.find_divert_field(squadron.aircraft, airfield),
+ )
+ self.package.add_flight(flight)
+ return True
+
+ def find_divert_field(
+ self, aircraft: AircraftType, arrival: ControlPoint
+ ) -> Optional[ControlPoint]:
+ divert_limit = nautical_miles(150)
+ for airfield in self.closest_airfields.operational_airfields_within(
+ divert_limit
+ ):
+ if airfield.captured != self.is_player:
+ continue
+ if airfield == arrival:
+ continue
+ if not airfield.can_operate(aircraft):
+ continue
+ if isinstance(airfield, OffMapSpawn):
+ continue
+ return airfield
+ return None
+
+ def build(self) -> Package:
+ """Returns the built package."""
+ return self.package
+
+ def release_planned_aircraft(self) -> None:
+ """Returns any planned flights to the inventory."""
+ flights = list(self.package.flights)
+ for flight in flights:
+ self.global_inventory.return_from_flight(flight)
+ flight.clear_roster()
+ self.package.remove_flight(flight)
diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py
new file mode 100644
index 00000000..83dbcf76
--- /dev/null
+++ b/game/commander/packagefulfiller.py
@@ -0,0 +1,228 @@
+from __future__ import annotations
+
+import logging
+from collections import defaultdict
+from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional
+
+from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType
+from game.data.doctrine import Doctrine
+from game.inventory import GlobalAircraftInventory
+from game.procurement import AircraftProcurementRequest
+from game.profiling import MultiEventTracer
+from game.settings import Settings
+from game.squadrons import AirWing
+from game.theater import ConflictTheater
+from game.threatzones import ThreatZones
+from gen import AirTaskingOrder, Package
+from game.commander.packagebuilder import PackageBuilder
+from gen.flights.closestairfields import ObjectiveDistanceCache
+from gen.flights.flight import FlightType
+from gen.flights.flightplan import FlightPlanBuilder
+
+if TYPE_CHECKING:
+ from game.coalition import Coalition
+
+
+class PackageFulfiller:
+ """Responsible for package aircraft allocation and flight plan layout."""
+
+ def __init__(
+ self,
+ coalition: Coalition,
+ theater: ConflictTheater,
+ aircraft_inventory: GlobalAircraftInventory,
+ settings: Settings,
+ ) -> None:
+ self.coalition = coalition
+ self.theater = theater
+ self.aircraft_inventory = aircraft_inventory
+ self.player_missions_asap = settings.auto_ato_player_missions_asap
+ self.default_start_type = settings.default_start_type
+
+ @property
+ def is_player(self) -> bool:
+ return self.coalition.player
+
+ @property
+ def ato(self) -> AirTaskingOrder:
+ return self.coalition.ato
+
+ @property
+ def air_wing(self) -> AirWing:
+ return self.coalition.air_wing
+
+ @property
+ def doctrine(self) -> Doctrine:
+ return self.coalition.doctrine
+
+ @property
+ def threat_zones(self) -> ThreatZones:
+ return self.coalition.opponent.threat_zone
+
+ def add_procurement_request(self, request: AircraftProcurementRequest) -> None:
+ self.coalition.add_procurement_request(request)
+
+ def air_wing_can_plan(self, mission_type: FlightType) -> bool:
+ """Returns True if it is possible for the air wing to plan this mission type.
+
+ Not all mission types can be fulfilled by all air wings. Many factions do not
+ have AEW&C aircraft, so they will never be able to plan those missions. It's
+ also possible for the player to exclude mission types from their squadron
+ designs.
+ """
+ return self.air_wing.can_auto_plan(mission_type)
+
+ def plan_flight(
+ self,
+ mission: ProposedMission,
+ flight: ProposedFlight,
+ builder: PackageBuilder,
+ missing_types: Set[FlightType],
+ purchase_multiplier: int,
+ ) -> None:
+ if not builder.plan_flight(flight):
+ missing_types.add(flight.task)
+ purchase_order = AircraftProcurementRequest(
+ near=mission.location,
+ task_capability=flight.task,
+ number=flight.num_aircraft * purchase_multiplier,
+ )
+ # Reserves are planned for critical missions, so prioritize those orders
+ # over aircraft needed for non-critical missions.
+ self.add_procurement_request(purchase_order)
+
+ def scrub_mission_missing_aircraft(
+ self,
+ mission: ProposedMission,
+ builder: PackageBuilder,
+ missing_types: Set[FlightType],
+ not_attempted: Iterable[ProposedFlight],
+ purchase_multiplier: int,
+ ) -> None:
+ # Try to plan the rest of the mission just so we can count the missing
+ # types to buy.
+ for flight in not_attempted:
+ self.plan_flight(
+ mission, flight, builder, missing_types, purchase_multiplier
+ )
+
+ missing_types_str = ", ".join(sorted([t.name for t in missing_types]))
+ builder.release_planned_aircraft()
+ color = "Blue" if self.is_player else "Red"
+ logging.debug(
+ f"{color}: not enough aircraft in range for {mission.location.name} "
+ f"capable of: {missing_types_str}"
+ )
+
+ def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]:
+ threats = defaultdict(bool)
+ for flight in builder.package.flights:
+ if self.threat_zones.waypoints_threatened_by_aircraft(
+ flight.flight_plan.escorted_waypoints()
+ ):
+ threats[EscortType.AirToAir] = True
+ if self.threat_zones.waypoints_threatened_by_radar_sam(
+ list(flight.flight_plan.escorted_waypoints())
+ ):
+ threats[EscortType.Sead] = True
+ return threats
+
+ def plan_mission(
+ self,
+ mission: ProposedMission,
+ purchase_multiplier: int,
+ tracer: MultiEventTracer,
+ ) -> Optional[Package]:
+ """Allocates aircraft for a proposed mission and adds it to the ATO."""
+ builder = PackageBuilder(
+ mission.location,
+ ObjectiveDistanceCache.get_closest_airfields(mission.location),
+ self.aircraft_inventory,
+ self.air_wing,
+ self.is_player,
+ self.coalition.country_name,
+ self.default_start_type,
+ mission.asap,
+ )
+
+ # Attempt to plan all the main elements of the mission first. Escorts
+ # will be planned separately so we can prune escorts for packages that
+ # are not expected to encounter that type of threat.
+ missing_types: Set[FlightType] = set()
+ escorts = []
+ for proposed_flight in mission.flights:
+ if not self.air_wing_can_plan(proposed_flight.task):
+ # This air wing can never plan this mission type because they do not
+ # have compatible aircraft or squadrons. Skip fulfillment so that we
+ # don't place the purchase request.
+ continue
+ if proposed_flight.escort_type is not None:
+ # Escorts are planned after the primary elements of the package.
+ # If the package does not need escorts they may be pruned.
+ escorts.append(proposed_flight)
+ continue
+ with tracer.trace("Flight planning"):
+ self.plan_flight(
+ mission,
+ proposed_flight,
+ builder,
+ missing_types,
+ purchase_multiplier,
+ )
+
+ if missing_types:
+ self.scrub_mission_missing_aircraft(
+ mission, builder, missing_types, escorts, purchase_multiplier
+ )
+ return None
+
+ if not builder.package.flights:
+ # The non-escort part of this mission is unplannable by this faction. Scrub
+ # the mission and do not attempt planning escorts because there's no reason
+ # to buy them because this mission will never be planned.
+ return None
+
+ # Create flight plans for the main flights of the package so we can
+ # determine threats. This is done *after* creating all of the flights
+ # rather than as each flight is added because the flight plan for
+ # flights that will rendezvous with their package will be affected by
+ # the other flights in the package. Escorts will not be able to
+ # contribute to this.
+ flight_plan_builder = FlightPlanBuilder(
+ builder.package, self.coalition, self.theater
+ )
+ for flight in builder.package.flights:
+ with tracer.trace("Flight plan population"):
+ flight_plan_builder.populate_flight_plan(flight)
+
+ needed_escorts = self.check_needed_escorts(builder)
+ for escort in escorts:
+ # This list was generated from the not None set, so this should be
+ # impossible.
+ assert escort.escort_type is not None
+ if needed_escorts[escort.escort_type]:
+ with tracer.trace("Flight planning"):
+ self.plan_flight(
+ mission, escort, builder, missing_types, purchase_multiplier
+ )
+
+ # Check again for unavailable aircraft. If the escort was required and
+ # none were found, scrub the mission.
+ if missing_types:
+ self.scrub_mission_missing_aircraft(
+ mission, builder, missing_types, escorts, purchase_multiplier
+ )
+ return None
+
+ package = builder.build()
+ # Add flight plans for escorts.
+ for flight in package.flights:
+ if not flight.flight_plan.waypoints:
+ with tracer.trace("Flight plan population"):
+ flight_plan_builder.populate_flight_plan(flight)
+
+ if package.has_players and self.player_missions_asap:
+ package.auto_asap = True
+ package.set_tot_asap()
+
+ return package
diff --git a/game/commander/tasks/compound/aewcsupport.py b/game/commander/tasks/compound/aewcsupport.py
new file mode 100644
index 00000000..5e66cb01
--- /dev/null
+++ b/game/commander/tasks/compound/aewcsupport.py
@@ -0,0 +1,11 @@
+from collections import Iterator
+
+from game.commander.tasks.primitive.aewc import PlanAewc
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+class PlanAewcSupport(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for target in state.aewc_targets:
+ yield [PlanAewc(target)]
diff --git a/game/commander/tasks/compound/attackairinfrastructure.py b/game/commander/tasks/compound/attackairinfrastructure.py
new file mode 100644
index 00000000..993ce73e
--- /dev/null
+++ b/game/commander/tasks/compound/attackairinfrastructure.py
@@ -0,0 +1,15 @@
+from collections import Iterator
+from dataclasses import dataclass
+
+from game.commander.tasks.primitive.oca import PlanOcaStrike
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+@dataclass(frozen=True)
+class AttackAirInfrastructure(CompoundTask[TheaterState]):
+ aircraft_cold_start: bool
+
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for garrison in state.oca_targets:
+ yield [PlanOcaStrike(garrison, self.aircraft_cold_start)]
diff --git a/game/commander/tasks/compound/attackbuildings.py b/game/commander/tasks/compound/attackbuildings.py
new file mode 100644
index 00000000..21e9a652
--- /dev/null
+++ b/game/commander/tasks/compound/attackbuildings.py
@@ -0,0 +1,15 @@
+from collections import Iterator
+
+from game.commander.tasks.primitive.strike import PlanStrike
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+class AttackBuildings(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for building in state.strike_targets:
+ # Ammo depots are targeted based on the needs of the front line by
+ # ReduceEnemyFrontLineCapacity. No reason to target them before that front
+ # line is active.
+ if not building.is_ammo_depot:
+ yield [PlanStrike(building)]
diff --git a/game/commander/tasks/compound/attackgarrisons.py b/game/commander/tasks/compound/attackgarrisons.py
new file mode 100644
index 00000000..479bcc71
--- /dev/null
+++ b/game/commander/tasks/compound/attackgarrisons.py
@@ -0,0 +1,12 @@
+from collections import Iterator
+
+from game.commander.tasks.primitive.bai import PlanBai
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+class AttackGarrisons(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for garrisons in state.enemy_garrisons.values():
+ for garrison in garrisons.in_priority_order:
+ yield [PlanBai(garrison)]
diff --git a/game/commander/tasks/compound/capturebase.py b/game/commander/tasks/compound/capturebase.py
new file mode 100644
index 00000000..11936033
--- /dev/null
+++ b/game/commander/tasks/compound/capturebase.py
@@ -0,0 +1,51 @@
+from collections import Iterator
+from dataclasses import dataclass
+
+from game.commander.tasks.compound.destroyenemygroundunits import (
+ DestroyEnemyGroundUnits,
+)
+from game.commander.tasks.compound.reduceenemyfrontlinecapacity import (
+ ReduceEnemyFrontLineCapacity,
+)
+from game.commander.tasks.primitive.breakthroughattack import BreakthroughAttack
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+from game.theater import FrontLine, ControlPoint
+
+
+@dataclass(frozen=True)
+class CaptureBase(CompoundTask[TheaterState]):
+ front_line: FrontLine
+
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ yield [BreakthroughAttack(self.front_line, state.context.coalition.player)]
+ yield [DestroyEnemyGroundUnits(self.front_line)]
+ if self.worth_destroying_ammo_depots(state):
+ yield [ReduceEnemyFrontLineCapacity(self.enemy_cp(state))]
+
+ def enemy_cp(self, state: TheaterState) -> ControlPoint:
+ return self.front_line.control_point_hostile_to(state.context.coalition.player)
+
+ def units_deployable(self, state: TheaterState, player: bool) -> int:
+ cp = self.front_line.control_point_friendly_to(player)
+ ammo_depots = list(state.ammo_dumps_at(cp))
+ return cp.deployable_front_line_units_with(len(ammo_depots))
+
+ def unit_cap(self, state: TheaterState, player: bool) -> int:
+ cp = self.front_line.control_point_friendly_to(player)
+ ammo_depots = list(state.ammo_dumps_at(cp))
+ return cp.front_line_capacity_with(len(ammo_depots))
+
+ def enemy_has_ammo_dumps(self, state: TheaterState) -> bool:
+ return bool(state.ammo_dumps_at(self.enemy_cp(state)))
+
+ def worth_destroying_ammo_depots(self, state: TheaterState) -> bool:
+ if not self.enemy_has_ammo_dumps(state):
+ return False
+
+ friendly_cap = self.unit_cap(state, state.context.coalition.player)
+ enemy_deployable = self.units_deployable(state, state.context.coalition.player)
+
+ # If the enemy can currently deploy 50% more units than we possibly could, it's
+ # worth killing an ammo depot.
+ return enemy_deployable / friendly_cap > 1.5
diff --git a/game/commander/tasks/compound/capturebases.py b/game/commander/tasks/compound/capturebases.py
new file mode 100644
index 00000000..3d338046
--- /dev/null
+++ b/game/commander/tasks/compound/capturebases.py
@@ -0,0 +1,13 @@
+from collections import Iterator
+from dataclasses import dataclass
+
+from game.commander.tasks.compound.capturebase import CaptureBase
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+@dataclass(frozen=True)
+class CaptureBases(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for front in state.active_front_lines:
+ yield [CaptureBase(front)]
diff --git a/game/commander/tasks/compound/defendbase.py b/game/commander/tasks/compound/defendbase.py
new file mode 100644
index 00000000..e7071489
--- /dev/null
+++ b/game/commander/tasks/compound/defendbase.py
@@ -0,0 +1,19 @@
+from collections import Iterator
+from dataclasses import dataclass
+
+from game.commander.tasks.primitive.cas import PlanCas
+from game.commander.tasks.primitive.defensivestance import DefensiveStance
+from game.commander.tasks.primitive.retreatstance import RetreatStance
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+from game.theater import FrontLine
+
+
+@dataclass(frozen=True)
+class DefendBase(CompoundTask[TheaterState]):
+ front_line: FrontLine
+
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ yield [DefensiveStance(self.front_line, state.context.coalition.player)]
+ yield [RetreatStance(self.front_line, state.context.coalition.player)]
+ yield [PlanCas(self.front_line)]
diff --git a/game/commander/tasks/compound/defendbases.py b/game/commander/tasks/compound/defendbases.py
new file mode 100644
index 00000000..df18fdc3
--- /dev/null
+++ b/game/commander/tasks/compound/defendbases.py
@@ -0,0 +1,13 @@
+from collections import Iterator
+from dataclasses import dataclass
+
+from game.commander.tasks.compound.defendbase import DefendBase
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+@dataclass(frozen=True)
+class DefendBases(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for front in state.active_front_lines:
+ yield [DefendBase(front)]
diff --git a/game/commander/tasks/compound/degradeiads.py b/game/commander/tasks/compound/degradeiads.py
new file mode 100644
index 00000000..21ddd02e
--- /dev/null
+++ b/game/commander/tasks/compound/degradeiads.py
@@ -0,0 +1,24 @@
+from collections import Iterator
+from typing import Union
+
+from game.commander.tasks.primitive.antiship import PlanAntiShip
+from game.commander.tasks.primitive.dead import PlanDead
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
+
+
+class DegradeIads(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for air_defense in state.threatening_air_defenses:
+ yield [self.plan_against(air_defense)]
+ for detector in state.detecting_air_defenses:
+ yield [self.plan_against(detector)]
+
+ @staticmethod
+ def plan_against(
+ target: Union[IadsGroundObject, NavalGroundObject]
+ ) -> Union[PlanDead, PlanAntiShip]:
+ if isinstance(target, IadsGroundObject):
+ return PlanDead(target)
+ return PlanAntiShip(target)
diff --git a/game/commander/tasks/compound/destroyenemygroundunits.py b/game/commander/tasks/compound/destroyenemygroundunits.py
new file mode 100644
index 00000000..327acecd
--- /dev/null
+++ b/game/commander/tasks/compound/destroyenemygroundunits.py
@@ -0,0 +1,19 @@
+from collections import Iterator
+from dataclasses import dataclass
+
+from game.commander.tasks.primitive.aggressiveattack import AggressiveAttack
+from game.commander.tasks.primitive.cas import PlanCas
+from game.commander.tasks.primitive.eliminationattack import EliminationAttack
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+from game.theater import FrontLine
+
+
+@dataclass(frozen=True)
+class DestroyEnemyGroundUnits(CompoundTask[TheaterState]):
+ front_line: FrontLine
+
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ yield [EliminationAttack(self.front_line, state.context.coalition.player)]
+ yield [AggressiveAttack(self.front_line, state.context.coalition.player)]
+ yield [PlanCas(self.front_line)]
diff --git a/game/commander/tasks/compound/frontlinedefense.py b/game/commander/tasks/compound/frontlinedefense.py
new file mode 100644
index 00000000..11ed083e
--- /dev/null
+++ b/game/commander/tasks/compound/frontlinedefense.py
@@ -0,0 +1,11 @@
+from collections import Iterator
+
+from game.commander.tasks.primitive.cas import PlanCas
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+class FrontLineDefense(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for front_line in state.vulnerable_front_lines:
+ yield [PlanCas(front_line)]
diff --git a/game/commander/tasks/compound/interdictreinforcements.py b/game/commander/tasks/compound/interdictreinforcements.py
new file mode 100644
index 00000000..a76921db
--- /dev/null
+++ b/game/commander/tasks/compound/interdictreinforcements.py
@@ -0,0 +1,27 @@
+from collections import Iterator
+
+from game.commander.tasks.primitive.antishipping import PlanAntiShipping
+from game.commander.tasks.primitive.convoyinterdiction import PlanConvoyInterdiction
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+class InterdictReinforcements(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ # These will only rarely get planned. When a convoy is travelling multiple legs,
+ # they're targetable after the first leg. The reason for this is that
+ # procurement happens *after* mission planning so that the missions that could
+ # not be filled will guide the procurement process. Procurement is the stage
+ # that convoys are created (because they're created to move ground units that
+ # were just purchased), so we haven't created any yet. Any incomplete transfers
+ # from the previous turn (multi-leg journeys) will still be present though so
+ # they can be targeted.
+ #
+ # Even after this is fixed, the player's convoys that were created through the
+ # UI will never be targeted on the first turn of their journey because the AI
+ # stops planning after the start of the turn. We could potentially fix this by
+ # moving opfor mission planning until the takeoff button is pushed.
+ for convoy in state.enemy_convoys:
+ yield [PlanConvoyInterdiction(convoy)]
+ for ship in state.enemy_shipping:
+ yield [PlanAntiShipping(ship)]
diff --git a/game/commander/tasks/compound/nextaction.py b/game/commander/tasks/compound/nextaction.py
new file mode 100644
index 00000000..3b4559d3
--- /dev/null
+++ b/game/commander/tasks/compound/nextaction.py
@@ -0,0 +1,34 @@
+from collections import Iterator
+from dataclasses import dataclass
+
+from game.commander.tasks.compound.attackairinfrastructure import (
+ AttackAirInfrastructure,
+)
+from game.commander.tasks.compound.attackbuildings import AttackBuildings
+from game.commander.tasks.compound.attackgarrisons import AttackGarrisons
+from game.commander.tasks.compound.capturebases import CaptureBases
+from game.commander.tasks.compound.defendbases import DefendBases
+from game.commander.tasks.compound.degradeiads import DegradeIads
+from game.commander.tasks.compound.interdictreinforcements import (
+ InterdictReinforcements,
+)
+from game.commander.tasks.compound.protectairspace import ProtectAirSpace
+from game.commander.tasks.compound.theatersupport import TheaterSupport
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+@dataclass(frozen=True)
+class PlanNextAction(CompoundTask[TheaterState]):
+ aircraft_cold_start: bool
+
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ yield [TheaterSupport()]
+ yield [ProtectAirSpace()]
+ yield [CaptureBases()]
+ yield [DefendBases()]
+ yield [InterdictReinforcements()]
+ yield [AttackGarrisons()]
+ yield [AttackAirInfrastructure(self.aircraft_cold_start)]
+ yield [AttackBuildings()]
+ yield [DegradeIads()]
diff --git a/game/commander/tasks/compound/protectairspace.py b/game/commander/tasks/compound/protectairspace.py
new file mode 100644
index 00000000..79306c65
--- /dev/null
+++ b/game/commander/tasks/compound/protectairspace.py
@@ -0,0 +1,12 @@
+from collections import Iterator
+
+from game.commander.tasks.primitive.barcap import PlanBarcap
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+class ProtectAirSpace(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for cp, needed in state.barcaps_needed.items():
+ if needed > 0:
+ yield [PlanBarcap(cp, needed)]
diff --git a/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py b/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py
new file mode 100644
index 00000000..1b8b0e7c
--- /dev/null
+++ b/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py
@@ -0,0 +1,16 @@
+from collections import Iterator
+from dataclasses import dataclass
+
+from game.commander.tasks.primitive.strike import PlanStrike
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+from game.theater import ControlPoint
+
+
+@dataclass(frozen=True)
+class ReduceEnemyFrontLineCapacity(CompoundTask[TheaterState]):
+ control_point: ControlPoint
+
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for ammo_dump in state.ammo_dumps_at(self.control_point):
+ yield [PlanStrike(ammo_dump)]
diff --git a/game/commander/tasks/compound/refuelingsupport.py b/game/commander/tasks/compound/refuelingsupport.py
new file mode 100644
index 00000000..6e2b141a
--- /dev/null
+++ b/game/commander/tasks/compound/refuelingsupport.py
@@ -0,0 +1,11 @@
+from collections import Iterator
+
+from game.commander.tasks.primitive.refueling import PlanRefueling
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+class PlanRefuelingSupport(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ for target in state.refueling_targets:
+ yield [PlanRefueling(target)]
diff --git a/game/commander/tasks/compound/theatersupport.py b/game/commander/tasks/compound/theatersupport.py
new file mode 100644
index 00000000..379ba7c2
--- /dev/null
+++ b/game/commander/tasks/compound/theatersupport.py
@@ -0,0 +1,14 @@
+from collections import Iterator
+from dataclasses import dataclass
+
+from game.commander.tasks.compound.aewcsupport import PlanAewcSupport
+from game.commander.tasks.compound.refuelingsupport import PlanRefuelingSupport
+from game.commander.theaterstate import TheaterState
+from game.htn import CompoundTask, Method
+
+
+@dataclass(frozen=True)
+class TheaterSupport(CompoundTask[TheaterState]):
+ def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
+ yield [PlanAewcSupport()]
+ yield [PlanRefuelingSupport()]
diff --git a/game/commander/tasks/frontlinestancetask.py b/game/commander/tasks/frontlinestancetask.py
new file mode 100644
index 00000000..f8c3b8d1
--- /dev/null
+++ b/game/commander/tasks/frontlinestancetask.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import math
+from abc import ABC, abstractmethod
+from typing import TYPE_CHECKING
+
+from game.commander.tasks.theatercommandertask import TheaterCommanderTask
+from game.commander.theaterstate import TheaterState
+from game.theater import FrontLine
+from gen.ground_forces.combat_stance import CombatStance
+
+if TYPE_CHECKING:
+ from game.coalition import Coalition
+
+
+class FrontLineStanceTask(TheaterCommanderTask, ABC):
+ def __init__(self, front_line: FrontLine, player: bool) -> None:
+ self.front_line = front_line
+ self.friendly_cp = self.front_line.control_point_friendly_to(player)
+ self.enemy_cp = self.front_line.control_point_hostile_to(player)
+
+ @property
+ @abstractmethod
+ def stance(self) -> CombatStance:
+ ...
+
+ @staticmethod
+ def management_allowed(state: TheaterState) -> bool:
+ return (
+ not state.context.coalition.player
+ or state.context.settings.automate_front_line_stance
+ )
+
+ def better_stance_already_set(self, state: TheaterState) -> bool:
+ current_stance = state.front_line_stances[self.front_line]
+ if current_stance is None:
+ return False
+ preference = (
+ CombatStance.RETREAT,
+ CombatStance.DEFENSIVE,
+ CombatStance.AMBUSH,
+ CombatStance.AGGRESSIVE,
+ CombatStance.ELIMINATION,
+ CombatStance.BREAKTHROUGH,
+ )
+ current_rating = preference.index(current_stance)
+ new_rating = preference.index(self.stance)
+ return current_rating >= new_rating
+
+ @property
+ @abstractmethod
+ def have_sufficient_front_line_advantage(self) -> bool:
+ ...
+
+ @property
+ def ground_force_balance(self) -> float:
+ # TODO: Planned CAS missions should reduce the expected opposing force size.
+ friendly_forces = self.friendly_cp.deployable_front_line_units
+ enemy_forces = self.enemy_cp.deployable_front_line_units
+ if enemy_forces == 0:
+ return math.inf
+ return friendly_forces / enemy_forces
+
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if not self.management_allowed(state):
+ return False
+ if self.better_stance_already_set(state):
+ return False
+ return self.have_sufficient_front_line_advantage
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.front_line_stances[self.front_line] = self.stance
+
+ def execute(self, coalition: Coalition) -> None:
+ self.friendly_cp.stances[self.enemy_cp.id] = self.stance
diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py
new file mode 100644
index 00000000..cf75eb1b
--- /dev/null
+++ b/game/commander/tasks/packageplanningtask.py
@@ -0,0 +1,178 @@
+from __future__ import annotations
+
+import itertools
+import operator
+from abc import abstractmethod
+from dataclasses import dataclass, field
+from enum import unique, IntEnum, auto
+from typing import TYPE_CHECKING, Optional, Generic, TypeVar, Iterator, Union
+
+from game.commander.missionproposals import ProposedFlight, EscortType, ProposedMission
+from game.commander.packagefulfiller import PackageFulfiller
+from game.commander.tasks.theatercommandertask import TheaterCommanderTask
+from game.commander.theaterstate import TheaterState
+from game.data.doctrine import Doctrine
+from game.settings import AutoAtoBehavior
+from game.theater import MissionTarget
+from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
+from game.utils import Distance, meters
+from gen import Package
+from gen.flights.flight import FlightType
+
+if TYPE_CHECKING:
+ from game.coalition import Coalition
+
+MissionTargetT = TypeVar("MissionTargetT", bound=MissionTarget)
+
+
+@unique
+class RangeType(IntEnum):
+ Detection = auto()
+ Threat = auto()
+
+
+# TODO: Refactor so that we don't need to call up to the mission planner.
+# Bypass type checker due to https://github.com/python/mypy/issues/5374
+@dataclass # type: ignore
+class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
+ target: MissionTargetT
+ flights: list[ProposedFlight] = field(init=False)
+ package: Optional[Package] = field(init=False, default=None)
+
+ def __post_init__(self) -> None:
+ self.flights = []
+ self.package = Package(self.target)
+
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if (
+ state.context.coalition.player
+ and state.context.settings.auto_ato_behavior is AutoAtoBehavior.Disabled
+ ):
+ return False
+ return self.fulfill_mission(state)
+
+ def execute(self, coalition: Coalition) -> None:
+ if self.package is None:
+ raise RuntimeError("Attempted to execute failed package planning task")
+ for flight in self.package.flights:
+ coalition.aircraft_inventory.claim_for_flight(flight)
+ coalition.ato.add_package(self.package)
+
+ @abstractmethod
+ def propose_flights(self) -> None:
+ ...
+
+ def propose_flight(
+ self,
+ task: FlightType,
+ num_aircraft: int,
+ escort_type: Optional[EscortType] = None,
+ ) -> None:
+ self.flights.append(ProposedFlight(task, num_aircraft, escort_type))
+
+ @property
+ def asap(self) -> bool:
+ return False
+
+ @property
+ def purchase_multiplier(self) -> int:
+ """The multiplier for aircraft quantity when missions could not be fulfilled.
+
+ For missions that do not schedule in rounds like BARCAPs do, this should be one
+ to ensure that the we only purchase enough aircraft to plan the mission once.
+
+ For missions that repeat within the same turn, however, we may need to buy for
+ the same mission more than once. If three rounds of BARCAP still need to be
+ fulfilled, this would return 3, and we'd triplicate the purchase order.
+
+ There is a small misbehavior here that's not symptomatic for our current mission
+ planning: multi-round, multi-flight packages will only purchase multiple sets of
+ aircraft for whatever is unavailable for the *first* failed package. For
+ example, if we extend this to CAS and have no CAS aircraft but enough TARCAP
+ aircraft for one round, we'll order CAS for every round but will not order any
+ TARCAP aircraft, since we can't know that TARCAP aircraft are needed until we
+ attempt to plan the second mission *without returning the first round aircraft*.
+ """
+ return 1
+
+ def fulfill_mission(self, state: TheaterState) -> bool:
+ self.propose_flights()
+ fulfiller = PackageFulfiller(
+ state.context.coalition,
+ state.context.theater,
+ state.available_aircraft,
+ state.context.settings,
+ )
+ self.package = fulfiller.plan_mission(
+ ProposedMission(self.target, self.flights),
+ self.purchase_multiplier,
+ state.context.tracer,
+ )
+ return self.package is not None
+
+ def propose_common_escorts(self) -> None:
+ self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
+ self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
+
+ def iter_iads_ranges(
+ self, state: TheaterState, range_type: RangeType
+ ) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
+ target_ranges: list[
+ tuple[Union[IadsGroundObject, NavalGroundObject], Distance]
+ ] = []
+ all_iads: Iterator[
+ Union[IadsGroundObject, NavalGroundObject]
+ ] = itertools.chain(state.enemy_air_defenses, state.enemy_ships)
+ for target in all_iads:
+ distance = meters(target.distance_to(self.target))
+ if range_type is RangeType.Detection:
+ target_range = target.max_detection_range()
+ elif range_type is RangeType.Threat:
+ target_range = target.max_threat_range()
+ else:
+ raise ValueError(f"Unknown RangeType: {range_type}")
+ if not target_range:
+ continue
+
+ # IADS out of range of our target area will have a positive
+ # distance_to_threat and should be pruned. The rest have a decreasing
+ # distance_to_threat as overlap increases. The most negative distance has
+ # the greatest coverage of the target and should be treated as the highest
+ # priority threat.
+ distance_to_threat = distance - target_range
+ if distance_to_threat > meters(0):
+ continue
+ target_ranges.append((target, distance_to_threat))
+
+ # TODO: Prioritize IADS by vulnerability?
+ target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
+ for target, _range in target_ranges:
+ yield target
+
+ def iter_detecting_iads(
+ self, state: TheaterState
+ ) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
+ return self.iter_iads_ranges(state, RangeType.Detection)
+
+ def iter_iads_threats(
+ self, state: TheaterState
+ ) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
+ return self.iter_iads_ranges(state, RangeType.Threat)
+
+ def target_area_preconditions_met(
+ self, state: TheaterState, ignore_iads: bool = False
+ ) -> bool:
+ """Checks if the target area has been cleared of threats."""
+ threatened = False
+
+ # Non-blocking, but analyzed so we can pick detectors worth eliminating.
+ for detector in self.iter_detecting_iads(state):
+ if detector not in state.detecting_air_defenses:
+ state.detecting_air_defenses.append(detector)
+
+ if not ignore_iads:
+ for iads_threat in self.iter_iads_threats(state):
+ threatened = True
+ if iads_threat not in state.threatening_air_defenses:
+ state.threatening_air_defenses.append(iads_threat)
+ return not threatened
diff --git a/game/commander/tasks/primitive/aewc.py b/game/commander/tasks/primitive/aewc.py
new file mode 100644
index 00000000..f9c6a7d2
--- /dev/null
+++ b/game/commander/tasks/primitive/aewc.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.theater import MissionTarget
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanAewc(PackagePlanningTask[MissionTarget]):
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if not super().preconditions_met(state):
+ return False
+ return self.target in state.aewc_targets
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.aewc_targets.remove(self.target)
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.AEWC, 1)
+
+ @property
+ def asap(self) -> bool:
+ # Supports all the early CAP flights, so should be in the air ASAP.
+ return True
diff --git a/game/commander/tasks/primitive/aggressiveattack.py b/game/commander/tasks/primitive/aggressiveattack.py
new file mode 100644
index 00000000..a5928dd3
--- /dev/null
+++ b/game/commander/tasks/primitive/aggressiveattack.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
+from gen.ground_forces.combat_stance import CombatStance
+
+
+class AggressiveAttack(FrontLineStanceTask):
+ @property
+ def stance(self) -> CombatStance:
+ return CombatStance.AGGRESSIVE
+
+ @property
+ def have_sufficient_front_line_advantage(self) -> bool:
+ return self.ground_force_balance >= 0.8
diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py
new file mode 100644
index 00000000..a135e1cd
--- /dev/null
+++ b/game/commander/tasks/primitive/antiship.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from game.commander.missionproposals import EscortType
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.theater.theatergroundobject import NavalGroundObject
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanAntiShip(PackagePlanningTask[NavalGroundObject]):
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if self.target not in state.threatening_air_defenses:
+ return False
+ if not self.target_area_preconditions_met(state, ignore_iads=True):
+ return False
+ return super().preconditions_met(state)
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.eliminate_ship(self.target)
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.ANTISHIP, 2)
+ self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
diff --git a/game/commander/tasks/primitive/antishipping.py b/game/commander/tasks/primitive/antishipping.py
new file mode 100644
index 00000000..64279d1b
--- /dev/null
+++ b/game/commander/tasks/primitive/antishipping.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.transfers import CargoShip
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanAntiShipping(PackagePlanningTask[CargoShip]):
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if self.target not in state.enemy_shipping:
+ return False
+ if not self.target_area_preconditions_met(state):
+ return False
+ return super().preconditions_met(state)
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.enemy_shipping.remove(self.target)
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.ANTISHIP, 2)
+ self.propose_common_escorts()
diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py
new file mode 100644
index 00000000..4878171d
--- /dev/null
+++ b/game/commander/tasks/primitive/bai.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.theater.theatergroundobject import VehicleGroupGroundObject
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]):
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if not state.has_garrison(self.target):
+ return False
+ if not self.target_area_preconditions_met(state):
+ return False
+ return super().preconditions_met(state)
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.eliminate_garrison(self.target)
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.BAI, 2)
+ self.propose_common_escorts()
diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py
new file mode 100644
index 00000000..b4e8455e
--- /dev/null
+++ b/game/commander/tasks/primitive/barcap.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.theater import ControlPoint
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanBarcap(PackagePlanningTask[ControlPoint]):
+ max_orders: int
+
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if not state.barcaps_needed[self.target]:
+ return False
+ return super().preconditions_met(state)
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.barcaps_needed[self.target] -= 1
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.BARCAP, 2)
+
+ @property
+ def purchase_multiplier(self) -> int:
+ return self.max_orders
diff --git a/game/commander/tasks/primitive/breakthroughattack.py b/game/commander/tasks/primitive/breakthroughattack.py
new file mode 100644
index 00000000..eb17b5ac
--- /dev/null
+++ b/game/commander/tasks/primitive/breakthroughattack.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
+from game.commander.theaterstate import TheaterState
+from gen.ground_forces.combat_stance import CombatStance
+
+
+class BreakthroughAttack(FrontLineStanceTask):
+ @property
+ def stance(self) -> CombatStance:
+ return CombatStance.BREAKTHROUGH
+
+ @property
+ def have_sufficient_front_line_advantage(self) -> bool:
+ return self.ground_force_balance >= 2.0
+
+ def opposing_garrisons_eliminated(self, state: TheaterState) -> bool:
+ garrisons = state.enemy_garrisons[self.enemy_cp]
+ return not bool(garrisons.blocking_capture)
+
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if not super().preconditions_met(state):
+ return False
+ return self.opposing_garrisons_eliminated(state)
+
+ def apply_effects(self, state: TheaterState) -> None:
+ super().apply_effects(state)
+ state.active_front_lines.remove(self.front_line)
diff --git a/game/commander/tasks/primitive/cas.py b/game/commander/tasks/primitive/cas.py
new file mode 100644
index 00000000..c2785405
--- /dev/null
+++ b/game/commander/tasks/primitive/cas.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.theater import FrontLine
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanCas(PackagePlanningTask[FrontLine]):
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if self.target not in state.vulnerable_front_lines:
+ return False
+ return super().preconditions_met(state)
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.vulnerable_front_lines.remove(self.target)
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.CAS, 2)
+ self.propose_flight(FlightType.TARCAP, 2)
diff --git a/game/commander/tasks/primitive/convoyinterdiction.py b/game/commander/tasks/primitive/convoyinterdiction.py
new file mode 100644
index 00000000..285326c7
--- /dev/null
+++ b/game/commander/tasks/primitive/convoyinterdiction.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.data.doctrine import Doctrine
+from game.transfers import Convoy
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanConvoyInterdiction(PackagePlanningTask[Convoy]):
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if self.target not in state.enemy_convoys:
+ return False
+ if not self.target_area_preconditions_met(state):
+ return False
+ return super().preconditions_met(state)
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.enemy_convoys.remove(self.target)
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.BAI, 2)
+ self.propose_common_escorts()
diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py
new file mode 100644
index 00000000..45da3cc3
--- /dev/null
+++ b/game/commander/tasks/primitive/dead.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from game.commander.missionproposals import EscortType
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.theater.theatergroundobject import IadsGroundObject
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanDead(PackagePlanningTask[IadsGroundObject]):
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if (
+ self.target not in state.threatening_air_defenses
+ and self.target not in state.detecting_air_defenses
+ ):
+ return False
+ if not self.target_area_preconditions_met(state, ignore_iads=True):
+ return False
+ return super().preconditions_met(state)
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.eliminate_air_defense(self.target)
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.DEAD, 2)
+
+ # Only include SEAD against SAMs that still have emitters. No need to
+ # suppress an EWR, and SEAD isn't useful against a SAM that no longer has a
+ # working track radar.
+ #
+ # For SAMs without track radars and EWRs, we still want a SEAD escort if
+ # needed.
+ #
+ # Note that there is a quirk here: we should potentially be included a SEAD
+ # escort *and* SEAD when the target is a radar SAM but the flight path is
+ # also threatened by SAMs. We don't want to include a SEAD escort if the
+ # package is *only* threatened by the target though. Could be improved, but
+ # needs a decent refactor to the escort planning to do so.
+ if self.target.has_live_radar_sam:
+ self.propose_flight(FlightType.SEAD, 2)
+ else:
+ self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
+ self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
diff --git a/game/commander/tasks/primitive/defensivestance.py b/game/commander/tasks/primitive/defensivestance.py
new file mode 100644
index 00000000..3e3510e2
--- /dev/null
+++ b/game/commander/tasks/primitive/defensivestance.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
+from gen.ground_forces.combat_stance import CombatStance
+
+
+class DefensiveStance(FrontLineStanceTask):
+ @property
+ def stance(self) -> CombatStance:
+ return CombatStance.DEFENSIVE
+
+ @property
+ def have_sufficient_front_line_advantage(self) -> bool:
+ return self.ground_force_balance >= 0.5
diff --git a/game/commander/tasks/primitive/eliminationattack.py b/game/commander/tasks/primitive/eliminationattack.py
new file mode 100644
index 00000000..409ecf97
--- /dev/null
+++ b/game/commander/tasks/primitive/eliminationattack.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
+from gen.ground_forces.combat_stance import CombatStance
+
+
+class EliminationAttack(FrontLineStanceTask):
+ @property
+ def stance(self) -> CombatStance:
+ return CombatStance.ELIMINATION
+
+ @property
+ def have_sufficient_front_line_advantage(self) -> bool:
+ return self.ground_force_balance >= 1.5
diff --git a/game/commander/tasks/primitive/oca.py b/game/commander/tasks/primitive/oca.py
new file mode 100644
index 00000000..be88df32
--- /dev/null
+++ b/game/commander/tasks/primitive/oca.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.theater import ControlPoint
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanOcaStrike(PackagePlanningTask[ControlPoint]):
+ aircraft_cold_start: bool
+
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if self.target not in state.oca_targets:
+ return False
+ if not self.target_area_preconditions_met(state):
+ return False
+ return super().preconditions_met(state)
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.oca_targets.remove(self.target)
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.OCA_RUNWAY, 2)
+ if self.aircraft_cold_start:
+ self.propose_flight(FlightType.OCA_AIRCRAFT, 2)
+ self.propose_common_escorts()
diff --git a/game/commander/tasks/primitive/refueling.py b/game/commander/tasks/primitive/refueling.py
new file mode 100644
index 00000000..5f17f3df
--- /dev/null
+++ b/game/commander/tasks/primitive/refueling.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.theater import MissionTarget
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanRefueling(PackagePlanningTask[MissionTarget]):
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if not super().preconditions_met(state):
+ return False
+ return self.target in state.refueling_targets
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.refueling_targets.remove(self.target)
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.REFUELING, 1)
diff --git a/game/commander/tasks/primitive/retreatstance.py b/game/commander/tasks/primitive/retreatstance.py
new file mode 100644
index 00000000..d3e4bcc8
--- /dev/null
+++ b/game/commander/tasks/primitive/retreatstance.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
+from gen.ground_forces.combat_stance import CombatStance
+
+
+class RetreatStance(FrontLineStanceTask):
+ @property
+ def stance(self) -> CombatStance:
+ return CombatStance.RETREAT
+
+ @property
+ def have_sufficient_front_line_advantage(self) -> bool:
+ return True
diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py
new file mode 100644
index 00000000..e89c9cac
--- /dev/null
+++ b/game/commander/tasks/primitive/strike.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from game.commander.tasks.packageplanningtask import PackagePlanningTask
+from game.commander.theaterstate import TheaterState
+from game.theater.theatergroundobject import TheaterGroundObject
+from gen.flights.flight import FlightType
+
+
+@dataclass
+class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]):
+ def preconditions_met(self, state: TheaterState) -> bool:
+ if self.target not in state.strike_targets:
+ return False
+ if not self.target_area_preconditions_met(state):
+ return False
+ return super().preconditions_met(state)
+
+ def apply_effects(self, state: TheaterState) -> None:
+ state.strike_targets.remove(self.target)
+
+ def propose_flights(self) -> None:
+ self.propose_flight(FlightType.STRIKE, 2)
+ self.propose_common_escorts()
diff --git a/game/commander/tasks/theatercommandertask.py b/game/commander/tasks/theatercommandertask.py
new file mode 100644
index 00000000..5daa6b6c
--- /dev/null
+++ b/game/commander/tasks/theatercommandertask.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from abc import abstractmethod
+from typing import TYPE_CHECKING
+
+from game.commander.theaterstate import TheaterState
+from game.htn import PrimitiveTask
+
+if TYPE_CHECKING:
+ from game.coalition import Coalition
+
+
+class TheaterCommanderTask(PrimitiveTask[TheaterState]):
+ @abstractmethod
+ def execute(self, coalition: Coalition) -> None:
+ ...
diff --git a/game/commander/theatercommander.py b/game/commander/theatercommander.py
new file mode 100644
index 00000000..3066ff54
--- /dev/null
+++ b/game/commander/theatercommander.py
@@ -0,0 +1,88 @@
+"""The Theater Commander is the highest level campaign AI.
+
+Target selection is performed with a hierarchical-task-network (HTN, linked below).
+These work by giving the planner an initial "task" which decomposes into other tasks
+until a concrete set of actions is formed. For example, the "capture base" task may
+decompose in the following manner:
+
+* Defend
+ * Reinforce front line
+ * Set front line stance to defend
+ * Destroy enemy front line units
+ * Set front line stance to elimination
+ * Plan CAS at front line
+* Prepare
+ * Destroy enemy IADS
+ * Plan DEAD against SAM Armadillo
+ * ...
+ * Destroy enemy front line units
+ * Set front line stance to elimination
+ * Plan CAS at front line
+* Inhibit
+ * Destroy enemy unit production infrastructure
+ * Destroy factory at Palmyra
+ * ...
+ * Destroy enemy front line units
+ * Set front line stance to elimination
+ * Plan CAS at front line
+* Attack
+ * Set front line stance to breakthrough
+ * Destroy enemy front line units
+ * Set front line stance to elimination
+ * Plan CAS at front line
+
+This is not a reflection of the actual task composition but illustrates the capability
+of the system. Each task has preconditions which are checked before the task is
+decomposed. If preconditions are not met the task is ignored and the next is considered.
+For example the task to destroy the factory at Palmyra might be excluded until the air
+defenses protecting it are eliminated; or defensive air operations might be excluded if
+the enemy does not have sufficient air forces, or if the protected target has sufficient
+SAM coverage.
+
+Each action updates the world state, which causes each action to account for the result
+of the tasks executed before it. Above, the preconditions for attacking the factory at
+Palmyra may not have been met due to the IADS coverage, leading the planning to decide
+on an attack against the IADS in the area instead. When planning the next task in the
+same turn, the world state will have been updated to account for the (hopefully)
+destroyed SAM sites, allowing the planner to choose the mission to attack the factory.
+
+Preconditions can be aware of previous actions as well. A precondition for "Plan CAS at
+front line" can be "No CAS missions planned at front line" to avoid over-planning CAS
+even though it is a primitive task used by many other tasks.
+
+https://en.wikipedia.org/wiki/Hierarchical_task_network
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from game.commander.tasks.compound.nextaction import PlanNextAction
+from game.commander.tasks.theatercommandertask import TheaterCommanderTask
+from game.commander.theaterstate import TheaterState
+from game.htn import Planner
+from game.profiling import MultiEventTracer
+
+if TYPE_CHECKING:
+ from game import Game
+
+
+class TheaterCommander(Planner[TheaterState, TheaterCommanderTask]):
+ def __init__(self, game: Game, player: bool) -> None:
+ super().__init__(
+ PlanNextAction(
+ aircraft_cold_start=game.settings.default_start_type == "Cold"
+ )
+ )
+ self.game = game
+ self.player = player
+
+ def plan_missions(self, tracer: MultiEventTracer) -> None:
+ state = TheaterState.from_game(self.game, self.player, tracer)
+ while True:
+ result = self.plan(state)
+ if result is None:
+ # Planned all viable tasks this turn.
+ return
+ for task in result.tasks:
+ task.execute(self.game.coalition_for(self.player))
+ state = result.end_state
diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py
new file mode 100644
index 00000000..4450c95b
--- /dev/null
+++ b/game/commander/theaterstate.py
@@ -0,0 +1,176 @@
+from __future__ import annotations
+
+import dataclasses
+import itertools
+import math
+from collections import Iterator
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Union, Optional
+
+from game.commander.garrisons import Garrisons
+from game.commander.objectivefinder import ObjectiveFinder
+from game.htn import WorldState
+from game.inventory import GlobalAircraftInventory
+from game.profiling import MultiEventTracer
+from game.settings import Settings
+from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater
+from game.theater.theatergroundobject import (
+ TheaterGroundObject,
+ NavalGroundObject,
+ IadsGroundObject,
+ VehicleGroupGroundObject,
+ BuildingGroundObject,
+)
+from game.threatzones import ThreatZones
+from gen.ground_forces.combat_stance import CombatStance
+
+if TYPE_CHECKING:
+ from game import Game
+ from game.coalition import Coalition
+ from game.transfers import Convoy, CargoShip
+
+
+@dataclass(frozen=True)
+class PersistentContext:
+ coalition: Coalition
+ theater: ConflictTheater
+ settings: Settings
+ tracer: MultiEventTracer
+
+
+@dataclass
+class TheaterState(WorldState["TheaterState"]):
+ context: PersistentContext
+ barcaps_needed: dict[ControlPoint, int]
+ active_front_lines: list[FrontLine]
+ front_line_stances: dict[FrontLine, Optional[CombatStance]]
+ vulnerable_front_lines: list[FrontLine]
+ aewc_targets: list[MissionTarget]
+ refueling_targets: list[MissionTarget]
+ enemy_air_defenses: list[IadsGroundObject]
+ threatening_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]]
+ detecting_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]]
+ enemy_convoys: list[Convoy]
+ enemy_shipping: list[CargoShip]
+ enemy_ships: list[NavalGroundObject]
+ enemy_garrisons: dict[ControlPoint, Garrisons]
+ oca_targets: list[ControlPoint]
+ strike_targets: list[TheaterGroundObject[Any]]
+ enemy_barcaps: list[ControlPoint]
+ threat_zones: ThreatZones
+ available_aircraft: GlobalAircraftInventory
+
+ def _rebuild_threat_zones(self) -> None:
+ """Recreates the theater's threat zones based on the current planned state."""
+ self.threat_zones = ThreatZones.for_threats(
+ self.context.coalition.opponent.doctrine,
+ barcap_locations=self.enemy_barcaps,
+ air_defenses=itertools.chain(self.enemy_air_defenses, self.enemy_ships),
+ )
+
+ def eliminate_air_defense(self, target: IadsGroundObject) -> None:
+ if target in self.threatening_air_defenses:
+ self.threatening_air_defenses.remove(target)
+ if target in self.detecting_air_defenses:
+ self.detecting_air_defenses.remove(target)
+ self.enemy_air_defenses.remove(target)
+ self._rebuild_threat_zones()
+
+ def eliminate_ship(self, target: NavalGroundObject) -> None:
+ if target in self.threatening_air_defenses:
+ self.threatening_air_defenses.remove(target)
+ if target in self.detecting_air_defenses:
+ self.detecting_air_defenses.remove(target)
+ self.enemy_ships.remove(target)
+ self._rebuild_threat_zones()
+
+ def has_garrison(self, target: VehicleGroupGroundObject) -> bool:
+ return target in self.enemy_garrisons[target.control_point]
+
+ def eliminate_garrison(self, target: VehicleGroupGroundObject) -> None:
+ self.enemy_garrisons[target.control_point].eliminate(target)
+
+ def ammo_dumps_at(
+ self, control_point: ControlPoint
+ ) -> Iterator[BuildingGroundObject]:
+ for target in self.strike_targets:
+ if target.control_point != control_point:
+ continue
+ if target.is_ammo_depot:
+ assert isinstance(target, BuildingGroundObject)
+ yield target
+
+ def clone(self) -> TheaterState:
+ # Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly
+ # expensive.
+ return TheaterState(
+ context=self.context,
+ barcaps_needed=dict(self.barcaps_needed),
+ active_front_lines=list(self.active_front_lines),
+ front_line_stances=dict(self.front_line_stances),
+ vulnerable_front_lines=list(self.vulnerable_front_lines),
+ aewc_targets=list(self.aewc_targets),
+ refueling_targets=list(self.refueling_targets),
+ enemy_air_defenses=list(self.enemy_air_defenses),
+ enemy_convoys=list(self.enemy_convoys),
+ enemy_shipping=list(self.enemy_shipping),
+ enemy_ships=list(self.enemy_ships),
+ enemy_garrisons={
+ cp: dataclasses.replace(g) for cp, g in self.enemy_garrisons.items()
+ },
+ oca_targets=list(self.oca_targets),
+ strike_targets=list(self.strike_targets),
+ enemy_barcaps=list(self.enemy_barcaps),
+ threat_zones=self.threat_zones,
+ available_aircraft=self.available_aircraft.clone(),
+ # Persistent properties are not copied. These are a way for failed subtasks
+ # to communicate requirements to other tasks. For example, the task to
+ # attack enemy garrisons might fail because the target area has IADS
+ # protection. In that case, the preconditions of PlanBai would fail, but
+ # would add the IADS that prevented it from being planned to the list of
+ # IADS threats so that DegradeIads will consider it a threat later.
+ threatening_air_defenses=self.threatening_air_defenses,
+ detecting_air_defenses=self.detecting_air_defenses,
+ )
+
+ @classmethod
+ def from_game(
+ cls, game: Game, player: bool, tracer: MultiEventTracer
+ ) -> TheaterState:
+ coalition = game.coalition_for(player)
+ finder = ObjectiveFinder(game, player)
+ ordered_capturable_points = finder.prioritized_unisolated_points()
+
+ context = PersistentContext(coalition, game.theater, game.settings, tracer)
+
+ # Plan enough rounds of CAP that the target has coverage over the expected
+ # mission duration.
+ mission_duration = game.settings.desired_player_mission_duration.total_seconds()
+ barcap_duration = coalition.doctrine.cap_duration.total_seconds()
+ barcap_rounds = math.ceil(mission_duration / barcap_duration)
+
+ return TheaterState(
+ context=context,
+ barcaps_needed={
+ cp: barcap_rounds for cp in finder.vulnerable_control_points()
+ },
+ active_front_lines=list(finder.front_lines()),
+ front_line_stances={f: None for f in finder.front_lines()},
+ vulnerable_front_lines=list(finder.front_lines()),
+ aewc_targets=[finder.farthest_friendly_control_point()],
+ refueling_targets=[finder.closest_friendly_control_point()],
+ enemy_air_defenses=list(finder.enemy_air_defenses()),
+ threatening_air_defenses=[],
+ detecting_air_defenses=[],
+ enemy_convoys=list(finder.convoys()),
+ enemy_shipping=list(finder.cargo_ships()),
+ enemy_ships=list(finder.enemy_ships()),
+ enemy_garrisons={
+ cp: Garrisons.for_control_point(cp) for cp in ordered_capturable_points
+ },
+ oca_targets=list(finder.oca_targets(min_aircraft=20)),
+ strike_targets=list(finder.strike_targets()),
+ enemy_barcaps=list(game.theater.control_points_for(not player)),
+ threat_zones=game.threat_zone_for(not player),
+ available_aircraft=game.aircraft_inventory.clone(),
+ )
diff --git a/game/data/alic.py b/game/data/alic.py
index f7700425..f8bc5e43 100644
--- a/game/data/alic.py
+++ b/game/data/alic.py
@@ -25,6 +25,7 @@ class AlicCodes:
AirDefence.SNR_75V.id: 126,
AirDefence.HQ_7_LN_SP.id: 127,
AirDefence.HQ_7_STR_SP.id: 128,
+ AirDefence.RLS_19J6.id: 130,
AirDefence.Roland_ADS.id: 201,
AirDefence.Patriot_str.id: 202,
AirDefence.Hawk_sr.id: 203,
@@ -33,6 +34,7 @@ class AlicCodes:
AirDefence.Hawk_cwar.id: 206,
AirDefence.Gepard.id: 207,
AirDefence.Vulcan.id: 208,
+ AirDefence.NASAMS_Radar_MPQ64F1.id: 209,
}
@classmethod
diff --git a/game/data/doctrine.py b/game/data/doctrine.py
index 262d5fa5..7ef7c59a 100644
--- a/game/data/doctrine.py
+++ b/game/data/doctrine.py
@@ -1,9 +1,10 @@
from dataclasses import dataclass
from datetime import timedelta
-from dcs.task import Reconnaissance
+from typing import Any
-from game.utils import Distance, feet, nautical_miles
from game.data.groundunitclass import GroundUnitClass
+from game.savecompat import has_save_compat_for
+from game.utils import Distance, feet, nautical_miles
@dataclass
@@ -26,13 +27,26 @@ class Doctrine:
antiship: bool
rendezvous_altitude: Distance
+
+ #: The minimum distance between the departure airfield and the hold point.
hold_distance: Distance
+
+ #: The minimum distance between the hold point and the join point.
push_distance: Distance
+
+ #: The distance between the join point and the ingress point. Only used for the
+ #: fallback flight plan layout (when the departure airfield is near a threat zone).
join_distance: Distance
- split_distance: Distance
- ingress_egress_distance: Distance
+
+ #: The maximum distance between the ingress point (beginning of the attack) and
+ #: target.
+ max_ingress_distance: Distance
+
+ #: The minimum distance between the ingress point (beginning of the attack) and
+ #: target.
+ min_ingress_distance: Distance
+
ingress_altitude: Distance
- egress_altitude: Distance
min_patrol_altitude: Distance
max_patrol_altitude: Distance
@@ -65,6 +79,32 @@ class Doctrine:
ground_unit_procurement_ratios: GroundUnitProcurementRatios
+ @has_save_compat_for(5)
+ def __setstate__(self, state: dict[str, Any]) -> None:
+ if "max_ingress_distance" not in state:
+ try:
+ state["max_ingress_distance"] = state["ingress_distance"]
+ del state["ingress_distance"]
+ except KeyError:
+ state["max_ingress_distance"] = state["ingress_egress_distance"]
+ del state["ingress_egress_distance"]
+
+ max_ip: Distance = state["max_ingress_distance"]
+ if "min_ingress_distance" not in state:
+ if max_ip < nautical_miles(10):
+ min_ip = nautical_miles(5)
+ else:
+ min_ip = nautical_miles(10)
+ state["min_ingress_distance"] = min_ip
+
+ self.__dict__.update(state)
+
+
+class MissionPlannerMaxRanges:
+ @has_save_compat_for(5)
+ def __init__(self) -> None:
+ pass
+
MODERN_DOCTRINE = Doctrine(
cap=True,
@@ -73,13 +113,12 @@ MODERN_DOCTRINE = Doctrine(
strike=True,
antiship=True,
rendezvous_altitude=feet(25000),
- hold_distance=nautical_miles(15),
+ hold_distance=nautical_miles(25),
push_distance=nautical_miles(20),
join_distance=nautical_miles(20),
- split_distance=nautical_miles(20),
- ingress_egress_distance=nautical_miles(45),
+ max_ingress_distance=nautical_miles(45),
+ min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(20000),
- egress_altitude=feet(20000),
min_patrol_altitude=feet(15000),
max_patrol_altitude=feet(33000),
pattern_altitude=feet(5000),
@@ -111,13 +150,12 @@ COLDWAR_DOCTRINE = Doctrine(
strike=True,
antiship=True,
rendezvous_altitude=feet(22000),
- hold_distance=nautical_miles(10),
+ hold_distance=nautical_miles(15),
push_distance=nautical_miles(10),
join_distance=nautical_miles(10),
- split_distance=nautical_miles(10),
- ingress_egress_distance=nautical_miles(30),
+ max_ingress_distance=nautical_miles(30),
+ min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(18000),
- egress_altitude=feet(18000),
min_patrol_altitude=feet(10000),
max_patrol_altitude=feet(24000),
pattern_altitude=feet(5000),
@@ -148,14 +186,13 @@ WWII_DOCTRINE = Doctrine(
sead=False,
strike=True,
antiship=True,
- hold_distance=nautical_miles(5),
+ hold_distance=nautical_miles(10),
push_distance=nautical_miles(5),
join_distance=nautical_miles(5),
- split_distance=nautical_miles(5),
rendezvous_altitude=feet(10000),
- ingress_egress_distance=nautical_miles(7),
+ max_ingress_distance=nautical_miles(7),
+ min_ingress_distance=nautical_miles(5),
ingress_altitude=feet(8000),
- egress_altitude=feet(8000),
min_patrol_altitude=feet(4000),
max_patrol_altitude=feet(15000),
pattern_altitude=feet(5000),
diff --git a/game/data/weapons.py b/game/data/weapons.py
index 22aa53b9..8e7c86c9 100644
--- a/game/data/weapons.py
+++ b/game/data/weapons.py
@@ -3,74 +3,224 @@ from __future__ import annotations
import datetime
import inspect
import logging
-from collections import defaultdict
from dataclasses import dataclass, field
-from typing import Dict, Iterator, Optional, Set, Tuple, Union, cast
+from enum import unique, Enum
+from functools import cached_property
+from pathlib import Path
+from typing import Iterator, Optional, Any, ClassVar
+import yaml
from dcs.unitgroup import FlyingGroup
-from dcs.weapons_data import Weapons, weapon_ids
+from dcs.weapons_data import weapon_ids
from game.dcs.aircrafttype import AircraftType
-PydcsWeapon = Dict[str, Union[int, str]]
-PydcsWeaponAssignment = Tuple[int, PydcsWeapon]
+PydcsWeapon = Any
+PydcsWeaponAssignment = tuple[int, PydcsWeapon]
@dataclass(frozen=True)
class Weapon:
- """Wraps a pydcs weapon dict in a hashable type."""
+ """Wrapper for DCS weapons."""
- cls_id: str
- name: str = field(compare=False)
- weight: int = field(compare=False)
+ #: The CLSID used by DCS.
+ clsid: str
+
+ #: The group this weapon belongs to.
+ weapon_group: WeaponGroup = field(compare=False)
+
+ _by_clsid: ClassVar[dict[str, Weapon]] = {}
+ _loaded: ClassVar[bool] = False
+
+ def __str__(self) -> str:
+ return self.name
+
+ @cached_property
+ def pydcs_data(self) -> PydcsWeapon:
+ if self.clsid == "":
+ # Special case for a "weapon" that isn't exposed by pydcs.
+ return {
+ "clsid": self.clsid,
+ "name": "Clean",
+ "weight": 0,
+ }
+ return weapon_ids[self.clsid]
+
+ @property
+ def name(self) -> str:
+ return self.pydcs_data["name"]
+
+ def __setstate__(self, state: dict[str, Any]) -> None:
+ # Update any existing models with new data on load.
+ updated = Weapon.with_clsid(state["clsid"])
+ state.update(updated.__dict__)
+ self.__dict__.update(state)
+
+ @classmethod
+ def register(cls, weapon: Weapon) -> None:
+ if weapon.clsid in cls._by_clsid:
+ duplicate = cls._by_clsid[weapon.clsid]
+ raise ValueError(
+ "Weapon CLSID used in more than one weapon type: "
+ f"{duplicate.name} and {weapon.name}: {weapon.clsid}"
+ )
+ cls._by_clsid[weapon.clsid] = weapon
+
+ @classmethod
+ def with_clsid(cls, clsid: str) -> Weapon:
+ if not cls._loaded:
+ cls._load_all()
+ return cls._by_clsid[clsid]
+
+ @classmethod
+ def _load_all(cls) -> None:
+ WeaponGroup.load_all()
+ cls._loaded = True
def available_on(self, date: datetime.date) -> bool:
- introduction_year = WEAPON_INTRODUCTION_YEARS.get(self)
+ introduction_year = self.weapon_group.introduction_year
if introduction_year is None:
- logging.warning(
- f"No introduction year for {self}, assuming always available"
- )
return True
return date >= datetime.date(introduction_year, 1, 1)
- @property
- def as_pydcs(self) -> PydcsWeapon:
- return {
- "clsid": self.cls_id,
- "name": self.name,
- "weight": self.weight,
- }
-
@property
def fallbacks(self) -> Iterator[Weapon]:
yield self
- fallback = WEAPON_FALLBACK_MAP[self]
- if fallback is not None:
- yield from fallback.fallbacks
+ fallback: Optional[WeaponGroup] = self.weapon_group
+ while fallback is not None:
+ yield from fallback.weapons
+ fallback = fallback.fallback
- @classmethod
- def from_pydcs(cls, weapon_data: PydcsWeapon) -> Weapon:
- return cls(
- cast(str, weapon_data["clsid"]),
- cast(str, weapon_data["name"]),
- cast(int, weapon_data["weight"]),
- )
- @classmethod
- def from_clsid(cls, clsid: str) -> Optional[Weapon]:
- data = weapon_ids.get(clsid)
- if clsid == "":
- # Special case for a "weapon" that isn't exposed by pydcs.
- return Weapon(clsid, "Clean", 0)
- if data is None:
+@unique
+class WeaponType(Enum):
+ LGB = "LGB"
+ TGP = "TGP"
+ UNKNOWN = "unknown"
+
+
+@dataclass(frozen=True)
+class WeaponGroup:
+ """Group of "identical" weapons loaded from resources/weapons.
+
+ DCS has multiple unique "weapons" for each type of weapon. There are four distinct
+ class IDs for the AIM-7M, some unique to certain aircraft. We group them in the
+ resources to make year/fallback data easier to track.
+ """
+
+ #: The name of the weapon group in the resource file.
+ name: str
+
+ #: The type of the weapon group.
+ type: WeaponType = field(compare=False)
+
+ #: The year of introduction.
+ introduction_year: Optional[int] = field(compare=False)
+
+ #: The name of the fallback weapon group.
+ fallback_name: Optional[str] = field(compare=False)
+
+ #: The specific weapons that belong to this weapon group.
+ weapons: list[Weapon] = field(init=False, default_factory=list)
+
+ _by_name: ClassVar[dict[str, WeaponGroup]] = {}
+ _loaded: ClassVar[bool] = False
+
+ def __str__(self) -> str:
+ return self.name
+
+ @property
+ def fallback(self) -> Optional[WeaponGroup]:
+ if self.fallback_name is None:
return None
- return cls.from_pydcs(data)
+ return WeaponGroup.named(self.fallback_name)
+
+ def __setstate__(self, state: dict[str, Any]) -> None:
+ # Update any existing models with new data on load.
+ updated = WeaponGroup.named(state["name"])
+ state.update(updated.__dict__)
+ self.__dict__.update(state)
+
+ @classmethod
+ def register(cls, group: WeaponGroup) -> None:
+ if group.name in cls._by_name:
+ duplicate = cls._by_name[group.name]
+ raise ValueError(
+ "Weapon group name used in more than one weapon type: "
+ f"{duplicate.name} and {group.name}"
+ )
+ cls._by_name[group.name] = group
+
+ @classmethod
+ def named(cls, name: str) -> WeaponGroup:
+ if not cls._loaded:
+ cls.load_all()
+ return cls._by_name[name]
+
+ @classmethod
+ def _each_weapon_group(cls) -> Iterator[WeaponGroup]:
+ for group_file_path in Path("resources/weapons").glob("**/*.yaml"):
+ with group_file_path.open(encoding="utf8") as group_file:
+ data = yaml.safe_load(group_file)
+ name = data["name"]
+ try:
+ weapon_type = WeaponType(data["type"])
+ except KeyError:
+ weapon_type = WeaponType.UNKNOWN
+ year = data.get("year")
+ fallback_name = data.get("fallback")
+ group = WeaponGroup(name, weapon_type, year, fallback_name)
+ for clsid in data["clsids"]:
+ weapon = Weapon(clsid, group)
+ Weapon.register(weapon)
+ group.weapons.append(weapon)
+ yield group
+
+ @classmethod
+ def register_clean_pylon(cls) -> None:
+ group = WeaponGroup(
+ "Clean pylon",
+ type=WeaponType.UNKNOWN,
+ introduction_year=None,
+ fallback_name=None,
+ )
+ cls.register(group)
+ weapon = Weapon("", group)
+ Weapon.register(weapon)
+ group.weapons.append(weapon)
+
+ @classmethod
+ def register_unknown_weapons(cls, seen_clsids: set[str]) -> None:
+ unknown_weapons = set(weapon_ids.keys()) - seen_clsids
+ group = WeaponGroup(
+ "Unknown",
+ type=WeaponType.UNKNOWN,
+ introduction_year=None,
+ fallback_name=None,
+ )
+ cls.register(group)
+ for clsid in unknown_weapons:
+ weapon = Weapon(clsid, group)
+ Weapon.register(weapon)
+ group.weapons.append(weapon)
+
+ @classmethod
+ def load_all(cls) -> None:
+ if cls._loaded:
+ return
+ seen_clsids: set[str] = set()
+ for group in cls._each_weapon_group():
+ cls.register(group)
+ seen_clsids.update(w.clsid for w in group.weapons)
+ cls.register_clean_pylon()
+ cls.register_unknown_weapons(seen_clsids)
+ cls._loaded = True
@dataclass(frozen=True)
class Pylon:
number: int
- allowed: Set[Weapon]
+ allowed: set[Weapon]
def can_equip(self, weapon: Weapon) -> bool:
# TODO: Fix pydcs to support the "weapon".
@@ -81,15 +231,15 @@ class Pylon:
# A similar hack exists in QPylonEditor to forcibly add "Clean" to the list of
# valid configurations for that pylon if a loadout has been seen with that
# configuration.
- return weapon in self.allowed or weapon.cls_id == ""
+ return weapon in self.allowed or weapon.clsid == ""
- def equip(self, group: FlyingGroup, weapon: Weapon) -> None:
+ def equip(self, group: FlyingGroup[Any], weapon: Weapon) -> None:
if not self.can_equip(weapon):
logging.error(f"Pylon {self.number} cannot equip {weapon.name}")
group.load_pylon(self.make_pydcs_assignment(weapon), self.number)
def make_pydcs_assignment(self, weapon: Weapon) -> PydcsWeaponAssignment:
- return self.number, weapon.as_pydcs
+ return self.number, weapon.pydcs_data
def available_on(self, date: datetime.date) -> Iterator[Weapon]:
for weapon in self.allowed:
@@ -116,7 +266,7 @@ class Pylon:
pylon_number, weapon = value
if pylon_number != number:
continue
- allowed.add(Weapon.from_pydcs(weapon))
+ allowed.add(Weapon.with_clsid(weapon["clsid"]))
return cls(number, allowed)
@@ -124,1053 +274,3 @@ class Pylon:
def iter_pylons(cls, aircraft: AircraftType) -> Iterator[Pylon]:
for pylon in sorted(list(aircraft.dcs_unit_type.pylons)):
yield cls.for_aircraft(aircraft, pylon)
-
-
-_WEAPON_FALLBACKS = [
- # ADM-141 TALD
- (Weapons.ADM_141A, None),
- (Weapons.ADM_141A_, None),
- (Weapons.ADM_141A_TALD, None),
- (Weapons.ADM_141B_TALD, None),
- # AGM-114K Hellfire
- (Weapons.AGM114x2_OH_58, Weapons.M260_HYDRA), # assuming OH-58 and not MQ-9
- (Weapons.AGM_114K, None), # Only for RQ-1
- (Weapons.AGM_114K___4, Weapons.LAU_61_pod___19_x_2_75_Hydra__UnGd_Rkts_M151__HE),
- # AGM-119 Penguin
- (Weapons.AGM_119B_Penguin_ASM, Weapons.Mk_82),
- # AGM-122 Sidearm
- (Weapons.AGM_122_Sidearm, Weapons.GBU_12), # outer pylons harrier
- (
- Weapons.AGM_122_Sidearm_,
- Weapons.LAU_117_with_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_,
- ), # internal pylons harrier
- # AGM-154 JSOW
- (
- Weapons.AGM_154A___JSOW_CEB__CBU_type_,
- Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
- ),
- (
- Weapons.BRU_55_with_2_x_AGM_154A___JSOW_CEB__CBU_type_,
- Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
- ),
- (
- Weapons.BRU_57_with_2_x_AGM_154A___JSOW_CEB__CBU_type_,
- Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
- ), # doesn't exist on any aircraft yet
- (Weapons.AGM_154B___JSOW_Anti_Armour, Weapons.CBU_105___10_x_SFW__CBU_with_WCMD),
- (
- Weapons.AGM_154C___JSOW_Unitary_BROACH,
- Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
- ),
- (
- Weapons.BRU_55_with_2_x_AGM_154C___JSOW_Unitary_BROACH,
- Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
- ),
- # AGM-45 Shrike
- (Weapons.AGM_45A_Shrike_ARM, None),
- (Weapons.LAU_118a_with_AGM_45B_Shrike_ARM__Imp_, Weapons.AGM_45A_Shrike_ARM),
- (Weapons.AGM_45B_Shrike_ARM__Imp_, Weapons.AGM_45A_Shrike_ARM),
- # AGM-62 Walleye
- (Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, Weapons.Mk_84),
- # AGM-65 Maverick
- (
- Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_,
- Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
- ), # Walleye is the predecessor to the maverick
- (Weapons.LAU_117_with_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_, None),
- (Weapons.LAU_117_AGM_65F, Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_),
- (Weapons.LAU_117_AGM_65G, Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_),
- (Weapons.LAU_117_AGM_65H, Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_),
- (
- Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_,
- Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_,
- ),
- (Weapons.LAU_117_AGM_65L, None),
- (Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_, None),
- (Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, None),
- (Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_, None),
- (Weapons.LAU_88_AGM_65D_ONE, None),
- (
- Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_,
- Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_,
- ),
- (
- Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd__,
- Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__,
- ),
- (
- Weapons.LAU_88_with_3_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_,
- Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_,
- ),
- (Weapons.LAU_88_AGM_65H, Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_),
- (
- Weapons.LAU_88_AGM_65H_2_L,
- Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__,
- ),
- (
- Weapons.LAU_88_AGM_65H_2_R,
- Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__,
- ),
- (Weapons.LAU_88_AGM_65H_3, Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_),
- (
- Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM_,
- Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_,
- ),
- (
- Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM__,
- Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__,
- ),
- (
- Weapons.LAU_88_with_3_x_AGM_65K___Maverick_K__CCD_Imp_ASM_,
- Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_,
- ),
- # AGM-84 Harpoon
- (Weapons.AGM_84A_Harpoon_ASM, Weapons.Mk_82),
- (Weapons._8_x_AGM_84A_Harpoon_ASM, Weapons._27_x_Mk_82___500lb_GP_Bombs_LD),
- (
- Weapons.AGM_84D_Harpoon_AShM,
- Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
- ),
- (
- Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile_,
- Weapons.LAU_117_AGM_65F,
- ),
- (
- Weapons.AGM_84H_SLAM_ER__Expanded_Response_,
- Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile_,
- ),
- # AGM-86 ALCM
- (Weapons.AGM_86C_ALCM, Weapons._27_x_Mk_82___500lb_GP_Bombs_LD),
- (Weapons._8_x_AGM_86C_ALCM, Weapons._27_x_Mk_82___500lb_GP_Bombs_LD),
- (
- Weapons._6_x_AGM_86C_ALCM_on_MER,
- Weapons.MER12_with_12_x_Mk_82___500lb_GP_Bombs_LD,
- ),
- # AGM-88 HARM
- (
- Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile,
- Weapons.LAU_88_AGM_65D_ONE,
- ),
- (
- Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile_,
- Weapons.LAU_88_AGM_65D_ONE,
- ),
- # AIM-120 AMRAAM
- (Weapons.AIM_120B_AMRAAM___Active_Rdr_AAM, Weapons.AIM_7MH),
- (
- Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM,
- Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar,
- ),
- (
- Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM_,
- Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar,
- ),
- (
- Weapons.LAU_115_2_LAU_127_AIM_120B,
- Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar,
- ),
- (
- Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM,
- Weapons.AIM_120B_AMRAAM___Active_Rdr_AAM,
- ),
- (
- Weapons.LAU_115_with_1_x_LAU_127_AIM_120C_5_AMRAAM___Active_Rdr_AAM,
- Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM,
- ),
- (
- Weapons.LAU_115_with_1_x_LAU_127_AIM_120C_5_AMRAAM___Active_Rdr_AAM_,
- Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM_,
- ),
- (Weapons.LAU_115_2_LAU_127_AIM_120C, Weapons.LAU_115_2_LAU_127_AIM_120B),
- # AIM-54 Phoenix
- (Weapons.AIM_54A_Mk47, None),
- (Weapons.AIM_54A_Mk47_, None),
- (Weapons.AIM_54A_Mk47__, None),
- (Weapons.AIM_54A_Mk60, Weapons.AIM_54A_Mk47),
- (Weapons.AIM_54A_Mk60_, Weapons.AIM_54A_Mk47_),
- (Weapons.AIM_54A_Mk60__, Weapons.AIM_54A_Mk47__),
- (Weapons.AIM_54C_Mk47, Weapons.AIM_54A_Mk60),
- (Weapons.AIM_54C_Mk47_, Weapons.AIM_54A_Mk60_),
- (Weapons.AIM_54C_Mk47__, Weapons.AIM_54A_Mk60__),
- # AIM-7 Sparrow
- (Weapons.AIM_7E_Sparrow_Semi_Active_Radar, None),
- (
- Weapons.AIM_7F_Sparrow_Semi_Active_Radar,
- Weapons.AIM_7E_Sparrow_Semi_Active_Radar,
- ),
- (Weapons.AIM_7F_, None),
- (Weapons.AIM_7M, Weapons.AIM_7F_Sparrow_Semi_Active_Radar),
- (Weapons.AIM_7M_, Weapons.AIM_7F_),
- (Weapons.AIM_7MH, Weapons.AIM_7M),
- (Weapons.AIM_7MH_, Weapons.AIM_7M_),
- (Weapons.LAU_115C_with_AIM_7F_Sparrow_Semi_Active_Radar, None),
- (
- Weapons.LAU_115_with_AIM_7M_Sparrow_Semi_Active_Radar,
- Weapons.LAU_115C_with_AIM_7F_Sparrow_Semi_Active_Radar,
- ),
- (
- Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar,
- Weapons.LAU_115_with_AIM_7M_Sparrow_Semi_Active_Radar,
- ),
- (Weapons.LAU_115C_with_AIM_7E_Sparrow_Semi_Active_Radar, None),
- # AIM-9 Sidewinder
- (Weapons.AIM_9M_Sidewinder_IR_AAM, Weapons.AIM_9P5_Sidewinder_IR_AAM),
- (Weapons.AIM_9P5_Sidewinder_IR_AAM, Weapons.AIM_9P_Sidewinder_IR_AAM),
- (Weapons.AIM_9P_Sidewinder_IR_AAM, Weapons.AIM_9L_Sidewinder_IR_AAM),
- (Weapons.AIM_9X_Sidewinder_IR_AAM, Weapons.AIM_9P_Sidewinder_IR_AAM),
- (Weapons.LAU_105_1_AIM_9L_L, None),
- (Weapons.LAU_105_1_AIM_9L_R, None),
- (Weapons.LAU_105_1_AIM_9M_L, Weapons.LAU_105_1_AIM_9L_L),
- (Weapons.LAU_105_1_AIM_9M_R, Weapons.LAU_105_1_AIM_9L_R),
- (Weapons.LAU_105_2_AIM_9L, None),
- (Weapons.LAU_105_2_AIM_9P5, Weapons.LAU_105_with_2_x_AIM_9P_Sidewinder_IR_AAM),
- (Weapons.LAU_105_with_2_x_AIM_9M_Sidewinder_IR_AAM, Weapons.LAU_105_2_AIM_9L),
- (
- Weapons.LAU_105_with_2_x_AIM_9P_Sidewinder_IR_AAM,
- Weapons.LAU_105_with_2_x_AIM_9M_Sidewinder_IR_AAM,
- ),
- (Weapons.LAU_115_2_LAU_127_AIM_9L, None),
- (Weapons.LAU_115_2_LAU_127_AIM_9M, Weapons.LAU_115_2_LAU_127_AIM_9L),
- (Weapons.LAU_115_2_LAU_127_AIM_9X, Weapons.LAU_115_2_LAU_127_AIM_9M),
- (Weapons.LAU_115_LAU_127_AIM_9L, None),
- (Weapons.LAU_115_LAU_127_AIM_9M, Weapons.LAU_115_LAU_127_AIM_9L),
- (Weapons.LAU_115_LAU_127_AIM_9X, Weapons.LAU_115_LAU_127_AIM_9M),
- (Weapons.LAU_127_AIM_9L, None),
- (Weapons.LAU_127_AIM_9M, Weapons.LAU_127_AIM_9L),
- (Weapons.LAU_127_AIM_9X, Weapons.LAU_127_AIM_9M),
- (Weapons.LAU_138_AIM_9L, None),
- (Weapons.LAU_138_AIM_9M, Weapons.LAU_138_AIM_9L),
- (Weapons.LAU_7_AIM_9L, None),
- (Weapons.LAU_7_AIM_9M, Weapons.LAU_7_AIM_9L),
- (
- Weapons.LAU_7_with_AIM_9M_Sidewinder_IR_AAM,
- Weapons.LAU_7_with_AIM_9P5_Sidewinder_IR_AAM,
- ),
- (
- Weapons.LAU_7_with_AIM_9P5_Sidewinder_IR_AAM,
- Weapons.LAU_7_with_AIM_9P_Sidewinder_IR_AAM,
- ),
- (Weapons.LAU_7_with_AIM_9P_Sidewinder_IR_AAM, Weapons.LAU_7_AIM_9L),
- (
- Weapons.LAU_7_with_AIM_9X_Sidewinder_IR_AAM,
- Weapons.LAU_7_with_AIM_9M_Sidewinder_IR_AAM,
- ),
- (
- Weapons.LAU_7_with_2_x_AIM_9M_Sidewinder_IR_AAM,
- Weapons.LAU_7_with_2_x_AIM_9P5_Sidewinder_IR_AAM,
- ),
- (
- Weapons.LAU_7_with_2_x_AIM_9P5_Sidewinder_IR_AAM,
- Weapons.LAU_7_with_2_x_AIM_9P_Sidewinder_IR_AAM,
- ),
- (
- Weapons.LAU_7_with_2_x_AIM_9P_Sidewinder_IR_AAM,
- Weapons.LAU_7_with_2_x_AIM_9L_Sidewinder_IR_AAM,
- ),
- # ALQ ECM Pods
- (Weapons.ALQ_131___ECM_Pod, None),
- (Weapons.ALQ_184, Weapons.ALQ_131___ECM_Pod),
- (Weapons.AN_ALQ_164_DECM_Pod, None),
- # TGP Pods
- (Weapons.AN_AAQ_28_LITENING___Targeting_Pod_, None),
- (Weapons.AN_AAQ_28_LITENING___Targeting_Pod, Weapons.Lantirn_F_16),
- (Weapons.AN_ASQ_228_ATFLIR___Targeting_Pod, None),
- (Weapons.AN_ASQ_173_Laser_Spot_Tracker_Strike_CAMera__LST_SCAM_, None),
- (Weapons.AWW_13_DATALINK_POD, None),
- (Weapons.LANTIRN_Targeting_Pod, None),
- (Weapons.Lantirn_F_16, None),
- (Weapons.Lantirn_Target_Pod, None),
- (Weapons.Pavetack_F_111, None),
- # BLU-107
- (Weapons.BLU_107___440lb_Anti_Runway_Penetrator_Bomb, None),
- (
- Weapons.MER6_with_6_x_BLU_107___440lb_Anti_Runway_Penetrator_Bombs,
- Weapons.MER6_with_6_x_Mk_82___500lb_GP_Bombs_LD,
- ),
- # GBU-10 LGB
- (Weapons.DIS_GBU_10, Weapons.Mk_84),
- (Weapons.GBU_10, Weapons.Mk_84),
- (Weapons.BRU_42_with_2_x_GBU_10___2000lb_Laser_Guided_Bombs, Weapons.Mk_84),
- (Weapons.DIS_GBU_10, Weapons.Mk_84),
- # GBU-12 LGB
- (Weapons.AUF2_GBU_12_x_2, None),
- (
- Weapons.BRU_33_with_2_x_GBU_12___500lb_Laser_Guided_Bomb,
- Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD,
- ),
- (Weapons.BRU_42_3_GBU_12, Weapons._3_Mk_82),
- (Weapons.DIS_GBU_12, Weapons.Mk_82),
- (
- Weapons.DIS_GBU_12_DUAL_GDJ_II19_L,
- Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD,
- ),
- (
- Weapons.DIS_GBU_12_DUAL_GDJ_II19_R,
- Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD,
- ),
- (Weapons.GBU_12, Weapons.Mk_82),
- (
- Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb,
- Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD,
- ),
- (
- Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb_,
- Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
- ),
- (Weapons._2_GBU_12, Weapons._2_Mk_82),
- (Weapons._2_GBU_12_, Weapons._2_Mk_82_),
- # GBU-16 LGB
- (Weapons.BRU_33_with_2_x_GBU_16___1000lb_Laser_Guided_Bomb, None),
- (Weapons.DIS_GBU_16, Weapons.Mk_83),
- (Weapons.GBU_16, Weapons.Mk_83),
- (Weapons.BRU_42_with_3_x_GBU_16___1000lb_Laser_Guided_Bombs, None),
- # GBU-24 LGB
- (Weapons.GBU_24, Weapons.GBU_10),
- (
- Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb,
- Weapons.GBU_16___1000lb_Laser_Guided_Bomb,
- ),
- (
- Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb_,
- Weapons.GBU_10___2000lb_Laser_Guided_Bomb,
- ),
- # GBU-27 LGB
- (
- Weapons.GBU_27___2000lb_Laser_Guided_Penetrator_Bomb,
- Weapons.GBU_16___1000lb_Laser_Guided_Bomb,
- ),
- # GBU-28 LGB
- (Weapons.GBU_28___5000lb_Laser_Guided_Penetrator_Bomb, None),
- # GBU-31 JDAM
- (Weapons.GBU_31V3B_8, Weapons.B_1B_Mk_84_8),
- (Weapons.GBU_31_8, Weapons.B_1B_Mk_84_8),
- (
- Weapons.GBU_31_V_1_B___JDAM__2000lb_GPS_Guided_Bomb,
- Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb,
- ),
- (
- Weapons.GBU_31_V_2_B___JDAM__2000lb_GPS_Guided_Bomb,
- Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb,
- ),
- (
- Weapons.GBU_31_V_3_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb,
- Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb,
- ),
- (
- Weapons.GBU_31_V_4_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb,
- Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb,
- ),
- # GBU-32 JDAM
- (Weapons.GBU_32_V_2_B___JDAM__1000lb_GPS_Guided_Bomb, Weapons.GBU_16),
- # GBU-32 JDAM
- (
- Weapons.BRU_55_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb,
- Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD,
- ),
- (
- Weapons.BRU_57_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb,
- None,
- ), # Doesn't exist
- (Weapons.GBU_38___JDAM__500lb_GPS_Guided_Bomb, Weapons.Mk_82),
- (Weapons.GBU_38_16, Weapons.MK_82_28),
- (Weapons._2_GBU_38, Weapons._2_Mk_82),
- (Weapons._2_GBU_38_, Weapons._2_Mk_82_),
- (Weapons._3_GBU_38, Weapons._3_Mk_82),
- # GBU-54 LJDAM
- (
- Weapons.GBU_54B___LJDAM__500lb_Laser__GPS_Guided_Bomb_LD,
- Weapons.GBU_38___JDAM__500lb_GPS_Guided_Bomb,
- ),
- (Weapons._2_GBU_54_V_1_B, Weapons._2_GBU_38),
- (Weapons._2_GBU_54_V_1_B_, Weapons._2_GBU_38_),
- (Weapons._3_GBU_54_V_1_B, Weapons._3_GBU_38),
- # CBU-52
- (Weapons.CBU_52B___220_x_HE_Frag_bomblets, None),
- # CBU-87 CEM
- (Weapons.CBU_87___202_x_CEM_Cluster_Bomb, Weapons.Mk_82),
- (
- Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb,
- Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD,
- ),
- (
- Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb_,
- Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD,
- ),
- (
- Weapons.TER_9A_with_3_x_CBU_87___202_x_CEM_Cluster_Bomb,
- Weapons.TER_9A_with_3_x_Mk_82___500lb_GP_Bomb_LD,
- ),
- # CBU-97
- (Weapons.CBU_97___10_x_SFW_Cluster_Bomb, Weapons.Mk_82),
- (
- Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb,
- Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD,
- ),
- (
- Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb_,
- Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
- ),
- (
- Weapons.TER_9A_with_3_x_CBU_97___10_x_SFW_Cluster_Bomb,
- Weapons.TER_9A_with_3_x_Mk_82___500lb_GP_Bomb_LD,
- ),
- # CBU-99 (It's a bomb made in 1968, I'm not bothering right now with backups)
- # CBU-103
- (
- Weapons.CBU_103___202_x_CEM__CBU_with_WCMD,
- Weapons.CBU_87___202_x_CEM_Cluster_Bomb,
- ),
- # CBU-105
- (Weapons.CBU_105___10_x_SFW__CBU_with_WCMD, Weapons.CBU_97___10_x_SFW_Cluster_Bomb),
- (
- Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS,
- Weapons.LAU_131_pod___7_x_2_75_Hydra__UnGd_Rkts_M151__HE,
- ),
- (
- Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS,
- Weapons.LAU_131_pod___7_x_2_75_Hydra__UnGd_Rkts_M151__HE,
- ),
- (
- Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS,
- Weapons.BRU_42_with_3_x_LAU_68_pods___21_x_2_75_Hydra__UnGd_Rkts_M151__HE,
- ),
- (
- Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS,
- Weapons.BRU_42_with_3_x_LAU_68_pods___21_x_2_75_Hydra__UnGd_Rkts_M151__HE,
- ),
- # Russia
- # KAB-1500
- (Weapons.KAB_1500Kr___1500kg_TV_Guided_Bomb, None),
- (
- Weapons.KAB_1500LG_Pr___1500kg_Laser_Guided_Penetrator_Bomb,
- Weapons.KAB_1500Kr___1500kg_TV_Guided_Bomb,
- ),
- (
- Weapons.KAB_1500L___1500kg_Laser_Guided_Bomb,
- Weapons.KAB_1500LG_Pr___1500kg_Laser_Guided_Penetrator_Bomb,
- ),
- # KAB-500
- (Weapons.KAB_500Kr___500kg_TV_Guided_Bomb, Weapons.FAB_500_M_62___500kg_GP_Bomb_LD),
- (
- Weapons.KAB_500LG___500kg_Laser_Guided_Bomb,
- Weapons.KAB_500Kr___500kg_TV_Guided_Bomb,
- ),
- (
- Weapons.KAB_500S___500kg_GPS_Guided_Bomb,
- Weapons.KAB_500LG___500kg_Laser_Guided_Bomb,
- ),
- # KH Series
- (Weapons.Kh_22__AS_4_Kitchen____1000kg__AShM__IN__Act_Pas_Rdr, None),
- (Weapons.Kh_23L_Grom__AS_7_Kerry____286kg__ASM__Laser_Guided, None),
- (Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser, None),
- (Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser_, None),
- (Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser__, None),
- (Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, None),
- (
- Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr,
- Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr,
- ),
- (Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__10km__RC_Guided, None),
- (Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided, None),
- (Weapons.Kh_28__AS_9_Kyle____720kg__ARM__Pas_Rdr, None),
- (
- Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser,
- Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser,
- ),
- (
- Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser_,
- Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser_,
- ),
- (
- Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser__,
- Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser__,
- ),
- (
- Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided,
- Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__10km__RC_Guided,
- ),
- (
- Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided_,
- Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided,
- ),
- (
- Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided_,
- Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided,
- ),
- (Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr, None),
- (Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr_, None),
- (Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr__, None),
- (
- Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr,
- Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr,
- ),
- (
- Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr_,
- Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr,
- ),
- (
- Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr__,
- Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr,
- ),
- (
- Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr,
- Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr,
- ),
- (
- Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr_,
- Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr_,
- ),
- (Weapons._6_x_Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr, None),
- (Weapons.Kh_41__SS_N_22_Sunburn____4500kg__AShM__IN__Act_Rdr, None),
- (
- Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr,
- Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr,
- ),
- (
- Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr_,
- Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr_,
- ),
- (
- Weapons.Kh_59M__AS_18_Kazoo____930kg__ASM__IN,
- Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr,
- ),
- (Weapons.Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC, None),
- (Weapons._6_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC, None),
- (Weapons._8_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC, None),
- (Weapons.Kh_66_Grom__21__APU_68, None),
- # ECM
- (Weapons.L175V_Khibiny_ECM_pod, None),
- # R-13
- (Weapons.R_13M, None),
- (Weapons.R_13M1, Weapons.R_13M),
- # R-24
- (Weapons.R_24R__AA_7_Apex_SA____Semi_Act_Rdr, None),
- (Weapons.R_24T__AA_7_Apex_IR____Infra_Red, None),
- # R-27
- (
- Weapons.R_27T__AA_10_Alamo_B____Infra_Red,
- Weapons.R_24T__AA_7_Apex_IR____Infra_Red,
- ),
- (
- Weapons.R_27R__AA_10_Alamo_A____Semi_Act_Rdr,
- Weapons.R_24R__AA_7_Apex_SA____Semi_Act_Rdr,
- ),
- (
- Weapons.R_27ER__AA_10_Alamo_C____Semi_Act_Extended_Range,
- Weapons.R_27R__AA_10_Alamo_A____Semi_Act_Rdr,
- ),
- (
- Weapons.R_27ET__AA_10_Alamo_D____IR_Extended_Range,
- Weapons.R_27T__AA_10_Alamo_B____Infra_Red,
- ),
- # R-33
- (Weapons.R_33__AA_9_Amos____Semi_Act_Rdr, None),
- # R-3
- (Weapons.R_3S, Weapons.R_13M),
- (Weapons.R_3R, Weapons.R_3S),
- # R-40
- (Weapons.R_40R__AA_6_Acrid____Semi_Act_Rdr, None),
- (Weapons.R_40T__AA_6_Acrid____Infra_Red, None),
- # R-55
- (Weapons.R_55, None),
- (Weapons.RS2US, None),
- # R-60
- (Weapons.R_60, Weapons.R_13M1),
- (Weapons.R_60_x_2, Weapons.R_13M1),
- (Weapons.R_60_x_2_, Weapons.R_13M1),
- (Weapons.R_60M, Weapons.R_60),
- (Weapons.APU_60_1M_with_R_60M__AA_8_Aphid____Infra_Red, Weapons.R_60),
- (Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red, Weapons.R_60M),
- (Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red_, Weapons.R_60M),
- (Weapons.R_60M_x_2, Weapons.R_60M),
- (Weapons.R_60M_x_2_, Weapons.R_60M),
- # R-73
- (Weapons.R_73__AA_11_Archer____Infra_Red, Weapons.R_60M),
- (Weapons.R_73__AA_11_Archer____Infra_Red_, None),
- # R-77
- (
- Weapons.R_77__AA_12_Adder____Active_Rdr,
- Weapons.R_27ER__AA_10_Alamo_C____Semi_Act_Extended_Range,
- ),
- (Weapons.R_77__AA_12_Adder____Active_Rdr_, None),
- # UK
- # ALARM
- (Weapons.ALARM, None),
- # France
- # BLG-66 Belouga
- (Weapons.AUF2_BLG_66_AC_x_2, Weapons.AUF2_MK_82_x_2),
- (Weapons.BLG_66_AC_Belouga, Weapons.Mk_82),
- (Weapons.BLG_66_Belouga___290kg_CBU__151_Frag_Pen_bomblets, Weapons.Mk_82),
- # HOT-3
- (Weapons.HOT3, None),
- (Weapons.HOT3_, None),
- # Magic 2
- (Weapons.Matra_Magic_II, None),
- (Weapons.R_550_Magic_2, None),
- # Super 530D
- (Weapons.Matra_Super_530D, Weapons.Matra_Magic_II),
- (Weapons.Super_530D, None),
-]
-
-WEAPON_FALLBACK_MAP: Dict[Weapon, Optional[Weapon]] = defaultdict(
- lambda: cast(Optional[Weapon], None),
- (
- (Weapon.from_pydcs(a), b if b is None else Weapon.from_pydcs(b))
- for a, b in _WEAPON_FALLBACKS
- ),
-)
-
-
-WEAPON_INTRODUCTION_YEARS = {
- # USA
- # ADM-141 TALD
- Weapon.from_pydcs(Weapons.ADM_141A): 1987,
- Weapon.from_pydcs(Weapons.ADM_141A_): 1987,
- Weapon.from_pydcs(Weapons.ADM_141A_TALD): 1987,
- Weapon.from_pydcs(Weapons.ADM_141B_TALD): 1987,
- # AGM-114K Hellfire
- Weapon.from_pydcs(Weapons.AGM114x2_OH_58): 1993,
- Weapon.from_pydcs(Weapons.AGM_114K): 1993,
- Weapon.from_pydcs(Weapons.AGM_114K___4): 1993,
- # AGM-119 Penguin
- Weapon.from_pydcs(Weapons.AGM_119B_Penguin_ASM): 1972,
- # AGM-122 Sidearm
- Weapon.from_pydcs(Weapons.AGM_122_Sidearm___light_ARM): 1986,
- Weapon.from_pydcs(Weapons.AGM_122_Sidearm): 1986,
- Weapon.from_pydcs(Weapons.AGM_122_Sidearm_): 1986,
- # AGM-154 JSOW
- Weapon.from_pydcs(Weapons.AGM_154A___JSOW_CEB__CBU_type_): 1998,
- Weapon.from_pydcs(Weapons.BRU_55_with_2_x_AGM_154A___JSOW_CEB__CBU_type_): 1998,
- Weapon.from_pydcs(Weapons.BRU_57_with_2_x_AGM_154A___JSOW_CEB__CBU_type_): 1998,
- Weapon.from_pydcs(Weapons.AGM_154B___JSOW_Anti_Armour): 2005,
- Weapon.from_pydcs(Weapons.AGM_154C___JSOW_Unitary_BROACH): 2005,
- Weapon.from_pydcs(Weapons._4_x_AGM_154C___JSOW_Unitary_BROACH): 2005,
- Weapon.from_pydcs(Weapons.BRU_55_with_2_x_AGM_154C___JSOW_Unitary_BROACH): 2005,
- # AGM-45 Shrike
- Weapon.from_pydcs(Weapons.AGM_45A_Shrike_ARM): 1965,
- Weapon.from_pydcs(Weapons.AGM_45B_Shrike_ARM__Imp_): 1970,
- Weapon.from_pydcs(Weapons.LAU_118a_with_AGM_45B_Shrike_ARM__Imp_): 1970,
- # AGM-62 Walleye
- Weapon.from_pydcs(Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_): 1972,
- # AGM-65 Maverick
- Weapon.from_pydcs(Weapons.LAU_88_AGM_65D_ONE): 1983,
- Weapon.from_pydcs(Weapons.AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_): 1985,
- Weapon.from_pydcs(Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_): 2007,
- Weapon.from_pydcs(Weapons.LAU_117_AGM_65A): 1972,
- Weapon.from_pydcs(Weapons.LAU_117_AGM_65B): 1972,
- Weapon.from_pydcs(Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_): 1986,
- Weapon.from_pydcs(
- Weapons.LAU_117_with_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_
- ): 1990,
- Weapon.from_pydcs(Weapons.LAU_117_AGM_65F): 1991,
- Weapon.from_pydcs(Weapons.LAU_117_AGM_65G): 1989,
- Weapon.from_pydcs(Weapons.LAU_117_AGM_65H): 2002,
- Weapon.from_pydcs(Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_): 2002,
- Weapon.from_pydcs(Weapons.LAU_117_AGM_65L): 1985,
- Weapon.from_pydcs(Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_): 1983,
- Weapon.from_pydcs(Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__): 1983,
- Weapon.from_pydcs(Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_): 1983,
- Weapon.from_pydcs(Weapons.LAU_88_AGM_65D_ONE): 1983,
- Weapon.from_pydcs(
- Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_
- ): 1985,
- Weapon.from_pydcs(
- Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd__
- ): 1985,
- Weapon.from_pydcs(
- Weapons.LAU_88_with_3_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_
- ): 1985,
- Weapon.from_pydcs(Weapons.LAU_88_AGM_65H): 2007,
- Weapon.from_pydcs(Weapons.LAU_88_AGM_65H_2_L): 2007,
- Weapon.from_pydcs(Weapons.LAU_88_AGM_65H_2_R): 2007,
- Weapon.from_pydcs(Weapons.LAU_88_AGM_65H_3): 2007,
- Weapon.from_pydcs(Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM_): 2007,
- Weapon.from_pydcs(
- Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM__
- ): 2007,
- Weapon.from_pydcs(Weapons.LAU_88_with_3_x_AGM_65K___Maverick_K__CCD_Imp_ASM_): 2007,
- # AGM-84 Harpoon
- Weapon.from_pydcs(Weapons.AGM_84): 1979,
- Weapon.from_pydcs(Weapons.AGM_84A_Harpoon_ASM): 1979,
- Weapon.from_pydcs(Weapons._8_x_AGM_84A_Harpoon_ASM): 1979,
- Weapon.from_pydcs(Weapons.AGM_84D_Harpoon_AShM): 1979,
- Weapon.from_pydcs(
- Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile_
- ): 1990,
- Weapon.from_pydcs(
- Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile__
- ): 1990,
- Weapon.from_pydcs(Weapons.AGM_84H_SLAM_ER__Expanded_Response_): 1998,
- # AGM-86 ALCM
- Weapon.from_pydcs(Weapons.AGM_86C_ALCM): 1986,
- Weapon.from_pydcs(Weapons._20_x_AGM_86C_ALCM): 1986,
- Weapon.from_pydcs(Weapons._8_x_AGM_86C_ALCM): 1986,
- Weapon.from_pydcs(Weapons._6_x_AGM_86C_ALCM_on_MER): 1986,
- # AGM-88 HARM
- Weapon.from_pydcs(Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile): 1983,
- Weapon.from_pydcs(Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile_): 1983,
- # for future reference: 1983 is the A model IOC. B model in 1986 and C model in 1994.
- # AIM-120 AMRAAM
- Weapon.from_pydcs(Weapons.AIM_120B_AMRAAM___Active_Rdr_AAM): 1994,
- Weapon.from_pydcs(Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM): 1996,
- Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_120B): 1994,
- Weapon.from_pydcs(
- Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM
- ): 1994,
- Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_120C): 1996,
- Weapon.from_pydcs(
- Weapons.LAU_115_with_1_x_LAU_127_AIM_120C_5_AMRAAM___Active_Rdr_AAM
- ): 1996,
- # AIM-54 Phoenix
- Weapon.from_pydcs(Weapons.AIM_54A_Mk47): 1974,
- Weapon.from_pydcs(Weapons.AIM_54A_Mk47_): 1974,
- Weapon.from_pydcs(Weapons.AIM_54A_Mk47__): 1974,
- Weapon.from_pydcs(Weapons.AIM_54A_Mk60): 1974,
- Weapon.from_pydcs(Weapons.AIM_54A_Mk60_): 1974,
- Weapon.from_pydcs(Weapons.AIM_54A_Mk60__): 1974,
- Weapon.from_pydcs(Weapons.AIM_54C_Mk47_Phoenix_IN__Semi_Active_Radar): 1974,
- Weapon.from_pydcs(Weapons.AIM_54C_Mk47): 1974,
- Weapon.from_pydcs(Weapons.AIM_54C_Mk47_): 1974,
- Weapon.from_pydcs(Weapons.AIM_54C_Mk47__): 1974,
- # AIM-7 Sparrow
- Weapon.from_pydcs(Weapons.AIM_7E_Sparrow_Semi_Active_Radar): 1963,
- Weapon.from_pydcs(Weapons.AIM_7F_Sparrow_Semi_Active_Radar): 1976,
- Weapon.from_pydcs(Weapons.AIM_7F_): 1976,
- Weapon.from_pydcs(Weapons.AIM_7F): 1976,
- Weapon.from_pydcs(Weapons.AIM_7M): 1982,
- Weapon.from_pydcs(Weapons.AIM_7M_): 1982,
- Weapon.from_pydcs(Weapons.LAU_115_with_AIM_7M_Sparrow_Semi_Active_Radar): 1982,
- Weapon.from_pydcs(Weapons.AIM_7MH): 1987,
- Weapon.from_pydcs(Weapons.AIM_7MH_): 1987,
- Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar): 1987,
- Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7E_Sparrow_Semi_Active_Radar): 1963,
- Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7F_Sparrow_Semi_Active_Radar): 1976,
- Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar): 1987,
- # AIM-9 Sidewinder
- Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9B_Sidewinder_IR_AAM): 1956,
- Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9B_Sidewinder_IR_AAM): 1956,
- Weapon.from_pydcs(Weapons.AIM_9L_Sidewinder_IR_AAM): 1977,
- Weapon.from_pydcs(Weapons.AIM_9M_Sidewinder_IR_AAM): 1982,
- Weapon.from_pydcs(Weapons.AIM_9P5_Sidewinder_IR_AAM): 1980,
- Weapon.from_pydcs(Weapons.AIM_9P_Sidewinder_IR_AAM): 1978,
- Weapon.from_pydcs(Weapons.AIM_9X_Sidewinder_IR_AAM): 2003,
- Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9L_L): 1977,
- Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9L_R): 1977,
- Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9M_L): 1982,
- Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9M_R): 1982,
- Weapon.from_pydcs(Weapons.LAU_105_2_AIM_9L): 1977,
- Weapon.from_pydcs(Weapons.LAU_105_2_AIM_9P5): 1980,
- Weapon.from_pydcs(Weapons.LAU_105_with_2_x_AIM_9M_Sidewinder_IR_AAM): 1982,
- Weapon.from_pydcs(Weapons.LAU_105_with_2_x_AIM_9P_Sidewinder_IR_AAM): 1978,
- Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_9L): 1977,
- Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_9M): 1982,
- Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_9X): 2003,
- Weapon.from_pydcs(Weapons.LAU_115_LAU_127_AIM_9L): 1977,
- Weapon.from_pydcs(Weapons.LAU_115_LAU_127_AIM_9M): 1982,
- Weapon.from_pydcs(Weapons.LAU_115_LAU_127_AIM_9X): 2003,
- Weapon.from_pydcs(Weapons.LAU_127_AIM_9L): 1977,
- Weapon.from_pydcs(Weapons.LAU_127_AIM_9M): 1982,
- Weapon.from_pydcs(Weapons.LAU_127_AIM_9X): 2003,
- Weapon.from_pydcs(Weapons.LAU_138_AIM_9L): 1977,
- Weapon.from_pydcs(Weapons.LAU_138_AIM_9M): 1982,
- Weapon.from_pydcs(Weapons.LAU_7_AIM_9L): 1977,
- Weapon.from_pydcs(Weapons.LAU_7_AIM_9M): 1982,
- Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9M_Sidewinder_IR_AAM): 1982,
- Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9P5_Sidewinder_IR_AAM): 1980,
- Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9P_Sidewinder_IR_AAM): 1978,
- Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9X_Sidewinder_IR_AAM): 2003,
- Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9L_Sidewinder_IR_AAM): 1977,
- Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9M_Sidewinder_IR_AAM): 1982,
- Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9P5_Sidewinder_IR_AAM): 1980,
- Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9P_Sidewinder_IR_AAM): 1978,
- # ALQ ECM Pods
- Weapon.from_pydcs(Weapons.ALQ_131___ECM_Pod): 1970,
- Weapon.from_pydcs(Weapons.ALQ_184): 1989,
- Weapon.from_pydcs(Weapons.AN_ALQ_164_DECM_Pod): 1984,
- # TGP Pods
- Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod): 1995,
- Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod_): 1995,
- Weapon.from_pydcs(Weapons.AN_ASQ_228_ATFLIR___Targeting_Pod): 1993,
- Weapon.from_pydcs(
- Weapons.AN_ASQ_173_Laser_Spot_Tracker_Strike_CAMera__LST_SCAM_
- ): 1993,
- Weapon.from_pydcs(Weapons.AWW_13_DATALINK_POD): 1967,
- Weapon.from_pydcs(Weapons.LANTIRN_Targeting_Pod): 1985,
- Weapon.from_pydcs(Weapons.Lantirn_F_16): 1985,
- Weapon.from_pydcs(Weapons.Lantirn_Target_Pod): 1985,
- Weapon.from_pydcs(Weapons.Pavetack_F_111): 1982,
- # BLU-107
- Weapon.from_pydcs(Weapons.BLU_107___440lb_Anti_Runway_Penetrator_Bomb): 1983,
- Weapon.from_pydcs(
- Weapons.MER6_with_6_x_BLU_107___440lb_Anti_Runway_Penetrator_Bombs
- ): 1983,
- # GBU-10 LGB
- Weapon.from_pydcs(Weapons.DIS_GBU_10): 1976,
- Weapon.from_pydcs(Weapons.GBU_10): 1976,
- Weapon.from_pydcs(Weapons.BRU_42_with_2_x_GBU_10___2000lb_Laser_Guided_Bombs): 1976,
- Weapon.from_pydcs(Weapons.GBU_10___2000lb_Laser_Guided_Bomb): 1976,
- # GBU-12 LGB
- Weapon.from_pydcs(Weapons.AUF2_GBU_12_x_2): 1976,
- Weapon.from_pydcs(Weapons.BRU_33_with_2_x_GBU_12___500lb_Laser_Guided_Bomb): 1976,
- Weapon.from_pydcs(Weapons.BRU_42_3_GBU_12): 1976,
- Weapon.from_pydcs(Weapons.DIS_GBU_12): 1976,
- Weapon.from_pydcs(Weapons.DIS_GBU_12_DUAL_GDJ_II19_L): 1976,
- Weapon.from_pydcs(Weapons.DIS_GBU_12_DUAL_GDJ_II19_R): 1976,
- Weapon.from_pydcs(Weapons.GBU_12): 1976,
- Weapon.from_pydcs(Weapons.GBU_12): 1976,
- Weapon.from_pydcs(Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb): 1976,
- Weapon.from_pydcs(Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb_): 1976,
- Weapon.from_pydcs(Weapons._2_GBU_12): 1976,
- Weapon.from_pydcs(Weapons._2_GBU_12_): 1976,
- Weapon.from_pydcs(Weapons._3_GBU_12): 1976,
- # GBU-16 LGB
- Weapon.from_pydcs(Weapons.BRU_33_with_2_x_GBU_16___1000lb_Laser_Guided_Bomb): 1976,
- Weapon.from_pydcs(Weapons.DIS_GBU_16): 1976,
- Weapon.from_pydcs(Weapons.GBU_16): 1976,
- Weapon.from_pydcs(Weapons.GBU_16___1000lb_Laser_Guided_Bomb): 1976,
- Weapon.from_pydcs(Weapons._2_GBU_16): 1976,
- Weapon.from_pydcs(Weapons._2_GBU_16_): 1976,
- Weapon.from_pydcs(Weapons._3_GBU_16): 1976,
- Weapon.from_pydcs(Weapons.BRU_42_with_3_x_GBU_16___1000lb_Laser_Guided_Bombs): 1976,
- # GBU-24 LGB
- Weapon.from_pydcs(Weapons.GBU_24): 1986,
- Weapon.from_pydcs(Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb): 1986,
- Weapon.from_pydcs(Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb_): 1986,
- # GBU-27 LGB
- Weapon.from_pydcs(Weapons.GBU_27___2000lb_Laser_Guided_Penetrator_Bomb): 1991,
- Weapon.from_pydcs(
- Weapons.BRU_42_with_2_x_GBU_27___2000lb_Laser_Guided_Penetrator_Bombs
- ): 1991,
- # GBU-28
- Weapon.from_pydcs(Weapons.GBU_28___5000lb_Laser_Guided_Penetrator_Bomb): 1991,
- # GBU-31 JDAM
- Weapon.from_pydcs(Weapons.GBU_31V3B_8): 2001,
- Weapon.from_pydcs(Weapons.GBU_31_8): 2001,
- Weapon.from_pydcs(Weapons.GBU_31_V_1_B___JDAM__2000lb_GPS_Guided_Bomb): 2001,
- Weapon.from_pydcs(Weapons.GBU_31_V_2_B___JDAM__2000lb_GPS_Guided_Bomb): 2001,
- Weapon.from_pydcs(
- Weapons.GBU_31_V_3_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb
- ): 2001,
- Weapon.from_pydcs(
- Weapons.GBU_31_V_4_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb
- ): 2001,
- # GBU-32 JDAM
- Weapon.from_pydcs(Weapons.GBU_32_V_2_B___JDAM__1000lb_GPS_Guided_Bomb): 2002,
- # GBU-38 JDAM
- Weapon.from_pydcs(
- Weapons.BRU_55_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb
- ): 2005,
- Weapon.from_pydcs(
- Weapons.BRU_57_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb
- ): 2005,
- Weapon.from_pydcs(Weapons.GBU_38___JDAM__500lb_GPS_Guided_Bomb): 2005,
- Weapon.from_pydcs(Weapons.GBU_38_16): 2005,
- Weapon.from_pydcs(Weapons._2_GBU_38): 2005,
- Weapon.from_pydcs(Weapons._2_GBU_38_): 2005,
- Weapon.from_pydcs(Weapons._3_GBU_38): 2005,
- # GBU-54 LJDAM
- Weapon.from_pydcs(Weapons.GBU_54B___LJDAM__500lb_Laser__GPS_Guided_Bomb_LD): 2008,
- Weapon.from_pydcs(Weapons._2_GBU_54_V_1_B): 2008,
- Weapon.from_pydcs(Weapons._2_GBU_54_V_1_B_): 2008,
- Weapon.from_pydcs(Weapons._3_GBU_54_V_1_B): 2008,
- # CBU-52
- Weapon.from_pydcs(Weapons.CBU_52B___220_x_HE_Frag_bomblets): 1970,
- # CBU-87 CEM
- Weapon.from_pydcs(Weapons.CBU_87___202_x_CEM_Cluster_Bomb): 1986,
- Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb): 1986,
- Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb_): 1986,
- Weapon.from_pydcs(Weapons.TER_9A_with_3_x_CBU_87___202_x_CEM_Cluster_Bomb): 1986,
- # CBU-97
- Weapon.from_pydcs(Weapons.CBU_97___10_x_SFW_Cluster_Bomb): 1992,
- Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb): 1992,
- Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb_): 1992,
- Weapon.from_pydcs(Weapons.TER_9A_with_3_x_CBU_97___10_x_SFW_Cluster_Bomb): 1992,
- # CBU-99
- Weapon.from_pydcs(
- Weapons.BRU_33_with_2_x_CBU_99___490lbs__247_x_HEAT_Bomblets
- ): 1968,
- Weapon.from_pydcs(
- Weapons.BRU_33_with_2_x_CBU_99___490lbs__247_x_HEAT_Bomblets
- ): 1968,
- Weapon.from_pydcs(
- Weapons.BRU_33_with_2_x_CBU_99___490lbs__247_x_HEAT_Bomblets
- ): 1968,
- Weapon.from_pydcs(Weapons.DIS_MK_20): 1968,
- Weapon.from_pydcs(Weapons.DIS_MK_20_DUAL_GDJ_II19_L): 1968,
- Weapon.from_pydcs(Weapons.DIS_MK_20_DUAL_GDJ_II19_R): 1968,
- Weapon.from_pydcs(
- Weapons.HSAB_with_9_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets
- ): 1968,
- Weapon.from_pydcs(Weapons.MAK79_2_MK_20): 1968,
- Weapon.from_pydcs(Weapons.MAK79_2_MK_20_): 1968,
- Weapon.from_pydcs(Weapons.MAK79_MK_20): 1968,
- Weapon.from_pydcs(Weapons.MAK79_MK_20_): 1968,
- Weapon.from_pydcs(
- Weapons.MER6_with_6_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets
- ): 1968,
- Weapon.from_pydcs(Weapons.Mk_20): 1968,
- Weapon.from_pydcs(Weapons.Mk_20_Rockeye___490lbs_CBU__247_x_HEAT_Bomblets): 1968,
- Weapon.from_pydcs(Weapons.Mk_20_18): 1968,
- Weapon.from_pydcs(
- Weapons._6_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets
- ): 1968,
- Weapon.from_pydcs(Weapons._2_MK_20): 1968,
- Weapon.from_pydcs(Weapons._2_MK_20_): 1968,
- Weapon.from_pydcs(Weapons._2_MK_20__): 1968,
- Weapon.from_pydcs(Weapons._2_MK_20___): 1968,
- Weapon.from_pydcs(Weapons._2_MK_20____): 1968,
- Weapon.from_pydcs(Weapons._2_MK_20_____): 1968,
- Weapon.from_pydcs(Weapons._2_Mk_20_Rockeye): 1968,
- Weapon.from_pydcs(Weapons._2_Mk_20_Rockeye_): 1968,
- Weapon.from_pydcs(
- Weapons.MER2_with_2_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets
- ): 1968,
- # CBU-103
- Weapon.from_pydcs(Weapons.BRU_57_with_2_x_CBU_103___202_x_CEM__CBU_with_WCMD): 2000,
- Weapon.from_pydcs(Weapons.CBU_103___202_x_CEM__CBU_with_WCMD): 2000,
- # CBU-105
- Weapon.from_pydcs(Weapons.BRU_57_with_2_x_CBU_105___10_x_SFW__CBU_with_WCMD): 2000,
- Weapon.from_pydcs(Weapons.CBU_105___10_x_SFW__CBU_with_WCMD): 2000,
- # APKWS
- Weapon.from_pydcs(
- Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS
- ): 2016,
- Weapon.from_pydcs(
- Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS
- ): 2016,
- Weapon.from_pydcs(
- Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS
- ): 2016,
- Weapon.from_pydcs(
- Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS
- ): 2016,
- # Russia
- # KAB-1500
- Weapon.from_pydcs(Weapons.KAB_1500Kr___1500kg_TV_Guided_Bomb): 1985,
- Weapon.from_pydcs(Weapons.KAB_1500L___1500kg_Laser_Guided_Bomb): 1995,
- Weapon.from_pydcs(
- Weapons.KAB_1500LG_Pr___1500kg_Laser_Guided_Penetrator_Bomb
- ): 1990,
- # KAB-500
- Weapon.from_pydcs(Weapons.KAB_500Kr___500kg_TV_Guided_Bomb): 1980,
- Weapon.from_pydcs(Weapons.KAB_500LG___500kg_Laser_Guided_Bomb): 1995,
- Weapon.from_pydcs(Weapons.KAB_500S___500kg_GPS_Guided_Bomb): 2000,
- # Kh Series
- Weapon.from_pydcs(
- Weapons.Kh_22__AS_4_Kitchen____1000kg__AShM__IN__Act_Pas_Rdr
- ): 1962,
- Weapon.from_pydcs(
- Weapons.Kh_23L_Grom__AS_7_Kerry____286kg__ASM__Laser_Guided
- ): 1975,
- Weapon.from_pydcs(Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser): 1975,
- Weapon.from_pydcs(
- Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser_
- ): 1975,
- Weapon.from_pydcs(
- Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser__
- ): 1975,
- Weapon.from_pydcs(Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr): 1975,
- Weapon.from_pydcs(
- Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr
- ): 1980,
- Weapon.from_pydcs(
- Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr_
- ): 1980,
- Weapon.from_pydcs(
- Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr__
- ): 1980,
- Weapon.from_pydcs(
- Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__10km__RC_Guided
- ): 1975,
- Weapon.from_pydcs(Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided): 1975,
- Weapon.from_pydcs(Weapons.Kh_28__AS_9_Kyle____720kg__ARM__Pas_Rdr): 1973,
- Weapon.from_pydcs(Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser): 1980,
- Weapon.from_pydcs(Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser_): 1980,
- Weapon.from_pydcs(
- Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser__
- ): 1980,
- Weapon.from_pydcs(Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided): 1980,
- Weapon.from_pydcs(Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided_): 1980,
- Weapon.from_pydcs(Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided__): 1980,
- Weapon.from_pydcs(Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr): 1980,
- Weapon.from_pydcs(Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr_): 1980,
- Weapon.from_pydcs(
- Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr__
- ): 1980,
- Weapon.from_pydcs(Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr): 1980,
- Weapon.from_pydcs(Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr_): 1980,
- Weapon.from_pydcs(Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr__): 1980,
- Weapon.from_pydcs(Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr): 2003,
- Weapon.from_pydcs(Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr_): 2003,
- Weapon.from_pydcs(
- Weapons._6_x_Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr
- ): 2003,
- Weapon.from_pydcs(
- Weapons.Kh_41__SS_N_22_Sunburn____4500kg__AShM__IN__Act_Rdr
- ): 1984,
- Weapon.from_pydcs(Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr): 1985,
- Weapon.from_pydcs(Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr_): 1985,
- Weapon.from_pydcs(Weapons.Kh_59M__AS_18_Kazoo____930kg__ASM__IN): 1990,
- Weapon.from_pydcs(Weapons.Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC): 1992,
- Weapon.from_pydcs(Weapons._6_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC): 1992,
- Weapon.from_pydcs(Weapons._8_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC): 1992,
- Weapon.from_pydcs(Weapons.Kh_66_Grom__21__APU_68): 1968,
- # ECM
- Weapon.from_pydcs(Weapons.L175V_Khibiny_ECM_pod): 1982,
- # R-13
- Weapon.from_pydcs(Weapons.R_13M): 1961,
- Weapon.from_pydcs(Weapons.R_13M1): 1965,
- # R-24
- Weapon.from_pydcs(Weapons.R_24R__AA_7_Apex_SA____Semi_Act_Rdr): 1981,
- Weapon.from_pydcs(Weapons.R_24T__AA_7_Apex_IR____Infra_Red): 1981,
- # R-27
- Weapon.from_pydcs(Weapons.R_27ER__AA_10_Alamo_C____Semi_Act_Extended_Range): 1983,
- Weapon.from_pydcs(Weapons.R_27ET__AA_10_Alamo_D____IR_Extended_Range): 1986,
- Weapon.from_pydcs(Weapons.R_27R__AA_10_Alamo_A____Semi_Act_Rdr): 1983,
- Weapon.from_pydcs(Weapons.R_27T__AA_10_Alamo_B____Infra_Red): 1983,
- # R-33
- Weapon.from_pydcs(Weapons.R_33__AA_9_Amos____Semi_Act_Rdr): 1981,
- # R-3
- Weapon.from_pydcs(Weapons.R_3R): 1966,
- Weapon.from_pydcs(Weapons.R_3S): 1962,
- # R-40
- Weapon.from_pydcs(Weapons.R_40R__AA_6_Acrid____Semi_Act_Rdr): 1976,
- Weapon.from_pydcs(Weapons.R_40T__AA_6_Acrid____Infra_Red): 1976,
- # R-55
- Weapon.from_pydcs(Weapons.R_55): 1957,
- Weapon.from_pydcs(Weapons.RS2US): 1957,
- # R-60
- Weapon.from_pydcs(Weapons.R_60): 1973,
- Weapon.from_pydcs(Weapons.R_60_x_2): 1973,
- Weapon.from_pydcs(Weapons.R_60_x_2_): 1973,
- Weapon.from_pydcs(Weapons.APU_60_1M_with_R_60M__AA_8_Aphid____Infra_Red): 1982,
- Weapon.from_pydcs(Weapons.R_60M): 1982,
- Weapon.from_pydcs(Weapons.R_60M__AA_8_Aphid____Infra_Red): 1982,
- Weapon.from_pydcs(Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red): 1982,
- Weapon.from_pydcs(Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red_): 1982,
- Weapon.from_pydcs(Weapons.R_60M_x_2): 1982,
- Weapon.from_pydcs(Weapons.R_60M_x_2_): 1982,
- # R-73
- Weapon.from_pydcs(Weapons.R_73__AA_11_Archer____Infra_Red): 1984,
- Weapon.from_pydcs(Weapons.R_73__AA_11_Archer____Infra_Red_): 1984,
- # R-77
- Weapon.from_pydcs(Weapons.R_77__AA_12_Adder____Active_Rdr): 2002,
- Weapon.from_pydcs(Weapons.R_77__AA_12_Adder____Active_Rdr_): 2002,
- # UK
- # ALARM
- Weapon.from_pydcs(Weapons.ALARM): 1990,
- # France
- # BLG-66 Belouga
- Weapon.from_pydcs(Weapons.AUF2_BLG_66_AC_x_2): 1979,
- Weapon.from_pydcs(Weapons.BLG_66_AC_Belouga): 1979,
- Weapon.from_pydcs(Weapons.BLG_66_Belouga___290kg_CBU__151_Frag_Pen_bomblets): 1979,
- # HOT-3
- Weapon.from_pydcs(Weapons.HOT3): 1998,
- Weapon.from_pydcs(Weapons.HOT3_): 1998,
- # Magic 2
- Weapon.from_pydcs(Weapons.Matra_Magic_II): 1986,
- Weapon.from_pydcs(Weapons.R_550_Magic_2): 1986,
- # Super 530D
- Weapon.from_pydcs(Weapons.Matra_Super_530D): 1988,
- Weapon.from_pydcs(Weapons.Super_530D): 1988,
-}
diff --git a/game/db.py b/game/db.py
index 61426d12..4a2fbf75 100644
--- a/game/db.py
+++ b/game/db.py
@@ -29,8 +29,9 @@ from dcs.ships import (
CV_1143_5,
)
from dcs.terrain.terrain import Airport
+from dcs.unit import Ship
from dcs.unitgroup import ShipGroup, StaticGroup
-from dcs.unittype import UnitType
+from dcs.unittype import UnitType, FlyingType, ShipType, VehicleType
from dcs.vehicles import (
vehicle_map,
)
@@ -255,7 +256,7 @@ Aircraft livery overrides. Syntax as follows:
`Identifier` is aircraft identifier (as used troughout the file) and "LiveryName" (with double quotes)
is livery name as found in mission editor.
"""
-PLANE_LIVERY_OVERRIDES = {
+PLANE_LIVERY_OVERRIDES: dict[Type[FlyingType], str] = {
FA_18C_hornet: "VFA-34", # default livery for the hornet is blue angels one
}
@@ -317,6 +318,8 @@ REWARDS = {
"comms": 10,
"oil": 10,
"derrick": 8,
+ "village": 0.25,
+ "allycamp": 0.5,
}
"""
@@ -326,7 +329,7 @@ REWARDS = {
StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point]
-def upgrade_to_supercarrier(unit, name: str):
+def upgrade_to_supercarrier(unit: Type[ShipType], name: str) -> Type[ShipType]:
if unit == Stennis:
if name == "CVN-71 Theodore Roosevelt":
return CVN_71
@@ -359,7 +362,15 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]:
return None
-def country_id_from_name(name):
+def vehicle_type_from_name(name: str) -> Type[VehicleType]:
+ return vehicle_map[name]
+
+
+def ship_type_from_name(name: str) -> Type[ShipType]:
+ return ship_map[name]
+
+
+def country_id_from_name(name: str) -> int:
for k, v in country_dict.items():
if v.name == name:
return k
@@ -372,7 +383,7 @@ class DefaultLiveries:
OH_58D.Liveries = DefaultLiveries
-F_16C_50.Liveries = DefaultLiveries
+F_16C_50.Liveries = DefaultLiveries # type: ignore
P_51D_30_NA.Liveries = DefaultLiveries
Ju_88A4.Liveries = DefaultLiveries
B_17G.Liveries = DefaultLiveries
diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py
index 05b3d557..bebc1123 100644
--- a/game/dcs/aircrafttype.py
+++ b/game/dcs/aircrafttype.py
@@ -29,7 +29,7 @@ from game.radio.channels import (
ViggenRadioChannelAllocator,
NoOpChannelAllocator,
)
-from game.utils import Distance, Speed, feet, kph, knots
+from game.utils import Distance, Speed, feet, kph, knots, nautical_miles
if TYPE_CHECKING:
from gen.aircraft import FlightData
@@ -98,7 +98,7 @@ class PatrolConfig:
@classmethod
def from_data(cls, data: dict[str, Any]) -> PatrolConfig:
altitude = data.get("altitude", None)
- speed = data.get("altitude", None)
+ speed = data.get("speed", None)
return PatrolConfig(
feet(altitude) if altitude is not None else None,
knots(speed) if speed is not None else None,
@@ -106,18 +106,55 @@ class PatrolConfig:
@dataclass(frozen=True)
-class AircraftType(UnitType[FlyingType]):
+class FuelConsumption:
+ #: The estimated taxi fuel requirement, in pounds.
+ taxi: int
+
+ #: The estimated fuel consumption for a takeoff climb, in pounds per nautical mile.
+ climb: float
+
+ #: The estimated fuel consumption for cruising, in pounds per nautical mile.
+ cruise: float
+
+ #: The estimated fuel consumption for combat speeds, in pounds per nautical mile.
+ combat: float
+
+ #: The minimum amount of fuel that the aircraft should land with, in pounds. This is
+ #: a reserve amount for landing delays or emergencies.
+ min_safe: int
+
+ @classmethod
+ def from_data(cls, data: dict[str, Any]) -> FuelConsumption:
+ return FuelConsumption(
+ int(data["taxi"]),
+ float(data["climb_ppm"]),
+ float(data["cruise_ppm"]),
+ float(data["combat_ppm"]),
+ int(data["min_safe"]),
+ )
+
+
+# TODO: Split into PlaneType and HelicopterType?
+@dataclass(frozen=True)
+class AircraftType(UnitType[Type[FlyingType]]):
carrier_capable: bool
lha_capable: bool
always_keeps_gun: bool
- # If true, the aircraft does not use the guns as the last resort weapons, but as a main weapon.
- # It'll RTB when it doesn't have gun ammo left.
+ # If true, the aircraft does not use the guns as the last resort weapons, but as a
+ # main weapon. It'll RTB when it doesn't have gun ammo left.
gunfighter: bool
max_group_size: int
patrol_altitude: Optional[Distance]
patrol_speed: Optional[Speed]
+
+ #: The maximum range between the origin airfield and the target for which the auto-
+ #: planner will consider this aircraft usable for a mission.
+ max_mission_range: Distance
+
+ fuel_consumption: Optional[FuelConsumption]
+
intra_flight_radio: Optional[Radio]
channel_allocator: Optional[RadioChannelAllocator]
channel_namer: Type[ChannelNamer]
@@ -147,13 +184,52 @@ class AircraftType(UnitType[FlyingType]):
def max_speed(self) -> Speed:
return kph(self.dcs_unit_type.max_speed)
+ @property
+ def preferred_patrol_altitude(self) -> Distance:
+ if self.patrol_altitude:
+ return self.patrol_altitude
+ else:
+ # Estimate based on max speed.
+ # Aircaft with max speed 600 kph will prefer patrol at 10 000 ft
+ # Aircraft with max speed 2800 kph will prefer pratrol at 33 000 ft
+ altitude_for_lowest_speed = feet(10 * 1000)
+ altitude_for_highest_speed = feet(33 * 1000)
+ lowest_speed = kph(600)
+ highest_speed = kph(2800)
+ factor = (self.max_speed - lowest_speed).kph / (
+ highest_speed - lowest_speed
+ ).kph
+ altitude = (
+ altitude_for_lowest_speed
+ + (altitude_for_highest_speed - altitude_for_lowest_speed) * factor
+ )
+ logging.debug(
+ f"Preferred patrol altitude for {self.dcs_unit_type.id}: {altitude.feet}"
+ )
+ rounded_altitude = feet(round(1000 * round(altitude.feet / 1000)))
+ return max(
+ altitude_for_lowest_speed,
+ min(altitude_for_highest_speed, rounded_altitude),
+ )
+
def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
- from gen.radios import ChannelInUseError, MHz
+ from gen.radios import ChannelInUseError, kHz
if self.intra_flight_radio is not None:
return radio_registry.alloc_for_radio(self.intra_flight_radio)
- freq = MHz(self.dcs_unit_type.radio_frequency)
+ # The default radio frequency is set in megahertz. For some aircraft, it is a
+ # floating point value. For all current aircraft, adjusting to kilohertz will be
+ # sufficient to convert to an integer.
+ in_khz = float(self.dcs_unit_type.radio_frequency) * 1000
+ if not in_khz.is_integer():
+ logging.warning(
+ f"Found unexpected sub-kHz default radio for {self}: {in_khz} kHz. "
+ "Truncating to integer. The truncated frequency may not be valid for "
+ "the aircraft."
+ )
+
+ freq = kHz(int(in_khz))
try:
radio_registry.reserve(freq)
except ChannelInUseError:
@@ -222,6 +298,25 @@ class AircraftType(UnitType[FlyingType]):
radio_config = RadioConfig.from_data(data.get("radios", {}))
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
+ try:
+ mission_range = nautical_miles(int(data["max_range"]))
+ except (KeyError, ValueError):
+ mission_range = (
+ nautical_miles(50) if aircraft.helicopter else nautical_miles(150)
+ )
+ logging.warning(
+ f"{aircraft.id} does not specify a max_range. Defaulting to "
+ f"{mission_range.nautical_miles}NM"
+ )
+
+ fuel_data = data.get("fuel")
+ if fuel_data is not None:
+ fuel_consumption: Optional[FuelConsumption] = FuelConsumption.from_data(
+ fuel_data
+ )
+ else:
+ fuel_consumption = None
+
try:
introduction = data["introduced"]
if introduction is None:
@@ -233,7 +328,10 @@ class AircraftType(UnitType[FlyingType]):
yield AircraftType(
dcs_unit_type=aircraft,
name=variant,
- description=data.get("description", "No data."),
+ description=data.get(
+ "description",
+ f"No data. Google {variant}",
+ ),
year_introduced=introduction,
country_of_origin=data.get("origin", "No data."),
manufacturer=data.get("manufacturer", "No data."),
@@ -246,6 +344,8 @@ class AircraftType(UnitType[FlyingType]):
max_group_size=data.get("max_group_size", aircraft.group_size_max),
patrol_altitude=patrol_config.altitude,
patrol_speed=patrol_config.speed,
+ max_mission_range=mission_range,
+ fuel_consumption=fuel_consumption,
intra_flight_radio=radio_config.intra_flight,
channel_allocator=radio_config.channel_allocator,
channel_namer=radio_config.channel_namer,
diff --git a/game/dcs/groundunittype.py b/game/dcs/groundunittype.py
index 7cf92fcf..c22d8a21 100644
--- a/game/dcs/groundunittype.py
+++ b/game/dcs/groundunittype.py
@@ -15,7 +15,7 @@ from game.dcs.unittype import UnitType
@dataclass(frozen=True)
-class GroundUnitType(UnitType[VehicleType]):
+class GroundUnitType(UnitType[Type[VehicleType]]):
unit_class: Optional[GroundUnitClass]
spawn_weight: int
@@ -88,7 +88,10 @@ class GroundUnitType(UnitType[VehicleType]):
unit_class=unit_class,
spawn_weight=data.get("spawn_weight", 0),
name=variant,
- description=data.get("description", "No data."),
+ description=data.get(
+ "description",
+ f"No data. Google {variant}",
+ ),
year_introduced=introduction,
country_of_origin=data.get("origin", "No data."),
manufacturer=data.get("manufacturer", "No data."),
diff --git a/game/dcs/unittype.py b/game/dcs/unittype.py
index 25181a66..2fc6ec9f 100644
--- a/game/dcs/unittype.py
+++ b/game/dcs/unittype.py
@@ -4,12 +4,12 @@ from typing import TypeVar, Generic, Type
from dcs.unittype import UnitType as DcsUnitType
-DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=DcsUnitType)
+DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType])
@dataclass(frozen=True)
class UnitType(Generic[DcsUnitTypeT]):
- dcs_unit_type: Type[DcsUnitTypeT]
+ dcs_unit_type: DcsUnitTypeT
name: str
description: str
year_introduced: str
diff --git a/game/debriefing.py b/game/debriefing.py
index 212ea7a4..b2a155ad 100644
--- a/game/debriefing.py
+++ b/game/debriefing.py
@@ -15,9 +15,9 @@ from typing import (
Iterator,
List,
TYPE_CHECKING,
+ Union,
)
-from game import db
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.theater import Airfield, ControlPoint
@@ -77,8 +77,8 @@ class GroundLosses:
player_airlifts: List[AirliftUnits] = field(default_factory=list)
enemy_airlifts: List[AirliftUnits] = field(default_factory=list)
- player_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
- enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
+ player_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
+ enemy_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
player_buildings: List[Building] = field(default_factory=list)
enemy_buildings: List[Building] = field(default_factory=list)
@@ -104,8 +104,9 @@ class StateData:
#: Names of vehicle (and ship) units that were killed during the mission.
killed_ground_units: List[str]
- #: Names of static units that were destroyed during the mission.
- destroyed_statics: List[str]
+ #: List of descriptions of destroyed statics. Format of each element is a mapping of
+ #: the coordinate type ("x", "y", "z", "type", "orientation") to the value.
+ destroyed_statics: List[dict[str, Union[float, str]]]
#: Mangled names of bases that were captured during the mission.
base_capture_events: List[str]
@@ -134,10 +135,8 @@ class Debriefing:
self.game = game
self.unit_map = unit_map
- self.player_country = game.player_country
- self.enemy_country = game.enemy_country
- self.player_country_id = db.country_id_from_name(game.player_country)
- self.enemy_country_id = db.country_id_from_name(game.enemy_country)
+ self.player_country = game.blue.country_name
+ self.enemy_country = game.red.country_name
self.air_losses = self.dead_aircraft()
self.ground_losses = self.dead_ground_units()
@@ -164,7 +163,7 @@ class Debriefing:
yield from self.ground_losses.enemy_airlifts
@property
- def ground_object_losses(self) -> Iterator[GroundObjectUnit]:
+ def ground_object_losses(self) -> Iterator[GroundObjectUnit[Any]]:
yield from self.ground_losses.player_ground_objects
yield from self.ground_losses.enemy_ground_objects
@@ -370,13 +369,13 @@ class PollDebriefingFileThread(threading.Thread):
self.game = game
self.unit_map = unit_map
- def stop(self):
+ def stop(self) -> None:
self._stop_event.set()
- def stopped(self):
+ def stopped(self) -> bool:
return self._stop_event.is_set()
- def run(self):
+ def run(self) -> None:
if os.path.isfile("state.json"):
last_modified = os.path.getmtime("state.json")
else:
@@ -401,7 +400,7 @@ class PollDebriefingFileThread(threading.Thread):
def wait_for_debriefing(
- callback: Callable[[Debriefing], None], game: Game, unit_map
+ callback: Callable[[Debriefing], None], game: Game, unit_map: UnitMap
) -> PollDebriefingFileThread:
thread = PollDebriefingFileThread(callback, game, unit_map)
thread.start()
diff --git a/game/event/airwar.py b/game/event/airwar.py
index ed22f3af..7b860a1b 100644
--- a/game/event/airwar.py
+++ b/game/event/airwar.py
@@ -1,14 +1,10 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
from .event import Event
-if TYPE_CHECKING:
- from game.theater import ConflictTheater
-
class AirWarEvent(Event):
"""Event handler for the air battle"""
- def __str__(self):
+ def __str__(self) -> str:
return "AirWar"
diff --git a/game/event/event.py b/game/event/event.py
index 1d554fca..757b9be1 100644
--- a/game/event/event.py
+++ b/game/event/event.py
@@ -5,7 +5,6 @@ from typing import List, TYPE_CHECKING, Type
from dcs.mapping import Point
from dcs.task import Task
-from dcs.unittype import VehicleType
from game import persistency
from game.debriefing import AirLosses, Debriefing
@@ -38,13 +37,13 @@ class Event:
def __init__(
self,
- game,
+ game: Game,
from_cp: ControlPoint,
target_cp: ControlPoint,
location: Point,
attacker_name: str,
defender_name: str,
- ):
+ ) -> None:
self.game = game
self.from_cp = from_cp
self.to_cp = target_cp
@@ -54,7 +53,7 @@ class Event:
@property
def is_player_attacking(self) -> bool:
- return self.attacker_name == self.game.player_faction.name
+ return self.attacker_name == self.game.blue.faction.name
@property
def tasks(self) -> List[Type[Task]]:
@@ -115,10 +114,10 @@ class Event:
def complete_aircraft_transfers(self, debriefing: Debriefing) -> None:
self._transfer_aircraft(
- self.game.blue_ato, debriefing.air_losses, for_player=True
+ self.game.blue.ato, debriefing.air_losses, for_player=True
)
self._transfer_aircraft(
- self.game.red_ato, debriefing.air_losses, for_player=False
+ self.game.red.ato, debriefing.air_losses, for_player=False
)
def commit_air_losses(self, debriefing: Debriefing) -> None:
@@ -155,8 +154,8 @@ class Event:
pilot.record.missions_flown += 1
def commit_pilot_experience(self) -> None:
- self._commit_pilot_experience(self.game.blue_ato)
- self._commit_pilot_experience(self.game.red_ato)
+ self._commit_pilot_experience(self.game.blue.ato)
+ self._commit_pilot_experience(self.game.red.ato)
@staticmethod
def commit_front_line_losses(debriefing: Debriefing) -> None:
@@ -220,10 +219,10 @@ class Event:
for loss in debriefing.ground_object_losses:
# TODO: This should be stored in the TGO, not in the pydcs Group.
if not hasattr(loss.group, "units_losts"):
- loss.group.units_losts = []
+ loss.group.units_losts = [] # type: ignore
loss.group.units.remove(loss.unit)
- loss.group.units_losts.append(loss.unit)
+ loss.group.units_losts.append(loss.unit) # type: ignore
def commit_building_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.building_losses:
@@ -265,7 +264,7 @@ class Event:
except Exception:
logging.exception(f"Could not process base capture {captured}")
- def commit(self, debriefing: Debriefing):
+ def commit(self, debriefing: Debriefing) -> None:
logging.info("Committing mission results")
self.commit_air_losses(debriefing)
@@ -298,15 +297,16 @@ class Event:
delta = 0.0
player_won = True
+ status_msg: str = ""
ally_casualties = debriefing.casualty_count(cp)
enemy_casualties = debriefing.casualty_count(enemy_cp)
ally_units_alive = cp.base.total_armor
enemy_units_alive = enemy_cp.base.total_armor
- print(ally_units_alive)
- print(enemy_units_alive)
- print(ally_casualties)
- print(enemy_casualties)
+ print(f"Remaining allied units: {ally_units_alive}")
+ print(f"Remaining enemy units: {enemy_units_alive}")
+ print(f"Allied casualties {ally_casualties}")
+ print(f"Enemy casualties {enemy_casualties}")
ratio = (1.0 + enemy_casualties) / (1.0 + ally_casualties)
@@ -319,24 +319,31 @@ class Event:
if ally_units_alive == 0:
player_won = False
delta = STRONG_DEFEAT_INFLUENCE
+ status_msg = f"No allied units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces suffer a strong defeat."
elif enemy_units_alive == 0:
player_won = True
delta = STRONG_DEFEAT_INFLUENCE
+ status_msg = f"No enemy units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces win a strong victory."
elif cp.stances[enemy_cp.id] == CombatStance.RETREAT:
player_won = False
delta = STRONG_DEFEAT_INFLUENCE
+ status_msg = f"Allied forces are retreating along the {cp.name}-{enemy_cp.name} frontline, suffering a strong defeat."
else:
if enemy_casualties > ally_casualties:
player_won = True
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
delta = STRONG_DEFEAT_INFLUENCE
+ status_msg = f"Allied forces break through the {cp.name}-{enemy_cp.name} frontline, winning a strong victory"
else:
if ratio > 3:
delta = STRONG_DEFEAT_INFLUENCE
+ status_msg = f"Enemy casualties massively outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a strong victory."
elif ratio < 1.5:
delta = MINOR_DEFEAT_INFLUENCE
+ status_msg = f"Enemy casualties minorly outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a minor victory."
else:
delta = DEFEAT_INFLUENCE
+ status_msg = f"Enemy casualties outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces claim a victory."
elif ally_casualties > enemy_casualties:
if (
@@ -346,54 +353,66 @@ class Event:
# Even with casualties if the enemy is overwhelmed, they are going to lose ground
player_won = True
delta = MINOR_DEFEAT_INFLUENCE
+ status_msg = f"Despite suffering losses, allied forces still outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a minor victory."
elif (
ally_units_alive > 3 * enemy_units_alive
and player_aggresive
):
player_won = True
delta = STRONG_DEFEAT_INFLUENCE
+ status_msg = f"Despite suffering losses, allied forces still heavily outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a major victory."
else:
- # But is the enemy is not outnumbered, we lose
+ # But if the enemy is not outnumbered, we lose
player_won = False
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
delta = STRONG_DEFEAT_INFLUENCE
+ status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces have overextended themselves, suffering a major defeat."
else:
- delta = STRONG_DEFEAT_INFLUENCE
+ delta = DEFEAT_INFLUENCE
+ status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces suffer a defeat."
# No progress with defensive strategies
if player_won and cp.stances[enemy_cp.id] in [
CombatStance.DEFENSIVE,
CombatStance.AMBUSH,
]:
- print("Defensive stance, progress is limited")
+ print(
+ f"Allied forces have adopted a defensive stance along the {cp.name}-{enemy_cp.name} "
+ f"frontline, making only limited progress."
+ )
delta = MINOR_DEFEAT_INFLUENCE
- if player_won:
- print(cp.name + " won ! factor > " + str(delta))
- cp.base.affect_strength(delta)
- enemy_cp.base.affect_strength(-delta)
+ # Handle the case where there are no casualties at all on either side but both sides still have units
+ if delta == 0.0:
+ print(status_msg)
info = Information(
"Frontline Report",
- "Our ground forces from "
- + cp.name
- + " are making progress toward "
- + enemy_cp.name,
+ f"Our ground forces from {cp.name} reached a stalemate with enemy forces from {enemy_cp.name}.",
self.game.turn,
)
self.game.informations.append(info)
else:
- print(cp.name + " lost ! factor > " + str(delta))
- enemy_cp.base.affect_strength(delta)
- cp.base.affect_strength(-delta)
- info = Information(
- "Frontline Report",
- "Our ground forces from "
- + cp.name
- + " are losing ground against the enemy forces from "
- + enemy_cp.name,
- self.game.turn,
- )
- self.game.informations.append(info)
+ if player_won:
+ print(status_msg)
+ cp.base.affect_strength(delta)
+ enemy_cp.base.affect_strength(-delta)
+ info = Information(
+ "Frontline Report",
+ f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}",
+ self.game.turn,
+ )
+ self.game.informations.append(info)
+ else:
+ print(status_msg)
+ enemy_cp.base.affect_strength(delta)
+ cp.base.affect_strength(-delta)
+ info = Information(
+ "Frontline Report",
+ f"Our ground forces from {cp.name} are losing ground against the enemy forces from "
+ f"{enemy_cp.name}. {status_msg}",
+ self.game.turn,
+ )
+ self.game.informations.append(info)
def redeploy_units(self, cp: ControlPoint) -> None:
""" "
diff --git a/game/event/frontlineattack.py b/game/event/frontlineattack.py
index d7749a2a..fefb3617 100644
--- a/game/event/frontlineattack.py
+++ b/game/event/frontlineattack.py
@@ -8,5 +8,5 @@ class FrontlineAttackEvent(Event):
future unique Event handling
"""
- def __str__(self):
+ def __str__(self) -> str:
return "Frontline attack"
diff --git a/game/factions/faction.py b/game/factions/faction.py
index 83a8f0fa..382525de 100644
--- a/game/factions/faction.py
+++ b/game/factions/faction.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import itertools
import logging
from dataclasses import dataclass, field
-from typing import Optional, Dict, Type, List, Any, Iterator
+from typing import Optional, Dict, Type, List, Any, Iterator, TYPE_CHECKING
import dcs
from dcs.countries import country_dict
@@ -25,6 +25,9 @@ from game.data.groundunitclass import GroundUnitClass
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
+if TYPE_CHECKING:
+ from game.theater.start_generator import ModSettings
+
@dataclass
class Faction:
@@ -81,10 +84,10 @@ class Faction:
requirements: Dict[str, str] = field(default_factory=dict)
# possible aircraft carrier units
- aircraft_carrier: List[Type[UnitType]] = field(default_factory=list)
+ aircraft_carrier: List[Type[ShipType]] = field(default_factory=list)
# possible helicopter carrier units
- helicopter_carrier: List[Type[UnitType]] = field(default_factory=list)
+ helicopter_carrier: List[Type[ShipType]] = field(default_factory=list)
# Possible carrier names
carrier_names: List[str] = field(default_factory=list)
@@ -257,7 +260,7 @@ class Faction:
if unit.unit_class is unit_class:
yield unit
- def apply_mod_settings(self, mod_settings) -> Faction:
+ def apply_mod_settings(self, mod_settings: ModSettings) -> Faction:
# aircraft
if not mod_settings.a4_skyhawk:
self.remove_aircraft("A-4E-C")
@@ -319,17 +322,17 @@ class Faction:
self.remove_air_defenses("KS19Generator")
return self
- def remove_aircraft(self, name):
+ def remove_aircraft(self, name: str) -> None:
for i in self.aircrafts:
if i.dcs_unit_type.id == name:
self.aircrafts.remove(i)
- def remove_air_defenses(self, name):
+ def remove_air_defenses(self, name: str) -> None:
for i in self.air_defenses:
if i == name:
self.air_defenses.remove(i)
- def remove_vehicle(self, name):
+ def remove_vehicle(self, name: str) -> None:
for i in self.frontline_units:
if i.dcs_unit_type.id == name:
self.frontline_units.remove(i)
@@ -342,7 +345,7 @@ def load_ship(name: str) -> Optional[Type[ShipType]]:
return None
-def load_all_ships(data) -> List[Type[ShipType]]:
+def load_all_ships(data: list[str]) -> List[Type[ShipType]]:
items = []
for name in data:
item = load_ship(name)
diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py
new file mode 100644
index 00000000..17a92708
--- /dev/null
+++ b/game/flightplan/__init__.py
@@ -0,0 +1,3 @@
+from .holdzonegeometry import HoldZoneGeometry
+from .ipzonegeometry import IpZoneGeometry
+from .joinzonegeometry import JoinZoneGeometry
diff --git a/game/flightplan/holdzonegeometry.py b/game/flightplan/holdzonegeometry.py
new file mode 100644
index 00000000..b382e11a
--- /dev/null
+++ b/game/flightplan/holdzonegeometry.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import shapely.ops
+from dcs import Point
+from shapely.geometry import Point as ShapelyPoint, Polygon, MultiPolygon
+
+from game.theater import ConflictTheater
+from game.utils import nautical_miles
+
+if TYPE_CHECKING:
+ from game.coalition import Coalition
+
+
+class HoldZoneGeometry:
+ """Defines the zones used for finding optimal hold point placement.
+
+ The zones themselves are stored in the class rather than just the resulting hold
+ point so that the zones can be drawn in the map for debugging purposes.
+ """
+
+ def __init__(
+ self,
+ target: Point,
+ home: Point,
+ ip: Point,
+ join: Point,
+ coalition: Coalition,
+ theater: ConflictTheater,
+ ) -> None:
+ # Hold points are placed one of two ways. Either approach guarantees:
+ #
+ # * Safe hold point.
+ # * Minimum distance to the join point.
+ # * Not closer to the target than the join point.
+ #
+ # 1. As near the join point as possible with a specific distance from the
+ # departure airfield. This prevents loitering directly above the airfield but
+ # also keeps the hold point close to the departure airfield.
+ #
+ # 2. Alternatively, if the entire home zone is excluded by the above criteria,
+ # as neat the departure airfield as possible within a minimum distance from
+ # the join point, with a restricted turn angle at the join point. This
+ # handles the case where we need to backtrack from the departure airfield and
+ # the join point to place the hold point, but the turn angle limit restricts
+ # the maximum distance of the backtrack while maintaining the direction of
+ # the flight plan.
+ self.threat_zone = coalition.opponent.threat_zone.all
+ self.home = ShapelyPoint(home.x, home.y)
+
+ self.join = ShapelyPoint(join.x, join.y)
+
+ self.join_bubble = self.join.buffer(coalition.doctrine.push_distance.meters)
+
+ join_to_target_distance = join.distance_to_point(target)
+ self.target_bubble = ShapelyPoint(target.x, target.y).buffer(
+ join_to_target_distance
+ )
+
+ self.home_bubble = self.home.buffer(coalition.doctrine.hold_distance.meters)
+
+ excluded_zones = shapely.ops.unary_union(
+ [self.join_bubble, self.target_bubble, self.threat_zone]
+ )
+ if not isinstance(excluded_zones, MultiPolygon):
+ excluded_zones = MultiPolygon([excluded_zones])
+ self.excluded_zones = excluded_zones
+
+ join_heading = ip.heading_between_point(join)
+
+ # Arbitrarily large since this is later constrained by the map boundary, and
+ # we'll be picking a location close to the IP anyway. Just used to avoid real
+ # distance calculations to project to the map edge.
+ large_distance = nautical_miles(400).meters
+ turn_limit = 40
+ join_limit_ccw = join.point_from_heading(
+ join_heading - turn_limit, large_distance
+ )
+ join_limit_cw = join.point_from_heading(
+ join_heading + turn_limit, large_distance
+ )
+
+ join_direction_limit_wedge = Polygon(
+ [
+ (join.x, join.y),
+ (join_limit_ccw.x, join_limit_ccw.y),
+ (join_limit_cw.x, join_limit_cw.y),
+ ]
+ )
+
+ permissible_zones = (
+ coalition.nav_mesh.map_bounds(theater)
+ .intersection(join_direction_limit_wedge)
+ .difference(self.excluded_zones)
+ .difference(self.home_bubble)
+ )
+ if not isinstance(permissible_zones, MultiPolygon):
+ permissible_zones = MultiPolygon([permissible_zones])
+ self.permissible_zones = permissible_zones
+ self.preferred_lines = self.home_bubble.boundary.difference(self.excluded_zones)
+
+ def find_best_hold_point(self) -> Point:
+ if self.preferred_lines.is_empty:
+ hold, _ = shapely.ops.nearest_points(self.permissible_zones, self.home)
+ else:
+ hold, _ = shapely.ops.nearest_points(self.preferred_lines, self.join)
+ return Point(hold.x, hold.y)
diff --git a/game/flightplan/ipzonegeometry.py b/game/flightplan/ipzonegeometry.py
new file mode 100644
index 00000000..a909cf03
--- /dev/null
+++ b/game/flightplan/ipzonegeometry.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import shapely.ops
+from dcs import Point
+from shapely.geometry import Point as ShapelyPoint, MultiPolygon
+
+from game.utils import nautical_miles, meters
+
+if TYPE_CHECKING:
+ from game.coalition import Coalition
+
+
+class IpZoneGeometry:
+ """Defines the zones used for finding optimal IP placement.
+
+ The zones themselves are stored in the class rather than just the resulting IP so
+ that the zones can be drawn in the map for debugging purposes.
+ """
+
+ def __init__(
+ self,
+ target: Point,
+ home: Point,
+ coalition: Coalition,
+ ) -> None:
+ self.threat_zone = coalition.opponent.threat_zone.all
+ self.home = ShapelyPoint(home.x, home.y)
+
+ max_ip_distance = coalition.doctrine.max_ingress_distance
+ min_ip_distance = coalition.doctrine.min_ingress_distance
+
+ # The minimum distance between the home location and the IP.
+ min_distance_from_home = nautical_miles(5)
+
+ # The distance that is expected to be needed between the beginning of the attack
+ # and weapon release. This buffers the threat zone to give a 5nm window between
+ # the edge of the "safe" zone and the actual threat so that "safe" IPs are less
+ # likely to end up with the attacker entering a threatened area.
+ attack_distance_buffer = nautical_miles(5)
+
+ home_threatened = coalition.opponent.threat_zone.threatened(home)
+
+ shapely_target = ShapelyPoint(target.x, target.y)
+ home_to_target_distance = meters(home.distance_to_point(target))
+
+ self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference(
+ self.home.buffer(min_distance_from_home.meters)
+ )
+
+ # If the home zone is not threatened and home is within LAR, constrain the max
+ # range to the home-to-target distance to prevent excessive backtracking.
+ #
+ # If the home zone *is* threatened, we need to back out of the zone to
+ # rendezvous anyway.
+ if not home_threatened and (
+ min_ip_distance < home_to_target_distance < max_ip_distance
+ ):
+ max_ip_distance = home_to_target_distance
+ max_ip_bubble = shapely_target.buffer(max_ip_distance.meters)
+ min_ip_bubble = shapely_target.buffer(min_ip_distance.meters)
+ self.ip_bubble = max_ip_bubble.difference(min_ip_bubble)
+
+ # The intersection of the home bubble and IP bubble will be all the points that
+ # are within the valid IP range that are not farther from home than the target
+ # is. However, if the origin airfield is threatened but there are safe
+ # placements for the IP, we should not constrain to the home zone. In this case
+ # we'll either end up with a safe zone outside the home zone and pick the
+ # closest point in to to home (minimizing backtracking), or we'll have no safe
+ # IP anywhere within range of the target, and we'll later pick the IP nearest
+ # the edge of the threat zone.
+ if home_threatened:
+ self.permissible_zone = self.ip_bubble
+ else:
+ self.permissible_zone = self.ip_bubble.intersection(self.home_bubble)
+
+ if self.permissible_zone.is_empty:
+ # If home is closer to the target than the min range, there will not be an
+ # IP solution that's close enough to home, in which case we need to ignore
+ # the home bubble.
+ self.permissible_zone = self.ip_bubble
+
+ safe_zones = self.permissible_zone.difference(
+ self.threat_zone.buffer(attack_distance_buffer.meters)
+ )
+
+ if not isinstance(safe_zones, MultiPolygon):
+ safe_zones = MultiPolygon([safe_zones])
+ self.safe_zones = safe_zones
+
+ def _unsafe_ip(self) -> ShapelyPoint:
+ unthreatened_home_zone = self.home_bubble.difference(self.threat_zone)
+ if unthreatened_home_zone.is_empty:
+ # Nowhere in our home zone is safe. The package will need to exit the
+ # threatened area to hold and rendezvous. Pick the IP closest to the
+ # edge of the threat zone.
+ return shapely.ops.nearest_points(
+ self.permissible_zone, self.threat_zone.boundary
+ )[0]
+
+ # No safe point in the IP zone, but the home zone is safe. Pick the max-
+ # distance IP that's closest to the untreatened home zone.
+ return shapely.ops.nearest_points(
+ self.permissible_zone, unthreatened_home_zone
+ )[0]
+
+ def _safe_ip(self) -> ShapelyPoint:
+ # We have a zone of possible IPs that are safe, close enough, and in range. Pick
+ # the IP in the zone that's closest to the target.
+ return shapely.ops.nearest_points(self.safe_zones, self.home)[0]
+
+ def find_best_ip(self) -> Point:
+ if self.safe_zones.is_empty:
+ ip = self._unsafe_ip()
+ else:
+ ip = self._safe_ip()
+ return Point(ip.x, ip.y)
diff --git a/game/flightplan/joinzonegeometry.py b/game/flightplan/joinzonegeometry.py
new file mode 100644
index 00000000..02e00fa4
--- /dev/null
+++ b/game/flightplan/joinzonegeometry.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import shapely.ops
+from dcs import Point
+from shapely.geometry import (
+ Point as ShapelyPoint,
+ Polygon,
+ MultiPolygon,
+ MultiLineString,
+)
+
+from game.utils import nautical_miles
+
+if TYPE_CHECKING:
+ from game.coalition import Coalition
+
+
+class JoinZoneGeometry:
+ """Defines the zones used for finding optimal join point placement.
+
+ The zones themselves are stored in the class rather than just the resulting join
+ point so that the zones can be drawn in the map for debugging purposes.
+ """
+
+ def __init__(
+ self, target: Point, home: Point, ip: Point, coalition: Coalition
+ ) -> None:
+ # Normal join placement is based on the path from home to the IP. If no path is
+ # found it means that the target is on a direct path. In that case we instead
+ # want to enforce that the join point is:
+ #
+ # * Not closer to the target than the IP.
+ # * Not too close to the home airfield.
+ # * Not threatened.
+ # * A minimum distance from the IP.
+ # * Not too sharp a turn at the ingress point.
+ self.ip = ShapelyPoint(ip.x, ip.y)
+ self.threat_zone = coalition.opponent.threat_zone.all
+ self.home = ShapelyPoint(home.x, home.y)
+
+ self.ip_bubble = self.ip.buffer(coalition.doctrine.join_distance.meters)
+
+ ip_distance = ip.distance_to_point(target)
+ self.target_bubble = ShapelyPoint(target.x, target.y).buffer(ip_distance)
+
+ # The minimum distance between the home location and the IP.
+ min_distance_from_home = nautical_miles(5)
+
+ self.home_bubble = self.home.buffer(min_distance_from_home.meters)
+
+ excluded_zones = shapely.ops.unary_union(
+ [self.ip_bubble, self.target_bubble, self.threat_zone]
+ )
+
+ if not isinstance(excluded_zones, MultiPolygon):
+ excluded_zones = MultiPolygon([excluded_zones])
+ self.excluded_zones = excluded_zones
+
+ ip_heading = target.heading_between_point(ip)
+
+ # Arbitrarily large since this is later constrained by the map boundary, and
+ # we'll be picking a location close to the IP anyway. Just used to avoid real
+ # distance calculations to project to the map edge.
+ large_distance = nautical_miles(400).meters
+ turn_limit = 40
+ ip_limit_ccw = ip.point_from_heading(ip_heading - turn_limit, large_distance)
+ ip_limit_cw = ip.point_from_heading(ip_heading + turn_limit, large_distance)
+
+ ip_direction_limit_wedge = Polygon(
+ [
+ (ip.x, ip.y),
+ (ip_limit_ccw.x, ip_limit_ccw.y),
+ (ip_limit_cw.x, ip_limit_cw.y),
+ ]
+ )
+
+ permissible_zones = ip_direction_limit_wedge.difference(
+ self.excluded_zones
+ ).difference(self.home_bubble)
+ if permissible_zones.is_empty:
+ permissible_zones = MultiPolygon([])
+ if not isinstance(permissible_zones, MultiPolygon):
+ permissible_zones = MultiPolygon([permissible_zones])
+ self.permissible_zones = permissible_zones
+
+ preferred_lines = ip_direction_limit_wedge.intersection(
+ self.excluded_zones.boundary
+ ).difference(self.home_bubble)
+
+ if preferred_lines.is_empty:
+ preferred_lines = MultiLineString([])
+ if not isinstance(preferred_lines, MultiLineString):
+ preferred_lines = MultiLineString([preferred_lines])
+ self.preferred_lines = preferred_lines
+
+ def find_best_join_point(self) -> Point:
+ if self.preferred_lines.is_empty:
+ join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip)
+ else:
+ join, _ = shapely.ops.nearest_points(self.preferred_lines, self.home)
+ return Point(join.x, join.y)
diff --git a/game/game.py b/game/game.py
index 0f9eacbb..dab02f8a 100644
--- a/game/game.py
+++ b/game/game.py
@@ -1,48 +1,43 @@
-from game.dcs.aircrafttype import AircraftType
import itertools
import logging
-import random
-import sys
+import math
+from collections import Iterator
from datetime import date, datetime, timedelta
from enum import Enum
-from typing import Any, List
+from typing import Any, List, Type, Union, cast
from dcs.action import Coalition
from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors
from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence
-from pydcs_extensions.a4ec.a4ec import A_4E_C
from faker import Faker
-from game import db
from game.inventory import GlobalAircraftInventory
from game.models.game_stats import GameStats
from game.plugins import LuaPluginManager
-from gen import aircraft, naming
+from gen import naming
from gen.ato import AirTaskingOrder
from gen.conflictgen import Conflict
-from gen.flights.ai_flight_planner import CoalitionMissionPlanner
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
from gen.ground_forces.ai_ground_planner import GroundPlanner
from . import persistency
+from .coalition import Coalition
from .debriefing import Debriefing
from .event.event import Event
from .event.frontlineattack import FrontlineAttackEvent
from .factions.faction import Faction
-from .income import Income
from .infos.information import Information
from .navmesh import NavMesh
-from .procurement import AircraftProcurementRequest, ProcurementAi
+from .procurement import AircraftProcurementRequest
from .profiling import logged_duration
-from .settings import Settings, AutoAtoBehavior
+from .settings import Settings
from .squadrons import AirWing
-from .theater import ConflictTheater
+from .theater import ConflictTheater, ControlPoint
from .theater.bullseye import Bullseye
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
from .threatzones import ThreatZones
-from .transfers import PendingTransfers
from .unitmap import UnitMap
from .weather import Conditions, TimeOfDay
@@ -100,151 +95,97 @@ class Game:
self.settings = settings
self.events: List[Event] = []
self.theater = theater
- self.player_faction = player_faction
- self.player_country = player_faction.country
- self.enemy_faction = enemy_faction
- self.enemy_country = enemy_faction.country
# pass_turn() will be called when initialization is complete which will
# increment this to turn 0 before it reaches the player.
self.turn = -1
# NB: This is the *start* date. It is never updated.
self.date = date(start_date.year, start_date.month, start_date.day)
self.game_stats = GameStats()
- self.game_stats.update(self)
+ self.notes = ""
self.ground_planners: dict[int, GroundPlanner] = {}
self.informations = []
self.informations.append(Information("Game Start", "-" * 40, 0))
# Culling Zones are for areas around points of interest that contain things we may not wish to cull.
self.__culling_zones: List[Point] = []
- self.__destroyed_units: List[str] = []
+ self.__destroyed_units: list[dict[str, Union[float, str]]] = []
self.savepath = ""
- self.budget = player_budget
- self.enemy_budget = enemy_budget
self.current_unit_id = 0
self.current_group_id = 0
self.name_generator = naming.namegen
self.conditions = self.generate_conditions()
- self.blue_transit_network = TransitNetwork()
- self.red_transit_network = TransitNetwork()
-
- self.blue_procurement_requests: List[AircraftProcurementRequest] = []
- self.red_procurement_requests: List[AircraftProcurementRequest] = []
-
- self.blue_ato = AirTaskingOrder()
- self.red_ato = AirTaskingOrder()
-
- self.blue_bullseye = Bullseye(Point(0, 0))
- self.red_bullseye = Bullseye(Point(0, 0))
+ self.sanitize_sides(player_faction, enemy_faction)
+ self.blue = Coalition(self, player_faction, player_budget, player=True)
+ self.red = Coalition(self, enemy_faction, enemy_budget, player=False)
+ self.blue.set_opponent(self.red)
+ self.red.set_opponent(self.blue)
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
- self.transfers = PendingTransfers(self)
-
- self.sanitize_sides()
-
- self.blue_faker = Faker(self.player_faction.locales)
- self.red_faker = Faker(self.enemy_faction.locales)
-
- self.blue_air_wing = AirWing(self, player=True)
- self.red_air_wing = AirWing(self, player=False)
-
self.on_load(game_still_initializing=True)
- def __getstate__(self) -> dict[str, Any]:
- state = self.__dict__.copy()
- # Avoid persisting any volatile types that can be deterministically
- # recomputed on load for the sake of save compatibility.
- del state["blue_threat_zone"]
- del state["red_threat_zone"]
- del state["blue_navmesh"]
- del state["red_navmesh"]
- del state["blue_faker"]
- del state["red_faker"]
- return state
-
def __setstate__(self, state: dict[str, Any]) -> None:
self.__dict__.update(state)
# Regenerate any state that was not persisted.
self.on_load()
- def ato_for(self, player: bool) -> AirTaskingOrder:
- if player:
- return self.blue_ato
- return self.red_ato
+ @property
+ def coalitions(self) -> Iterator[Coalition]:
+ yield self.blue
+ yield self.red
- def procurement_requests_for(
- self, player: bool
- ) -> List[AircraftProcurementRequest]:
- if player:
- return self.blue_procurement_requests
- return self.red_procurement_requests
+ def ato_for(self, player: bool) -> AirTaskingOrder:
+ return self.coalition_for(player).ato
def transit_network_for(self, player: bool) -> TransitNetwork:
- if player:
- return self.blue_transit_network
- return self.red_transit_network
+ return self.coalition_for(player).transit_network
def generate_conditions(self) -> Conditions:
return Conditions.generate(
self.theater, self.current_day, self.current_turn_time_of_day, self.settings
)
- def sanitize_sides(self):
+ @staticmethod
+ def sanitize_sides(player_faction: Faction, enemy_faction: Faction) -> None:
"""
Make sure the opposing factions are using different countries
:return:
"""
- if self.player_country == self.enemy_country:
- if self.player_country == "USA":
- self.enemy_country = "USAF Aggressors"
- elif self.player_country == "Russia":
- self.enemy_country = "USSR"
+ if player_faction.country == enemy_faction.country:
+ if player_faction.country == "USA":
+ enemy_faction.country = "USAF Aggressors"
+ elif player_faction.country == "Russia":
+ enemy_faction.country = "USSR"
else:
- self.enemy_country = "Russia"
+ enemy_faction.country = "Russia"
def faction_for(self, player: bool) -> Faction:
- if player:
- return self.player_faction
- return self.enemy_faction
+ return self.coalition_for(player).faction
def faker_for(self, player: bool) -> Faker:
- if player:
- return self.blue_faker
- return self.red_faker
+ return self.coalition_for(player).faker
def air_wing_for(self, player: bool) -> AirWing:
- if player:
- return self.blue_air_wing
- return self.red_air_wing
+ return self.coalition_for(player).air_wing
def country_for(self, player: bool) -> str:
- if player:
- return self.player_country
- return self.enemy_country
+ return self.coalition_for(player).country_name
def bullseye_for(self, player: bool) -> Bullseye:
- if player:
- return self.blue_bullseye
- return self.red_bullseye
+ return self.coalition_for(player).bullseye
- def _roll(self, prob, mult):
- if self.settings.version == "dev":
- # always generate all events for dev
- return 100
- else:
- return random.randint(1, 100) <= prob * mult
-
- def _generate_player_event(self, event_class, player_cp, enemy_cp):
+ def _generate_player_event(
+ self, event_class: Type[Event], player_cp: ControlPoint, enemy_cp: ControlPoint
+ ) -> None:
self.events.append(
event_class(
self,
player_cp,
enemy_cp,
enemy_cp.position,
- self.player_faction.name,
- self.enemy_faction.name,
+ self.blue.faction.name,
+ self.red.faction.name,
)
)
@@ -259,7 +200,7 @@ class Game:
else:
return USAFAggressors
- def _generate_events(self):
+ def _generate_events(self) -> None:
for front_line in self.theater.conflicts():
self._generate_player_event(
FrontlineAttackEvent,
@@ -267,27 +208,21 @@ class Game:
front_line.red_cp,
)
- def adjust_budget(self, amount: float, player: bool) -> None:
+ def coalition_for(self, player: bool) -> Coalition:
if player:
- self.budget += amount
- else:
- self.enemy_budget += amount
+ return self.blue
+ return self.red
- def process_player_income(self):
- self.budget += Income(self, player=True).total
+ def adjust_budget(self, amount: float, player: bool) -> None:
+ self.coalition_for(player).adjust_budget(amount)
- def process_enemy_income(self):
- # TODO: Clean up save compat.
- if not hasattr(self, "enemy_budget"):
- self.enemy_budget = 0
- self.enemy_budget += Income(self, player=False).total
-
- def initiate_event(self, event: Event) -> UnitMap:
+ @staticmethod
+ def initiate_event(event: Event) -> UnitMap:
# assert event in self.events
logging.info("Generating {} (regular)".format(event))
return event.generate()
- def finish_event(self, event: Event, debriefing: Debriefing):
+ def finish_event(self, event: Event, debriefing: Debriefing) -> None:
logging.info("Finishing event {}".format(event))
event.commit(debriefing)
@@ -296,16 +231,6 @@ class Game:
else:
logging.info("finish_event: event not in the events!")
- def is_player_attack(self, event):
- if isinstance(event, Event):
- return (
- event
- and event.attacker_name
- and event.attacker_name == self.player_faction.name
- )
- else:
- raise RuntimeError(f"{event} was passed when an Event type was expected")
-
def on_load(self, game_still_initializing: bool = False) -> None:
if not hasattr(self, "name_generator"):
self.name_generator = naming.namegen
@@ -320,36 +245,50 @@ class Game:
self.compute_conflicts_position()
if not game_still_initializing:
self.compute_threat_zones()
- self.blue_faker = Faker(self.faction_for(player=True).locales)
- self.red_faker = Faker(self.faction_for(player=False).locales)
-
- def reset_ato(self) -> None:
- self.blue_ato.clear()
- self.red_ato.clear()
def finish_turn(self, skipped: bool = False) -> None:
+ """Finalizes the current turn and advances to the next turn.
+
+ This handles the turn-end portion of passing a turn. Initialization of the next
+ turn is handled by `initialize_turn`. These are separate processes because while
+ turns may be initialized more than once under some circumstances (see the
+ documentation for `initialize_turn`), `finish_turn` performs the work that
+ should be guaranteed to happen only once per turn:
+
+ * Turn counter increment.
+ * Delivering units ordered the previous turn.
+ * Transfer progress.
+ * Squadron replenishment.
+ * Income distribution.
+ * Base strength (front line position) adjustment.
+ * Weather/time-of-day generation.
+
+ Some actions (like transit network assembly) will happen both here and in
+ `initialize_turn`. We need the network to be up to date so we can account for
+ base captures when processing the transfers that occurred last turn, but we also
+ need it to be up to date in the case of a re-initialization in `initialize_turn`
+ (such as to account for a cheat base capture) so that orders are only placed
+ where a supply route exists to the destination. This is a relatively cheap
+ operation so duplicating the effort is not a problem.
+
+ Args:
+ skipped: True if the turn was skipped.
+ """
self.informations.append(
Information("End of turn #" + str(self.turn), "-" * 40, 0)
)
self.turn += 1
- # Need to recompute before transfers and deliveries to account for captures.
- # This happens in in initialize_turn as well, because cheating doesn't advance a
- # turn but can capture bases so we need to recompute there as well.
- self.compute_transit_networks()
+ # The coalition-specific turn finalization *must* happen before unit deliveries,
+ # since the coalition-specific finalization handles transit network updates and
+ # transfer processing. If in the other order, units may be delivered to captured
+ # bases, and freshly delivered units will spawn one leg through their journey.
+ self.blue.end_turn()
+ self.red.end_turn()
- # Must happen *before* unit deliveries are handled, or else new units will spawn
- # one hop ahead. ControlPoint.process_turn handles unit deliveries.
- self.transfers.perform_transfers()
-
- # Needs to happen *before* planning transfers so we don't cancel them.
- self.reset_ato()
for control_point in self.theater.controlpoints:
control_point.process_turn(self)
- self.blue_air_wing.replenish()
- self.red_air_wing.replenish()
-
if not skipped:
for cp in self.theater.player_points():
cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY)
@@ -360,14 +299,21 @@ class Game:
self.conditions = self.generate_conditions()
- self.process_enemy_income()
- self.process_player_income()
-
def begin_turn_0(self) -> None:
+ """Initialization for the first turn of the game."""
self.turn = 0
+ self.blue.preinit_turn_0()
+ self.red.preinit_turn_0()
self.initialize_turn()
def pass_turn(self, no_action: bool = False) -> None:
+ """Ends the current turn and initializes the new turn.
+
+ Called both when skipping a turn or by ending the turn as the result of combat.
+
+ Args:
+ no_action: True if the turn was skipped.
+ """
logging.info("Pass turn")
with logged_duration("Turn finalization"):
self.finish_turn(no_action)
@@ -377,7 +323,7 @@ class Game:
# Autosave progress
persistency.autosave(self)
- def check_win_loss(self):
+ def check_win_loss(self) -> TurnState:
player_airbases = {
cp for cp in self.theater.player_points() if cp.runway_is_operational()
}
@@ -394,24 +340,50 @@ class Game:
def set_bullseye(self) -> None:
player_cp, enemy_cp = self.theater.closest_opposing_control_points()
- self.blue_bullseye = Bullseye(enemy_cp.position)
- self.red_bullseye = Bullseye(player_cp.position)
+ self.blue.bullseye = Bullseye(enemy_cp.position)
+ self.red.bullseye = Bullseye(player_cp.position)
- def initialize_turn(self) -> None:
+ def initialize_turn(self, for_red: bool = True, for_blue: bool = True) -> None:
+ """Performs turn initialization for the specified players.
+
+ Turn initialization performs all of the beginning-of-turn actions. *End-of-turn*
+ processing happens in `pass_turn` (despite the name, it's called both for
+ skipping the turn and ending the turn after combat).
+
+ Special care needs to be taken here because initialization can occur more than
+ once per turn. A number of events can require re-initializing a turn:
+
+ * Cheat capture. Bases changing hands invalidates many missions in both ATOs,
+ purchase orders, threat zones, transit networks, etc. Practically speaking,
+ after a base capture the turn needs to be treated as fully new. The game might
+ even be over after a capture.
+ * Cheat front line position. CAS missions are no longer in the correct location,
+ and the ground planner may also need changes.
+ * Selling/buying units at TGOs. Selling a TGO might leave missions in the ATO
+ with invalid targets. Buying a new SAM (or even replacing some units in a SAM)
+ potentially changes the threat zone and may alter mission priorities and
+ flight planning.
+
+ Most of the work is delegated to initialize_turn_for, which handles the
+ coalition-specific turn initialization. In some cases only one coalition will be
+ (re-) initialized. This is the case when buying or selling TGO units, since we
+ don't want to force the player to redo all their planning just because they
+ repaired a SAM, but should replan opfor when that happens. On the other hand,
+ base captures are significant enough (and likely enough to be the first thing
+ the player does in a turn) that we replan blue as well. Front lines are less
+ impactful but also likely to be early, so they also cause a blue replan.
+
+ Args:
+ for_red: True if opfor should be re-initialized.
+ for_blue: True if the player coalition should be re-initialized.
+ """
self.events = []
self._generate_events()
-
self.set_bullseye()
# Update statistics
self.game_stats.update(self)
- self.blue_air_wing.reset()
- self.red_air_wing.reset()
- self.aircraft_inventory.reset()
- for cp in self.theater.controlpoints:
- self.aircraft_inventory.set_from_control_point(cp)
-
# Check for win or loss condition
turn_state = self.check_win_loss()
if turn_state in (TurnState.LOSS, TurnState.WIN):
@@ -422,59 +394,26 @@ class Game:
self.compute_conflicts_position()
with logged_duration("Threat zone computation"):
self.compute_threat_zones()
- with logged_duration("Transit network identification"):
- self.compute_transit_networks()
+
+ # Plan Coalition specific turn
+ if for_blue:
+ self.initialize_turn_for(player=True)
+ if for_red:
+ self.initialize_turn_for(player=False)
+
+ # Plan GroundWar
self.ground_planners = {}
-
- self.blue_procurement_requests.clear()
- self.red_procurement_requests.clear()
-
- with logged_duration("Procurement of airlift assets"):
- self.transfers.order_airlift_assets()
- with logged_duration("Transport planning"):
- self.transfers.plan_transports()
-
- with logged_duration("Blue mission planning"):
- if self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled:
- blue_planner = CoalitionMissionPlanner(self, is_player=True)
- blue_planner.plan_missions()
-
- with logged_duration("Red mission planning"):
- red_planner = CoalitionMissionPlanner(self, is_player=False)
- red_planner.plan_missions()
-
for cp in self.theater.controlpoints:
if cp.has_frontline:
gplanner = GroundPlanner(cp, self)
gplanner.plan_groundwar()
self.ground_planners[cp.id] = gplanner
- self.plan_procurement()
-
- def plan_procurement(self) -> None:
- # The first turn needs to buy a *lot* of aircraft to fill CAPs, so it
- # gets much more of the budget that turn. Otherwise budget (after
- # repairs) is split evenly between air and ground. For the default
- # starting budget of 2000 this gives 600 to ground forces and 1400 to
- # aircraft. After that the budget will be spend proportionally based on how much is already invested
-
- self.budget = ProcurementAi(
- self,
- for_player=True,
- faction=self.player_faction,
- manage_runways=self.settings.automate_runway_repair,
- manage_front_line=self.settings.automate_front_line_reinforcements,
- manage_aircraft=self.settings.automate_aircraft_reinforcements,
- ).spend_budget(self.budget)
-
- self.enemy_budget = ProcurementAi(
- self,
- for_player=False,
- faction=self.enemy_faction,
- manage_runways=True,
- manage_front_line=True,
- manage_aircraft=True,
- ).spend_budget(self.enemy_budget)
+ def initialize_turn_for(self, player: bool) -> None:
+ self.aircraft_inventory.reset(player)
+ for cp in self.theater.control_points_for(player):
+ self.aircraft_inventory.set_from_control_point(cp)
+ self.coalition_for(player).initialize_turn()
def message(self, text: str) -> None:
self.informations.append(Information(text, turn=self.turn))
@@ -487,48 +426,36 @@ class Game:
def current_day(self) -> date:
return self.date + timedelta(days=self.turn // 4)
- def next_unit_id(self):
+ def next_unit_id(self) -> int:
"""
Next unit id for pre-generated units
"""
self.current_unit_id += 1
return self.current_unit_id
- def next_group_id(self):
+ def next_group_id(self) -> int:
"""
Next unit id for pre-generated units
"""
self.current_group_id += 1
return self.current_group_id
- def compute_transit_networks(self) -> None:
- self.blue_transit_network = self.compute_transit_network_for(player=True)
- self.red_transit_network = self.compute_transit_network_for(player=False)
-
def compute_transit_network_for(self, player: bool) -> TransitNetwork:
return TransitNetworkBuilder(self.theater, player).build()
def compute_threat_zones(self) -> None:
- self.blue_threat_zone = ThreatZones.for_faction(self, player=True)
- self.red_threat_zone = ThreatZones.for_faction(self, player=False)
- self.blue_navmesh = NavMesh.from_threat_zones(
- self.red_threat_zone, self.theater
- )
- self.red_navmesh = NavMesh.from_threat_zones(
- self.blue_threat_zone, self.theater
- )
+ self.blue.compute_threat_zones()
+ self.red.compute_threat_zones()
+ self.blue.compute_nav_meshes()
+ self.red.compute_nav_meshes()
def threat_zone_for(self, player: bool) -> ThreatZones:
- if player:
- return self.blue_threat_zone
- return self.red_threat_zone
+ return self.coalition_for(player).threat_zone
def navmesh_for(self, player: bool) -> NavMesh:
- if player:
- return self.blue_navmesh
- return self.red_navmesh
+ return self.coalition_for(player).nav_mesh
- def compute_conflicts_position(self):
+ def compute_conflicts_position(self) -> None:
"""
Compute the current conflict center position(s), mainly used for culling calculation
:return: List of points of interests
@@ -551,7 +478,7 @@ class Game:
# If there is no conflict take the center point between the two nearest opposing bases
if len(zones) == 0:
cpoint = None
- min_distance = sys.maxsize
+ min_distance = math.inf
for cp in self.theater.player_points():
for cp2 in self.theater.enemy_points():
d = cp.position.distance_to_point(cp2.position)
@@ -569,7 +496,7 @@ class Game:
if cpoint is not None:
zones.append(cpoint)
- packages = itertools.chain(self.blue_ato.packages, self.red_ato.packages)
+ packages = itertools.chain(self.blue.ato.packages, self.red.ato.packages)
for package in packages:
if package.primary_task is FlightType.BARCAP:
# BARCAPs will be planned at most locations on smaller theaters,
@@ -587,15 +514,15 @@ class Game:
self.__culling_zones = zones
- def add_destroyed_units(self, data):
- pos = Point(data["x"], data["z"])
+ def add_destroyed_units(self, data: dict[str, Union[float, str]]) -> None:
+ pos = Point(cast(float, data["x"]), cast(float, data["z"]))
if self.theater.is_on_land(pos):
self.__destroyed_units.append(data)
- def get_destroyed_units(self):
+ def get_destroyed_units(self) -> list[dict[str, Union[float, str]]]:
return self.__destroyed_units
- def position_culled(self, pos):
+ def position_culled(self, pos: Point) -> bool:
"""
Check if unit can be generated at given position depending on culling performance settings
:param pos: Position you are tryng to spawn stuff at
@@ -608,38 +535,17 @@ class Game:
return False
return True
- def get_culling_zones(self):
+ def get_culling_zones(self) -> list[Point]:
"""
Check culling points
:return: List of culling zones
"""
return self.__culling_zones
- # 1 = red, 2 = blue
- def get_player_coalition_id(self):
- return 2
-
- def get_enemy_coalition_id(self):
- return 1
-
- def get_player_coalition(self):
- return Coalition.Blue
-
- def get_enemy_coalition(self):
- return Coalition.Red
-
- def get_player_color(self):
- return "blue"
-
- def get_enemy_color(self):
- return "red"
-
- def process_win_loss(self, turn_state: TurnState):
+ def process_win_loss(self, turn_state: TurnState) -> None:
if turn_state is TurnState.WIN:
- return self.message(
- "Congratulations, you are victorious! Start a new campaign to continue."
+ self.message(
+ "Congratulations, you are victorious! Start a new campaign to continue."
)
elif turn_state is TurnState.LOSS:
- return self.message(
- "Game Over, you lose. Start a new campaign to continue."
- )
+ self.message("Game Over, you lose. Start a new campaign to continue.")
diff --git a/game/htn.py b/game/htn.py
new file mode 100644
index 00000000..49699892
--- /dev/null
+++ b/game/htn.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from collections import Iterator, deque, Sequence
+from dataclasses import dataclass
+from typing import Any, Generic, Optional, TypeVar
+
+WorldStateT = TypeVar("WorldStateT", bound="WorldState[Any]")
+
+
+class WorldState(ABC, Generic[WorldStateT]):
+ @abstractmethod
+ def clone(self) -> WorldStateT:
+ ...
+
+
+class Task(Generic[WorldStateT]):
+ pass
+
+
+Method = Sequence[Task[WorldStateT]]
+
+
+class PrimitiveTask(Task[WorldStateT], Generic[WorldStateT], ABC):
+ @abstractmethod
+ def preconditions_met(self, state: WorldStateT) -> bool:
+ ...
+
+ @abstractmethod
+ def apply_effects(self, state: WorldStateT) -> None:
+ ...
+
+
+class CompoundTask(Task[WorldStateT], Generic[WorldStateT], ABC):
+ @abstractmethod
+ def each_valid_method(self, state: WorldStateT) -> Iterator[Method[WorldStateT]]:
+ ...
+
+
+PrimitiveTaskT = TypeVar("PrimitiveTaskT", bound=PrimitiveTask[Any])
+
+
+@dataclass
+class PlanningState(Generic[WorldStateT, PrimitiveTaskT]):
+ state: WorldStateT
+ tasks_to_process: deque[Task[WorldStateT]]
+ plan: list[PrimitiveTaskT]
+ methods: Optional[Iterator[Method[WorldStateT]]]
+
+
+@dataclass(frozen=True)
+class PlanningResult(Generic[WorldStateT, PrimitiveTaskT]):
+ tasks: list[PrimitiveTaskT]
+ end_state: WorldStateT
+
+
+class PlanningHistory(Generic[WorldStateT, PrimitiveTaskT]):
+ def __init__(self) -> None:
+ self.states: list[PlanningState[WorldStateT, PrimitiveTaskT]] = []
+
+ def push(self, planning_state: PlanningState[WorldStateT, PrimitiveTaskT]) -> None:
+ self.states.append(planning_state)
+
+ def pop(self) -> PlanningState[WorldStateT, PrimitiveTaskT]:
+ return self.states.pop()
+
+
+class Planner(Generic[WorldStateT, PrimitiveTaskT]):
+ def __init__(self, main_task: Task[WorldStateT]) -> None:
+ self.main_task = main_task
+
+ def plan(
+ self, initial_state: WorldStateT
+ ) -> Optional[PlanningResult[WorldStateT, PrimitiveTaskT]]:
+ planning_state: PlanningState[WorldStateT, PrimitiveTaskT] = PlanningState(
+ initial_state, deque([self.main_task]), [], None
+ )
+ history: PlanningHistory[WorldStateT, PrimitiveTaskT] = PlanningHistory()
+ while planning_state.tasks_to_process:
+ task = planning_state.tasks_to_process.popleft()
+ if isinstance(task, PrimitiveTask):
+ if task.preconditions_met(planning_state.state):
+ task.apply_effects(planning_state.state)
+ # Ignore type erasure. We've already verified that this is a Planner
+ # with a WorldStateT and a PrimitiveTaskT, so we know that the task
+ # list is a list of CompoundTask[WorldStateT] and PrimitiveTaskT. We
+ # could scatter more unions throughout to be more explicit but
+ # there's no way around the type erasure that mypy uses for
+ # isinstance.
+ planning_state.plan.append(task) # type: ignore
+ else:
+ planning_state = history.pop()
+ else:
+ assert isinstance(task, CompoundTask)
+ # If the methods field of our current state is not None that means we're
+ # resuming a prior attempt to execute this task after a subtask of the
+ # previously selected method failed.
+ #
+ # Otherwise this is the first exectution of this task so we need to
+ # create the generator.
+ if planning_state.methods is None:
+ methods = task.each_valid_method(planning_state.state)
+ else:
+ methods = planning_state.methods
+ try:
+ method = next(methods)
+ # Push the current node back onto the stack so that we resume
+ # handling this task when we pop back to this state.
+ resume_tasks: deque[Task[WorldStateT]] = deque([task])
+ resume_tasks.extend(planning_state.tasks_to_process)
+ history.push(
+ PlanningState(
+ planning_state.state.clone(),
+ resume_tasks,
+ planning_state.plan,
+ methods,
+ )
+ )
+ planning_state.methods = None
+ planning_state.tasks_to_process.extendleft(reversed(method))
+ except StopIteration:
+ try:
+ planning_state = history.pop()
+ except IndexError:
+ # No valid plan was found.
+ return None
+ return PlanningResult(planning_state.plan, planning_state.state)
diff --git a/game/income.py b/game/income.py
index dd2be887..f9a74eb6 100644
--- a/game/income.py
+++ b/game/income.py
@@ -14,10 +14,10 @@ class BuildingIncome:
name: str
category: str
number: int
- income_per_building: int
+ income_per_building: float
@property
- def income(self) -> int:
+ def income(self) -> float:
return self.number * self.income_per_building
diff --git a/game/infos/information.py b/game/infos/information.py
index 1c132d46..efc3fb96 100644
--- a/game/infos/information.py
+++ b/game/infos/information.py
@@ -2,13 +2,13 @@ import datetime
class Information:
- def __init__(self, title="", text="", turn=0):
+ def __init__(self, title: str = "", text: str = "", turn: int = 0) -> None:
self.title = title
self.text = text
self.turn = turn
self.timestamp = datetime.datetime.now()
- def __str__(self):
+ def __str__(self) -> str:
return "[{}][{}] {} {}".format(
self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
if self.timestamp is not None
diff --git a/game/inventory.py b/game/inventory.py
index 4014c05c..f7f0dbe1 100644
--- a/game/inventory.py
+++ b/game/inventory.py
@@ -1,10 +1,8 @@
"""Inventory management APIs."""
from __future__ import annotations
-from collections import defaultdict
-from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING, Type
-
-from dcs.unittype import FlyingType
+from collections import defaultdict, Iterator, Iterable
+from typing import TYPE_CHECKING
from game.dcs.aircrafttype import AircraftType
from gen.flights.flight import Flight
@@ -18,7 +16,12 @@ class ControlPointAircraftInventory:
def __init__(self, control_point: ControlPoint) -> None:
self.control_point = control_point
- self.inventory: Dict[AircraftType, int] = defaultdict(int)
+ self.inventory: dict[AircraftType, int] = defaultdict(int)
+
+ def clone(self) -> ControlPointAircraftInventory:
+ new = ControlPointAircraftInventory(self.control_point)
+ new.inventory = self.inventory.copy()
+ return new
def add_aircraft(self, aircraft: AircraftType, count: int) -> None:
"""Adds aircraft to the inventory.
@@ -67,7 +70,7 @@ class ControlPointAircraftInventory:
yield aircraft
@property
- def all_aircraft(self) -> Iterator[Tuple[AircraftType, int]]:
+ def all_aircraft(self) -> Iterator[tuple[AircraftType, int]]:
"""Iterates over all available aircraft types, including amounts."""
for aircraft, count in self.inventory.items():
if count > 0:
@@ -82,14 +85,22 @@ class GlobalAircraftInventory:
"""Game-wide aircraft inventory."""
def __init__(self, control_points: Iterable[ControlPoint]) -> None:
- self.inventories: Dict[ControlPoint, ControlPointAircraftInventory] = {
+ self.inventories: dict[ControlPoint, ControlPointAircraftInventory] = {
cp: ControlPointAircraftInventory(cp) for cp in control_points
}
- def reset(self) -> None:
- """Clears all control points and their inventories."""
+ def clone(self) -> GlobalAircraftInventory:
+ new = GlobalAircraftInventory([])
+ new.inventories = {
+ cp: inventory.clone() for cp, inventory in self.inventories.items()
+ }
+ return new
+
+ def reset(self, for_player: bool) -> None:
+ """Clears the inventory of every control point owned by the given coalition."""
for inventory in self.inventories.values():
- inventory.clear()
+ if inventory.control_point.captured == for_player:
+ inventory.clear()
def set_from_control_point(self, control_point: ControlPoint) -> None:
"""Set the control point's aircraft inventory.
@@ -110,7 +121,7 @@ class GlobalAircraftInventory:
@property
def available_types_for_player(self) -> Iterator[AircraftType]:
"""Iterates over all aircraft types available to the player."""
- seen: Set[AircraftType] = set()
+ seen: set[AircraftType] = set()
for control_point, inventory in self.inventories.items():
if control_point.captured:
for aircraft in inventory.types_available:
diff --git a/game/models/destroyed_units.py b/game/models/destroyed_units.py
deleted file mode 100644
index 7d0de042..00000000
--- a/game/models/destroyed_units.py
+++ /dev/null
@@ -1,13 +0,0 @@
-class DestroyedUnit:
- """
- Store info about a destroyed unit
- """
-
- x: int
- y: int
- name: str
-
- def __init__(self, x, y, name):
- self.x = x
- self.y = y
- self.name = name
diff --git a/game/models/game_stats.py b/game/models/game_stats.py
index a4d8e623..7e828021 100644
--- a/game/models/game_stats.py
+++ b/game/models/game_stats.py
@@ -1,4 +1,9 @@
-from typing import List
+from __future__ import annotations
+
+from typing import List, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from game import Game
class FactionTurnMetadata:
@@ -10,7 +15,7 @@ class FactionTurnMetadata:
vehicles_count: int = 0
sam_count: int = 0
- def __init__(self):
+ def __init__(self) -> None:
self.aircraft_count = 0
self.vehicles_count = 0
self.sam_count = 0
@@ -24,7 +29,7 @@ class GameTurnMetadata:
allied_units: FactionTurnMetadata
enemy_units: FactionTurnMetadata
- def __init__(self):
+ def __init__(self) -> None:
self.allied_units = FactionTurnMetadata()
self.enemy_units = FactionTurnMetadata()
@@ -34,15 +39,19 @@ class GameStats:
Store statistics for the current game
"""
- def __init__(self):
+ def __init__(self) -> None:
self.data_per_turn: List[GameTurnMetadata] = []
- def update(self, game):
+ def update(self, game: Game) -> None:
"""
Save data for current turn
:param game: Game we want to save the data about
"""
+ # Remove the current turn if its just an update for this turn
+ if 0 < game.turn < len(self.data_per_turn):
+ del self.data_per_turn[-1]
+
turn_data = GameTurnMetadata()
for cp in game.theater.controlpoints:
diff --git a/game/operation/operation.py b/game/operation/operation.py
index 6b44cb9f..299aaf15 100644
--- a/game/operation/operation.py
+++ b/game/operation/operation.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import os
from pathlib import Path
-from typing import Iterable, List, Set, TYPE_CHECKING
+from typing import List, Set, TYPE_CHECKING, cast
from dcs import Mission
from dcs.action import DoScript, DoScriptFile
@@ -16,11 +16,11 @@ from dcs.triggers import TriggerStart
from game.plugins import LuaPluginManager
from game.theater.theatergroundobject import TheaterGroundObject
-from gen import Conflict, FlightType, VisualGenerator, Bullseye
+from gen import Conflict, FlightType, VisualGenerator, Bullseye, AirSupport
from gen.aircraft import AircraftConflictGenerator, FlightData
from gen.airfields import AIRFIELD_DATA
-from gen.airsupportgen import AirSupport, AirSupportConflictGenerator
-from gen.armor import GroundConflictGenerator, JtacInfo
+from gen.airsupportgen import AirSupportConflictGenerator
+from gen.armor import GroundConflictGenerator
from gen.beacons import load_beacons_for_terrain
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
from gen.cargoshipgen import CargoShipGenerator
@@ -29,6 +29,7 @@ from gen.environmentgen import EnvironmentGenerator
from gen.forcedoptionsgen import ForcedOptionsGenerator
from gen.groundobjectsgen import GroundObjectsGenerator
from gen.kneeboard import KneeboardGenerator
+from gen.lasercoderegistry import LaserCodeRegistry
from gen.naming import namegen
from gen.radios import RadioFrequency, RadioRegistry
from gen.tacan import TacanRegistry
@@ -50,6 +51,7 @@ class Operation:
groundobjectgen: GroundObjectsGenerator
radio_registry: RadioRegistry
tacan_registry: TacanRegistry
+ laser_code_registry: LaserCodeRegistry
game: Game
trigger_radius = TRIGGER_RADIUS_MEDIUM
is_quick = None
@@ -58,11 +60,11 @@ class Operation:
enemy_awacs_enabled = True
ca_slots = 1
unit_map: UnitMap
- jtacs: List[JtacInfo] = []
plugin_scripts: List[str] = []
+ air_support = AirSupport()
@classmethod
- def prepare(cls, game: Game):
+ def prepare(cls, game: Game) -> None:
with open("resources/default_options.lua", "r") as f:
options_dict = loads(f.read())["options"]
cls._set_mission(Mission(game.theater.terrain))
@@ -70,20 +72,6 @@ class Operation:
cls._setup_mission_coalitions()
cls.current_mission.options.load_from_dict(options_dict)
- @classmethod
- def conflicts(cls) -> Iterable[Conflict]:
- assert cls.game
- for frontline in cls.game.theater.conflicts():
- yield Conflict(
- cls.game.theater,
- frontline,
- cls.game.player_faction.name,
- cls.game.enemy_faction.name,
- cls.game.player_country,
- cls.game.enemy_country,
- frontline.position,
- )
-
@classmethod
def air_conflict(cls) -> Conflict:
assert cls.game
@@ -95,10 +83,10 @@ class Operation:
return Conflict(
cls.game.theater,
FrontLine(player_cp, enemy_cp),
- cls.game.player_faction.name,
- cls.game.enemy_faction.name,
- cls.game.player_country,
- cls.game.enemy_country,
+ cls.game.blue.faction.name,
+ cls.game.red.faction.name,
+ cls.current_mission.country(cls.game.blue.country_name),
+ cls.current_mission.country(cls.game.red.country_name),
mid_point,
)
@@ -107,20 +95,19 @@ class Operation:
cls.current_mission = mission
@classmethod
- def _setup_mission_coalitions(cls):
+ def _setup_mission_coalitions(cls) -> None:
cls.current_mission.coalition["blue"] = Coalition(
- "blue", bullseye=cls.game.blue_bullseye.to_pydcs()
+ "blue", bullseye=cls.game.blue.bullseye.to_pydcs()
)
cls.current_mission.coalition["red"] = Coalition(
- "red", bullseye=cls.game.red_bullseye.to_pydcs()
+ "red", bullseye=cls.game.red.bullseye.to_pydcs()
)
cls.current_mission.coalition["neutrals"] = Coalition(
"neutrals", bullseye=Bullseye(Point(0, 0)).to_pydcs()
)
- p_country = cls.game.player_country
- e_country = cls.game.enemy_country
-
+ p_country = cls.game.blue.country_name
+ e_country = cls.game.red.country_name
cls.current_mission.coalition["blue"].add_country(
country_dict[db.country_id_from_name(p_country)]()
)
@@ -174,10 +161,9 @@ class Operation:
def notify_info_generators(
cls,
groundobjectgen: GroundObjectsGenerator,
- airsupportgen: AirSupportConflictGenerator,
- jtacs: List[JtacInfo],
+ air_support: AirSupport,
airgen: AircraftConflictGenerator,
- ):
+ ) -> None:
"""Generates subscribed MissionInfoGenerator objects (currently kneeboards and briefings)"""
gens: List[MissionInfoGenerator] = [
@@ -188,15 +174,15 @@ class Operation:
for dynamic_runway in groundobjectgen.runways.values():
gen.add_dynamic_runway(dynamic_runway)
- for tanker in airsupportgen.air_support.tankers:
+ for tanker in air_support.tankers:
if tanker.blue:
gen.add_tanker(tanker)
- for aewc in airsupportgen.air_support.awacs:
+ for aewc in air_support.awacs:
if aewc.blue:
gen.add_awacs(aewc)
- for jtac in jtacs:
+ for jtac in air_support.jtacs:
if jtac.blue:
gen.add_jtac(jtac)
@@ -221,6 +207,10 @@ class Operation:
for frequency in unique_map_frequencies:
cls.radio_registry.reserve(frequency)
+ @classmethod
+ def create_laser_code_registry(cls) -> None:
+ cls.laser_code_registry = LaserCodeRegistry()
+
@classmethod
def assign_channels_to_flights(
cls, flights: List[FlightData], air_support: AirSupport
@@ -265,7 +255,7 @@ class Operation:
# beacon list.
@classmethod
- def _generate_ground_units(cls):
+ def _generate_ground_units(cls) -> None:
cls.groundobjectgen = GroundObjectsGenerator(
cls.current_mission,
cls.game,
@@ -280,18 +270,23 @@ class Operation:
"""Add destroyed units to the Mission"""
for d in cls.game.get_destroyed_units():
try:
- utype = db.unit_type_from_name(d["type"])
+ type_name = d["type"]
+ if not isinstance(type_name, str):
+ raise TypeError(
+ "Expected the type of the destroyed static to be a string"
+ )
+ utype = db.unit_type_from_name(type_name)
except KeyError:
continue
- pos = Point(d["x"], d["z"])
+ pos = Point(cast(float, d["x"]), cast(float, d["z"]))
if (
utype is not None
and not cls.game.position_culled(pos)
and cls.game.settings.perf_destroyed_units
):
cls.current_mission.static_group(
- country=cls.current_mission.country(cls.game.player_country),
+ country=cls.current_mission.country(cls.game.blue.country_name),
name="",
_type=utype,
hidden=True,
@@ -303,18 +298,22 @@ class Operation:
@classmethod
def generate(cls) -> UnitMap:
"""Build the final Mission to be exported"""
+ cls.air_support = AirSupport()
cls.create_unit_map()
cls.create_radio_registries()
+ cls.create_laser_code_registry()
# Set mission time and weather conditions.
EnvironmentGenerator(cls.current_mission, cls.game.conditions).generate()
cls._generate_ground_units()
cls._generate_transports()
cls._generate_destroyed_units()
+ # Generate ground conflicts first so the JTACs get the first laser code (1688)
+ # rather than the first player flight with a TGP.
+ cls._generate_ground_conflicts()
cls._generate_air_units()
cls.assign_channels_to_flights(
cls.airgen.flights, cls.airsupportgen.air_support
)
- cls._generate_ground_conflicts()
# Triggers
triggersgen = TriggersGenerator(cls.current_mission, cls.game)
@@ -334,7 +333,7 @@ class Operation:
if cls.game.settings.perf_smoke_gen:
visualgen.generate()
- cls.generate_lua(cls.airgen, cls.airsupportgen, cls.jtacs)
+ cls.generate_lua(cls.airgen, cls.air_support)
# Inject Plugins Lua Scripts and data
cls.plugin_scripts.clear()
@@ -346,9 +345,7 @@ class Operation:
cls.assign_channels_to_flights(
cls.airgen.flights, cls.airsupportgen.air_support
)
- cls.notify_info_generators(
- cls.groundobjectgen, cls.airsupportgen, cls.jtacs, cls.airgen
- )
+ cls.notify_info_generators(cls.groundobjectgen, cls.air_support, cls.airgen)
cls.reset_naming_ids()
return cls.unit_map
@@ -364,6 +361,7 @@ class Operation:
cls.game,
cls.radio_registry,
cls.tacan_registry,
+ cls.air_support,
)
cls.airsupportgen.generate()
@@ -374,6 +372,7 @@ class Operation:
cls.game,
cls.radio_registry,
cls.tacan_registry,
+ cls.laser_code_registry,
cls.unit_map,
air_support=cls.airsupportgen.air_support,
)
@@ -381,32 +380,31 @@ class Operation:
cls.airgen.clear_parking_slots()
cls.airgen.generate_flights(
- cls.current_mission.country(cls.game.player_country),
- cls.game.blue_ato,
+ cls.current_mission.country(cls.game.blue.country_name),
+ cls.game.blue.ato,
cls.groundobjectgen.runways,
)
cls.airgen.generate_flights(
- cls.current_mission.country(cls.game.enemy_country),
- cls.game.red_ato,
+ cls.current_mission.country(cls.game.red.country_name),
+ cls.game.red.ato,
cls.groundobjectgen.runways,
)
cls.airgen.spawn_unused_aircraft(
- cls.current_mission.country(cls.game.player_country),
- cls.current_mission.country(cls.game.enemy_country),
+ cls.current_mission.country(cls.game.blue.country_name),
+ cls.current_mission.country(cls.game.red.country_name),
)
@classmethod
def _generate_ground_conflicts(cls) -> None:
"""For each frontline in the Operation, generate the ground conflicts and JTACs"""
- cls.jtacs = []
for front_line in cls.game.theater.conflicts():
player_cp = front_line.blue_cp
enemy_cp = front_line.red_cp
conflict = Conflict.frontline_cas_conflict(
- cls.game.player_faction.name,
- cls.game.enemy_faction.name,
- cls.current_mission.country(cls.game.player_country),
- cls.current_mission.country(cls.game.enemy_country),
+ cls.game.blue.faction.name,
+ cls.game.red.faction.name,
+ cls.current_mission.country(cls.game.blue.country_name),
+ cls.current_mission.country(cls.game.red.country_name),
front_line,
cls.game.theater,
)
@@ -420,10 +418,13 @@ class Operation:
player_gp,
enemy_gp,
player_cp.stances[enemy_cp.id],
+ enemy_cp.stances[player_cp.id],
cls.unit_map,
+ cls.radio_registry,
+ cls.air_support,
+ cls.laser_code_registry,
)
ground_conflict_gen.generate()
- cls.jtacs.extend(ground_conflict_gen.jtacs)
@classmethod
def _generate_transports(cls) -> None:
@@ -432,15 +433,12 @@ class Operation:
CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate()
@classmethod
- def reset_naming_ids(cls):
+ def reset_naming_ids(cls) -> None:
namegen.reset_numbers()
@classmethod
def generate_lua(
- cls,
- airgen: AircraftConflictGenerator,
- airsupportgen: AirSupportConflictGenerator,
- jtacs: List[JtacInfo],
+ cls, airgen: AircraftConflictGenerator, air_support: AirSupport
) -> None:
# TODO: Refactor this
luaData = {
@@ -453,8 +451,8 @@ class Operation:
"BlueAA": {},
} # type: ignore
- for tanker in airsupportgen.air_support.tankers:
- luaData["Tankers"][tanker.callsign] = {
+ for i, tanker in enumerate(air_support.tankers):
+ luaData["Tankers"][i] = {
"dcsGroupName": tanker.group_name,
"callsign": tanker.callsign,
"variant": tanker.variant,
@@ -462,23 +460,23 @@ class Operation:
"tacan": str(tanker.tacan.number) + tanker.tacan.band.name,
}
- if airsupportgen.air_support.awacs:
- for awacs in airsupportgen.air_support.awacs:
- luaData["AWACs"][awacs.callsign] = {
- "dcsGroupName": awacs.group_name,
- "callsign": awacs.callsign,
- "radio": awacs.freq.mhz,
- }
+ for i, awacs in enumerate(air_support.awacs):
+ luaData["AWACs"][i] = {
+ "dcsGroupName": awacs.group_name,
+ "callsign": awacs.callsign,
+ "radio": awacs.freq.mhz,
+ }
- for jtac in jtacs:
- luaData["JTACs"][jtac.callsign] = {
+ for i, jtac in enumerate(air_support.jtacs):
+ luaData["JTACs"][i] = {
"dcsGroupName": jtac.group_name,
"callsign": jtac.callsign,
"zone": jtac.region,
"dcsUnit": jtac.unit_name,
"laserCode": jtac.code,
+ "radio": jtac.freq.mhz,
}
-
+ flight_count = 0
for flight in airgen.flights:
if flight.friendly and flight.flight_type in [
FlightType.ANTISHIP,
@@ -499,7 +497,7 @@ class Operation:
elif hasattr(flightTarget, "name"):
flightTargetName = flightTarget.name
flightTargetType = flightType + " TGT (Airbase)"
- luaData["TargetPoints"][flightTargetName] = {
+ luaData["TargetPoints"][flight_count] = {
"name": flightTargetName,
"type": flightTargetType,
"position": {
@@ -507,6 +505,7 @@ class Operation:
"y": flightTarget.position.y,
},
}
+ flight_count += 1
for cp in cls.game.theater.controlpoints:
for ground_object in cp.ground_objects:
@@ -592,7 +591,8 @@ class Operation:
zone = data["zone"]
laserCode = data["laserCode"]
dcsUnit = data["dcsUnit"]
- lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n"
+ radio = data["radio"]
+ lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}', radio='{radio}' }}, \n"
lua += "}"
# Process the Target Points
diff --git a/game/orderedset.py b/game/orderedset.py
new file mode 100644
index 00000000..07a5964d
--- /dev/null
+++ b/game/orderedset.py
@@ -0,0 +1,23 @@
+from collections import Iterator, Iterable
+from typing import Generic, TypeVar, Optional
+
+ValueT = TypeVar("ValueT")
+
+
+class OrderedSet(Generic[ValueT]):
+ def __init__(self, initial_data: Optional[Iterable[ValueT]] = None) -> None:
+ if initial_data is None:
+ initial_data = []
+ self._data: dict[ValueT, None] = {v: None for v in initial_data}
+
+ def __iter__(self) -> Iterator[ValueT]:
+ yield from self._data
+
+ def __contains__(self, item: ValueT) -> bool:
+ return item in self._data
+
+ def add(self, item: ValueT) -> None:
+ self._data[item] = None
+
+ def clear(self) -> None:
+ self._data.clear()
diff --git a/game/persistency.py b/game/persistency.py
index d9d9d135..7685dd09 100644
--- a/game/persistency.py
+++ b/game/persistency.py
@@ -1,15 +1,19 @@
+from __future__ import annotations
+
import logging
import os
import pickle
import shutil
from pathlib import Path
-from typing import Optional
+from typing import Optional, TYPE_CHECKING
+if TYPE_CHECKING:
+ from game import Game
_dcs_saved_game_folder: Optional[str] = None
-def setup(user_folder: str):
+def setup(user_folder: str) -> None:
global _dcs_saved_game_folder
_dcs_saved_game_folder = user_folder
if not save_dir().exists():
@@ -38,7 +42,7 @@ def mission_path_for(name: str) -> str:
return os.path.join(base_path(), "Missions", name)
-def load_game(path):
+def load_game(path: str) -> Optional[Game]:
with open(path, "rb") as f:
try:
save = pickle.load(f)
@@ -49,7 +53,7 @@ def load_game(path):
return None
-def save_game(game) -> bool:
+def save_game(game: Game) -> bool:
try:
with open(_temporary_save_file(), "wb") as f:
pickle.dump(game, f)
@@ -60,7 +64,7 @@ def save_game(game) -> bool:
return False
-def autosave(game) -> bool:
+def autosave(game: Game) -> bool:
"""
Autosave to the autosave location
:param game: Game to save
diff --git a/game/plugins/luaplugin.py b/game/plugins/luaplugin.py
index b58446a9..5799c748 100644
--- a/game/plugins/luaplugin.py
+++ b/game/plugins/luaplugin.py
@@ -38,7 +38,7 @@ class PluginSettings:
self.settings = Settings()
self.initialize_settings()
- def set_settings(self, settings: Settings):
+ def set_settings(self, settings: Settings) -> None:
self.settings = settings
self.initialize_settings()
@@ -146,7 +146,7 @@ class LuaPlugin(PluginSettings):
return cls(definition)
- def set_settings(self, settings: Settings):
+ def set_settings(self, settings: Settings) -> None:
super().set_settings(settings)
for option in self.definition.options:
option.set_settings(self.settings)
diff --git a/game/point_with_heading.py b/game/point_with_heading.py
index fa322723..7eed4da2 100644
--- a/game/point_with_heading.py
+++ b/game/point_with_heading.py
@@ -1,13 +1,16 @@
+from __future__ import annotations
+
from dcs import Point
+from game.utils import Heading
class PointWithHeading(Point):
- def __init__(self):
+ def __init__(self) -> None:
super(PointWithHeading, self).__init__(0, 0)
- self.heading = 0
+ self.heading: Heading = Heading.from_degrees(0)
@staticmethod
- def from_point(point: Point, heading: int):
+ def from_point(point: Point, heading: Heading) -> PointWithHeading:
p = PointWithHeading()
p.x = point.x
p.y = point.y
diff --git a/game/positioned.py b/game/positioned.py
new file mode 100644
index 00000000..09952351
--- /dev/null
+++ b/game/positioned.py
@@ -0,0 +1,9 @@
+from typing import Protocol
+
+from dcs import Point
+
+
+class Positioned(Protocol):
+ @property
+ def position(self) -> Point:
+ raise NotImplementedError
diff --git a/game/procurement.py b/game/procurement.py
index 2e2c0e79..950e19d0 100644
--- a/game/procurement.py
+++ b/game/procurement.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import math
import random
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
from game import db
@@ -11,7 +11,7 @@ from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction
from game.theater import ControlPoint, MissionTarget
-from game.utils import Distance
+from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
@@ -25,15 +25,13 @@ FRONTLINE_RESERVES_FACTOR = 1.3
@dataclass(frozen=True)
class AircraftProcurementRequest:
near: MissionTarget
- range: Distance
task_capability: FlightType
number: int
def __str__(self) -> str:
task = self.task_capability.value
- distance = self.range.nautical_miles
target = self.near.name
- return f"{self.number} ship {task} within {distance} nm of {target}"
+ return f"{self.number} ship {task} near {target}"
class ProcurementAi:
@@ -72,7 +70,9 @@ class ProcurementAi:
return 1
for cp in self.owned_points:
- cp_ground_units = cp.allocated_ground_units(self.game.transfers)
+ cp_ground_units = cp.allocated_ground_units(
+ self.game.coalition_for(self.is_player).transfers
+ )
armor_investment += cp_ground_units.total_value
cp_aircraft = cp.allocated_aircraft(self.game)
aircraft_investment += cp_aircraft.total_value
@@ -209,24 +209,28 @@ class ProcurementAi:
return GroundUnitClass.Tank
return worst_balanced
- def _affordable_aircraft_for_task(
- self,
- task: FlightType,
- airbase: ControlPoint,
- number: int,
- max_price: float,
+ def affordable_aircraft_for(
+ self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
) -> Optional[AircraftType]:
best_choice: Optional[AircraftType] = None
- for unit in aircraft_for_task(task):
+ for unit in aircraft_for_task(request.task_capability):
if unit not in self.faction.aircrafts:
continue
- if unit.price * number > max_price:
+ if unit.price * request.number > budget:
continue
if not airbase.can_operate(unit):
continue
+ distance_to_target = meters(request.near.distance_to(airbase))
+ if distance_to_target > unit.max_mission_range:
+ continue
+
for squadron in self.air_wing.squadrons_for(unit):
- if task in squadron.auto_assignable_mission_types:
+ if (
+ squadron.operates_from(airbase)
+ and request.task_capability
+ in squadron.auto_assignable_mission_types
+ ):
break
else:
continue
@@ -239,13 +243,6 @@ class ProcurementAi:
break
return best_choice
- def affordable_aircraft_for(
- self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
- ) -> Optional[AircraftType]:
- return self._affordable_aircraft_for_task(
- request.task_capability, airbase, request.number, budget
- )
-
def fulfill_aircraft_request(
self, request: AircraftProcurementRequest, budget: float
) -> Tuple[float, bool]:
@@ -265,7 +262,7 @@ class ProcurementAi:
return budget, False
def purchase_aircraft(self, budget: float) -> float:
- for request in self.game.procurement_requests_for(self.is_player):
+ for request in self.game.coalition_for(self.is_player).procurement_requests:
if not list(self.best_airbases_for(request)):
# No airbases in range of this request. Skip it.
continue
@@ -291,7 +288,7 @@ class ProcurementAi:
) -> Iterator[ControlPoint]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
threatened = []
- for cp in distance_cache.operational_airfields_within(request.range):
+ for cp in distance_cache.operational_airfields:
if not cp.is_friendly(self.is_player):
continue
if cp.unclaimed_parking(self.game) < request.number:
@@ -316,7 +313,9 @@ class ProcurementAi:
continue
purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR
- allocated = cp.allocated_ground_units(self.game.transfers)
+ allocated = cp.allocated_ground_units(
+ self.game.coalition_for(self.is_player).transfers
+ )
if allocated.total >= purchase_target:
# Control point is already sufficiently defended.
continue
@@ -343,7 +342,9 @@ class ProcurementAi:
if not cp.can_recruit_ground_units(self.game):
continue
- allocated = cp.allocated_ground_units(self.game.transfers)
+ allocated = cp.allocated_ground_units(
+ self.game.coalition_for(self.is_player).transfers
+ )
if allocated.total >= self.game.settings.reserves_procurement_target:
continue
@@ -356,7 +357,9 @@ class ProcurementAi:
def cost_ratio_of_ground_unit(
self, control_point: ControlPoint, unit_class: GroundUnitClass
) -> float:
- allocations = control_point.allocated_ground_units(self.game.transfers)
+ allocations = control_point.allocated_ground_units(
+ self.game.coalition_for(self.is_player).transfers
+ )
class_cost = 0
total_cost = 0
for unit_type, count in allocations.all.items():
diff --git a/game/profiling.py b/game/profiling.py
index 82c2c326..219453d9 100644
--- a/game/profiling.py
+++ b/game/profiling.py
@@ -5,7 +5,8 @@ import timeit
from collections import defaultdict
from contextlib import contextmanager
from datetime import timedelta
-from typing import Iterator
+from types import TracebackType
+from typing import Iterator, Optional, Type
@contextmanager
@@ -23,7 +24,12 @@ class MultiEventTracer:
def __enter__(self) -> MultiEventTracer:
return self
- def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ def __exit__(
+ self,
+ exc_type: Optional[Type[BaseException]],
+ exc_val: Optional[BaseException],
+ exc_tb: Optional[TracebackType],
+ ) -> None:
for event, duration in self.events.items():
logging.debug("%s took %s", event, duration)
diff --git a/game/radio/channels.py b/game/radio/channels.py
index 83df8e6c..4fbf7e23 100644
--- a/game/radio/channels.py
+++ b/game/radio/channels.py
@@ -72,6 +72,9 @@ class CommonRadioChannelAllocator(RadioChannelAllocator):
for awacs in air_support.awacs:
flight.assign_channel(radio_id, next(channel_alloc), awacs.freq)
+ for jtac in air_support.jtacs:
+ flight.assign_channel(radio_id, next(channel_alloc), jtac.freq)
+
if flight.arrival != flight.departure and flight.arrival.atc is not None:
flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc)
diff --git a/game/savecompat.py b/game/savecompat.py
new file mode 100644
index 00000000..5388dd24
--- /dev/null
+++ b/game/savecompat.py
@@ -0,0 +1,48 @@
+"""Tools for aiding in save compat removal after compatibility breaks."""
+from collections import Callable
+from typing import TypeVar
+
+from game.version import MAJOR_VERSION
+
+ReturnT = TypeVar("ReturnT")
+
+
+class DeprecatedSaveCompatError(RuntimeError):
+ def __init__(self, function_name: str) -> None:
+ super().__init__(
+ f"{function_name} has save compat code for a different major version."
+ )
+
+
+def has_save_compat_for(
+ major: int,
+) -> Callable[[Callable[..., ReturnT]], Callable[..., ReturnT]]:
+ """Declares a function or method as having save compat code for a given version.
+
+ If the function has save compatibility for the current major version, there is no
+ change in behavior.
+
+ If the function has save compatibility for a *different* (future or past) major
+ version, DeprecatedSaveCompatError will be raised during startup. Since a break in
+ save compatibility is the definition of a major version break, there's no need to
+ keep around old save compat code; it only serves to mask initialization bugs.
+
+ Args:
+ major: The major version for which the decorated function has save
+ compatibility.
+
+ Returns:
+ The decorated function or method.
+
+ Raises:
+ DeprecatedSaveCompatError: The decorated function has save compat code for
+ another version of liberation, and that code (and the decorator declaring it)
+ should be removed from this branch.
+ """
+
+ def decorator(func: Callable[..., ReturnT]) -> Callable[..., ReturnT]:
+ if major != MAJOR_VERSION:
+ raise DeprecatedSaveCompatError(func.__name__)
+ return func
+
+ return decorator
diff --git a/game/settings.py b/game/settings.py
index 49aee945..e76e0816 100644
--- a/game/settings.py
+++ b/game/settings.py
@@ -1,7 +1,7 @@
from dataclasses import dataclass, field
from datetime import timedelta
from enum import Enum, unique
-from typing import Dict, Optional
+from typing import Dict, Optional, Any
from dcs.forcedoptions import ForcedOptions
@@ -55,6 +55,7 @@ class Settings:
automate_runway_repair: bool = False
automate_front_line_reinforcements: bool = False
automate_aircraft_reinforcements: bool = False
+ automate_front_line_stance: bool = True
restrict_weapons_by_date: bool = False
disable_legacy_aewc: bool = True
disable_legacy_tanker: bool = True
@@ -104,7 +105,7 @@ class Settings:
def set_plugin_option(self, identifier: str, enabled: bool) -> None:
self.plugins[self.plugin_settings_key(identifier)] = enabled
- def __setstate__(self, state) -> None:
+ def __setstate__(self, state: dict[str, Any]) -> None:
# __setstate__ is called with the dict of the object being unpickled. We
# can provide save compatibility for new settings options (which
# normally would not be present in the unpickled object) by creating a
diff --git a/game/squadrons.py b/game/squadrons.py
index 9b13eed3..5777102f 100644
--- a/game/squadrons.py
+++ b/game/squadrons.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import dataclasses
import itertools
import logging
import random
@@ -13,17 +14,20 @@ from typing import (
Optional,
Iterator,
Sequence,
+ Any,
)
import yaml
from faker import Faker
from game.dcs.aircrafttype import AircraftType
-from game.settings import AutoAtoBehavior
+from game.settings import AutoAtoBehavior, Settings
if TYPE_CHECKING:
from game import Game
+ from game.coalition import Coalition
from gen.flights.flight import FlightType
+ from game.theater import ControlPoint
@dataclass
@@ -71,6 +75,33 @@ class Pilot:
return Pilot(faker.name())
+@dataclass(frozen=True)
+class OperatingBases:
+ shore: bool
+ carrier: bool
+ lha: bool
+
+ @classmethod
+ def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases:
+ if aircraft.dcs_unit_type.helicopter:
+ # Helicopters operate from anywhere by default.
+ return OperatingBases(shore=True, carrier=True, lha=True)
+ if aircraft.lha_capable:
+ # Marine aircraft operate from LHAs and the shore by default.
+ return OperatingBases(shore=True, carrier=False, lha=True)
+ if aircraft.carrier_capable:
+ # Carrier aircraft operate from carriers by default.
+ return OperatingBases(shore=False, carrier=True, lha=False)
+ # And the rest are only capable of shore operation.
+ return OperatingBases(shore=True, carrier=False, lha=False)
+
+ @classmethod
+ def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases:
+ return dataclasses.replace(
+ OperatingBases.default_for_aircraft(aircraft), **data
+ )
+
+
@dataclass
class Squadron:
name: str
@@ -80,6 +111,7 @@ class Squadron:
aircraft: AircraftType
livery: Optional[str]
mission_types: tuple[FlightType, ...]
+ operating_bases: OperatingBases
#: The pool of pilots that have not yet been assigned to the squadron. This only
#: happens when a preset squadron defines more preset pilots than the squadron limit
@@ -95,16 +127,10 @@ class Squadron:
init=False, hash=False, compare=False
)
- # We need a reference to the Game so that we can access the Faker without needing to
- # persist it to the save game, or having to reconstruct it (it's not cheap) each
- # time we create or load a squadron.
- game: Game = field(hash=False, compare=False)
- player: bool
+ coalition: Coalition = field(hash=False, compare=False)
+ settings: Settings = field(hash=False, compare=False)
def __post_init__(self) -> None:
- if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
- raise ValueError("Squadrons can only be created with active pilots.")
- self._recruit_pilots(self.game.settings.squadron_pilot_limit)
self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str:
@@ -112,9 +138,13 @@ class Squadron:
return self.name
return f'{self.name} "{self.nickname}"'
+ @property
+ def player(self) -> bool:
+ return self.coalition.player
+
@property
def pilot_limits_enabled(self) -> bool:
- return self.game.settings.enable_squadron_pilot_limits
+ return self.settings.enable_squadron_pilot_limits
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
if self.pilot_limits_enabled:
@@ -130,7 +160,7 @@ class Squadron:
if not self.player:
return self.available_pilots.pop()
- preference = self.game.settings.auto_ato_behavior
+ preference = self.settings.auto_ato_behavior
# No preference, so the first pilot is fine.
if preference is AutoAtoBehavior.Default:
@@ -178,12 +208,17 @@ class Squadron:
self.current_roster.extend(new_pilots)
self.available_pilots.extend(new_pilots)
+ def populate_for_turn_0(self) -> None:
+ if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
+ raise ValueError("Squadrons can only be created with active pilots.")
+ self._recruit_pilots(self.settings.squadron_pilot_limit)
+
def replenish_lost_pilots(self) -> None:
if not self.pilot_limits_enabled:
return
replenish_count = min(
- self.game.settings.squadron_replenishment_rate,
+ self.settings.squadron_replenishment_rate,
self._number_of_unfilled_pilot_slots,
)
if replenish_count > 0:
@@ -196,7 +231,7 @@ class Squadron:
def send_on_leave(pilot: Pilot) -> None:
pilot.send_on_leave()
- def return_from_leave(self, pilot: Pilot):
+ def return_from_leave(self, pilot: Pilot) -> None:
if not self.has_unfilled_pilot_slots:
raise RuntimeError(
f"Cannot return {pilot} from leave because {self} is full"
@@ -205,7 +240,7 @@ class Squadron:
@property
def faker(self) -> Faker:
- return self.game.faker_for(self.player)
+ return self.coalition.faker
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster if p.status == status]
@@ -227,7 +262,7 @@ class Squadron:
@property
def _number_of_unfilled_pilot_slots(self) -> int:
- return self.game.settings.squadron_pilot_limit - len(self.active_pilots)
+ return self.settings.squadron_pilot_limit - len(self.active_pilots)
@property
def number_of_available_pilots(self) -> int:
@@ -247,11 +282,19 @@ class Squadron:
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
+ def operates_from(self, control_point: ControlPoint) -> bool:
+ if control_point.is_carrier:
+ return self.operating_bases.carrier
+ elif control_point.is_lha:
+ return self.operating_bases.lha
+ else:
+ return self.operating_bases.shore
+
def pilot_at_index(self, index: int) -> Pilot:
return self.current_roster[index]
@classmethod
- def from_yaml(cls, path: Path, game: Game, player: bool) -> Squadron:
+ def from_yaml(cls, path: Path, game: Game, coalition: Coalition) -> Squadron:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
from gen.flights.flight import FlightType
@@ -285,12 +328,13 @@ class Squadron:
aircraft=unit_type,
livery=data.get("livery"),
mission_types=tuple(mission_types),
+ operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),
pilot_pool=pilots,
- game=game,
- player=player,
+ coalition=coalition,
+ settings=game.settings,
)
- def __setstate__(self, state) -> None:
+ def __setstate__(self, state: dict[str, Any]) -> None:
# TODO: Remove save compat.
if "auto_assignable_mission_types" not in state:
state["auto_assignable_mission_types"] = set(state["mission_types"])
@@ -298,9 +342,9 @@ class Squadron:
class SquadronLoader:
- def __init__(self, game: Game, player: bool) -> None:
+ def __init__(self, game: Game, coalition: Coalition) -> None:
self.game = game
- self.player = player
+ self.coalition = coalition
@staticmethod
def squadron_directories() -> Iterator[Path]:
@@ -311,8 +355,8 @@ class SquadronLoader:
def load(self) -> dict[AircraftType, list[Squadron]]:
squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
- country = self.game.country_for(self.player)
- faction = self.game.faction_for(self.player)
+ country = self.coalition.country_name
+ faction = self.coalition.faction
any_country = country.startswith("Combined Joint Task Forces ")
for directory in self.squadron_directories():
for path, squadron in self.load_squadrons_from(directory):
@@ -346,7 +390,7 @@ class SquadronLoader:
for squadron_path in directory.glob("*/*.yaml"):
try:
yield squadron_path, Squadron.from_yaml(
- squadron_path, self.game, self.player
+ squadron_path, self.game, self.coalition
)
except Exception as ex:
raise RuntimeError(
@@ -355,29 +399,29 @@ class SquadronLoader:
class AirWing:
- def __init__(self, game: Game, player: bool) -> None:
+ def __init__(self, game: Game, coalition: Coalition) -> None:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
self.game = game
- self.player = player
- self.squadrons = SquadronLoader(game, player).load()
+ self.squadrons = SquadronLoader(game, coalition).load()
count = itertools.count(1)
- for aircraft in game.faction_for(player).aircrafts:
+ for aircraft in coalition.faction.aircrafts:
if aircraft in self.squadrons:
continue
self.squadrons[aircraft] = [
Squadron(
name=f"Squadron {next(count):03}",
nickname=self.random_nickname(),
- country=game.country_for(player),
+ country=coalition.country_name,
role="Flying Squadron",
aircraft=aircraft,
livery=None,
mission_types=tuple(tasks_for_aircraft(aircraft)),
+ operating_bases=OperatingBases.default_for_aircraft(aircraft),
pilot_pool=[],
- game=game,
- player=player,
+ coalition=coalition,
+ settings=game.settings,
)
]
@@ -412,6 +456,10 @@ class AirWing:
def squadron_at_index(self, index: int) -> Squadron:
return list(self.iter_squadrons())[index]
+ def populate_for_turn_0(self) -> None:
+ for squadron in self.iter_squadrons():
+ squadron.populate_for_turn_0()
+
def replenish(self) -> None:
for squadron in self.iter_squadrons():
squadron.replenish_lost_pilots()
diff --git a/game/theater/base.py b/game/theater/base.py
index 4547e3d3..02839481 100644
--- a/game/theater/base.py
+++ b/game/theater/base.py
@@ -6,15 +6,15 @@ from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.dcs.unittype import UnitType
-BASE_MAX_STRENGTH = 1
-BASE_MIN_STRENGTH = 0
+BASE_MAX_STRENGTH = 1.0
+BASE_MIN_STRENGTH = 0.0
class Base:
- def __init__(self):
+ def __init__(self) -> None:
self.aircraft: dict[AircraftType, int] = {}
self.armor: dict[GroundUnitType, int] = {}
- self.strength = 1
+ self.strength = 1.0
@property
def total_aircraft(self) -> int:
@@ -31,7 +31,7 @@ class Base:
total += unit_type.price * count
return total
- def total_units_of_type(self, unit_type: UnitType) -> int:
+ def total_units_of_type(self, unit_type: UnitType[Any]) -> int:
return sum(
[
c
@@ -40,7 +40,7 @@ class Base:
]
)
- def commission_units(self, units: dict[Any, int]):
+ def commission_units(self, units: dict[Any, int]) -> None:
for unit_type, unit_count in units.items():
if unit_count <= 0:
continue
@@ -56,7 +56,7 @@ class Base:
target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count
- def commit_losses(self, units_lost: dict[Any, int]):
+ def commit_losses(self, units_lost: dict[Any, int]) -> None:
for unit_type, count in units_lost.items():
target_dict: dict[Any, int]
if unit_type in self.aircraft:
@@ -75,7 +75,7 @@ class Base:
if target_dict[unit_type] == 0:
del target_dict[unit_type]
- def affect_strength(self, amount):
+ def affect_strength(self, amount: float) -> None:
self.strength += amount
if self.strength > BASE_MAX_STRENGTH:
self.strength = BASE_MAX_STRENGTH
diff --git a/game/theater/caucasus.py b/game/theater/caucasus.py
index 8d0d0adc..6b1cb67e 100644
--- a/game/theater/caucasus.py
+++ b/game/theater/caucasus.py
@@ -1,3 +1,5 @@
+# DO NOT EDIT:
+# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py
index d075a20c..6651e918 100644
--- a/game/theater/conflicttheater.py
+++ b/game/theater/conflicttheater.py
@@ -5,7 +5,7 @@ import math
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
-from typing import Any, Dict, Iterator, List, Optional, Tuple
+from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
from dcs import Mission
from dcs.countries import (
@@ -29,14 +29,14 @@ from dcs.terrain import (
persiangulf,
syria,
thechannel,
+ marianaislands,
)
from dcs.terrain.terrain import Airport, Terrain
from dcs.unitgroup import (
- FlyingGroup,
- Group,
ShipGroup,
StaticGroup,
VehicleGroup,
+ PlaneGroup,
)
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
from pyproj import CRS, Transformer
@@ -51,15 +51,20 @@ from .controlpoint import (
MissionTarget,
OffMapSpawn,
)
+from .seasonalconditions import SeasonalConditions
from .frontline import FrontLine
from .landmap import Landmap, load_landmap, poly_contains
from .latlon import LatLon
from .projections import TransverseMercator
from ..helipad import Helipad
from ..point_with_heading import PointWithHeading
+from ..positioned import Positioned
from ..profiling import logged_duration
from ..scenery_group import SceneryGroup
-from ..utils import Distance, meters
+from ..utils import Distance, Heading, meters
+
+if TYPE_CHECKING:
+ from . import TheaterGroundObject
SIZE_TINY = 150
SIZE_SMALL = 600
@@ -182,7 +187,7 @@ class MizCampaignLoader:
def red(self) -> Country:
return self.country(blue=False)
- def off_map_spawns(self, blue: bool) -> Iterator[FlyingGroup]:
+ def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]:
for group in self.country(blue).plane_group:
if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
yield group
@@ -306,26 +311,26 @@ class MizCampaignLoader:
control_point.captured = blue
control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point
- for group in self.carriers(blue):
+ for ship in self.carriers(blue):
# TODO: Name the carrier.
control_point = Carrier(
- "carrier", group.position, next(self.control_point_id)
+ "carrier", ship.position, next(self.control_point_id)
)
control_point.captured = blue
- control_point.captured_invert = group.late_activation
+ control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
- for group in self.lhas(blue):
+ for ship in self.lhas(blue):
# TODO: Name the LHA.db
- control_point = Lha("lha", group.position, next(self.control_point_id))
+ control_point = Lha("lha", ship.position, next(self.control_point_id))
control_point.captured = blue
- control_point.captured_invert = group.late_activation
+ control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
- for group in self.fobs(blue):
+ for fob in self.fobs(blue):
control_point = Fob(
- str(group.name), group.position, next(self.control_point_id)
+ str(fob.name), fob.position, next(self.control_point_id)
)
control_point.captured = blue
- control_point.captured_invert = group.late_activation
+ control_point.captured_invert = fob.late_activation
control_points[control_point.id] = control_point
return control_points
@@ -386,99 +391,129 @@ class MizCampaignLoader:
origin, list(reversed(waypoints))
)
- def objective_info(self, group: Group) -> Tuple[ControlPoint, Distance]:
- closest = self.theater.closest_control_point(group.position)
- distance = meters(closest.position.distance_to_point(group.position))
+ def objective_info(
+ self, near: Positioned, allow_naval: bool = False
+ ) -> Tuple[ControlPoint, Distance]:
+ closest = self.theater.closest_control_point(near.position, allow_naval)
+ distance = meters(closest.position.distance_to_point(near.position))
return closest, distance
def add_preset_locations(self) -> None:
- for group in self.offshore_strike_targets:
- closest, distance = self.objective_info(group)
+ for static in self.offshore_strike_targets:
+ closest, distance = self.objective_info(static)
closest.preset_locations.offshore_strike_locations.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ static.position, Heading.from_degrees(static.units[0].heading)
+ )
)
- for group in self.ships:
- closest, distance = self.objective_info(group)
+ for ship in self.ships:
+ closest, distance = self.objective_info(ship, allow_naval=True)
closest.preset_locations.ships.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ ship.position, Heading.from_degrees(ship.units[0].heading)
+ )
)
for group in self.missile_sites:
closest, distance = self.objective_info(group)
closest.preset_locations.missile_sites.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ group.position, Heading.from_degrees(group.units[0].heading)
+ )
)
for group in self.coastal_defenses:
closest, distance = self.objective_info(group)
closest.preset_locations.coastal_defenses.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ group.position, Heading.from_degrees(group.units[0].heading)
+ )
)
for group in self.long_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.long_range_sams.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ group.position, Heading.from_degrees(group.units[0].heading)
+ )
)
for group in self.medium_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.medium_range_sams.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ group.position, Heading.from_degrees(group.units[0].heading)
+ )
)
for group in self.short_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.short_range_sams.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ group.position, Heading.from_degrees(group.units[0].heading)
+ )
)
for group in self.aaa:
closest, distance = self.objective_info(group)
closest.preset_locations.aaa.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ group.position, Heading.from_degrees(group.units[0].heading)
+ )
)
for group in self.ewrs:
closest, distance = self.objective_info(group)
closest.preset_locations.ewrs.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ group.position, Heading.from_degrees(group.units[0].heading)
+ )
)
for group in self.armor_groups:
closest, distance = self.objective_info(group)
closest.preset_locations.armor_groups.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ group.position, Heading.from_degrees(group.units[0].heading)
+ )
)
- for group in self.helipads:
- closest, distance = self.objective_info(group)
+ for static in self.helipads:
+ closest, distance = self.objective_info(static)
closest.helipads.append(
- Helipad.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ static.position, Heading.from_degrees(static.units[0].heading)
+ )
)
- for group in self.factories:
- closest, distance = self.objective_info(group)
+ for static in self.factories:
+ closest, distance = self.objective_info(static)
closest.preset_locations.factories.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ static.position, Heading.from_degrees(static.units[0].heading)
+ )
)
- for group in self.ammunition_depots:
- closest, distance = self.objective_info(group)
+ for static in self.ammunition_depots:
+ closest, distance = self.objective_info(static)
closest.preset_locations.ammunition_depots.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ static.position, Heading.from_degrees(static.units[0].heading)
+ )
)
- for group in self.strike_targets:
- closest, distance = self.objective_info(group)
+ for static in self.strike_targets:
+ closest, distance = self.objective_info(static)
closest.preset_locations.strike_locations.append(
- PointWithHeading.from_point(group.position, group.units[0].heading)
+ PointWithHeading.from_point(
+ static.position, Heading.from_degrees(static.units[0].heading)
+ )
)
- for group in self.scenery:
- closest, distance = self.objective_info(group)
- closest.preset_locations.scenery.append(group)
+ for scenery_group in self.scenery:
+ closest, distance = self.objective_info(scenery_group)
+ closest.preset_locations.scenery.append(scenery_group)
def populate_theater(self) -> None:
for control_point in self.control_points.values():
@@ -505,7 +540,7 @@ class ConflictTheater:
"""
daytime_map: Dict[str, Tuple[int, int]]
- def __init__(self):
+ def __init__(self) -> None:
self.controlpoints: List[ControlPoint] = []
self.point_to_ll_transformer = Transformer.from_crs(
self.projection_parameters.to_crs(), CRS("WGS84")
@@ -537,10 +572,12 @@ class ConflictTheater:
CRS("WGS84"), self.projection_parameters.to_crs()
)
- def add_controlpoint(self, point: ControlPoint):
+ def add_controlpoint(self, point: ControlPoint) -> None:
self.controlpoints.append(point)
- def find_ground_objects_by_obj_name(self, obj_name):
+ def find_ground_objects_by_obj_name(
+ self, obj_name: str
+ ) -> list[TheaterGroundObject[Any]]:
found = []
for cp in self.controlpoints:
for g in cp.ground_objects:
@@ -582,12 +619,12 @@ class ConflictTheater:
return True
- def nearest_land_pos(self, point: Point, extend_dist: int = 50) -> Point:
+ def nearest_land_pos(self, near: Point, extend_dist: int = 50) -> Point:
"""Returns the nearest point inside a land exclusion zone from point
`extend_dist` determines how far inside the zone the point should be placed"""
- if self.is_on_land(point):
- return point
- point = geometry.Point(point.x, point.y)
+ if self.is_on_land(near):
+ return near
+ point = geometry.Point(near.x, near.y)
nearest_points = []
if not self.landmap:
raise RuntimeError("Landmap not initialized")
@@ -628,10 +665,14 @@ class ConflictTheater:
def enemy_points(self) -> List[ControlPoint]:
return list(self.control_points_for(player=False))
- def closest_control_point(self, point: Point) -> ControlPoint:
+ def closest_control_point(
+ self, point: Point, allow_naval: bool = False
+ ) -> ControlPoint:
closest = self.controlpoints[0]
closest_distance = point.distance_to_point(closest.position)
for control_point in self.controlpoints[1:]:
+ if control_point.is_fleet and not allow_naval:
+ continue
distance = point.distance_to_point(control_point.position)
if distance < closest_distance:
closest = control_point
@@ -699,6 +740,7 @@ class ConflictTheater:
"Normandy": NormandyTheater,
"The Channel": TheChannelTheater,
"Syria": SyriaTheater,
+ "MarianaIslands": MarianaIslandsTheater,
}
theater = theaters[data["theater"]]
t = theater()
@@ -713,6 +755,10 @@ class ConflictTheater:
MizCampaignLoader(directory / miz, t).populate_theater()
return t
+ @property
+ def seasonal_conditions(self) -> SeasonalConditions:
+ raise NotImplementedError
+
@property
def projection_parameters(self) -> TransverseMercator:
raise NotImplementedError
@@ -742,6 +788,12 @@ class CaucasusTheater(ConflictTheater):
"night": (0, 5),
}
+ @property
+ def seasonal_conditions(self) -> SeasonalConditions:
+ from .seasonalconditions.caucasus import CONDITIONS
+
+ return CONDITIONS
+
@property
def projection_parameters(self) -> TransverseMercator:
from .caucasus import PARAMETERS
@@ -764,6 +816,12 @@ class PersianGulfTheater(ConflictTheater):
"night": (0, 5),
}
+ @property
+ def seasonal_conditions(self) -> SeasonalConditions:
+ from .seasonalconditions.persiangulf import CONDITIONS
+
+ return CONDITIONS
+
@property
def projection_parameters(self) -> TransverseMercator:
from .persiangulf import PARAMETERS
@@ -786,6 +844,12 @@ class NevadaTheater(ConflictTheater):
"night": (0, 5),
}
+ @property
+ def seasonal_conditions(self) -> SeasonalConditions:
+ from .seasonalconditions.nevada import CONDITIONS
+
+ return CONDITIONS
+
@property
def projection_parameters(self) -> TransverseMercator:
from .nevada import PARAMETERS
@@ -808,6 +872,12 @@ class NormandyTheater(ConflictTheater):
"night": (0, 5),
}
+ @property
+ def seasonal_conditions(self) -> SeasonalConditions:
+ from .seasonalconditions.normandy import CONDITIONS
+
+ return CONDITIONS
+
@property
def projection_parameters(self) -> TransverseMercator:
from .normandy import PARAMETERS
@@ -830,6 +900,12 @@ class TheChannelTheater(ConflictTheater):
"night": (0, 5),
}
+ @property
+ def seasonal_conditions(self) -> SeasonalConditions:
+ from .seasonalconditions.thechannel import CONDITIONS
+
+ return CONDITIONS
+
@property
def projection_parameters(self) -> TransverseMercator:
from .thechannel import PARAMETERS
@@ -852,8 +928,39 @@ class SyriaTheater(ConflictTheater):
"night": (0, 5),
}
+ @property
+ def seasonal_conditions(self) -> SeasonalConditions:
+ from .seasonalconditions.syria import CONDITIONS
+
+ return CONDITIONS
+
@property
def projection_parameters(self) -> TransverseMercator:
from .syria import PARAMETERS
return PARAMETERS
+
+
+class MarianaIslandsTheater(ConflictTheater):
+ terrain = marianaislands.MarianaIslands()
+ overview_image = "marianaislands.gif"
+
+ landmap = load_landmap("resources\\marianaislandslandmap.p")
+ daytime_map = {
+ "dawn": (6, 8),
+ "day": (8, 16),
+ "dusk": (16, 18),
+ "night": (0, 5),
+ }
+
+ @property
+ def seasonal_conditions(self) -> SeasonalConditions:
+ from .seasonalconditions.marianaislands import CONDITIONS
+
+ return CONDITIONS
+
+ @property
+ def projection_parameters(self) -> TransverseMercator:
+ from .marianaislands import PARAMETERS
+
+ return PARAMETERS
diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py
index d4ceb7d1..68df933b 100644
--- a/game/theater/controlpoint.py
+++ b/game/theater/controlpoint.py
@@ -36,6 +36,7 @@ from dcs.unittype import FlyingType
from game import db
from game.point_with_heading import PointWithHeading
from game.scenery_group import SceneryGroup
+from game.utils import Heading
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.ground_forces.combat_stance import CombatStance
from gen.runways import RunwayAssigner, RunwayData
@@ -44,6 +45,7 @@ from .missiontarget import MissionTarget
from .theatergroundobject import (
GenericCarrierGroundObject,
TheaterGroundObject,
+ BuildingGroundObject,
)
from ..dcs.aircrafttype import AircraftType
from ..dcs.groundunittype import GroundUnitType
@@ -272,6 +274,9 @@ class ControlPointStatus(IntEnum):
class ControlPoint(MissionTarget, ABC):
+ # Not sure what distance DCS uses, but assuming it's about 2NM since that's roughly
+ # the distance of the circle on the map.
+ CAPTURE_DISTANCE = nautical_miles(2)
position = None # type: Point
name = None # type: str
@@ -292,15 +297,15 @@ class ControlPoint(MissionTarget, ABC):
at: db.StartingPosition,
size: int,
importance: float,
- has_frontline=True,
- cptype=ControlPointType.AIRBASE,
- ):
+ has_frontline: bool = True,
+ cptype: ControlPointType = ControlPointType.AIRBASE,
+ ) -> None:
super().__init__(name, position)
# TODO: Should be Airbase specific.
self.id = cp_id
self.full_name = name
self.at = at
- self.connected_objectives: List[TheaterGroundObject] = []
+ self.connected_objectives: List[TheaterGroundObject[Any]] = []
self.preset_locations = PresetLocations()
self.helipads: List[Helipad] = []
@@ -324,25 +329,29 @@ class ControlPoint(MissionTarget, ABC):
self.target_position: Optional[Point] = None
- def __repr__(self):
- return f"<{__class__}: {self.name}>"
+ def __repr__(self) -> str:
+ return f"<{self.__class__}: {self.name}>"
@property
- def ground_objects(self) -> List[TheaterGroundObject]:
+ def ground_objects(self) -> List[TheaterGroundObject[Any]]:
return list(self.connected_objectives)
@property
@abstractmethod
- def heading(self) -> int:
+ def heading(self) -> Heading:
...
- def __str__(self):
+ def __str__(self) -> str:
return self.name
@property
- def is_global(self):
+ def is_isolated(self) -> bool:
return not self.connected_points
+ @property
+ def is_global(self) -> bool:
+ return self.is_isolated
+
def transitive_connected_friendly_points(
self, seen: Optional[Set[ControlPoint]] = None
) -> List[ControlPoint]:
@@ -430,21 +439,21 @@ class ControlPoint(MissionTarget, ABC):
return False
@property
- def is_carrier(self):
+ def is_carrier(self) -> bool:
"""
:return: Whether this control point is an aircraft carrier
"""
return False
@property
- def is_fleet(self):
+ def is_fleet(self) -> bool:
"""
:return: Whether this control point is a boat (mobile)
"""
return False
@property
- def is_lha(self):
+ def is_lha(self) -> bool:
"""
:return: Whether this control point is an LHA
"""
@@ -464,7 +473,7 @@ class ControlPoint(MissionTarget, ABC):
@property
@abstractmethod
- def total_aircraft_parking(self):
+ def total_aircraft_parking(self) -> int:
"""
:return: The maximum number of aircraft that can be stored in this
control point
@@ -496,7 +505,7 @@ class ControlPoint(MissionTarget, ABC):
...
# TODO: Should be naval specific.
- def get_carrier_group_name(self):
+ def get_carrier_group_name(self) -> Optional[str]:
"""
Get the carrier group name if the airbase is a carrier
:return: Carrier group name
@@ -522,10 +531,12 @@ class ControlPoint(MissionTarget, ABC):
return None
# TODO: Should be Airbase specific.
- def is_connected(self, to) -> bool:
+ def is_connected(self, to: ControlPoint) -> bool:
return to in self.connected_points
- def find_ground_objects_by_obj_name(self, obj_name):
+ def find_ground_objects_by_obj_name(
+ self, obj_name: str
+ ) -> list[TheaterGroundObject[Any]]:
found = []
for g in self.ground_objects:
if g.obj_name == obj_name:
@@ -547,7 +558,7 @@ class ControlPoint(MissionTarget, ABC):
f"vehicles have been captured and sold for ${total}M."
)
- def retreat_ground_units(self, game: Game):
+ def retreat_ground_units(self, game: Game) -> None:
# When there are multiple valid destinations, deliver units to whichever
# base is least defended first. The closest approximation of unit
# strength we have is price
@@ -621,7 +632,7 @@ class ControlPoint(MissionTarget, ABC):
# TODO: Should be Airbase specific.
def capture(self, game: Game, for_player: bool) -> None:
- self.pending_unit_deliveries.refund_all(game)
+ self.pending_unit_deliveries.refund_all(game.coalition_for(for_player))
self.retreat_ground_units(game)
self.retreat_air_units(game)
self.depopulate_uncapturable_tgos()
@@ -638,11 +649,7 @@ class ControlPoint(MissionTarget, ABC):
...
def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]:
- if self.captured:
- ato = game.blue_ato
- else:
- ato = game.red_ato
-
+ ato = game.coalition_for(self.captured).ato
transferring: defaultdict[AircraftType, int] = defaultdict(int)
for package in ato.packages:
for flight in package.flights:
@@ -750,27 +757,48 @@ class ControlPoint(MissionTarget, ABC):
return self.captured != other.captured
@property
- def frontline_unit_count_limit(self) -> int:
+ def deployable_front_line_units(self) -> int:
+ return self.deployable_front_line_units_with(self.active_ammo_depots_count)
+
+ def deployable_front_line_units_with(self, ammo_depot_count: int) -> int:
+ return min(
+ self.front_line_capacity_with(ammo_depot_count), self.base.total_armor
+ )
+
+ @classmethod
+ def front_line_capacity_with(cls, ammo_depot_count: int) -> int:
return (
FREE_FRONTLINE_UNIT_SUPPLY
- + self.active_ammo_depots_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION
+ + ammo_depot_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION
)
+ @property
+ def frontline_unit_count_limit(self) -> int:
+ return self.front_line_capacity_with(self.active_ammo_depots_count)
+
+ @property
+ def all_ammo_depots(self) -> Iterator[BuildingGroundObject]:
+ for tgo in self.connected_objectives:
+ if not tgo.is_ammo_depot:
+ continue
+ assert isinstance(tgo, BuildingGroundObject)
+ yield tgo
+
+ @property
+ def active_ammo_depots(self) -> Iterator[BuildingGroundObject]:
+ for tgo in self.all_ammo_depots:
+ if not tgo.is_dead:
+ yield tgo
+
@property
def active_ammo_depots_count(self) -> int:
"""Return the number of available ammo depots"""
- return len(
- [
- obj
- for obj in self.connected_objectives
- if obj.category == "ammo" and not obj.is_dead
- ]
- )
+ return len(list(self.active_ammo_depots))
@property
def total_ammo_depots_count(self) -> int:
"""Return the number of ammo depots, including dead ones"""
- return len([obj for obj in self.connected_objectives if obj.category == "ammo"])
+ return len(list(self.all_ammo_depots))
@property
def active_fuel_depots_count(self) -> int:
@@ -789,7 +817,7 @@ class ControlPoint(MissionTarget, ABC):
return len([obj for obj in self.connected_objectives if obj.category == "fuel"])
@property
- def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
+ def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]:
return []
@property
@@ -805,8 +833,8 @@ class ControlPoint(MissionTarget, ABC):
class Airfield(ControlPoint):
def __init__(
- self, airport: Airport, size: int, importance: float, has_frontline=True
- ):
+ self, airport: Airport, size: int, importance: float, has_frontline: bool = True
+ ) -> None:
super().__init__(
airport.id,
airport.name,
@@ -852,8 +880,8 @@ class Airfield(ControlPoint):
return len(self.airport.parking_slots)
@property
- def heading(self) -> int:
- return self.airport.runways[0].heading
+ def heading(self) -> Heading:
+ return Heading.from_degrees(self.airport.runways[0].heading)
def runway_is_operational(self) -> bool:
return not self.runway_status.damaged
@@ -917,12 +945,15 @@ class NavalControlPoint(ControlPoint, ABC):
yield from super().mission_types(for_player)
@property
- def heading(self) -> int:
- return 0 # TODO compute heading
+ def heading(self) -> Heading:
+ return Heading.from_degrees(0) # TODO compute heading
- def find_main_tgo(self) -> TheaterGroundObject:
+ def find_main_tgo(self) -> GenericCarrierGroundObject:
for g in self.ground_objects:
- if g.dcs_identifier in ["CARRIER", "LHA"]:
+ if isinstance(g, GenericCarrierGroundObject) and g.dcs_identifier in [
+ "CARRIER",
+ "LHA",
+ ]:
return g
raise RuntimeError(f"Found no carrier/LHA group for {self.name}")
@@ -944,7 +975,9 @@ class NavalControlPoint(ControlPoint, ABC):
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
) -> RunwayData:
# TODO: Assign TACAN and ICLS earlier so we don't need this.
- fallback = RunwayData(self.full_name, runway_heading=0, runway_name="")
+ fallback = RunwayData(
+ self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
+ )
return dynamic_runways.get(self.name, fallback)
@property
@@ -1001,7 +1034,7 @@ class Carrier(NavalControlPoint):
raise RuntimeError("Carriers cannot be captured")
@property
- def is_carrier(self):
+ def is_carrier(self) -> bool:
return True
def can_operate(self, aircraft: AircraftType) -> bool:
@@ -1082,14 +1115,16 @@ class OffMapSpawn(ControlPoint):
return True
@property
- def heading(self) -> int:
- return 0
+ def heading(self) -> Heading:
+ return Heading.from_degrees(0)
def active_runway(
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
) -> RunwayData:
logging.warning("TODO: Off map spawns have no runways.")
- return RunwayData(self.full_name, runway_heading=0, runway_name="")
+ return RunwayData(
+ self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
+ )
@property
def runway_status(self) -> RunwayStatus:
@@ -1131,7 +1166,9 @@ class Fob(ControlPoint):
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
) -> RunwayData:
logging.warning("TODO: FOBs have no runways.")
- return RunwayData(self.full_name, runway_heading=0, runway_name="")
+ return RunwayData(
+ self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
+ )
@property
def runway_status(self) -> RunwayStatus:
@@ -1158,8 +1195,8 @@ class Fob(ControlPoint):
return False
@property
- def heading(self) -> int:
- return 0
+ def heading(self) -> Heading:
+ return Heading.from_degrees(0)
@property
def can_deploy_ground_units(self) -> bool:
diff --git a/game/theater/frontline.py b/game/theater/frontline.py
index 8d46327c..98aa88f6 100644
--- a/game/theater/frontline.py
+++ b/game/theater/frontline.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
-from typing import Iterator, List, Tuple
+from typing import Iterator, List, Tuple, Any
from dcs.mapping import Point
@@ -11,7 +11,7 @@ from .controlpoint import (
ControlPoint,
MissionTarget,
)
-from ..utils import pairwise
+from ..utils import Heading, pairwise
FRONTLINE_MIN_CP_DISTANCE = 5000
@@ -27,9 +27,9 @@ class FrontLineSegment:
point_b: Point
@property
- def attack_heading(self) -> float:
+ def attack_heading(self) -> Heading:
"""The heading of the frontline segment from player to enemy control point"""
- return self.point_a.heading_between_point(self.point_b)
+ return Heading.from_degrees(self.point_a.heading_between_point(self.point_b))
@property
def attack_distance(self) -> float:
@@ -66,12 +66,31 @@ class FrontLine(MissionTarget):
self.segments: List[FrontLineSegment] = [
FrontLineSegment(a, b) for a, b in pairwise(route)
]
- self.name = f"Front line {blue_point}/{red_point}"
+ super().__init__(
+ f"Front line {blue_point}/{red_point}",
+ self.point_from_a(self._position_distance),
+ )
+
+ def __eq__(self, other: Any) -> bool:
+ if not isinstance(other, FrontLine):
+ return False
+ return (self.blue_cp, self.red_cp) == (other.blue_cp, other.red_cp)
+
+ def __hash__(self) -> int:
+ return hash((self.blue_cp, self.red_cp))
+
+ def __setstate__(self, state: dict[str, Any]) -> None:
+ self.__dict__.update(state)
+ if not hasattr(self, "position"):
+ self.position = self.point_from_a(self._position_distance)
+
+ def control_point_friendly_to(self, player: bool) -> ControlPoint:
+ if player:
+ return self.blue_cp
+ return self.red_cp
def control_point_hostile_to(self, player: bool) -> ControlPoint:
- if player:
- return self.red_cp
- return self.blue_cp
+ return self.control_point_friendly_to(not player)
def is_friendly(self, to_player: bool) -> bool:
"""Returns True if the objective is in friendly territory."""
@@ -87,14 +106,6 @@ class FrontLine(MissionTarget):
]
yield from super().mission_types(for_player)
- @property
- def position(self):
- """
- The position where the conflict should occur
- according to the current strength of each control point.
- """
- return self.point_from_a(self._position_distance)
-
@property
def points(self) -> Iterator[Point]:
yield self.segments[0].point_a
@@ -107,12 +118,12 @@ class FrontLine(MissionTarget):
return self.blue_cp, self.red_cp
@property
- def attack_distance(self):
+ def attack_distance(self) -> float:
"""The total distance of all segments"""
return sum(i.attack_distance for i in self.segments)
@property
- def attack_heading(self):
+ def attack_heading(self) -> Heading:
"""The heading of the active attack segment from player to enemy control point"""
return self.active_segment.attack_heading
@@ -139,16 +150,19 @@ class FrontLine(MissionTarget):
"""
if distance < self.segments[0].attack_distance:
return self.blue_cp.position.point_from_heading(
- self.segments[0].attack_heading, distance
+ self.segments[0].attack_heading.degrees, distance
)
remaining_dist = distance
for segment in self.segments:
if remaining_dist < segment.attack_distance:
return segment.point_a.point_from_heading(
- segment.attack_heading, remaining_dist
+ segment.attack_heading.degrees, remaining_dist
)
else:
remaining_dist -= segment.attack_distance
+ raise RuntimeError(
+ f"Could not find front line point {distance} from {self.blue_cp}"
+ )
@property
def _position_distance(self) -> float:
diff --git a/game/theater/landmap.py b/game/theater/landmap.py
index 29d551b3..2cc3867c 100644
--- a/game/theater/landmap.py
+++ b/game/theater/landmap.py
@@ -14,7 +14,7 @@ class Landmap:
exclusion_zones: MultiPolygon
sea_zones: MultiPolygon
- def __post_init__(self):
+ def __post_init__(self) -> None:
if not self.inclusion_zones.is_valid:
raise RuntimeError("Inclusion zones not valid")
if not self.exclusion_zones.is_valid:
@@ -36,13 +36,5 @@ def load_landmap(filename: str) -> Optional[Landmap]:
return None
-def poly_contains(x, y, poly: Union[MultiPolygon, Polygon]):
+def poly_contains(x: float, y: float, poly: Union[MultiPolygon, Polygon]) -> bool:
return poly.contains(geometry.Point(x, y))
-
-
-def poly_centroid(poly) -> Tuple[float, float]:
- x_list = [vertex[0] for vertex in poly]
- y_list = [vertex[1] for vertex in poly]
- x = sum(x_list) / len(poly)
- y = sum(y_list) / len(poly)
- return (x, y)
diff --git a/game/theater/marianaislands.py b/game/theater/marianaislands.py
new file mode 100644
index 00000000..3fc39672
--- /dev/null
+++ b/game/theater/marianaislands.py
@@ -0,0 +1,10 @@
+# DO NOT EDIT:
+# This file is generated by resources/tools/export_coordinates.py.
+from game.theater.projections import TransverseMercator
+
+PARAMETERS = TransverseMercator(
+ central_meridian=147,
+ false_easting=238417.99999989968,
+ false_northing=-1491840.000000048,
+ scale_factor=0.9996,
+)
diff --git a/game/theater/missiontarget.py b/game/theater/missiontarget.py
index ea426603..a475bc9f 100644
--- a/game/theater/missiontarget.py
+++ b/game/theater/missiontarget.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from collections import Sequence
from typing import Iterator, TYPE_CHECKING, List, Union
from dcs.mapping import Point
@@ -20,7 +21,7 @@ class MissionTarget:
self.name = name
self.position = position
- def distance_to(self, other: MissionTarget) -> int:
+ def distance_to(self, other: MissionTarget) -> float:
"""Computes the distance to the given mission target."""
return self.position.distance_to_point(other.position)
@@ -45,5 +46,5 @@ class MissionTarget:
]
@property
- def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
+ def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]:
return []
diff --git a/game/theater/nevada.py b/game/theater/nevada.py
index ad245611..e18700d6 100644
--- a/game/theater/nevada.py
+++ b/game/theater/nevada.py
@@ -1,3 +1,5 @@
+# DO NOT EDIT:
+# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
diff --git a/game/theater/normandy.py b/game/theater/normandy.py
index a74c692d..8f91ab78 100644
--- a/game/theater/normandy.py
+++ b/game/theater/normandy.py
@@ -1,3 +1,5 @@
+# DO NOT EDIT:
+# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
diff --git a/game/theater/persiangulf.py b/game/theater/persiangulf.py
index 600801dd..69ce5288 100644
--- a/game/theater/persiangulf.py
+++ b/game/theater/persiangulf.py
@@ -1,3 +1,5 @@
+# DO NOT EDIT:
+# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
diff --git a/game/theater/seasonalconditions/__init__.py b/game/theater/seasonalconditions/__init__.py
new file mode 100644
index 00000000..713a85f5
--- /dev/null
+++ b/game/theater/seasonalconditions/__init__.py
@@ -0,0 +1 @@
+from .seasonalconditions import *
diff --git a/game/theater/seasonalconditions/caucasus.py b/game/theater/seasonalconditions/caucasus.py
new file mode 100644
index 00000000..e605a543
--- /dev/null
+++ b/game/theater/seasonalconditions/caucasus.py
@@ -0,0 +1,36 @@
+from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
+
+CONDITIONS = SeasonalConditions(
+ summer_avg_pressure=30.02, # TODO: Find real-world data
+ winter_avg_pressure=29.72, # TODO: Find real-world data
+ summer_avg_temperature=22.5,
+ winter_avg_temperature=3.0,
+ temperature_day_night_difference=6.0,
+ weather_type_chances={
+ # TODO: Find real-world data for all these values
+ Season.Winter: WeatherTypeChances(
+ thunderstorm=1,
+ raining=20,
+ cloudy=60,
+ clear_skies=20,
+ ),
+ Season.Spring: WeatherTypeChances(
+ thunderstorm=1,
+ raining=20,
+ cloudy=40,
+ clear_skies=40,
+ ),
+ Season.Summer: WeatherTypeChances(
+ thunderstorm=1,
+ raining=10,
+ cloudy=30,
+ clear_skies=60,
+ ),
+ Season.Fall: WeatherTypeChances(
+ thunderstorm=1,
+ raining=30,
+ cloudy=50,
+ clear_skies=20,
+ ),
+ },
+)
diff --git a/game/theater/seasonalconditions/marianaislands.py b/game/theater/seasonalconditions/marianaislands.py
new file mode 100644
index 00000000..0d662908
--- /dev/null
+++ b/game/theater/seasonalconditions/marianaislands.py
@@ -0,0 +1,38 @@
+from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
+
+CONDITIONS = SeasonalConditions(
+ summer_avg_pressure=30.02, # TODO: Find real-world data
+ winter_avg_pressure=29.82, # TODO: Find real-world data
+ summer_avg_temperature=28.0,
+ winter_avg_temperature=27.0,
+ temperature_day_night_difference=1.0,
+ weather_type_chances={
+ # TODO: Find real-world data for all these values
+ Season.Winter: WeatherTypeChances(
+ thunderstorm=2,
+ raining=20,
+ cloudy=40,
+ clear_skies=40,
+ ),
+ Season.Spring: WeatherTypeChances(
+ # Spring is dry/sunny in Marianas
+ thunderstorm=1,
+ raining=10,
+ cloudy=30,
+ clear_skies=60,
+ ),
+ Season.Summer: WeatherTypeChances(
+ thunderstorm=2,
+ raining=20,
+ cloudy=40,
+ clear_skies=40,
+ ),
+ Season.Fall: WeatherTypeChances(
+ # Rain season
+ thunderstorm=5,
+ raining=45,
+ cloudy=30,
+ clear_skies=20,
+ ),
+ },
+)
diff --git a/game/theater/seasonalconditions/nevada.py b/game/theater/seasonalconditions/nevada.py
new file mode 100644
index 00000000..352ca456
--- /dev/null
+++ b/game/theater/seasonalconditions/nevada.py
@@ -0,0 +1,36 @@
+from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
+
+CONDITIONS = SeasonalConditions(
+ summer_avg_pressure=30.02, # TODO: Find real-world data
+ winter_avg_pressure=29.72, # TODO: Find real-world data
+ summer_avg_temperature=31.5,
+ winter_avg_temperature=5.0,
+ temperature_day_night_difference=6.0,
+ weather_type_chances={
+ # TODO: Find real-world data for all these values
+ Season.Winter: WeatherTypeChances(
+ thunderstorm=1,
+ raining=10,
+ cloudy=50,
+ clear_skies=40,
+ ),
+ Season.Spring: WeatherTypeChances(
+ thunderstorm=1,
+ raining=5,
+ cloudy=45,
+ clear_skies=50,
+ ),
+ Season.Summer: WeatherTypeChances(
+ thunderstorm=1,
+ raining=5,
+ cloudy=25,
+ clear_skies=70,
+ ),
+ Season.Fall: WeatherTypeChances(
+ thunderstorm=1,
+ raining=10,
+ cloudy=45,
+ clear_skies=45,
+ ),
+ },
+)
diff --git a/game/theater/seasonalconditions/normandy.py b/game/theater/seasonalconditions/normandy.py
new file mode 100644
index 00000000..109c781f
--- /dev/null
+++ b/game/theater/seasonalconditions/normandy.py
@@ -0,0 +1,36 @@
+from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
+
+CONDITIONS = SeasonalConditions(
+ summer_avg_pressure=30.02, # TODO: Find real-world data
+ winter_avg_pressure=29.72, # TODO: Find real-world data
+ summer_avg_temperature=20.0,
+ winter_avg_temperature=0.0,
+ temperature_day_night_difference=5.0,
+ weather_type_chances={
+ # TODO: Find real-world data for all these values
+ Season.Winter: WeatherTypeChances(
+ thunderstorm=1,
+ raining=20,
+ cloudy=60,
+ clear_skies=20,
+ ),
+ Season.Spring: WeatherTypeChances(
+ thunderstorm=1,
+ raining=20,
+ cloudy=40,
+ clear_skies=40,
+ ),
+ Season.Summer: WeatherTypeChances(
+ thunderstorm=1,
+ raining=10,
+ cloudy=30,
+ clear_skies=60,
+ ),
+ Season.Fall: WeatherTypeChances(
+ thunderstorm=1,
+ raining=30,
+ cloudy=50,
+ clear_skies=20,
+ ),
+ },
+)
diff --git a/game/theater/seasonalconditions/persiangulf.py b/game/theater/seasonalconditions/persiangulf.py
new file mode 100644
index 00000000..467168ab
--- /dev/null
+++ b/game/theater/seasonalconditions/persiangulf.py
@@ -0,0 +1,37 @@
+from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
+
+CONDITIONS = SeasonalConditions(
+ summer_avg_pressure=29.98, # TODO: Find real-world data
+ winter_avg_pressure=29.80, # TODO: Find real-world data
+ summer_avg_temperature=32.5,
+ winter_avg_temperature=15.0,
+ temperature_day_night_difference=2.0,
+ weather_type_chances={
+ # TODO: Find real-world data for all these values
+ Season.Winter: WeatherTypeChances(
+ # Winter there is some rain in PG (Dubai)
+ thunderstorm=1,
+ raining=15,
+ cloudy=35,
+ clear_skies=50,
+ ),
+ Season.Spring: WeatherTypeChances(
+ thunderstorm=1,
+ raining=2,
+ cloudy=18,
+ clear_skies=80,
+ ),
+ Season.Summer: WeatherTypeChances(
+ thunderstorm=1,
+ raining=1,
+ cloudy=8,
+ clear_skies=90,
+ ),
+ Season.Fall: WeatherTypeChances(
+ thunderstorm=1,
+ raining=2,
+ cloudy=18,
+ clear_skies=80,
+ ),
+ },
+)
diff --git a/game/theater/seasonalconditions/seasonalconditions.py b/game/theater/seasonalconditions/seasonalconditions.py
new file mode 100644
index 00000000..2280d15e
--- /dev/null
+++ b/game/theater/seasonalconditions/seasonalconditions.py
@@ -0,0 +1,48 @@
+import datetime
+from dataclasses import dataclass
+from enum import Enum
+
+
+class Season(Enum):
+ Winter = "winter"
+ Spring = "spring"
+ Summer = "summer"
+ Fall = "fall"
+
+
+def determine_season(day: datetime.date) -> Season:
+ # Note: This logic doesn't need to be very precise
+ # Currently refers strictly to northern-hemisphere seasons
+ day_of_year = day.timetuple().tm_yday
+ season_length = 365.0 / 4
+ winter_end_day = season_length / 2
+ if day_of_year < winter_end_day:
+ return Season.Winter
+ elif day_of_year < winter_end_day + season_length:
+ return Season.Spring
+ elif day_of_year < winter_end_day + season_length * 2:
+ return Season.Summer
+ elif day_of_year < winter_end_day + season_length * 3:
+ return Season.Fall
+ else:
+ return Season.Winter
+
+
+@dataclass(frozen=True)
+class WeatherTypeChances:
+ thunderstorm: float
+ raining: float
+ cloudy: float
+ clear_skies: float
+
+
+@dataclass(frozen=True)
+class SeasonalConditions:
+ # Units are inHg and degrees Celsius
+ summer_avg_pressure: float
+ winter_avg_pressure: float
+ summer_avg_temperature: float
+ winter_avg_temperature: float
+ temperature_day_night_difference: float
+
+ weather_type_chances: dict[Season, WeatherTypeChances]
diff --git a/game/theater/seasonalconditions/syria.py b/game/theater/seasonalconditions/syria.py
new file mode 100644
index 00000000..0a6c7ec1
--- /dev/null
+++ b/game/theater/seasonalconditions/syria.py
@@ -0,0 +1,36 @@
+from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
+
+CONDITIONS = SeasonalConditions(
+ summer_avg_pressure=29.98, # TODO: Find real-world data
+ winter_avg_pressure=29.86, # TODO: Find real-world data
+ summer_avg_temperature=28.5,
+ winter_avg_temperature=10.0,
+ temperature_day_night_difference=8.0,
+ weather_type_chances={
+ # TODO: Find real-world data for all these values
+ Season.Winter: WeatherTypeChances(
+ thunderstorm=1,
+ raining=25,
+ cloudy=25,
+ clear_skies=50,
+ ),
+ Season.Spring: WeatherTypeChances(
+ thunderstorm=1,
+ raining=10,
+ cloudy=30,
+ clear_skies=60,
+ ),
+ Season.Summer: WeatherTypeChances(
+ thunderstorm=1,
+ raining=3,
+ cloudy=20,
+ clear_skies=77,
+ ),
+ Season.Fall: WeatherTypeChances(
+ thunderstorm=1,
+ raining=10,
+ cloudy=30,
+ clear_skies=60,
+ ),
+ },
+)
diff --git a/game/theater/seasonalconditions/thechannel.py b/game/theater/seasonalconditions/thechannel.py
new file mode 100644
index 00000000..109c781f
--- /dev/null
+++ b/game/theater/seasonalconditions/thechannel.py
@@ -0,0 +1,36 @@
+from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances
+
+CONDITIONS = SeasonalConditions(
+ summer_avg_pressure=30.02, # TODO: Find real-world data
+ winter_avg_pressure=29.72, # TODO: Find real-world data
+ summer_avg_temperature=20.0,
+ winter_avg_temperature=0.0,
+ temperature_day_night_difference=5.0,
+ weather_type_chances={
+ # TODO: Find real-world data for all these values
+ Season.Winter: WeatherTypeChances(
+ thunderstorm=1,
+ raining=20,
+ cloudy=60,
+ clear_skies=20,
+ ),
+ Season.Spring: WeatherTypeChances(
+ thunderstorm=1,
+ raining=20,
+ cloudy=40,
+ clear_skies=40,
+ ),
+ Season.Summer: WeatherTypeChances(
+ thunderstorm=1,
+ raining=10,
+ cloudy=30,
+ clear_skies=60,
+ ),
+ Season.Fall: WeatherTypeChances(
+ thunderstorm=1,
+ raining=30,
+ cloudy=50,
+ clear_skies=20,
+ ),
+ },
+)
diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py
index 4ec827ec..61cc25af 100644
--- a/game/theater/start_generator.py
+++ b/game/theater/start_generator.py
@@ -11,7 +11,7 @@ from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence
-from game import Game, db
+from game import Game
from game.factions.faction import Faction
from game.scenery_group import SceneryGroup
from game.theater import Carrier, Lha, PointWithHeading
@@ -28,6 +28,7 @@ from game.theater.theatergroundobject import (
VehicleGroupGroundObject,
CoastalSiteGroundObject,
)
+from game.utils import Heading
from game.version import VERSION
from gen import namegen
from gen.coastal.coastal_group_generator import generate_coastal_group
@@ -123,7 +124,6 @@ class GameGenerator:
GroundObjectGenerator(game, self.generator_settings).generate()
game.settings.version = VERSION
- game.begin_turn_0()
return game
def prepare_theater(self) -> None:
@@ -171,14 +171,11 @@ class ControlPointGroundObjectGenerator:
@property
def faction_name(self) -> str:
- if self.control_point.captured:
- return self.game.player_faction.name
- else:
- return self.game.enemy_faction.name
+ return self.faction.name
@property
def faction(self) -> Faction:
- return db.FACTIONS[self.faction_name]
+ return self.game.coalition_for(self.control_point.captured).faction
def generate(self) -> bool:
self.control_point.connected_objectives = []
@@ -389,7 +386,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
group_id,
object_id,
position + template_point,
- unit["heading"],
+ Heading.from_degrees(unit["heading"]),
self.control_point,
unit["type"],
)
@@ -589,7 +586,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator):
group_id,
object_id,
point + template_point,
- unit["heading"],
+ Heading.from_degrees(unit["heading"]),
self.control_point,
unit["type"],
is_fob_structure=True,
diff --git a/game/theater/syria.py b/game/theater/syria.py
index 7fe83db3..6daff280 100644
--- a/game/theater/syria.py
+++ b/game/theater/syria.py
@@ -1,3 +1,5 @@
+# DO NOT EDIT:
+# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py
index df637cbc..d3bfae64 100644
--- a/game/theater/theatergroundobject.py
+++ b/game/theater/theatergroundobject.py
@@ -2,13 +2,14 @@ from __future__ import annotations
import itertools
import logging
-from typing import Iterator, List, TYPE_CHECKING, Union
+from abc import ABC
+from collections import Sequence
+from typing import Iterator, List, TYPE_CHECKING, Union, Generic, TypeVar
from dcs.mapping import Point
from dcs.triggers import TriggerZone
from dcs.unit import Unit
-from dcs.unitgroup import Group
-from dcs.unittype import VehicleType
+from dcs.unitgroup import ShipGroup, VehicleGroup
from .. import db
from ..data.radar_db import (
@@ -16,7 +17,7 @@ from ..data.radar_db import (
TELARS,
LAUNCHER_TRACKER_PAIRS,
)
-from ..utils import Distance, meters
+from ..utils import Distance, Heading, meters
if TYPE_CHECKING:
from .controlpoint import ControlPoint
@@ -47,14 +48,17 @@ NAME_BY_CATEGORY = {
}
-class TheaterGroundObject(MissionTarget):
+GroupT = TypeVar("GroupT", ShipGroup, VehicleGroup)
+
+
+class TheaterGroundObject(MissionTarget, Generic[GroupT]):
def __init__(
self,
name: str,
category: str,
group_id: int,
position: Point,
- heading: int,
+ heading: Heading,
control_point: ControlPoint,
dcs_identifier: str,
sea_object: bool,
@@ -66,7 +70,7 @@ class TheaterGroundObject(MissionTarget):
self.control_point = control_point
self.dcs_identifier = dcs_identifier
self.sea_object = sea_object
- self.groups: List[Group] = []
+ self.groups: List[GroupT] = []
@property
def is_dead(self) -> bool:
@@ -147,7 +151,7 @@ class TheaterGroundObject(MissionTarget):
return True
return False
- def _max_range_of_type(self, group: Group, range_type: str) -> Distance:
+ def _max_range_of_type(self, group: GroupT, range_type: str) -> Distance:
if not self.might_have_aa:
return meters(0)
@@ -168,15 +172,19 @@ class TheaterGroundObject(MissionTarget):
def max_detection_range(self) -> Distance:
return max(self.detection_range(g) for g in self.groups)
- def detection_range(self, group: Group) -> Distance:
+ def detection_range(self, group: GroupT) -> Distance:
return self._max_range_of_type(group, "detection_range")
def max_threat_range(self) -> Distance:
return max(self.threat_range(g) for g in self.groups)
- def threat_range(self, group: Group, radar_only: bool = False) -> Distance:
+ def threat_range(self, group: GroupT, radar_only: bool = False) -> Distance:
return self._max_range_of_type(group, "threat_range")
+ @property
+ def is_ammo_depot(self) -> bool:
+ return self.category == "ammo"
+
@property
def is_factory(self) -> bool:
return self.category == "factory"
@@ -187,7 +195,7 @@ class TheaterGroundObject(MissionTarget):
return False
@property
- def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
+ def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]:
return self.units
@property
@@ -206,7 +214,7 @@ class TheaterGroundObject(MissionTarget):
raise NotImplementedError
-class BuildingGroundObject(TheaterGroundObject):
+class BuildingGroundObject(TheaterGroundObject[VehicleGroup]):
def __init__(
self,
name: str,
@@ -214,10 +222,10 @@ class BuildingGroundObject(TheaterGroundObject):
group_id: int,
object_id: int,
position: Point,
- heading: int,
+ heading: Heading,
control_point: ControlPoint,
dcs_identifier: str,
- is_fob_structure=False,
+ is_fob_structure: bool = False,
) -> None:
super().__init__(
name=name,
@@ -253,13 +261,17 @@ class BuildingGroundObject(TheaterGroundObject):
def kill(self) -> None:
self._dead = True
- def iter_building_group(self) -> Iterator[TheaterGroundObject]:
+ def iter_building_group(self) -> Iterator[BuildingGroundObject]:
for tgo in self.control_point.ground_objects:
- if tgo.obj_name == self.obj_name and not tgo.is_dead:
+ if (
+ tgo.obj_name == self.obj_name
+ and not tgo.is_dead
+ and isinstance(tgo, BuildingGroundObject)
+ ):
yield tgo
@property
- def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
+ def strike_targets(self) -> List[BuildingGroundObject]:
return list(self.iter_building_group())
@property
@@ -298,7 +310,7 @@ class SceneryGroundObject(BuildingGroundObject):
group_id=group_id,
object_id=object_id,
position=position,
- heading=0,
+ heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier=dcs_identifier,
is_fob_structure=False,
@@ -322,7 +334,7 @@ class FactoryGroundObject(BuildingGroundObject):
name: str,
group_id: int,
position: Point,
- heading: int,
+ heading: Heading,
control_point: ControlPoint,
) -> None:
super().__init__(
@@ -338,7 +350,7 @@ class FactoryGroundObject(BuildingGroundObject):
)
-class NavalGroundObject(TheaterGroundObject):
+class NavalGroundObject(TheaterGroundObject[ShipGroup]):
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
@@ -373,7 +385,7 @@ class CarrierGroundObject(GenericCarrierGroundObject):
category="CARRIER",
group_id=group_id,
position=control_point.position,
- heading=0,
+ heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="CARRIER",
sea_object=True,
@@ -394,7 +406,7 @@ class LhaGroundObject(GenericCarrierGroundObject):
category="LHA",
group_id=group_id,
position=control_point.position,
- heading=0,
+ heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="LHA",
sea_object=True,
@@ -407,7 +419,7 @@ class LhaGroundObject(GenericCarrierGroundObject):
return f"{self.faction_color}|EWR|{super().group_name}"
-class MissileSiteGroundObject(TheaterGroundObject):
+class MissileSiteGroundObject(TheaterGroundObject[VehicleGroup]):
def __init__(
self, name: str, group_id: int, position: Point, control_point: ControlPoint
) -> None:
@@ -416,7 +428,7 @@ class MissileSiteGroundObject(TheaterGroundObject):
category="missile",
group_id=group_id,
position=position,
- heading=0,
+ heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="AA",
sea_object=False,
@@ -431,14 +443,14 @@ class MissileSiteGroundObject(TheaterGroundObject):
return False
-class CoastalSiteGroundObject(TheaterGroundObject):
+class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]):
def __init__(
self,
name: str,
group_id: int,
position: Point,
control_point: ControlPoint,
- heading,
+ heading: Heading,
) -> None:
super().__init__(
name=name,
@@ -460,10 +472,19 @@ class CoastalSiteGroundObject(TheaterGroundObject):
return False
-# TODO: Differentiate types.
-# This type gets used both for AA sites (SAM, AAA, or SHORAD). These should each
-# be split into their own types.
-class SamGroundObject(TheaterGroundObject):
+class IadsGroundObject(TheaterGroundObject[VehicleGroup], ABC):
+ def mission_types(self, for_player: bool) -> Iterator[FlightType]:
+ from gen.flights.flight import FlightType
+
+ if not self.is_friendly(for_player):
+ yield FlightType.DEAD
+ yield from super().mission_types(for_player)
+
+
+# The SamGroundObject represents all type of AA
+# The TGO can have multiple types of units (AAA,SAM,Support...)
+# Differentiation can be made during generation with the airdefensegroupgenerator
+class SamGroundObject(IadsGroundObject):
def __init__(
self,
name: str,
@@ -476,23 +497,11 @@ class SamGroundObject(TheaterGroundObject):
category="aa",
group_id=group_id,
position=position,
- heading=0,
+ heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="AA",
sea_object=False,
)
- # Set by the SAM unit generator if the generated group is compatible
- # with Skynet.
- self.skynet_capable = False
-
- @property
- def group_name(self) -> str:
- if self.skynet_capable:
- # Prefix the group names of SAM sites with the side color so Skynet
- # can find them.
- return f"{self.faction_color}|SAM|{self.group_id}"
- else:
- return super().group_name
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
@@ -500,39 +509,35 @@ class SamGroundObject(TheaterGroundObject):
if not self.is_friendly(for_player):
yield FlightType.DEAD
yield FlightType.SEAD
- yield from super().mission_types(for_player)
+ for mission_type in super().mission_types(for_player):
+ # We yielded this ourselves to move it to the top of the list. Don't yield
+ # it twice.
+ if mission_type is not FlightType.DEAD:
+ yield mission_type
@property
def might_have_aa(self) -> bool:
return True
- def threat_range(self, group: Group, radar_only: bool = False) -> Distance:
+ def threat_range(self, group: VehicleGroup, radar_only: bool = False) -> Distance:
max_non_radar = meters(0)
live_trs = set()
max_telar_range = meters(0)
launchers = set()
for unit in group.units:
- unit_type = db.unit_type_from_name(unit.type)
- if unit_type is None or not issubclass(unit_type, VehicleType):
- continue
+ unit_type = db.vehicle_type_from_name(unit.type)
if unit_type in TRACK_RADARS:
live_trs.add(unit_type)
elif unit_type in TELARS:
- max_telar_range = max(
- max_telar_range, meters(getattr(unit_type, "threat_range", 0))
- )
+ max_telar_range = max(max_telar_range, meters(unit_type.threat_range))
elif unit_type in LAUNCHER_TRACKER_PAIRS:
launchers.add(unit_type)
else:
- max_non_radar = max(
- max_non_radar, meters(getattr(unit_type, "threat_range", 0))
- )
+ max_non_radar = max(max_non_radar, meters(unit_type.threat_range))
max_tel_range = meters(0)
for launcher in launchers:
if LAUNCHER_TRACKER_PAIRS[launcher] in live_trs:
- max_tel_range = max(
- max_tel_range, meters(getattr(launcher, "threat_range"))
- )
+ max_tel_range = max(max_tel_range, meters(unit_type.threat_range))
if radar_only:
return max(max_tel_range, max_telar_range)
else:
@@ -547,7 +552,7 @@ class SamGroundObject(TheaterGroundObject):
return True
-class VehicleGroupGroundObject(TheaterGroundObject):
+class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]):
def __init__(
self,
name: str,
@@ -560,7 +565,7 @@ class VehicleGroupGroundObject(TheaterGroundObject):
category="armor",
group_id=group_id,
position=position,
- heading=0,
+ heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="AA",
sea_object=False,
@@ -575,7 +580,7 @@ class VehicleGroupGroundObject(TheaterGroundObject):
return True
-class EwrGroundObject(TheaterGroundObject):
+class EwrGroundObject(IadsGroundObject):
def __init__(
self,
name: str,
@@ -588,7 +593,7 @@ class EwrGroundObject(TheaterGroundObject):
category="ewr",
group_id=group_id,
position=position,
- heading=0,
+ heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="EWR",
sea_object=False,
@@ -600,13 +605,6 @@ class EwrGroundObject(TheaterGroundObject):
# Use Group Id and uppercase EWR
return f"{self.faction_color}|EWR|{self.group_id}"
- def mission_types(self, for_player: bool) -> Iterator[FlightType]:
- from gen.flights.flight import FlightType
-
- if not self.is_friendly(for_player):
- yield FlightType.DEAD
- yield from super().mission_types(for_player)
-
@property
def might_have_aa(self) -> bool:
return True
@@ -629,7 +627,7 @@ class ShipGroundObject(NavalGroundObject):
category="ship",
group_id=group_id,
position=position,
- heading=0,
+ heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="AA",
sea_object=True,
diff --git a/game/theater/thechannel.py b/game/theater/thechannel.py
index 33137bd7..0ac20788 100644
--- a/game/theater/thechannel.py
+++ b/game/theater/thechannel.py
@@ -1,3 +1,5 @@
+# DO NOT EDIT:
+# This file is generated by resources/tools/export_coordinates.py.
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
diff --git a/game/threatzones.py b/game/threatzones.py
index 4d29c6c3..16416573 100644
--- a/game/threatzones.py
+++ b/game/threatzones.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from functools import singledispatchmethod
-from typing import Optional, TYPE_CHECKING, Union, Iterable
+from typing import Optional, TYPE_CHECKING, Union, Iterable, Any
from dcs.mapping import Point as DcsPoint
from shapely.geometry import (
@@ -13,7 +13,8 @@ from shapely.geometry import (
from shapely.geometry.base import BaseGeometry
from shapely.ops import nearest_points, unary_union
-from game.theater import ControlPoint, MissionTarget
+from game.data.doctrine import Doctrine
+from game.theater import ControlPoint, MissionTarget, TheaterGroundObject
from game.utils import Distance, meters, nautical_miles
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import Flight, FlightWaypoint
@@ -27,7 +28,10 @@ ThreatPoly = Union[MultiPolygon, Polygon]
class ThreatZones:
def __init__(
- self, airbases: ThreatPoly, air_defenses: ThreatPoly, radar_sam_threats
+ self,
+ airbases: ThreatPoly,
+ air_defenses: ThreatPoly,
+ radar_sam_threats: ThreatPoly,
) -> None:
self.airbases = airbases
self.air_defenses = air_defenses
@@ -44,8 +48,10 @@ class ThreatZones:
boundary = self.closest_boundary(point)
return meters(boundary.distance_to_point(point))
+ # Type checking ignored because singledispatchmethod doesn't work with required type
+ # definitions. The implementation methods are all typed, so should be fine.
@singledispatchmethod
- def threatened(self, position) -> bool:
+ def threatened(self, position) -> bool: # type: ignore
raise NotImplementedError
@threatened.register
@@ -61,8 +67,10 @@ class ThreatZones:
LineString([self.dcs_to_shapely_point(a), self.dcs_to_shapely_point(b)])
)
+ # Type checking ignored because singledispatchmethod doesn't work with required type
+ # definitions. The implementation methods are all typed, so should be fine.
@singledispatchmethod
- def threatened_by_aircraft(self, target) -> bool:
+ def threatened_by_aircraft(self, target) -> bool: # type: ignore
raise NotImplementedError
@threatened_by_aircraft.register
@@ -75,6 +83,10 @@ class ThreatZones:
LineString((self.dcs_to_shapely_point(p.position) for p in flight.points))
)
+ @threatened_by_aircraft.register
+ def _threatened_by_aircraft_mission_target(self, target: MissionTarget) -> bool:
+ return self.threatened_by_aircraft(self.dcs_to_shapely_point(target.position))
+
def waypoints_threatened_by_aircraft(
self, waypoints: Iterable[FlightWaypoint]
) -> bool:
@@ -82,8 +94,10 @@ class ThreatZones:
LineString((self.dcs_to_shapely_point(p.position) for p in waypoints))
)
+ # Type checking ignored because singledispatchmethod doesn't work with required type
+ # definitions. The implementation methods are all typed, so should be fine.
@singledispatchmethod
- def threatened_by_air_defense(self, target) -> bool:
+ def threatened_by_air_defense(self, target) -> bool: # type: ignore
raise NotImplementedError
@threatened_by_air_defense.register
@@ -102,8 +116,10 @@ class ThreatZones:
self.dcs_to_shapely_point(target.position)
)
+ # Type checking ignored because singledispatchmethod doesn't work with required type
+ # definitions. The implementation methods are all typed, so should be fine.
@singledispatchmethod
- def threatened_by_radar_sam(self, target) -> bool:
+ def threatened_by_radar_sam(self, target) -> bool: # type: ignore
raise NotImplementedError
@threatened_by_radar_sam.register
@@ -134,8 +150,9 @@ class ThreatZones:
return None
@classmethod
- def barcap_threat_range(cls, game: Game, control_point: ControlPoint) -> Distance:
- doctrine = game.faction_for(control_point.captured).doctrine
+ def barcap_threat_range(
+ cls, doctrine: Doctrine, control_point: ControlPoint
+ ) -> Distance:
cap_threat_range = (
doctrine.cap_max_distance_from_cp + doctrine.cap_engagement_range
)
@@ -174,33 +191,59 @@ class ThreatZones:
"""
air_threats = []
air_defenses = []
- radar_sam_threats = []
- for control_point in game.theater.controlpoints:
- if control_point.captured != player:
- continue
- if control_point.runway_is_operational():
- point = ShapelyPoint(control_point.position.x, control_point.position.y)
- cap_threat_range = cls.barcap_threat_range(game, control_point)
- air_threats.append(point.buffer(cap_threat_range.meters))
+ for control_point in game.theater.control_points_for(player):
+ air_threats.append(control_point)
+ air_defenses.extend(control_point.ground_objects)
- for tgo in control_point.ground_objects:
- for group in tgo.groups:
- threat_range = tgo.threat_range(group)
- # Any system with a shorter range than this is not worth
- # even avoiding.
- if threat_range > nautical_miles(3):
- point = ShapelyPoint(tgo.position.x, tgo.position.y)
- threat_zone = point.buffer(threat_range.meters)
- air_defenses.append(threat_zone)
- radar_threat_range = tgo.threat_range(group, radar_only=True)
- if radar_threat_range > nautical_miles(3):
- point = ShapelyPoint(tgo.position.x, tgo.position.y)
- threat_zone = point.buffer(threat_range.meters)
- radar_sam_threats.append(threat_zone)
+ return cls.for_threats(
+ game.faction_for(player).doctrine, air_threats, air_defenses
+ )
+
+ @classmethod
+ def for_threats(
+ cls,
+ doctrine: Doctrine,
+ barcap_locations: Iterable[ControlPoint],
+ air_defenses: Iterable[TheaterGroundObject[Any]],
+ ) -> ThreatZones:
+ """Generates the threat zones projected by the given locations.
+
+ Args:
+ doctrine: The doctrine of the owning coalition.
+ barcap_locations: The locations that will be considered for BARCAP planning.
+ air_defenses: TGOs that may have air defenses.
+
+ Returns:
+ The threat zones projected by the given locations. If the threat zone
+ belongs to the player, it is the zone that will be avoided by the enemy and
+ vice versa.
+ """
+ air_threats = []
+ air_defense_threats = []
+ radar_sam_threats = []
+ for barcap in barcap_locations:
+ point = ShapelyPoint(barcap.position.x, barcap.position.y)
+ cap_threat_range = cls.barcap_threat_range(doctrine, barcap)
+ air_threats.append(point.buffer(cap_threat_range.meters))
+
+ for tgo in air_defenses:
+ for group in tgo.groups:
+ threat_range = tgo.threat_range(group)
+ # Any system with a shorter range than this is not worth
+ # even avoiding.
+ if threat_range > nautical_miles(3):
+ point = ShapelyPoint(tgo.position.x, tgo.position.y)
+ threat_zone = point.buffer(threat_range.meters)
+ air_defense_threats.append(threat_zone)
+ radar_threat_range = tgo.threat_range(group, radar_only=True)
+ if radar_threat_range > nautical_miles(3):
+ point = ShapelyPoint(tgo.position.x, tgo.position.y)
+ threat_zone = point.buffer(threat_range.meters)
+ radar_sam_threats.append(threat_zone)
return cls(
airbases=unary_union(air_threats),
- air_defenses=unary_union(air_defenses),
+ air_defenses=unary_union(air_defense_threats),
radar_sam_threats=unary_union(radar_sam_threats),
)
diff --git a/game/transfers.py b/game/transfers.py
index fadbf3dc..ffb879e4 100644
--- a/game/transfers.py
+++ b/game/transfers.py
@@ -130,7 +130,10 @@ class TransferOrder:
def kill_unit(self, unit_type: GroundUnitType) -> None:
if unit_type not in self.units or not self.units[unit_type]:
raise KeyError(f"{self} has no {unit_type} remaining")
- self.units[unit_type] -= 1
+ if self.units[unit_type] == 1:
+ del self.units[unit_type]
+ else:
+ self.units[unit_type] -= 1
@property
def size(self) -> int:
@@ -163,21 +166,58 @@ class TransferOrder:
return self.transport.find_escape_route()
return None
- def proceed(self) -> None:
- if not self.destination.is_friendly(self.player):
- logging.info(f"Transfer destination {self.destination} was captured.")
- if self.position.is_friendly(self.player):
- self.disband_at(self.position)
- elif (escape_route := self.find_escape_route()) is not None:
- self.disband_at(escape_route)
- else:
- logging.info(
- f"No escape route available. Units were surrounded and destroyed "
- "during transfer."
- )
- self.kill_all()
- return
+ def disband(self) -> None:
+ """
+ Disbands the specific transfer at the current position if friendly, at a
+ possible escape route or kills all units if none is possible
+ """
+ if self.position.is_friendly(self.player):
+ self.disband_at(self.position)
+ elif (escape_route := self.find_escape_route()) is not None:
+ self.disband_at(escape_route)
+ else:
+ logging.info(
+ f"No escape route available. Units were surrounded and destroyed "
+ "during transfer."
+ )
+ self.kill_all()
+ def is_completable(self, network: TransitNetwork) -> bool:
+ """
+ Checks if the transfer can be completed with the current theater state / transit
+ network to ensure that there is possible route between the current position and
+ the planned destination. This also ensures that the points are friendly.
+ """
+ if self.transport is None:
+ # Check if unplanned transfers could be completed
+ if not self.position.is_friendly(self.player):
+ logging.info(
+ f"Current position ({self.position}) "
+ f"of the halting transfer was captured."
+ )
+ return False
+ if not network.has_path_between(self.position, self.destination):
+ logging.info(
+ f"Destination of transfer ({self.destination}) "
+ f"can not be reached anymore."
+ )
+ return False
+
+ if self.transport is not None and not self.next_stop.is_friendly(self.player):
+ # check if already proceeding transfers can reach the next stop
+ logging.info(
+ f"The next stop of the transfer ({self.next_stop}) "
+ f"was captured while transfer was on route."
+ )
+ return False
+
+ return True
+
+ def proceed(self) -> None:
+ """
+ Let the transfer proceed to the next stop and disbands it if the next stop
+ is the destination
+ """
if self.transport is None:
return
@@ -313,7 +353,9 @@ class AirliftPlanner:
capacity = flight_size * capacity_each
if capacity < self.transfer.size:
- transfer = self.game.transfers.split_transfer(self.transfer, capacity)
+ transfer = self.game.coalition_for(
+ self.for_player
+ ).transfers.split_transfer(self.transfer, capacity)
else:
transfer = self.transfer
@@ -335,7 +377,9 @@ class AirliftPlanner:
transfer.transport = transport
self.package.add_flight(flight)
- planner = FlightPlanBuilder(self.game, self.package, self.for_player)
+ planner = FlightPlanBuilder(
+ self.package, self.game.coalition_for(self.for_player), self.game.theater
+ )
planner.populate_flight_plan(flight)
self.game.aircraft_inventory.claim_for_flight(flight)
return flight_size
@@ -516,14 +560,14 @@ class TransportMap(Generic[TransportType]):
yield from destination_dict.values()
-class ConvoyMap(TransportMap):
+class ConvoyMap(TransportMap[Convoy]):
def create_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> Convoy:
return Convoy(origin, destination)
-class CargoShipMap(TransportMap):
+class CargoShipMap(TransportMap[CargoShip]):
def create_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> CargoShip:
@@ -531,8 +575,9 @@ class CargoShipMap(TransportMap):
class PendingTransfers:
- def __init__(self, game: Game) -> None:
+ def __init__(self, game: Game, player: bool) -> None:
self.game = game
+ self.player = player
self.convoys = ConvoyMap()
self.cargo_ships = CargoShipMap()
self.pending_transfers: List[TransferOrder] = []
@@ -589,8 +634,14 @@ class PendingTransfers:
self.pending_transfers.append(new_transfer)
return new_transfer
+ # Type checking ignored because singledispatchmethod doesn't work with required type
+ # definitions. The implementation methods are all typed, so should be fine.
@singledispatchmethod
- def cancel_transport(self, transport, transfer: TransferOrder) -> None:
+ def cancel_transport( # type: ignore
+ self,
+ transport,
+ transfer: TransferOrder,
+ ) -> None:
pass
@cancel_transport.register
@@ -600,7 +651,7 @@ class PendingTransfers:
flight = transport.flight
flight.package.remove_flight(flight)
if not flight.package.flights:
- self.game.ato_for(transport.player_owned).remove_package(flight.package)
+ self.game.ato_for(self.player).remove_package(flight.package)
self.game.aircraft_inventory.return_from_flight(flight)
flight.clear_roster()
@@ -623,6 +674,12 @@ class PendingTransfers:
transfer.origin.base.commission_units(transfer.units)
def perform_transfers(self) -> None:
+ """
+ Performs completable transfers from the list of pending transfers and adds
+ uncompleted transfers which are en route back to the list of pending transfers.
+ Disbands all convoys and cargo ships
+ """
+ self.disband_uncompletable_transfers()
incomplete = []
for transfer in self.pending_transfers:
transfer.proceed()
@@ -633,12 +690,33 @@ class PendingTransfers:
self.cargo_ships.disband_all()
def plan_transports(self) -> None:
+ """
+ Plan transports for all pending and completable transfers which don't have a
+ transport assigned already. This calculates the shortest path between current
+ position and destination on every execution to ensure the route is adopted to
+ recent changes in the theater state / transit network.
+ """
+ self.disband_uncompletable_transfers()
for transfer in self.pending_transfers:
if transfer.transport is None:
self.arrange_transport(transfer)
+ def disband_uncompletable_transfers(self) -> None:
+ """
+ Disbands all transfers from the list of pending_transfers which can not be
+ completed anymore because the theater state changed or the transit network does
+ not allow a route to the destination anymore
+ """
+ completable_transfers = []
+ for transfer in self.pending_transfers:
+ if not transfer.is_completable(self.network_for(transfer.position)):
+ transfer.disband()
+ else:
+ completable_transfers.append(transfer)
+ self.pending_transfers = completable_transfers
+
def order_airlift_assets(self) -> None:
- for control_point in self.game.theater.controlpoints:
+ for control_point in self.game.theater.control_points_for(self.player):
if self.game.air_wing_for(control_point.captured).can_auto_plan(
FlightType.TRANSPORT
):
@@ -673,8 +751,6 @@ class PendingTransfers:
# aesthetic.
gap += 1
- self.game.procurement_requests_for(player=control_point.captured).append(
- AircraftProcurementRequest(
- control_point, nautical_miles(200), FlightType.TRANSPORT, gap
- )
+ self.game.coalition_for(self.player).add_procurement_request(
+ AircraftProcurementRequest(control_point, FlightType.TRANSPORT, gap)
)
diff --git a/game/unitdelivery.py b/game/unitdelivery.py
index a6de6a71..cf1af512 100644
--- a/game/unitdelivery.py
+++ b/game/unitdelivery.py
@@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Optional, TYPE_CHECKING, Any
from game.theater import ControlPoint
+from .coalition import Coalition
from .dcs.groundunittype import GroundUnitType
from .dcs.unittype import UnitType
from .theater.transitnetwork import (
@@ -28,62 +29,64 @@ class PendingUnitDeliveries:
self.destination = destination
# Maps unit type to order quantity.
- self.units: dict[UnitType, int] = defaultdict(int)
+ self.units: dict[UnitType[Any], int] = defaultdict(int)
def __str__(self) -> str:
return f"Pending delivery to {self.destination}"
- def order(self, units: dict[UnitType, int]) -> None:
+ def order(self, units: dict[UnitType[Any], int]) -> None:
for k, v in units.items():
self.units[k] += v
- def sell(self, units: dict[UnitType, int]) -> None:
+ def sell(self, units: dict[UnitType[Any], int]) -> None:
for k, v in units.items():
- self.units[k] -= v
+ if self.units[k] > v:
+ self.units[k] -= v
+ else:
+ del self.units[k]
- def refund_all(self, game: Game) -> None:
- self.refund(game, self.units)
+ def refund_all(self, coalition: Coalition) -> None:
+ self.refund(coalition, self.units)
self.units = defaultdict(int)
- def refund_ground_units(self, game: Game) -> None:
+ def refund_ground_units(self, coalition: Coalition) -> None:
ground_units: dict[UnitType[Any], int] = {
u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType)
}
- self.refund(game, ground_units)
+ self.refund(coalition, ground_units)
for gu in ground_units.keys():
del self.units[gu]
- def refund(self, game: Game, units: dict[UnitType, int]) -> None:
+ def refund(self, coalition: Coalition, units: dict[UnitType[Any], int]) -> None:
for unit_type, count in units.items():
logging.info(f"Refunding {count} {unit_type} at {self.destination.name}")
- game.adjust_budget(
- unit_type.price * count, player=self.destination.captured
- )
+ coalition.adjust_budget(unit_type.price * count)
- def pending_orders(self, unit_type: UnitType) -> int:
+ def pending_orders(self, unit_type: UnitType[Any]) -> int:
pending_units = self.units.get(unit_type)
if pending_units is None:
pending_units = 0
return pending_units
- def available_next_turn(self, unit_type: UnitType) -> int:
+ def available_next_turn(self, unit_type: UnitType[Any]) -> int:
current_units = self.destination.base.total_units_of_type(unit_type)
return self.pending_orders(unit_type) + current_units
def process(self, game: Game) -> None:
+ coalition = game.coalition_for(self.destination.captured)
ground_unit_source = self.find_ground_unit_source(game)
if ground_unit_source is None:
game.message(
f"{self.destination.name} lost its source for ground unit "
"reinforcements. Refunding purchase price."
)
- self.refund_ground_units(game)
+ self.refund_ground_units(coalition)
- bought_units: dict[UnitType, int] = {}
+ bought_units: dict[UnitType[Any], int] = {}
units_needing_transfer: dict[GroundUnitType, int] = {}
- sold_units: dict[UnitType, int] = {}
+ sold_units: dict[UnitType[Any], int] = {}
for unit_type, count in self.units.items():
- coalition = "Ally" if self.destination.captured else "Enemy"
+ allegiance = "Ally" if self.destination.captured else "Enemy"
d: dict[Any, int]
if (
isinstance(unit_type, GroundUnitType)
@@ -98,11 +101,11 @@ class PendingUnitDeliveries:
if count >= 0:
d[unit_type] = count
game.message(
- f"{coalition} reinforcements: {unit_type} x {count} at {source}"
+ f"{allegiance} reinforcements: {unit_type} x {count} at {source}"
)
else:
sold_units[unit_type] = -count
- game.message(f"{coalition} sold: {unit_type} x {-count} at {source}")
+ game.message(f"{allegiance} sold: {unit_type} x {-count} at {source}")
self.units = defaultdict(int)
self.destination.base.commission_units(bought_units)
@@ -111,16 +114,19 @@ class PendingUnitDeliveries:
if units_needing_transfer:
if ground_unit_source is None:
raise RuntimeError(
- f"ground unit source could not be found for {self.destination} but still tried to "
- f"transfer units to there"
+ f"Ground unit source could not be found for {self.destination} but "
+ "still tried to transfer units to there"
)
ground_unit_source.base.commission_units(units_needing_transfer)
- self.create_transfer(game, ground_unit_source, units_needing_transfer)
+ self.create_transfer(coalition, ground_unit_source, units_needing_transfer)
def create_transfer(
- self, game: Game, source: ControlPoint, units: dict[GroundUnitType, int]
+ self,
+ coalition: Coalition,
+ source: ControlPoint,
+ units: dict[GroundUnitType, int],
) -> None:
- game.transfers.new_transfer(TransferOrder(source, self.destination, units))
+ coalition.transfers.new_transfer(TransferOrder(source, self.destination, units))
def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]:
# This is running *after* the turn counter has been incremented, so this is the
diff --git a/game/unitmap.py b/game/unitmap.py
index 98793991..a1d5c110 100644
--- a/game/unitmap.py
+++ b/game/unitmap.py
@@ -2,10 +2,10 @@
import itertools
import math
from dataclasses import dataclass
-from typing import Dict, Optional
+from typing import Dict, Optional, Any, Union, TypeVar, Generic
-from dcs.unit import Unit
-from dcs.unitgroup import FlyingGroup, Group, VehicleGroup
+from dcs.unit import Vehicle, Ship
+from dcs.unitgroup import FlyingGroup, VehicleGroup, StaticGroup, ShipGroup, MovingGroup
from game.dcs.groundunittype import GroundUnitType
from game.squadrons import Pilot
@@ -27,11 +27,14 @@ class FrontLineUnit:
origin: ControlPoint
+UnitT = TypeVar("UnitT", Ship, Vehicle)
+
+
@dataclass(frozen=True)
-class GroundObjectUnit:
- ground_object: TheaterGroundObject
- group: Group
- unit: Unit
+class GroundObjectUnit(Generic[UnitT]):
+ ground_object: TheaterGroundObject[Any]
+ group: MovingGroup[UnitT]
+ unit: UnitT
@dataclass(frozen=True)
@@ -56,13 +59,13 @@ class UnitMap:
self.aircraft: Dict[str, FlyingUnit] = {}
self.airfields: Dict[str, Airfield] = {}
self.front_line_units: Dict[str, FrontLineUnit] = {}
- self.ground_object_units: Dict[str, GroundObjectUnit] = {}
+ self.ground_object_units: Dict[str, GroundObjectUnit[Any]] = {}
self.buildings: Dict[str, Building] = {}
self.convoys: Dict[str, ConvoyUnit] = {}
self.cargo_ships: Dict[str, CargoShip] = {}
self.airlifts: Dict[str, AirliftUnits] = {}
- def add_aircraft(self, group: FlyingGroup, flight: Flight) -> None:
+ def add_aircraft(self, group: FlyingGroup[Any], flight: Flight) -> None:
for pilot, unit in zip(flight.roster.pilots, group.units):
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
@@ -85,7 +88,7 @@ class UnitMap:
return self.airfields.get(name, None)
def add_front_line_units(
- self, group: Group, origin: ControlPoint, unit_type: GroundUnitType
+ self, group: VehicleGroup, origin: ControlPoint, unit_type: GroundUnitType
) -> None:
for unit in group.units:
# The actual name is a String (the pydcs translatable string), which
@@ -100,9 +103,9 @@ class UnitMap:
def add_ground_object_units(
self,
- ground_object: TheaterGroundObject,
- persistence_group: Group,
- miz_group: Group,
+ ground_object: TheaterGroundObject[Any],
+ persistence_group: Union[ShipGroup, VehicleGroup],
+ miz_group: Union[ShipGroup, VehicleGroup],
) -> None:
"""Adds a group associated with a TGO to the unit map.
@@ -131,10 +134,10 @@ class UnitMap:
ground_object, persistence_group, persistent_unit
)
- def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit]:
+ def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit[Any]]:
return self.ground_object_units.get(name, None)
- def add_convoy_units(self, group: Group, convoy: Convoy) -> None:
+ def add_convoy_units(self, group: VehicleGroup, convoy: Convoy) -> None:
for unit, unit_type in zip(group.units, convoy.iter_units()):
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
@@ -146,7 +149,7 @@ class UnitMap:
def convoy_unit(self, name: str) -> Optional[ConvoyUnit]:
return self.convoys.get(name, None)
- def add_cargo_ship(self, group: Group, ship: CargoShip) -> None:
+ def add_cargo_ship(self, group: ShipGroup, ship: CargoShip) -> None:
if len(group.units) > 1:
# Cargo ship "groups" are single units. Killing the one ship kills the whole
# transfer. If we ever want to add escorts or create multiple cargo ships in
@@ -163,7 +166,9 @@ class UnitMap:
def cargo_ship(self, name: str) -> Optional[CargoShip]:
return self.cargo_ships.get(name, None)
- def add_airlift_units(self, group: FlyingGroup, transfer: TransferOrder) -> None:
+ def add_airlift_units(
+ self, group: FlyingGroup[Any], transfer: TransferOrder
+ ) -> None:
capacity_each = math.ceil(transfer.size / len(group.units))
for idx, transport in enumerate(group.units):
# Slice the units in groups based on the capacity of each unit. Cargo is
@@ -186,7 +191,9 @@ class UnitMap:
def airlift_unit(self, name: str) -> Optional[AirliftUnits]:
return self.airlifts.get(name, None)
- def add_building(self, ground_object: BuildingGroundObject, group: Group) -> None:
+ def add_building(
+ self, ground_object: BuildingGroundObject, group: StaticGroup
+ ) -> None:
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
# The name of the initiator in the DCS dead event will have " object"
diff --git a/game/utils.py b/game/utils.py
index 0bd1f79c..119a741a 100644
--- a/game/utils.py
+++ b/game/utils.py
@@ -2,8 +2,10 @@ from __future__ import annotations
import itertools
import math
+import random
+from collections import Iterable
from dataclasses import dataclass
-from typing import Union
+from typing import Union, Any, TypeVar
METERS_TO_FEET = 3.28084
FEET_TO_METERS = 1 / METERS_TO_FEET
@@ -15,19 +17,8 @@ KPH_TO_KNOTS = 1 / KNOTS_TO_KPH
MS_TO_KPH = 3.6
KPH_TO_MS = 1 / MS_TO_KPH
-
-def heading_sum(h, a) -> int:
- h += a
- if h > 360:
- return h - 360
- elif h < 0:
- return 360 + h
- else:
- return h
-
-
-def opposite_heading(h):
- return heading_sum(h, 180)
+INHG_TO_HPA = 33.86389
+INHG_TO_MMHG = 25.400002776728
@dataclass(frozen=True, order=True)
@@ -185,7 +176,85 @@ def mach(value: float, altitude: Distance) -> Speed:
SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5)
-def pairwise(iterable):
+@dataclass(frozen=True, order=True)
+class Heading:
+ heading_in_degrees: int
+
+ @property
+ def degrees(self) -> int:
+ return Heading.reduce_angle(self.heading_in_degrees)
+
+ @property
+ def radians(self) -> float:
+ return math.radians(Heading.reduce_angle(self.heading_in_degrees))
+
+ @property
+ def opposite(self) -> Heading:
+ return self + Heading.from_degrees(180)
+
+ @property
+ def right(self) -> Heading:
+ return self + Heading.from_degrees(90)
+
+ @property
+ def left(self) -> Heading:
+ return self - Heading.from_degrees(90)
+
+ def angle_between(self, other: Heading) -> Heading:
+ angle_between = abs(self.degrees - other.degrees)
+ if angle_between > 180:
+ angle_between = 360 - angle_between
+ return Heading.from_degrees(angle_between)
+
+ @staticmethod
+ def reduce_angle(angle: int) -> int:
+ return angle % 360
+
+ @classmethod
+ def from_degrees(cls, angle: Union[int, float]) -> Heading:
+ return cls(Heading.reduce_angle(round(angle)))
+
+ @classmethod
+ def from_radians(cls, angle: Union[int, float]) -> Heading:
+ deg = round(math.degrees(angle))
+ return cls(Heading.reduce_angle(deg))
+
+ @classmethod
+ def random(cls, min_angle: int = 0, max_angle: int = 0) -> Heading:
+ return Heading.from_degrees(random.randint(min_angle, max_angle))
+
+ def __add__(self, other: Heading) -> Heading:
+ return Heading.from_degrees(self.degrees + other.degrees)
+
+ def __sub__(self, other: Heading) -> Heading:
+ return Heading.from_degrees(self.degrees - other.degrees)
+
+
+@dataclass(frozen=True, order=True)
+class Pressure:
+ pressure_in_inches_hg: float
+
+ @property
+ def inches_hg(self) -> float:
+ return self.pressure_in_inches_hg
+
+ @property
+ def mm_hg(self) -> float:
+ return self.pressure_in_inches_hg * INHG_TO_MMHG
+
+ @property
+ def hecto_pascals(self) -> float:
+ return self.pressure_in_inches_hg * INHG_TO_HPA
+
+
+def inches_hg(value: float) -> Pressure:
+ return Pressure(value)
+
+
+PairwiseT = TypeVar("PairwiseT")
+
+
+def pairwise(iterable: Iterable[PairwiseT]) -> Iterable[tuple[PairwiseT, PairwiseT]]:
"""
itertools recipe
s -> (s0,s1), (s1,s2), (s2, s3), ...
@@ -193,3 +262,15 @@ def pairwise(iterable):
a, b = itertools.tee(iterable)
next(b, None)
return zip(a, b)
+
+
+def interpolate(value1: float, value2: float, factor: float, clamp: bool) -> float:
+ """Inerpolate between two values, factor 0-1"""
+ interpolated = value1 + (value2 - value1) * factor
+
+ if clamp:
+ bigger_value = max(value1, value2)
+ smaller_value = min(value1, value2)
+ return min(bigger_value, max(smaller_value, interpolated))
+ else:
+ return interpolated
diff --git a/game/version.py b/game/version.py
index bcb8b8cf..87d8a841 100644
--- a/game/version.py
+++ b/game/version.py
@@ -1,8 +1,15 @@
from pathlib import Path
+MAJOR_VERSION = 5
+MINOR_VERSION = 0
+MICRO_VERSION = 0
+
+
def _build_version_string() -> str:
- components = ["5.0.0"]
+ components = [
+ ".".join(str(v) for v in (MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION))
+ ]
build_number_path = Path("resources/buildnumber")
if build_number_path.exists():
with build_number_path.open("r") as build_number_file:
@@ -96,4 +103,11 @@ VERSION = _build_version_string()
#: mission using map buildings as strike targets must check and potentially recreate
#: all those objectives. This definitely affects all Syria campaigns, other maps are
#: not yet verified.
-CAMPAIGN_FORMAT_VERSION = (7, 0)
+#:
+#: Version 7.1
+#: * Support for Mariana Islands terrain
+#:
+#: Version 8.0
+#: * DCS 2.7.4.9632 changed scenery target IDs. Any mission using map buildings as
+#: strike targets must check and potentially recreate all those objectives.
+CAMPAIGN_FORMAT_VERSION = (8, 0)
diff --git a/game/weather.py b/game/weather.py
index fc077634..fb0ea68c 100644
--- a/game/weather.py
+++ b/game/weather.py
@@ -5,16 +5,20 @@ import logging
import random
from dataclasses import dataclass, field
from enum import Enum
-from typing import Optional, TYPE_CHECKING
+from typing import Optional, TYPE_CHECKING, Any
from dcs.cloud_presets import Clouds as PydcsClouds
from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind
+from game.savecompat import has_save_compat_for
from game.settings import Settings
-from game.utils import Distance, meters
+from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg
+
+from game.theater.seasonalconditions import determine_season
if TYPE_CHECKING:
from game.theater import ConflictTheater
+ from game.theater.seasonalconditions import SeasonalConditions
class TimeOfDay(Enum):
@@ -24,6 +28,22 @@ class TimeOfDay(Enum):
Night = "night"
+@dataclass(frozen=True)
+class AtmosphericConditions:
+ #: Pressure at sea level.
+ qnh: Pressure
+
+ #: Temperature at sea level in Celcius.
+ temperature_celsius: float
+
+ @has_save_compat_for(5)
+ def __setstate__(self, state: dict[str, Any]) -> None:
+ if "qnh" not in state:
+ state["qnh"] = inches_hg(state["qnh_inches_mercury"])
+ del state["qnh_inches_mercury"]
+ self.__dict__.update(state)
+
+
@dataclass(frozen=True)
class WindConditions:
at_0m: Wind
@@ -63,11 +83,66 @@ class Fog:
class Weather:
- def __init__(self) -> None:
+ def __init__(
+ self,
+ seasonal_conditions: SeasonalConditions,
+ day: datetime.date,
+ time_of_day: TimeOfDay,
+ ) -> None:
+ # Future improvement: Use theater, day and time of day
+ # to get a more realistic conditions
+ self.atmospheric = self.generate_atmospheric(
+ seasonal_conditions, day, time_of_day
+ )
self.clouds = self.generate_clouds()
self.fog = self.generate_fog()
self.wind = self.generate_wind()
+ def generate_atmospheric(
+ self,
+ seasonal_conditions: SeasonalConditions,
+ day: datetime.date,
+ time_of_day: TimeOfDay,
+ ) -> AtmosphericConditions:
+ pressure = self.interpolate_summer_winter(
+ seasonal_conditions.summer_avg_pressure,
+ seasonal_conditions.winter_avg_pressure,
+ day,
+ )
+ temperature = self.interpolate_summer_winter(
+ seasonal_conditions.summer_avg_temperature,
+ seasonal_conditions.winter_avg_temperature,
+ day,
+ )
+
+ if time_of_day == TimeOfDay.Day:
+ temperature += seasonal_conditions.temperature_day_night_difference / 2
+ if time_of_day == TimeOfDay.Night:
+ temperature -= seasonal_conditions.temperature_day_night_difference / 2
+ pressure += self.pressure_adjustment
+ temperature += self.temperature_adjustment
+ logging.debug(
+ "Weather: Before random: temp {} press {}".format(temperature, pressure)
+ )
+ conditions = AtmosphericConditions(
+ qnh=self.random_pressure(pressure),
+ temperature_celsius=self.random_temperature(temperature),
+ )
+ logging.debug(
+ "Weather: After random: temp {} press {}".format(
+ conditions.temperature_celsius, conditions.qnh.pressure_in_inches_hg
+ )
+ )
+ return conditions
+
+ @property
+ def pressure_adjustment(self) -> float:
+ raise NotImplementedError
+
+ @property
+ def temperature_adjustment(self) -> float:
+ raise NotImplementedError
+
def generate_clouds(self) -> Optional[Clouds]:
raise NotImplementedError
@@ -83,8 +158,8 @@ class Weather:
raise NotImplementedError
@staticmethod
- def random_wind(minimum: int, maximum) -> WindConditions:
- wind_direction = random.randint(0, 360)
+ def random_wind(minimum: int, maximum: int) -> WindConditions:
+ wind_direction = Heading.random()
at_0m_factor = 1
at_2000m_factor = 2
at_8000m_factor = 3
@@ -92,9 +167,9 @@ class Weather:
return WindConditions(
# Always some wind to make the smoke move a bit.
- at_0m=Wind(wind_direction, max(1, base_wind * at_0m_factor)),
- at_2000m=Wind(wind_direction, base_wind * at_2000m_factor),
- at_8000m=Wind(wind_direction, base_wind * at_8000m_factor),
+ at_0m=Wind(wind_direction.degrees, max(1, base_wind * at_0m_factor)),
+ at_2000m=Wind(wind_direction.degrees, base_wind * at_2000m_factor),
+ at_8000m=Wind(wind_direction.degrees, base_wind * at_8000m_factor),
)
@staticmethod
@@ -105,8 +180,47 @@ class Weather:
def random_cloud_thickness() -> int:
return random.randint(100, 400)
+ @staticmethod
+ def random_pressure(average_pressure: float) -> Pressure:
+ # "Safe" constants based roughly on ME and viper altimeter.
+ # Units are inches of mercury.
+ SAFE_MIN = 28.4
+ SAFE_MAX = 30.9
+ # Use normalvariate to get normal distribution, more realistic than uniform
+ pressure = random.normalvariate(average_pressure, 0.1)
+ return inches_hg(max(SAFE_MIN, min(SAFE_MAX, pressure)))
+
+ @staticmethod
+ def random_temperature(average_temperature: float) -> float:
+ # "Safe" constants based roughly on ME.
+ # Temperatures are in Celcius.
+ SAFE_MIN = -12
+ SAFE_MAX = 49
+ # Use normalvariate to get normal distribution, more realistic than uniform
+ temperature = random.normalvariate(average_temperature, 2)
+ temperature = round(temperature)
+ return max(SAFE_MIN, min(SAFE_MAX, temperature))
+
+ @staticmethod
+ def interpolate_summer_winter(
+ summer_value: float, winter_value: float, day: datetime.date
+ ) -> float:
+ day_of_year = day.timetuple().tm_yday
+ day_of_year_peak_summer = 183
+ distance_from_peak_summer = abs(-day_of_year_peak_summer + day_of_year)
+ winter_factor = distance_from_peak_summer / day_of_year_peak_summer
+ return interpolate(summer_value, winter_value, winter_factor, clamp=True)
+
class ClearSkies(Weather):
+ @property
+ def pressure_adjustment(self) -> float:
+ return 0.22
+
+ @property
+ def temperature_adjustment(self) -> float:
+ return 3.0
+
def generate_clouds(self) -> Optional[Clouds]:
return None
@@ -118,6 +232,14 @@ class ClearSkies(Weather):
class Cloudy(Weather):
+ @property
+ def pressure_adjustment(self) -> float:
+ return 0.0
+
+ @property
+ def temperature_adjustment(self) -> float:
+ return 0.0
+
def generate_clouds(self) -> Optional[Clouds]:
return Clouds.random_preset(rain=False)
@@ -130,6 +252,14 @@ class Cloudy(Weather):
class Raining(Weather):
+ @property
+ def pressure_adjustment(self) -> float:
+ return -0.22
+
+ @property
+ def temperature_adjustment(self) -> float:
+ return -3.0
+
def generate_clouds(self) -> Optional[Clouds]:
return Clouds.random_preset(rain=True)
@@ -142,6 +272,14 @@ class Raining(Weather):
class Thunderstorm(Weather):
+ @property
+ def pressure_adjustment(self) -> float:
+ return 0.1
+
+ @property
+ def temperature_adjustment(self) -> float:
+ return -3.0
+
def generate_clouds(self) -> Optional[Clouds]:
return Clouds(
base=self.random_cloud_base(),
@@ -168,12 +306,13 @@ class Conditions:
time_of_day: TimeOfDay,
settings: Settings,
) -> Conditions:
+ _start_time = cls.generate_start_time(
+ theater, day, time_of_day, settings.night_disabled
+ )
return cls(
time_of_day=time_of_day,
- start_time=cls.generate_start_time(
- theater, day, time_of_day, settings.night_disabled
- ),
- weather=cls.generate_weather(),
+ start_time=_start_time,
+ weather=cls.generate_weather(theater.seasonal_conditions, day, time_of_day),
)
@classmethod
@@ -199,14 +338,24 @@ class Conditions:
return datetime.datetime.combine(day, time)
@classmethod
- def generate_weather(cls) -> Weather:
+ def generate_weather(
+ cls,
+ seasonal_conditions: SeasonalConditions,
+ day: datetime.date,
+ time_of_day: TimeOfDay,
+ ) -> Weather:
+ season = determine_season(day)
+ logging.debug("Weather: Season {}".format(season))
+ weather_chances = seasonal_conditions.weather_type_chances[season]
chances = {
- Thunderstorm: 1,
- Raining: 20,
- Cloudy: 60,
- ClearSkies: 20,
+ Thunderstorm: weather_chances.thunderstorm,
+ Raining: weather_chances.raining,
+ Cloudy: weather_chances.cloudy,
+ ClearSkies: weather_chances.clear_skies,
}
+ logging.debug("Weather: Chances {}".format(weather_chances))
weather_type = random.choices(
list(chances.keys()), weights=list(chances.values())
)[0]
- return weather_type()
+ logging.debug("Weather: Type {}".format(weather_type))
+ return weather_type(seasonal_conditions, day, time_of_day)
diff --git a/gen/aircraft.py b/gen/aircraft.py
index 93befe97..2a64dbd2 100644
--- a/gen/aircraft.py
+++ b/gen/aircraft.py
@@ -1,11 +1,12 @@
from __future__ import annotations
+import itertools
import logging
import random
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from datetime import timedelta
from functools import cached_property
-from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union, Iterable
+from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union, Iterable, Any
from dcs import helicopters
from dcs.action import AITaskPush, ActivateGroup
@@ -22,7 +23,6 @@ from dcs.planes import (
C_101EB,
F_14B,
JF_17,
- PlaneType,
Su_33,
Tu_22M3,
)
@@ -65,7 +65,7 @@ from dcs.unitgroup import FlyingGroup, ShipGroup, StaticGroup
from dcs.unittype import FlyingType
from game import db
-from game.data.weapons import Pylon
+from game.data.weapons import Pylon, WeaponType as WeaponTypeEnum
from game.dcs.aircrafttype import AircraftType
from game.factions.faction import Faction
from game.settings import Settings
@@ -81,7 +81,7 @@ from game.theater.missiontarget import MissionTarget
from game.theater.theatergroundobject import TheaterGroundObject
from game.transfers import MultiGroupTransport
from game.unitmap import UnitMap
-from game.utils import Distance, meters, nautical_miles
+from game.utils import Distance, meters, nautical_miles, pairwise
from gen.ato import AirTaskingOrder, Package
from gen.callsigns import create_group_callsign_from_unit
from gen.flights.flight import (
@@ -90,10 +90,11 @@ from gen.flights.flight import (
FlightWaypoint,
FlightWaypointType,
)
+from gen.lasercoderegistry import LaserCodeRegistry
from gen.radios import RadioFrequency, RadioRegistry
from gen.runways import RunwayData
from gen.tacan import TacanBand, TacanRegistry
-from .airsupportgen import AirSupport, AwacsInfo, TankerInfo
+from .airsupport import AirSupport, AwacsInfo, TankerInfo
from .callsigns import callsign_for_support_unit
from .flights.flightplan import (
AwacsFlightPlan,
@@ -138,6 +139,8 @@ class FlightData:
flight_type: FlightType
+ aircraft_type: AircraftType
+
#: All units in the flight.
units: List[FlyingUnit]
@@ -165,49 +168,24 @@ class FlightData:
#: Radio frequency for intra-flight communications.
intra_flight_channel: RadioFrequency
- #: Map of radio frequencies to their assigned radio and channel, if any.
- frequency_to_channel_map: Dict[RadioFrequency, ChannelAssignment]
-
#: Bingo fuel value in lbs.
bingo_fuel: Optional[int]
joker_fuel: Optional[int]
- def __init__(
- self,
- package: Package,
- aircraft_type: AircraftType,
- flight_type: FlightType,
- units: List[FlyingUnit],
- size: int,
- friendly: bool,
- departure_delay: timedelta,
- departure: RunwayData,
- arrival: RunwayData,
- divert: Optional[RunwayData],
- waypoints: List[FlightWaypoint],
- intra_flight_channel: RadioFrequency,
- bingo_fuel: Optional[int],
- joker_fuel: Optional[int],
- custom_name: Optional[str],
- ) -> None:
- self.package = package
- self.aircraft_type = aircraft_type
- self.flight_type = flight_type
- self.units = units
- self.size = size
- self.friendly = friendly
- self.departure_delay = departure_delay
- self.departure = departure
- self.arrival = arrival
- self.divert = divert
- self.waypoints = waypoints
- self.intra_flight_channel = intra_flight_channel
- self.frequency_to_channel_map = {}
- self.bingo_fuel = bingo_fuel
- self.joker_fuel = joker_fuel
+ laser_codes: list[Optional[int]]
+
+ custom_name: Optional[str]
+
+ callsign: str = field(init=False)
+
+ #: Map of radio frequencies to their assigned radio and channel, if any.
+ frequency_to_channel_map: Dict[RadioFrequency, ChannelAssignment] = field(
+ init=False, default_factory=dict
+ )
+
+ def __post_init__(self) -> None:
self.callsign = create_group_callsign_from_unit(self.units[0])
- self.custom_name = custom_name
@property
def client_units(self) -> List[FlyingUnit]:
@@ -247,6 +225,7 @@ class AircraftConflictGenerator:
game: Game,
radio_registry: RadioRegistry,
tacan_registry: TacanRegistry,
+ laser_code_registry: LaserCodeRegistry,
unit_map: UnitMap,
air_support: AirSupport,
) -> None:
@@ -255,6 +234,7 @@ class AircraftConflictGenerator:
self.settings = settings
self.radio_registry = radio_registry
self.tacan_registy = tacan_registry
+ self.laser_code_registry = laser_code_registry
self.unit_map = unit_map
self.flights: List[FlightData] = []
self.air_support = air_support
@@ -262,8 +242,8 @@ class AircraftConflictGenerator:
@cached_property
def use_client(self) -> bool:
"""True if Client should be used instead of Player."""
- blue_clients = self.client_slots_in_ato(self.game.blue_ato)
- red_clients = self.client_slots_in_ato(self.game.red_ato)
+ blue_clients = self.client_slots_in_ato(self.game.blue.ato)
+ red_clients = self.client_slots_in_ato(self.game.red.ato)
return blue_clients + red_clients > 1
@staticmethod
@@ -321,7 +301,7 @@ class AircraftConflictGenerator:
@staticmethod
def livery_from_db(flight: Flight) -> Optional[str]:
- return db.PLANE_LIVERY_OVERRIDES.get(flight.unit_type)
+ return db.PLANE_LIVERY_OVERRIDES.get(flight.unit_type.dcs_unit_type)
def livery_from_faction(self, flight: Flight) -> Optional[str]:
faction = self.game.faction_for(player=flight.departure.captured)
@@ -342,7 +322,7 @@ class AircraftConflictGenerator:
return livery
return None
- def _setup_livery(self, flight: Flight, group: FlyingGroup) -> None:
+ def _setup_livery(self, flight: Flight, group: FlyingGroup[Any]) -> None:
livery = self.livery_for(flight)
if livery is None:
return
@@ -351,7 +331,7 @@ class AircraftConflictGenerator:
def _setup_group(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -361,6 +341,7 @@ class AircraftConflictGenerator:
self._setup_payload(flight, group)
self._setup_livery(flight, group)
+ laser_codes = []
for unit, pilot in zip(group.units, flight.roster.pilots):
player = pilot is not None and pilot.player
self.set_skill(unit, pilot, blue=flight.departure.captured)
@@ -368,6 +349,11 @@ class AircraftConflictGenerator:
if player and group.late_activation:
group.late_activation = False
+ code: Optional[int] = None
+ if flight.loadout.has_weapon_of_type(WeaponTypeEnum.TGP) and player:
+ code = self.laser_code_registry.get_next_laser_code()
+ laser_codes.append(code)
+
# Set up F-14 Client to have pre-stored alignment
if unit_type is F_14B:
unit.set_property(F_14B.Properties.INSAlignmentStored.id, True)
@@ -383,7 +369,18 @@ class AircraftConflictGenerator:
channel = self.radio_registry.alloc_uhf()
else:
channel = flight.unit_type.alloc_flight_radio(self.radio_registry)
- group.set_frequency(channel.mhz)
+
+ try:
+ group.set_frequency(channel.mhz)
+ except TypeError:
+ # TODO: Remote try/except when pydcs bug is fixed.
+ # https://github.com/pydcs/dcs/issues/175
+ # pydcs now emits an error when attempting to set a preset channel for an
+ # aircraft that doesn't support them. We're not choosing to set a preset
+ # here, we're just trying to set the AI's frequency. pydcs automatically
+ # tries to set channel 1 when it does that and doesn't suppress this new
+ # error.
+ pass
divert = None
if flight.divert is not None:
@@ -412,6 +409,7 @@ class AircraftConflictGenerator:
bingo_fuel=flight.flight_plan.bingo_fuel,
joker_fuel=flight.flight_plan.joker_fuel,
custom_name=flight.custom_name,
+ laser_codes=laser_codes,
)
)
@@ -458,8 +456,8 @@ class AircraftConflictGenerator:
unit_type: Type[FlyingType],
count: int,
start_type: str,
- airport: Optional[Airport] = None,
- ) -> FlyingGroup:
+ airport: Airport,
+ ) -> FlyingGroup[Any]:
assert count > 0
logging.info("airgen: {} for {} at {}".format(unit_type, side.id, airport))
@@ -476,7 +474,7 @@ class AircraftConflictGenerator:
def _generate_inflight(
self, name: str, side: Country, flight: Flight, origin: ControlPoint
- ) -> FlyingGroup:
+ ) -> FlyingGroup[Any]:
assert flight.count > 0
at = origin.position
@@ -521,7 +519,7 @@ class AircraftConflictGenerator:
count: int,
start_type: str,
at: Union[ShipGroup, StaticGroup],
- ) -> FlyingGroup:
+ ) -> FlyingGroup[Any]:
assert count > 0
logging.info("airgen: {} for {} at unit {}".format(unit_type, side.id, at))
@@ -536,34 +534,18 @@ class AircraftConflictGenerator:
)
def _add_radio_waypoint(
- self, group: FlyingGroup, position, altitude: Distance, airspeed: int = 600
+ self,
+ group: FlyingGroup[Any],
+ position: Point,
+ altitude: Distance,
+ airspeed: int = 600,
) -> MovingPoint:
point = group.add_waypoint(position, altitude.meters, airspeed)
point.alt_type = "RADIO"
return point
- def _rtb_for(
- self,
- group: FlyingGroup,
- cp: ControlPoint,
- at: Optional[db.StartingPosition] = None,
- ):
- if at is None:
- at = cp.at
- position = at if isinstance(at, Point) else at.position
-
- last_waypoint = group.points[-1]
- if last_waypoint is not None:
- heading = position.heading_between_point(last_waypoint.position)
- tod_location = position.point_from_heading(heading, RTB_DISTANCE)
- self._add_radio_waypoint(group, tod_location, last_waypoint.alt)
-
- destination_waypoint = self._add_radio_waypoint(group, position, RTB_ALTITUDE)
- if isinstance(at, Airport):
- group.land_at(at)
- return destination_waypoint
-
- def _at_position(self, at) -> Point:
+ @staticmethod
+ def _at_position(at: Union[Point, ShipGroup, Type[Airport]]) -> Point:
if isinstance(at, Point):
return at
elif isinstance(at, ShipGroup):
@@ -573,7 +555,7 @@ class AircraftConflictGenerator:
else:
assert False
- def _setup_payload(self, flight: Flight, group: FlyingGroup) -> None:
+ def _setup_payload(self, flight: Flight, group: FlyingGroup[Any]) -> None:
for p in group.units:
p.pylons.clear()
@@ -593,7 +575,10 @@ class AircraftConflictGenerator:
parking_slot.unit_id = None
def generate_flights(
- self, country, ato: AirTaskingOrder, dynamic_runways: Dict[str, RunwayData]
+ self,
+ country: Country,
+ ato: AirTaskingOrder,
+ dynamic_runways: Dict[str, RunwayData],
) -> None:
for package in ato.packages:
@@ -614,12 +599,11 @@ class AircraftConflictGenerator:
if not isinstance(control_point, Airfield):
continue
+ faction = self.game.coalition_for(control_point.captured).faction
if control_point.captured:
country = player_country
- faction = self.game.player_faction
else:
country = enemy_country
- faction = self.game.enemy_faction
for aircraft, available in inventory.all_aircraft:
try:
@@ -672,7 +656,7 @@ class AircraftConflictGenerator:
self.unit_map.add_aircraft(group, flight)
def set_activation_time(
- self, flight: Flight, group: FlyingGroup, delay: timedelta
+ self, flight: Flight, group: FlyingGroup[Any], delay: timedelta
) -> None:
# Note: Late activation causes the waypoint TOTs to look *weird* in the
# mission editor. Waypoint times will be relative to the group
@@ -691,7 +675,7 @@ class AircraftConflictGenerator:
self.m.triggerrules.triggers.append(activation_trigger)
def set_startup_time(
- self, flight: Flight, group: FlyingGroup, delay: timedelta
+ self, flight: Flight, group: FlyingGroup[Any], delay: timedelta
) -> None:
# Uncontrolled causes the AI unit to spawn, but not begin startup.
group.uncontrolled = True
@@ -712,14 +696,12 @@ class AircraftConflictGenerator:
if flight.from_cp.cptype != ControlPointType.AIRBASE:
return
- if flight.from_cp.captured:
- coalition = self.game.get_player_coalition_id()
- else:
- coalition = self.game.get_enemy_coalition_id()
-
+ coalition = self.game.coalition_for(flight.departure.captured).coalition_id
trigger.add_condition(CoalitionHasAirdrome(coalition, flight.from_cp.id))
- def generate_planned_flight(self, cp, country, flight: Flight):
+ def generate_planned_flight(
+ self, cp: ControlPoint, country: Country, flight: Flight
+ ) -> FlyingGroup[Any]:
name = namegen.next_aircraft_name(country, cp.id, flight)
try:
if flight.start_type == "In Flight":
@@ -728,13 +710,19 @@ class AircraftConflictGenerator:
)
elif isinstance(cp, NavalControlPoint):
group_name = cp.get_carrier_group_name()
+ carrier_group = self.m.find_group(group_name)
+ if not isinstance(carrier_group, ShipGroup):
+ raise RuntimeError(
+ f"Carrier group {carrier_group} is a "
+ "{carrier_group.__class__.__name__}, expected a ShipGroup"
+ )
group = self._generate_at_group(
name=name,
side=country,
unit_type=flight.unit_type.dcs_unit_type,
count=flight.count,
start_type=flight.start_type,
- at=self.m.find_group(group_name),
+ at=carrier_group,
)
else:
@@ -796,7 +784,7 @@ class AircraftConflictGenerator:
@staticmethod
def set_reduced_fuel(
- flight: Flight, group: FlyingGroup, unit_type: Type[PlaneType]
+ flight: Flight, group: FlyingGroup[Any], unit_type: Type[FlyingType]
) -> None:
if unit_type is Su_33:
for unit in group.units:
@@ -822,9 +810,9 @@ class AircraftConflictGenerator:
def configure_behavior(
self,
flight: Flight,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
react_on_threat: Optional[OptReactOnThreat.Values] = None,
- roe: Optional[OptROE.Values] = None,
+ roe: Optional[int] = None,
rtb_winchester: Optional[OptRTBOnOutOfAmmo.Values] = None,
restrict_jettison: Optional[bool] = None,
mission_uses_gun: bool = True,
@@ -855,13 +843,13 @@ class AircraftConflictGenerator:
# https://forums.eagle.ru/forum/english/digital-combat-simulator/dcs-world-2-5/bugs-and-problems-ai/ai-ad/7121294-ai-stuck-at-high-aoa-after-making-sharp-turn-if-afterburner-is-restricted
@staticmethod
- def configure_eplrs(group: FlyingGroup, flight: Flight) -> None:
+ def configure_eplrs(group: FlyingGroup[Any], flight: Flight) -> None:
if flight.unit_type.eplrs_capable:
group.points[0].tasks.append(EPLRS(group.id))
def configure_cap(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -878,7 +866,7 @@ class AircraftConflictGenerator:
def configure_sweep(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -895,7 +883,7 @@ class AircraftConflictGenerator:
def configure_cas(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -913,7 +901,7 @@ class AircraftConflictGenerator:
def configure_dead(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -938,7 +926,7 @@ class AircraftConflictGenerator:
def configure_sead(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -962,7 +950,7 @@ class AircraftConflictGenerator:
def configure_strike(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -980,7 +968,7 @@ class AircraftConflictGenerator:
def configure_anti_ship(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -998,7 +986,7 @@ class AircraftConflictGenerator:
def configure_runway_attack(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -1016,7 +1004,7 @@ class AircraftConflictGenerator:
def configure_oca_strike(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -1033,7 +1021,7 @@ class AircraftConflictGenerator:
def configure_awacs(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -1061,7 +1049,7 @@ class AircraftConflictGenerator:
def configure_refueling(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -1087,7 +1075,7 @@ class AircraftConflictGenerator:
def configure_escort(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -1103,7 +1091,7 @@ class AircraftConflictGenerator:
def configure_sead_escort(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -1126,7 +1114,7 @@ class AircraftConflictGenerator:
def configure_transport(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -1141,13 +1129,13 @@ class AircraftConflictGenerator:
restrict_jettison=True,
)
- def configure_unknown_task(self, group: FlyingGroup, flight: Flight) -> None:
+ def configure_unknown_task(self, group: FlyingGroup[Any], flight: Flight) -> None:
logging.error(f"Unhandled flight type: {flight.flight_type}")
self.configure_behavior(flight, group)
def setup_flight_group(
self,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
dynamic_runways: Dict[str, RunwayData],
@@ -1191,7 +1179,7 @@ class AircraftConflictGenerator:
self.configure_eplrs(group, flight)
def create_waypoints(
- self, group: FlyingGroup, package: Package, flight: Flight
+ self, group: FlyingGroup[Any], package: Package, flight: Flight
) -> None:
for waypoint in flight.points:
@@ -1236,8 +1224,57 @@ class AircraftConflictGenerator:
).build()
# Set here rather than when the FlightData is created so they waypoints
- # have their TOTs set.
- self.flights[-1].waypoints = [takeoff_point] + flight.points
+ # have their TOTs and fuel minimums set. Once we're more confident in our fuel
+ # estimation ability the minimum fuel amounts will be calculated during flight
+ # plan construction, but for now it's only used by the kneeboard so is generated
+ # late.
+ waypoints = [takeoff_point] + flight.points
+ self._estimate_min_fuel_for(flight, waypoints)
+ self.flights[-1].waypoints = waypoints
+
+ @staticmethod
+ def _estimate_min_fuel_for(flight: Flight, waypoints: list[FlightWaypoint]) -> None:
+ if flight.unit_type.fuel_consumption is None:
+ return
+
+ combat_speed_types = {
+ FlightWaypointType.INGRESS_BAI,
+ FlightWaypointType.INGRESS_CAS,
+ FlightWaypointType.INGRESS_DEAD,
+ FlightWaypointType.INGRESS_ESCORT,
+ FlightWaypointType.INGRESS_OCA_AIRCRAFT,
+ FlightWaypointType.INGRESS_OCA_RUNWAY,
+ FlightWaypointType.INGRESS_SEAD,
+ FlightWaypointType.INGRESS_STRIKE,
+ FlightWaypointType.INGRESS_SWEEP,
+ FlightWaypointType.SPLIT,
+ } | set(TARGET_WAYPOINTS)
+
+ consumption = flight.unit_type.fuel_consumption
+ min_fuel: float = consumption.min_safe
+
+ # The flight plan (in reverse) up to and including the arrival point.
+ main_flight_plan = reversed(waypoints)
+ try:
+ while waypoint := next(main_flight_plan):
+ if waypoint.waypoint_type is FlightWaypointType.LANDING_POINT:
+ waypoint.min_fuel = min_fuel
+ main_flight_plan = itertools.chain([waypoint], main_flight_plan)
+ break
+ except StopIteration:
+ # Some custom flight plan without a landing point. Skip it.
+ return
+
+ for b, a in pairwise(main_flight_plan):
+ distance = meters(a.position.distance_to_point(b.position))
+ if a.waypoint_type is FlightWaypointType.TAKEOFF:
+ ppm = consumption.climb
+ elif b.waypoint_type in combat_speed_types:
+ ppm = consumption.combat
+ else:
+ ppm = consumption.cruise
+ min_fuel += distance.nautical_miles * ppm
+ a.min_fuel = min_fuel
def should_delay_flight(self, flight: Flight, start_time: timedelta) -> bool:
if start_time.total_seconds() <= 0:
@@ -1259,7 +1296,7 @@ class AircraftConflictGenerator:
waypoint: FlightWaypoint,
package: Package,
flight: Flight,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
) -> None:
estimator = TotEstimator(package)
start_time = estimator.mission_start_time(flight)
@@ -1302,7 +1339,7 @@ class PydcsWaypointBuilder:
def __init__(
self,
waypoint: FlightWaypoint,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
mission: Mission,
@@ -1345,7 +1382,7 @@ class PydcsWaypointBuilder:
def for_waypoint(
cls,
waypoint: FlightWaypoint,
- group: FlyingGroup,
+ group: FlyingGroup[Any],
package: Package,
flight: Flight,
mission: Mission,
@@ -1459,7 +1496,7 @@ class CasIngressBuilder(PydcsWaypointBuilder):
if isinstance(self.flight.flight_plan, CasFlightPlan):
waypoint.add_task(
EngageTargetsInZone(
- position=self.flight.flight_plan.target,
+ position=self.flight.flight_plan.target.position,
radius=int(self.flight.flight_plan.engagement_distance.meters),
targets=[
Targets.All.GroundUnits.GroundVehicles,
diff --git a/gen/airfields.py b/gen/airfields.py
index 7d499cf1..7998cbf4 100644
--- a/gen/airfields.py
+++ b/gen/airfields.py
@@ -1521,4 +1521,47 @@ AIRFIELD_DATA = {
runway_length=3953,
atc=AtcData(MHz(3, 850), MHz(118, 200), MHz(38, 600), MHz(250, 200)),
),
+ "Antonio B. Won Pat Intl": AirfieldData(
+ theater="MarianaIslands",
+ icao="PGUM",
+ elevation=255,
+ runway_length=9359,
+ atc=AtcData(MHz(3, 825), MHz(118, 100), MHz(38, 550), MHz(340, 200)),
+ ils={
+ "06": ("IGUM", MHz(110, 30)),
+ },
+ ),
+ "Andersen AFB": AirfieldData(
+ theater="MarianaIslands",
+ icao="PGUA",
+ elevation=545,
+ runway_length=10490,
+ tacan=TacanChannel(54, TacanBand.X),
+ tacan_callsign="UAM",
+ atc=AtcData(MHz(3, 850), MHz(126, 200), MHz(38, 600), MHz(250, 100)),
+ ),
+ "Rota Intl": AirfieldData(
+ theater="MarianaIslands",
+ icao="PGRO",
+ elevation=568,
+ runway_length=6105,
+ atc=AtcData(MHz(3, 750), MHz(123, 600), MHz(38, 400), MHz(250, 0)),
+ ),
+ "Tinian Intl": AirfieldData(
+ theater="MarianaIslands",
+ icao="PGWT",
+ elevation=240,
+ runway_length=7777,
+ atc=AtcData(MHz(3, 800), MHz(123, 650), MHz(38, 500), MHz(250, 50)),
+ ),
+ "Saipan Intl": AirfieldData(
+ theater="MarianaIslands",
+ icao="PGSN",
+ elevation=213,
+ runway_length=7790,
+ atc=AtcData(MHz(3, 775), MHz(125, 700), MHz(38, 450), MHz(256, 900)),
+ ils={
+ "07": ("IGSN", MHz(109, 90)),
+ },
+ ),
}
diff --git a/gen/airsupport.py b/gen/airsupport.py
new file mode 100644
index 00000000..1ce520de
--- /dev/null
+++ b/gen/airsupport.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import timedelta
+from typing import Optional, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from gen import RadioFrequency, TacanChannel
+
+
+@dataclass
+class AwacsInfo:
+ """AWACS information for the kneeboard."""
+
+ group_name: str
+ callsign: str
+ freq: RadioFrequency
+ depature_location: Optional[str]
+ start_time: Optional[timedelta]
+ end_time: Optional[timedelta]
+ blue: bool
+
+
+@dataclass
+class TankerInfo:
+ """Tanker information for the kneeboard."""
+
+ group_name: str
+ callsign: str
+ variant: str
+ freq: RadioFrequency
+ tacan: TacanChannel
+ start_time: Optional[timedelta]
+ end_time: Optional[timedelta]
+ blue: bool
+
+
+@dataclass(frozen=True)
+class JtacInfo:
+ """JTAC information."""
+
+ group_name: str
+ unit_name: str
+ callsign: str
+ region: str
+ code: str
+ blue: bool
+ freq: RadioFrequency
+
+
+@dataclass
+class AirSupport:
+ awacs: list[AwacsInfo] = field(default_factory=list)
+ tankers: list[TankerInfo] = field(default_factory=list)
+ jtacs: list[JtacInfo] = field(default_factory=list)
diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py
index 875a0e58..2f20a7c3 100644
--- a/gen/airsupportgen.py
+++ b/gen/airsupportgen.py
@@ -1,11 +1,10 @@
+from __future__ import annotations
+
import logging
-from dataclasses import dataclass, field
-from datetime import timedelta
-from typing import List, Type, Tuple, Optional
+from typing import List, Type, Tuple, TYPE_CHECKING
from dcs.mission import Mission, StartType
-from dcs.planes import IL_78M, KC130, KC135MPRS, KC_135
-from dcs.unittype import UnitType
+from dcs.planes import IL_78M, KC130, KC135MPRS, KC_135, PlaneType
from dcs.task import (
AWACS,
ActivateBeaconCommand,
@@ -14,15 +13,20 @@ from dcs.task import (
SetImmortalCommand,
SetInvisibleCommand,
)
+from dcs.unittype import UnitType
-from game import db
-from .flights.ai_flight_planner_db import AEWC_CAPABLE
-from .naming import namegen
+from game.utils import Heading
+from . import AirSupport
+from .airsupport import TankerInfo, AwacsInfo
from .callsigns import callsign_for_support_unit
from .conflictgen import Conflict
-from .radios import RadioFrequency, RadioRegistry
-from .tacan import TacanBand, TacanChannel, TacanRegistry
+from .flights.ai_flight_planner_db import AEWC_CAPABLE
+from .naming import namegen
+from .radios import RadioRegistry
+from .tacan import TacanBand, TacanRegistry
+if TYPE_CHECKING:
+ from game import Game
TANKER_DISTANCE = 15000
TANKER_ALT = 4572
@@ -32,54 +36,22 @@ AWACS_DISTANCE = 150000
AWACS_ALT = 13000
-@dataclass
-class AwacsInfo:
- """AWACS information for the kneeboard."""
-
- group_name: str
- callsign: str
- freq: RadioFrequency
- depature_location: Optional[str]
- start_time: Optional[timedelta]
- end_time: Optional[timedelta]
- blue: bool
-
-
-@dataclass
-class TankerInfo:
- """Tanker information for the kneeboard."""
-
- group_name: str
- callsign: str
- variant: str
- freq: RadioFrequency
- tacan: TacanChannel
- start_time: Optional[timedelta]
- end_time: Optional[timedelta]
- blue: bool
-
-
-@dataclass
-class AirSupport:
- awacs: List[AwacsInfo] = field(default_factory=list)
- tankers: List[TankerInfo] = field(default_factory=list)
-
-
class AirSupportConflictGenerator:
def __init__(
self,
mission: Mission,
conflict: Conflict,
- game,
+ game: Game,
radio_registry: RadioRegistry,
tacan_registry: TacanRegistry,
+ air_support: AirSupport,
) -> None:
self.mission = mission
self.conflict = conflict
self.game = game
- self.air_support = AirSupport()
self.radio_registry = radio_registry
self.tacan_registry = tacan_registry
+ self.air_support = air_support
@classmethod
def support_tasks(cls) -> List[Type[MainTask]]:
@@ -88,46 +60,51 @@ class AirSupportConflictGenerator:
@staticmethod
def _get_tanker_params(unit_type: Type[UnitType]) -> Tuple[int, int]:
if unit_type is KC130:
- return (TANKER_ALT - 500, 596)
+ return TANKER_ALT - 500, 596
elif unit_type is KC_135:
- return (TANKER_ALT, 770)
+ return TANKER_ALT, 770
elif unit_type is KC135MPRS:
- return (TANKER_ALT + 500, 596)
- return (TANKER_ALT, 574)
+ return TANKER_ALT + 500, 596
+ return TANKER_ALT, 574
- def generate(self):
+ def generate(self) -> None:
player_cp = (
self.conflict.blue_cp
if self.conflict.blue_cp.captured
else self.conflict.red_cp
)
+ country = self.mission.country(self.game.blue.country_name)
+
if not self.game.settings.disable_legacy_tanker:
fallback_tanker_number = 0
for i, tanker_unit_type in enumerate(
self.game.faction_for(player=True).tankers
):
+ unit_type = tanker_unit_type.dcs_unit_type
+ if not issubclass(unit_type, PlaneType):
+ logging.warning(f"Refueling aircraft {unit_type} must be a plane")
+ continue
+
# TODO: Make loiter altitude a property of the unit type.
alt, airspeed = self._get_tanker_params(tanker_unit_type.dcs_unit_type)
freq = self.radio_registry.alloc_uhf()
tacan = self.tacan_registry.alloc_for_band(TacanBand.Y)
- tanker_heading = (
+ tanker_heading = Heading.from_degrees(
self.conflict.red_cp.position.heading_between_point(
self.conflict.blue_cp.position
)
+ TANKER_HEADING_OFFSET * i
)
tanker_position = player_cp.position.point_from_heading(
- tanker_heading, TANKER_DISTANCE
+ tanker_heading.degrees, TANKER_DISTANCE
)
tanker_group = self.mission.refuel_flight(
- country=self.mission.country(self.game.player_country),
- name=namegen.next_tanker_name(
- self.mission.country(self.game.player_country), tanker_unit_type
- ),
+ country=country,
+ name=namegen.next_tanker_name(country, tanker_unit_type),
airport=None,
- plane_type=tanker_unit_type,
+ plane_type=unit_type,
position=tanker_position,
altitude=alt,
race_distance=58000,
@@ -177,6 +154,8 @@ class AirSupportConflictGenerator:
tanker_unit_type.name,
freq,
tacan,
+ start_time=None,
+ end_time=None,
blue=True,
)
)
@@ -195,12 +174,15 @@ class AirSupportConflictGenerator:
awacs_unit = possible_awacs[0]
freq = self.radio_registry.alloc_uhf()
+ unit_type = awacs_unit.dcs_unit_type
+ if not issubclass(unit_type, PlaneType):
+ logging.warning(f"AWACS aircraft {unit_type} must be a plane")
+ return
+
awacs_flight = self.mission.awacs_flight(
- country=self.mission.country(self.game.player_country),
- name=namegen.next_awacs_name(
- self.mission.country(self.game.player_country)
- ),
- plane_type=awacs_unit,
+ country=country,
+ name=namegen.next_awacs_name(country),
+ plane_type=unit_type,
altitude=AWACS_ALT,
airport=None,
position=self.conflict.position.random_point_within(
diff --git a/gen/armor.py b/gen/armor.py
index bae64166..e13827a8 100644
--- a/gen/armor.py
+++ b/gen/armor.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
+import math
import random
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Tuple
@@ -12,9 +13,11 @@ from dcs.country import Country
from dcs.mapping import Point
from dcs.point import PointAction
from dcs.task import (
+ AFAC,
EPLRS,
AttackGroup,
ControlledTask,
+ FAC,
FireAtPoint,
GoToWaypoint,
Hold,
@@ -23,7 +26,7 @@ from dcs.task import (
SetInvisibleCommand,
)
from dcs.triggers import Event, TriggerOnce
-from dcs.unit import Vehicle
+from dcs.unit import Vehicle, Skill
from dcs.unitgroup import VehicleGroup
from game.data.groundunitclass import GroundUnitClass
@@ -31,16 +34,19 @@ from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.theater.controlpoint import ControlPoint
from game.unitmap import UnitMap
-from game.utils import heading_sum, opposite_heading
+from game.utils import Heading
from gen.ground_forces.ai_ground_planner import (
DISTANCE_FROM_FRONTLINE,
CombatGroup,
CombatGroupRole,
)
+from .airsupport import AirSupport, JtacInfo
from .callsigns import callsign_for_support_unit
from .conflictgen import Conflict
from .ground_forces.combat_stance import CombatStance
+from .lasercoderegistry import LaserCodeRegistry
from .naming import namegen
+from .radios import MHz, RadioFrequency, RadioRegistry
if TYPE_CHECKING:
from game import Game
@@ -63,19 +69,6 @@ RANDOM_OFFSET_ATTACK = 250
INFANTRY_GROUP_SIZE = 5
-@dataclass(frozen=True)
-class JtacInfo:
- """JTAC information."""
-
- group_name: str
- unit_name: str
- callsign: str
- region: str
- code: str
- blue: bool
- # TODO: Radio info? Type?
-
-
class GroundConflictGenerator:
def __init__(
self,
@@ -85,57 +78,29 @@ class GroundConflictGenerator:
player_planned_combat_groups: List[CombatGroup],
enemy_planned_combat_groups: List[CombatGroup],
player_stance: CombatStance,
+ enemy_stance: CombatStance,
unit_map: UnitMap,
+ radio_registry: RadioRegistry,
+ air_support: AirSupport,
+ laser_code_registry: LaserCodeRegistry,
) -> None:
self.mission = mission
self.conflict = conflict
self.enemy_planned_combat_groups = enemy_planned_combat_groups
self.player_planned_combat_groups = player_planned_combat_groups
- self.player_stance = CombatStance(player_stance)
- self.enemy_stance = self._enemy_stance()
+ self.player_stance = player_stance
+ self.enemy_stance = enemy_stance
self.game = game
self.unit_map = unit_map
- self.jtacs: List[JtacInfo] = []
+ self.radio_registry = radio_registry
+ self.air_support = air_support
+ self.laser_code_registry = laser_code_registry
- def _enemy_stance(self):
- """Picks the enemy stance according to the number of planned groups on the frontline for each side"""
- if len(self.enemy_planned_combat_groups) > len(
- self.player_planned_combat_groups
- ):
- return random.choice(
- [
- CombatStance.AGGRESSIVE,
- CombatStance.AGGRESSIVE,
- CombatStance.AGGRESSIVE,
- CombatStance.ELIMINATION,
- CombatStance.BREAKTHROUGH,
- ]
- )
- else:
- return random.choice(
- [
- CombatStance.DEFENSIVE,
- CombatStance.DEFENSIVE,
- CombatStance.DEFENSIVE,
- CombatStance.AMBUSH,
- CombatStance.AGGRESSIVE,
- ]
- )
-
- @staticmethod
- def _group_point(point: Point, base_distance) -> Point:
- distance = random.randint(
- int(base_distance * SPREAD_DISTANCE_FACTOR[0]),
- int(base_distance * SPREAD_DISTANCE_FACTOR[1]),
- )
- return point.random_point_within(
- distance, base_distance * SPREAD_DISTANCE_SIZE_FACTOR
- )
-
- def generate(self):
+ def generate(self) -> None:
position = Conflict.frontline_position(
self.conflict.front_line, self.game.theater
)
+
frontline_vector = Conflict.frontline_vector(
self.conflict.front_line, self.game.theater
)
@@ -150,12 +115,19 @@ class GroundConflictGenerator:
self.enemy_planned_combat_groups, frontline_vector, False
)
+ # TODO: Differentiate AirConflict and GroundConflict classes.
+ if self.conflict.heading is None:
+ raise RuntimeError(
+ "Cannot generate ground units for non-ground conflict. Ground unit "
+ "conflicts cannot have the heading `None`."
+ )
+
# Plan combat actions for groups
self.plan_action_for_groups(
self.player_stance,
player_groups,
enemy_groups,
- self.conflict.heading + 90,
+ self.conflict.heading.right,
self.conflict.blue_cp,
self.conflict.red_cp,
)
@@ -163,27 +135,32 @@ class GroundConflictGenerator:
self.enemy_stance,
enemy_groups,
player_groups,
- self.conflict.heading - 90,
+ self.conflict.heading.left,
self.conflict.red_cp,
self.conflict.blue_cp,
)
# Add JTAC
- if self.game.player_faction.has_jtac:
+ if self.game.blue.faction.has_jtac:
n = "JTAC" + str(self.conflict.blue_cp.id) + str(self.conflict.red_cp.id)
- code = 1688 - len(self.jtacs)
+ code: int = self.laser_code_registry.get_next_laser_code()
+ freq = self.radio_registry.alloc_uhf()
- utype = self.game.player_faction.jtac_unit
- if self.game.player_faction.jtac_unit is None:
+ utype = self.game.blue.faction.jtac_unit
+ if utype is None:
utype = AircraftType.named("MQ-9 Reaper")
jtac = self.mission.flight_group(
- country=self.mission.country(self.game.player_country),
+ country=self.mission.country(self.game.blue.country_name),
name=n,
aircraft_type=utype.dcs_unit_type,
position=position[0],
airport=None,
altitude=5000,
+ maintask=AFAC,
+ )
+ jtac.points[0].tasks.append(
+ FAC(callsign=len(self.air_support.jtacs) + 1, frequency=int(freq.mhz))
)
jtac.points[0].tasks.append(SetInvisibleCommand(True))
jtac.points[0].tasks.append(SetImmortalCommand(True))
@@ -195,7 +172,7 @@ class GroundConflictGenerator:
)
# Note: Will need to change if we ever add ground based JTAC.
callsign = callsign_for_support_unit(jtac)
- self.jtacs.append(
+ self.air_support.jtacs.append(
JtacInfo(
str(jtac.name),
n,
@@ -203,11 +180,16 @@ class GroundConflictGenerator:
frontline,
str(code),
blue=True,
+ freq=freq,
)
)
def gen_infantry_group_for_group(
- self, group: VehicleGroup, is_player: bool, side: Country, forward_heading: int
+ self,
+ group: VehicleGroup,
+ is_player: bool,
+ side: Country,
+ forward_heading: Heading,
) -> None:
infantry_position = self.conflict.find_ground_position(
@@ -242,7 +224,7 @@ class GroundConflictGenerator:
u.dcs_unit_type,
position=infantry_position,
group_size=1,
- heading=forward_heading,
+ heading=forward_heading.degrees,
move_formation=PointAction.OffRoad,
)
return
@@ -269,7 +251,7 @@ class GroundConflictGenerator:
units[0].dcs_unit_type,
position=infantry_position,
group_size=1,
- heading=forward_heading,
+ heading=forward_heading.degrees,
move_formation=PointAction.OffRoad,
)
@@ -281,17 +263,19 @@ class GroundConflictGenerator:
unit.dcs_unit_type,
position=position,
group_size=1,
- heading=forward_heading,
+ heading=forward_heading.degrees,
move_formation=PointAction.OffRoad,
)
def _set_reform_waypoint(
- self, dcs_group: VehicleGroup, forward_heading: int
+ self, dcs_group: VehicleGroup, forward_heading: Heading
) -> None:
"""Setting a waypoint close to the spawn position allows the group to reform gracefully
rather than spin
"""
- reform_point = dcs_group.position.point_from_heading(forward_heading, 50)
+ reform_point = dcs_group.position.point_from_heading(
+ forward_heading.degrees, 50
+ )
dcs_group.add_waypoint(reform_point)
def _plan_artillery_action(
@@ -299,7 +283,7 @@ class GroundConflictGenerator:
stance: CombatStance,
gen_group: CombatGroup,
dcs_group: VehicleGroup,
- forward_heading: int,
+ forward_heading: Heading,
target: Point,
) -> bool:
"""
@@ -333,7 +317,7 @@ class GroundConflictGenerator:
dcs_group, forward_heading, (int)(RETREAT_DISTANCE / 3)
)
dcs_group.add_waypoint(
- dcs_group.position.point_from_heading(forward_heading, 1),
+ dcs_group.position.point_from_heading(forward_heading.degrees, 1),
PointAction.OffRoad,
)
dcs_group.points[2].tasks.append(Hold())
@@ -361,8 +345,7 @@ class GroundConflictGenerator:
self.mission.triggerrules.triggers.append(artillery_fallback)
for u in dcs_group.units:
- u.initial = True
- u.heading = forward_heading + random.randint(-5, 5)
+ u.heading = (forward_heading + Heading.random(-5, 5)).degrees
return True
return False
@@ -371,7 +354,7 @@ class GroundConflictGenerator:
stance: CombatStance,
enemy_groups: List[Tuple[VehicleGroup, CombatGroup]],
dcs_group: VehicleGroup,
- forward_heading: int,
+ forward_heading: Heading,
to_cp: ControlPoint,
) -> bool:
"""
@@ -404,9 +387,7 @@ class GroundConflictGenerator:
else:
# We use an offset heading here because DCS doesn't always
# force vehicles to move if there's no heading change.
- offset_heading = forward_heading - 2
- if offset_heading < 0:
- offset_heading = 358
+ offset_heading = forward_heading - Heading.from_degrees(2)
attack_point = self.find_offensive_point(
dcs_group, offset_heading, AGGRESIVE_MOVE_DISTANCE
)
@@ -424,9 +405,7 @@ class GroundConflictGenerator:
else:
# We use an offset heading here because DCS doesn't always
# force vehicles to move if there's no heading change.
- offset_heading = forward_heading - 1
- if offset_heading < 0:
- offset_heading = 359
+ offset_heading = forward_heading - Heading.from_degrees(1)
attack_point = self.find_offensive_point(
dcs_group, offset_heading, BREAKTHROUGH_OFFENSIVE_DISTANCE
)
@@ -462,7 +441,7 @@ class GroundConflictGenerator:
self,
stance: CombatStance,
dcs_group: VehicleGroup,
- forward_heading: int,
+ forward_heading: Heading,
to_cp: ControlPoint,
) -> bool:
"""
@@ -499,7 +478,7 @@ class GroundConflictGenerator:
stance: CombatStance,
ally_groups: List[Tuple[VehicleGroup, CombatGroup]],
enemy_groups: List[Tuple[VehicleGroup, CombatGroup]],
- forward_heading: int,
+ forward_heading: Heading,
from_cp: ControlPoint,
to_cp: ControlPoint,
) -> None:
@@ -540,12 +519,14 @@ class GroundConflictGenerator:
else:
retreat_point = self.find_retreat_point(dcs_group, forward_heading)
reposition_point = retreat_point.point_from_heading(
- forward_heading, 10
+ forward_heading.degrees, 10
) # Another point to make the unit face the enemy
dcs_group.add_waypoint(retreat_point, PointAction.OffRoad)
dcs_group.add_waypoint(reposition_point, PointAction.OffRoad)
- def add_morale_trigger(self, dcs_group: VehicleGroup, forward_heading: int) -> None:
+ def add_morale_trigger(
+ self, dcs_group: VehicleGroup, forward_heading: Heading
+ ) -> None:
"""
This add a trigger to manage units fleeing whenever their group is hit hard, or being engaged by CAS
"""
@@ -558,7 +539,7 @@ class GroundConflictGenerator:
# Force unit heading
for unit in dcs_group.units:
- unit.heading = forward_heading
+ unit.heading = forward_heading.degrees
dcs_group.manualHeading = True
# We add a new retreat waypoint
@@ -570,10 +551,10 @@ class GroundConflictGenerator:
)
# Fallback task
- fallback = ControlledTask(GoToWaypoint(to_index=len(dcs_group.points)))
- fallback.enabled = False
+ task = ControlledTask(GoToWaypoint(to_index=len(dcs_group.points)))
+ task.enabled = False
dcs_group.add_trigger_action(Hold())
- dcs_group.add_trigger_action(fallback)
+ dcs_group.add_trigger_action(task)
# Create trigger
fallback = TriggerOnce(Event.NoEvent, "Morale manager #" + str(dcs_group.id))
@@ -589,7 +570,7 @@ class GroundConflictGenerator:
def find_retreat_point(
self,
dcs_group: VehicleGroup,
- frontline_heading: int,
+ frontline_heading: Heading,
distance: int = RETREAT_DISTANCE,
) -> Point:
"""
@@ -599,14 +580,14 @@ class GroundConflictGenerator:
:return: dcs.mapping.Point object with the desired position
"""
desired_point = dcs_group.points[0].position.point_from_heading(
- heading_sum(frontline_heading, +180), distance
+ frontline_heading.opposite.degrees, distance
)
if self.conflict.theater.is_on_land(desired_point):
return desired_point
return self.conflict.theater.nearest_land_pos(desired_point)
def find_offensive_point(
- self, dcs_group: VehicleGroup, frontline_heading: int, distance: int
+ self, dcs_group: VehicleGroup, frontline_heading: Heading, distance: int
) -> Point:
"""
Find a point to attack
@@ -616,7 +597,7 @@ class GroundConflictGenerator:
:return: dcs.mapping.Point object with the desired position
"""
desired_point = dcs_group.points[0].position.point_from_heading(
- frontline_heading, distance
+ frontline_heading.degrees, distance
)
if self.conflict.theater.is_on_land(desired_point):
return desired_point
@@ -634,7 +615,7 @@ class GroundConflictGenerator:
@param enemy_groups Potential enemy groups
@param n number of nearby groups to take
"""
- targets = [] # type: List[Optional[VehicleGroup]]
+ targets = [] # type: List[VehicleGroup]
sorted_list = sorted(
enemy_groups,
key=lambda group: player_group.points[0].position.distance_to_point(
@@ -658,7 +639,7 @@ class GroundConflictGenerator:
@param group Group for which we should find the nearest ennemy
@param enemy_groups Potential enemy groups
"""
- min_distance = 99999999
+ min_distance = math.inf
target = None
for dcs_group, _ in enemy_groups:
dist = player_group.points[0].position.distance_to_point(
@@ -696,7 +677,7 @@ class GroundConflictGenerator:
"""
For artilery group, decide the distance from frontline with the range of the unit
"""
- rg = getattr(group.unit_type.dcs_unit_type, "threat_range", 0) - 7500
+ rg = group.unit_type.dcs_unit_type.threat_range - 7500
if rg > DISTANCE_FROM_FRONTLINE[CombatGroupRole.ARTILLERY][1]:
rg = random.randint(
DISTANCE_FROM_FRONTLINE[CombatGroupRole.ARTILLERY][0],
@@ -714,14 +695,14 @@ class GroundConflictGenerator:
conflict_position: Point,
combat_width: int,
distance_from_frontline: int,
- heading: int,
- spawn_heading: int,
- ):
+ heading: Heading,
+ spawn_heading: Heading,
+ ) -> Optional[Point]:
shifted = conflict_position.point_from_heading(
- heading, random.randint(0, combat_width)
+ heading.degrees, random.randint(0, combat_width)
)
desired_point = shifted.point_from_heading(
- spawn_heading, distance_from_frontline
+ spawn_heading.degrees, distance_from_frontline
)
return Conflict.find_ground_position(
desired_point, combat_width, heading, self.conflict.theater
@@ -730,18 +711,14 @@ class GroundConflictGenerator:
def _generate_groups(
self,
groups: list[CombatGroup],
- frontline_vector: Tuple[Point, int, int],
+ frontline_vector: Tuple[Point, Heading, int],
is_player: bool,
) -> List[Tuple[VehicleGroup, CombatGroup]]:
"""Finds valid positions for planned groups and generates a pydcs group for them"""
positioned_groups = []
position, heading, combat_width = frontline_vector
- spawn_heading = (
- int(heading_sum(heading, -90))
- if is_player
- else int(heading_sum(heading, 90))
- )
- country = self.game.player_country if is_player else self.game.enemy_country
+ spawn_heading = heading.left if is_player else heading.right
+ country = self.game.coalition_for(is_player).country_name
for group in groups:
if group.role == CombatGroupRole.ARTILLERY:
distance_from_frontline = (
@@ -763,12 +740,12 @@ class GroundConflictGenerator:
group.unit_type,
group.size,
final_position,
- heading=opposite_heading(spawn_heading),
+ heading=spawn_heading.opposite,
)
if is_player:
- g.set_skill(self.game.settings.player_skill)
+ g.set_skill(Skill(self.game.settings.player_skill))
else:
- g.set_skill(self.game.settings.enemy_vehicle_skill)
+ g.set_skill(Skill(self.game.settings.enemy_vehicle_skill))
positioned_groups.append((g, group))
if group.role in [CombatGroupRole.APC, CombatGroupRole.IFV]:
@@ -776,7 +753,7 @@ class GroundConflictGenerator:
g,
is_player,
self.mission.country(country),
- opposite_heading(spawn_heading),
+ spawn_heading.opposite,
)
else:
logging.warning(f"Unable to get valid position for {group}")
@@ -790,7 +767,7 @@ class GroundConflictGenerator:
count: int,
at: Point,
move_formation: PointAction = PointAction.OffRoad,
- heading=0,
+ heading: Heading = Heading.from_degrees(0),
) -> VehicleGroup:
if side == self.conflict.attackers_country:
@@ -804,7 +781,7 @@ class GroundConflictGenerator:
unit_type.dcs_unit_type,
position=at,
group_size=count,
- heading=heading,
+ heading=heading.degrees,
move_formation=move_formation,
)
diff --git a/gen/ato.py b/gen/ato.py
index da6fbf1c..944cf316 100644
--- a/gen/ato.py
+++ b/gen/ato.py
@@ -40,7 +40,6 @@ class Task:
class PackageWaypoints:
join: Point
ingress: Point
- egress: Point
split: Point
diff --git a/gen/briefinggen.py b/gen/briefinggen.py
index 87029d7b..5a6911f0 100644
--- a/gen/briefinggen.py
+++ b/gen/briefinggen.py
@@ -136,6 +136,16 @@ def format_waypoint_time(waypoint: FlightWaypoint, depart_prefix: str) -> str:
return ""
+def format_intra_flight_channel(flight: FlightData) -> str:
+ frequency = flight.intra_flight_channel
+ channel = flight.channel_for(frequency)
+ if channel is None:
+ return str(frequency)
+
+ channel_name = flight.aircraft_type.channel_name(channel.radio_id, channel.channel)
+ return f"{channel_name} ({frequency})"
+
+
class BriefingGenerator(MissionInfoGenerator):
def __init__(self, mission: Mission, game: Game):
super().__init__(mission, game)
@@ -151,6 +161,7 @@ class BriefingGenerator(MissionInfoGenerator):
lstrip_blocks=True,
)
env.filters["waypoint_timing"] = format_waypoint_time
+ env.filters["intra_flight_channel"] = format_intra_flight_channel
self.template = env.get_template("briefingtemplate_EN.j2")
def generate(self) -> None:
diff --git a/gen/callsigns.py b/gen/callsigns.py
index 8ebda467..a722606f 100644
--- a/gen/callsigns.py
+++ b/gen/callsigns.py
@@ -1,12 +1,13 @@
"""Support for working with DCS group callsigns."""
import logging
import re
+from typing import Any
from dcs.unitgroup import FlyingGroup
from dcs.flyingunit import FlyingUnit
-def callsign_for_support_unit(group: FlyingGroup) -> str:
+def callsign_for_support_unit(group: FlyingGroup[Any]) -> str:
# Either something like Overlord11 for Western AWACS, or else just a number.
# Convert to either "Overlord" or "Flight 123".
lead = group.units[0]
diff --git a/gen/cargoshipgen.py b/gen/cargoshipgen.py
index 9de370b9..ec7e6577 100644
--- a/gen/cargoshipgen.py
+++ b/gen/cargoshipgen.py
@@ -24,12 +24,13 @@ class CargoShipGenerator:
def generate(self) -> None:
# Reset the count to make generation deterministic.
- for ship in self.game.transfers.cargo_ships:
- self.generate_cargo_ship(ship)
+ for coalition in self.game.coalitions:
+ for ship in coalition.transfers.cargo_ships:
+ self.generate_cargo_ship(ship)
def generate_cargo_ship(self, ship: CargoShip) -> ShipGroup:
country = self.mission.country(
- self.game.player_country if ship.player_owned else self.game.enemy_country
+ self.game.coalition_for(ship.player_owned).country_name
)
waypoints = ship.route
group = self.mission.ship_group(
diff --git a/gen/coastal/coastal_group_generator.py b/gen/coastal/coastal_group_generator.py
index 160712e0..0d263e3b 100644
--- a/gen/coastal/coastal_group_generator.py
+++ b/gen/coastal/coastal_group_generator.py
@@ -1,6 +1,11 @@
import logging
import random
-from game import db
+from typing import Optional
+
+from dcs.unitgroup import VehicleGroup
+
+from game import db, Game
+from game.theater.theatergroundobject import CoastalSiteGroundObject
from gen.coastal.silkworm import SilkwormGenerator
COASTAL_MAP = {
@@ -8,10 +13,13 @@ COASTAL_MAP = {
}
-def generate_coastal_group(game, ground_object, faction_name: str):
+def generate_coastal_group(
+ game: Game, ground_object: CoastalSiteGroundObject, faction_name: str
+) -> Optional[VehicleGroup]:
"""
This generate a coastal defenses group
- :return: Nothing, but put the group reference inside the ground object
+ :return: The generated group, or None if this faction does not support coastal
+ defenses.
"""
faction = db.FACTIONS[faction_name]
if len(faction.coastal_defenses) > 0:
diff --git a/gen/coastal/silkworm.py b/gen/coastal/silkworm.py
index 4198e004..b0fb98c5 100644
--- a/gen/coastal/silkworm.py
+++ b/gen/coastal/silkworm.py
@@ -1,14 +1,20 @@
from dcs.vehicles import MissilesSS, Unarmed, AirDefence
-from gen.sam.group_generator import GroupGenerator
+from game import Game
+from game.factions.faction import Faction
+from game.theater.theatergroundobject import CoastalSiteGroundObject
+from game.utils import Heading
+from gen.sam.group_generator import VehicleGroupGenerator
-class SilkwormGenerator(GroupGenerator):
- def __init__(self, game, ground_object, faction):
+class SilkwormGenerator(VehicleGroupGenerator[CoastalSiteGroundObject]):
+ def __init__(
+ self, game: Game, ground_object: CoastalSiteGroundObject, faction: Faction
+ ) -> None:
super(SilkwormGenerator, self).__init__(game, ground_object)
self.faction = faction
- def generate(self):
+ def generate(self) -> None:
positions = self.get_circular_position(5, launcher_distance=120, coverage=180)
@@ -23,7 +29,7 @@ class SilkwormGenerator(GroupGenerator):
# Launchers
for i, p in enumerate(positions):
self.add_unit(
- MissilesSS.Silkworm_SR,
+ MissilesSS.Hy_launcher,
"Missile#" + str(i),
p[0],
p[1],
@@ -54,5 +60,5 @@ class SilkwormGenerator(GroupGenerator):
"STRELA#0",
self.position.x + 200,
self.position.y + 15,
- 90,
+ Heading.from_degrees(90),
)
diff --git a/gen/conflictgen.py b/gen/conflictgen.py
index eabf4e4e..6693367e 100644
--- a/gen/conflictgen.py
+++ b/gen/conflictgen.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import logging
from typing import Tuple, Optional
@@ -7,7 +9,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint
from game.theater.conflicttheater import ConflictTheater, FrontLine
from game.theater.controlpoint import ControlPoint
-from game.utils import heading_sum, opposite_heading
+from game.utils import Heading
FRONTLINE_LENGTH = 80000
@@ -23,7 +25,7 @@ class Conflict:
attackers_country: Country,
defenders_country: Country,
position: Point,
- heading: Optional[int] = None,
+ heading: Optional[Heading] = None,
size: Optional[int] = None,
):
@@ -53,26 +55,28 @@ class Conflict:
@classmethod
def frontline_position(
cls, frontline: FrontLine, theater: ConflictTheater
- ) -> Tuple[Point, int]:
+ ) -> Tuple[Point, Heading]:
attack_heading = frontline.attack_heading
position = cls.find_ground_position(
frontline.position,
FRONTLINE_LENGTH,
- heading_sum(attack_heading, 90),
+ attack_heading.right,
theater,
)
- return position, opposite_heading(attack_heading)
+ if position is None:
+ raise RuntimeError("Could not find front line position")
+ return position, attack_heading.opposite
@classmethod
def frontline_vector(
cls, front_line: FrontLine, theater: ConflictTheater
- ) -> Tuple[Point, int, int]:
+ ) -> Tuple[Point, Heading, int]:
"""
Returns a vector for a valid frontline location avoiding exclusion zones.
"""
center_position, heading = cls.frontline_position(front_line, theater)
- left_heading = heading_sum(heading, -90)
- right_heading = heading_sum(heading, 90)
+ left_heading = heading.left
+ right_heading = heading.right
left_position = cls.extend_ground_position(
center_position, int(FRONTLINE_LENGTH / 2), left_heading, theater
)
@@ -91,7 +95,7 @@ class Conflict:
defender: Country,
front_line: FrontLine,
theater: ConflictTheater,
- ):
+ ) -> Conflict:
assert cls.has_frontline_between(front_line.blue_cp, front_line.red_cp)
position, heading, distance = cls.frontline_vector(front_line, theater)
conflict = cls(
@@ -109,10 +113,14 @@ class Conflict:
@classmethod
def extend_ground_position(
- cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater
+ cls,
+ initial: Point,
+ max_distance: int,
+ heading: Heading,
+ theater: ConflictTheater,
) -> Point:
"""Finds the first intersection with an exclusion zone in one heading from an initial point up to max_distance"""
- extended = initial.point_from_heading(heading, max_distance)
+ extended = initial.point_from_heading(heading.degrees, max_distance)
if theater.landmap is None:
# TODO: Why is this possible?
return extended
@@ -129,16 +137,16 @@ class Conflict:
return extended
# Otherwise extend the front line only up to the intersection.
- return initial.point_from_heading(heading, p0.distance(intersection))
+ return initial.point_from_heading(heading.degrees, p0.distance(intersection))
@classmethod
def find_ground_position(
cls,
initial: Point,
max_distance: int,
- heading: int,
+ heading: Heading,
theater: ConflictTheater,
- coerce=True,
+ coerce: bool = True,
) -> Optional[Point]:
"""
Finds the nearest valid ground position along a provided heading and it's inverse up to max_distance.
@@ -149,10 +157,10 @@ class Conflict:
if theater.is_on_land(pos):
return pos
for distance in range(0, int(max_distance), 100):
- pos = initial.point_from_heading(heading, distance)
+ pos = initial.point_from_heading(heading.degrees, distance)
if theater.is_on_land(pos):
return pos
- pos = initial.point_from_heading(opposite_heading(heading), distance)
+ pos = initial.point_from_heading(heading.opposite.degrees, distance)
if theater.is_on_land(pos):
return pos
if coerce:
diff --git a/gen/convoygen.py b/gen/convoygen.py
index 303c286f..b695d144 100644
--- a/gen/convoygen.py
+++ b/gen/convoygen.py
@@ -27,8 +27,9 @@ class ConvoyGenerator:
def generate(self) -> None:
# Reset the count to make generation deterministic.
- for convoy in self.game.transfers.convoys:
- self.generate_convoy(convoy)
+ for coalition in self.game.coalitions:
+ for convoy in coalition.transfers.convoys:
+ self.generate_convoy(convoy)
def generate_convoy(self, convoy: Convoy) -> VehicleGroup:
group = self._create_mixed_unit_group(
@@ -53,9 +54,7 @@ class ConvoyGenerator:
units: dict[GroundUnitType, int],
for_player: bool,
) -> VehicleGroup:
- country = self.mission.country(
- self.game.player_country if for_player else self.game.enemy_country
- )
+ country = self.mission.country(self.game.coalition_for(for_player).country_name)
unit_types = list(units.items())
main_unit_type, main_unit_count = unit_types[0]
diff --git a/gen/defenses/armor_group_generator.py b/gen/defenses/armor_group_generator.py
index fc549ca9..1ed04e06 100644
--- a/gen/defenses/armor_group_generator.py
+++ b/gen/defenses/armor_group_generator.py
@@ -1,4 +1,5 @@
import random
+from typing import Optional
from dcs.unitgroup import VehicleGroup
@@ -12,7 +13,9 @@ from gen.defenses.armored_group_generator import (
)
-def generate_armor_group(faction: str, game, ground_object):
+def generate_armor_group(
+ faction: str, game: Game, ground_object: VehicleGroupGroundObject
+) -> Optional[VehicleGroup]:
"""
This generate a group of ground units
:return: Generated group
diff --git a/gen/defenses/armored_group_generator.py b/gen/defenses/armored_group_generator.py
index f68b520b..c7404d0d 100644
--- a/gen/defenses/armored_group_generator.py
+++ b/gen/defenses/armored_group_generator.py
@@ -3,10 +3,10 @@ import random
from game import Game
from game.dcs.groundunittype import GroundUnitType
from game.theater.theatergroundobject import VehicleGroupGroundObject
-from gen.sam.group_generator import GroupGenerator
+from gen.sam.group_generator import VehicleGroupGenerator
-class ArmoredGroupGenerator(GroupGenerator):
+class ArmoredGroupGenerator(VehicleGroupGenerator[VehicleGroupGroundObject]):
def __init__(
self,
game: Game,
@@ -35,7 +35,7 @@ class ArmoredGroupGenerator(GroupGenerator):
)
-class FixedSizeArmorGroupGenerator(GroupGenerator):
+class FixedSizeArmorGroupGenerator(VehicleGroupGenerator[VehicleGroupGroundObject]):
def __init__(
self,
game: Game,
@@ -47,7 +47,7 @@ class FixedSizeArmorGroupGenerator(GroupGenerator):
self.unit_type = unit_type
self.size = size
- def generate(self):
+ def generate(self) -> None:
spacing = random.randint(20, 70)
index = 0
diff --git a/gen/environmentgen.py b/gen/environmentgen.py
index 5e393e04..84f5bd59 100644
--- a/gen/environmentgen.py
+++ b/gen/environmentgen.py
@@ -2,7 +2,7 @@ from typing import Optional
from dcs.mission import Mission
-from game.weather import Clouds, Fog, Conditions, WindConditions
+from game.weather import Clouds, Fog, Conditions, WindConditions, AtmosphericConditions
class EnvironmentGenerator:
@@ -10,6 +10,10 @@ class EnvironmentGenerator:
self.mission = mission
self.conditions = conditions
+ def set_atmospheric(self, atmospheric: AtmosphericConditions) -> None:
+ self.mission.weather.qnh = atmospheric.qnh.mm_hg
+ self.mission.weather.season_temperature = atmospheric.temperature_celsius
+
def set_clouds(self, clouds: Optional[Clouds]) -> None:
if clouds is None:
return
@@ -22,7 +26,7 @@ class EnvironmentGenerator:
def set_fog(self, fog: Optional[Fog]) -> None:
if fog is None:
return
- self.mission.weather.fog_visibility = fog.visibility.meters
+ self.mission.weather.fog_visibility = int(fog.visibility.meters)
self.mission.weather.fog_thickness = fog.thickness
def set_wind(self, wind: WindConditions) -> None:
@@ -30,8 +34,9 @@ class EnvironmentGenerator:
self.mission.weather.wind_at_2000 = wind.at_2000m
self.mission.weather.wind_at_8000 = wind.at_8000m
- def generate(self):
+ def generate(self) -> None:
self.mission.start_time = self.conditions.start_time
+ self.set_atmospheric(self.conditions.weather.atmospheric)
self.set_clouds(self.conditions.weather.clouds)
self.set_fog(self.conditions.weather.fog)
self.set_wind(self.conditions.weather.wind)
diff --git a/gen/fleet/carrier_group.py b/gen/fleet/carrier_group.py
index 4200caca..74ca4c67 100644
--- a/gen/fleet/carrier_group.py
+++ b/gen/fleet/carrier_group.py
@@ -1,12 +1,13 @@
import random
from gen.sam.group_generator import ShipGroupGenerator
+from game.utils import Heading
from dcs.ships import USS_Arleigh_Burke_IIa, TICONDEROG
class CarrierGroupGenerator(ShipGroupGenerator):
- def generate(self):
+ def generate(self) -> None:
# Carrier Strike Group 8
if self.faction.carrier_names[0] == "Carrier Strike Group 8":
@@ -54,7 +55,7 @@ class CarrierGroupGenerator(ShipGroupGenerator):
)
# Add Ticonderoga escort
- if self.heading >= 180:
+ if self.heading >= Heading.from_degrees(180):
self.add_unit(
TICONDEROG,
"USS Hué City",
diff --git a/gen/fleet/cn_dd_group.py b/gen/fleet/cn_dd_group.py
index 91c710a0..144df4b4 100644
--- a/gen/fleet/cn_dd_group.py
+++ b/gen/fleet/cn_dd_group.py
@@ -3,7 +3,6 @@ from __future__ import annotations
import random
from typing import TYPE_CHECKING
-
from dcs.ships import (
Type_052C,
Type_052B,
@@ -11,16 +10,16 @@ from dcs.ships import (
)
from game.factions.faction import Faction
+from game.theater.theatergroundobject import ShipGroundObject
from gen.fleet.dd_group import DDGroupGenerator
from gen.sam.group_generator import ShipGroupGenerator
-from game.theater.theatergroundobject import TheaterGroundObject
if TYPE_CHECKING:
from game.game import Game
class ChineseNavyGroupGenerator(ShipGroupGenerator):
- def generate(self):
+ def generate(self) -> None:
include_frigate = random.choice([True, True, False])
include_dd = random.choice([True, False])
@@ -65,9 +64,7 @@ class ChineseNavyGroupGenerator(ShipGroupGenerator):
class Type54GroupGenerator(DDGroupGenerator):
- def __init__(
- self, game: Game, ground_object: TheaterGroundObject, faction: Faction
- ):
+ def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction):
super(Type54GroupGenerator, self).__init__(
game, ground_object, faction, Type_054A
)
diff --git a/gen/fleet/dd_group.py b/gen/fleet/dd_group.py
index db5dd0dd..db766a0d 100644
--- a/gen/fleet/dd_group.py
+++ b/gen/fleet/dd_group.py
@@ -1,12 +1,13 @@
from __future__ import annotations
+
from typing import TYPE_CHECKING, Type
-from game.factions.faction import Faction
-from game.theater.theatergroundobject import TheaterGroundObject
-
-from gen.sam.group_generator import ShipGroupGenerator
-from dcs.unittype import ShipType
from dcs.ships import PERRY, USS_Arleigh_Burke_IIa
+from dcs.unittype import ShipType
+
+from game.factions.faction import Faction
+from game.theater.theatergroundobject import ShipGroundObject
+from gen.sam.group_generator import ShipGroupGenerator
if TYPE_CHECKING:
from game.game import Game
@@ -16,14 +17,14 @@ class DDGroupGenerator(ShipGroupGenerator):
def __init__(
self,
game: Game,
- ground_object: TheaterGroundObject,
+ ground_object: ShipGroundObject,
faction: Faction,
ddtype: Type[ShipType],
):
super(DDGroupGenerator, self).__init__(game, ground_object, faction)
self.ddtype = ddtype
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
self.ddtype,
"DD1",
@@ -42,18 +43,14 @@ class DDGroupGenerator(ShipGroupGenerator):
class OliverHazardPerryGroupGenerator(DDGroupGenerator):
- def __init__(
- self, game: Game, ground_object: TheaterGroundObject, faction: Faction
- ):
+ def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction):
super(OliverHazardPerryGroupGenerator, self).__init__(
game, ground_object, faction, PERRY
)
class ArleighBurkeGroupGenerator(DDGroupGenerator):
- def __init__(
- self, game: Game, ground_object: TheaterGroundObject, faction: Faction
- ):
+ def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction):
super(ArleighBurkeGroupGenerator, self).__init__(
game, ground_object, faction, USS_Arleigh_Burke_IIa
)
diff --git a/gen/fleet/lacombattanteII.py b/gen/fleet/lacombattanteII.py
index 7de47da1..bd476f45 100644
--- a/gen/fleet/lacombattanteII.py
+++ b/gen/fleet/lacombattanteII.py
@@ -1,12 +1,13 @@
from dcs.ships import La_Combattante_II
+from game import Game
from game.factions.faction import Faction
-from game.theater import TheaterGroundObject
+from game.theater.theatergroundobject import ShipGroundObject
from gen.fleet.dd_group import DDGroupGenerator
class LaCombattanteIIGroupGenerator(DDGroupGenerator):
- def __init__(self, game, ground_object: TheaterGroundObject, faction: Faction):
+ def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction):
super(LaCombattanteIIGroupGenerator, self).__init__(
game, ground_object, faction, La_Combattante_II
)
diff --git a/gen/fleet/lha_group.py b/gen/fleet/lha_group.py
index a1a78d37..a7a896b9 100644
--- a/gen/fleet/lha_group.py
+++ b/gen/fleet/lha_group.py
@@ -4,7 +4,7 @@ from gen.sam.group_generator import ShipGroupGenerator
class LHAGroupGenerator(ShipGroupGenerator):
- def generate(self):
+ def generate(self) -> None:
# Add carrier
if len(self.faction.helicopter_carrier) > 0:
diff --git a/gen/fleet/ru_dd_group.py b/gen/fleet/ru_dd_group.py
index 8ec15d26..67f9c923 100644
--- a/gen/fleet/ru_dd_group.py
+++ b/gen/fleet/ru_dd_group.py
@@ -1,4 +1,5 @@
from __future__ import annotations
+
import random
from typing import TYPE_CHECKING
@@ -12,18 +13,17 @@ from dcs.ships import (
SOM,
)
+from game.factions.faction import Faction
+from game.theater.theatergroundobject import ShipGroundObject
from gen.fleet.dd_group import DDGroupGenerator
from gen.sam.group_generator import ShipGroupGenerator
-from game.factions.faction import Faction
-from game.theater.theatergroundobject import TheaterGroundObject
-
if TYPE_CHECKING:
from game.game import Game
class RussianNavyGroupGenerator(ShipGroupGenerator):
- def generate(self):
+ def generate(self) -> None:
include_frigate = random.choice([True, True, False])
include_dd = random.choice([True, False])
@@ -85,32 +85,24 @@ class RussianNavyGroupGenerator(ShipGroupGenerator):
class GrishaGroupGenerator(DDGroupGenerator):
- def __init__(
- self, game: Game, ground_object: TheaterGroundObject, faction: Faction
- ):
+ def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction):
super(GrishaGroupGenerator, self).__init__(
game, ground_object, faction, ALBATROS
)
class MolniyaGroupGenerator(DDGroupGenerator):
- def __init__(
- self, game: Game, ground_object: TheaterGroundObject, faction: Faction
- ):
+ def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction):
super(MolniyaGroupGenerator, self).__init__(
game, ground_object, faction, MOLNIYA
)
class KiloSubGroupGenerator(DDGroupGenerator):
- def __init__(
- self, game: Game, ground_object: TheaterGroundObject, faction: Faction
- ):
+ def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction):
super(KiloSubGroupGenerator, self).__init__(game, ground_object, faction, KILO)
class TangoSubGroupGenerator(DDGroupGenerator):
- def __init__(
- self, game: Game, ground_object: TheaterGroundObject, faction: Faction
- ):
+ def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction):
super(TangoSubGroupGenerator, self).__init__(game, ground_object, faction, SOM)
diff --git a/gen/fleet/schnellboot.py b/gen/fleet/schnellboot.py
index 83a83fdf..d5fe16a6 100644
--- a/gen/fleet/schnellboot.py
+++ b/gen/fleet/schnellboot.py
@@ -6,7 +6,7 @@ from gen.sam.group_generator import ShipGroupGenerator
class SchnellbootGroupGenerator(ShipGroupGenerator):
- def generate(self):
+ def generate(self) -> None:
for i in range(random.randint(2, 4)):
self.add_unit(
diff --git a/gen/fleet/ship_group_generator.py b/gen/fleet/ship_group_generator.py
index 03ab852f..1cd8338c 100644
--- a/gen/fleet/ship_group_generator.py
+++ b/gen/fleet/ship_group_generator.py
@@ -1,7 +1,17 @@
+from __future__ import annotations
+
import logging
import random
+from typing import TYPE_CHECKING, Optional
+
+from dcs.unitgroup import ShipGroup
from game import db
+from game.theater.theatergroundobject import (
+ LhaGroundObject,
+ CarrierGroundObject,
+ ShipGroundObject,
+)
from gen.fleet.carrier_group import CarrierGroupGenerator
from gen.fleet.cn_dd_group import ChineseNavyGroupGenerator, Type54GroupGenerator
from gen.fleet.dd_group import (
@@ -21,6 +31,9 @@ from gen.fleet.schnellboot import SchnellbootGroupGenerator
from gen.fleet.uboat import UBoatGroupGenerator
from gen.fleet.ww2lst import WW2LSTGroupGenerator
+if TYPE_CHECKING:
+ from game import Game
+
SHIP_MAP = {
"SchnellbootGroupGenerator": SchnellbootGroupGenerator,
@@ -39,10 +52,12 @@ SHIP_MAP = {
}
-def generate_ship_group(game, ground_object, faction_name: str):
+def generate_ship_group(
+ game: Game, ground_object: ShipGroundObject, faction_name: str
+) -> Optional[ShipGroup]:
"""
This generate a ship group
- :return: Nothing, but put the group reference inside the ground object
+ :return: The generated group, or None if this faction does not support ships.
"""
faction = db.FACTIONS[faction_name]
if len(faction.navy_generators) > 0:
@@ -61,26 +76,30 @@ def generate_ship_group(game, ground_object, faction_name: str):
return None
-def generate_carrier_group(faction: str, game, ground_object):
- """
- This generate a carrier group
- :param parentCp: The parent control point
+def generate_carrier_group(
+ faction: str, game: Game, ground_object: CarrierGroundObject
+) -> ShipGroup:
+ """Generates a carrier group.
+
+ :param faction: The faction the TGO belongs to.
+ :param game: The Game the group is being generated for.
:param ground_object: The ground object which will own the ship group
- :param country: Owner country
- :return: Nothing, but put the group reference inside the ground object
+ :return: The generated group.
"""
generator = CarrierGroupGenerator(game, ground_object, db.FACTIONS[faction])
generator.generate()
return generator.get_generated_group()
-def generate_lha_group(faction: str, game, ground_object):
- """
- This generate a lha carrier group
- :param parentCp: The parent control point
+def generate_lha_group(
+ faction: str, game: Game, ground_object: LhaGroundObject
+) -> ShipGroup:
+ """Generate an LHA group.
+
+ :param faction: The faction the TGO belongs to.
+ :param game: The Game the group is being generated for.
:param ground_object: The ground object which will own the ship group
- :param country: Owner country
- :return: Nothing, but put the group reference inside the ground object
+ :return: The generated group.
"""
generator = LHAGroupGenerator(game, ground_object, db.FACTIONS[faction])
generator.generate()
diff --git a/gen/fleet/uboat.py b/gen/fleet/uboat.py
index 6333021f..ee8c3114 100644
--- a/gen/fleet/uboat.py
+++ b/gen/fleet/uboat.py
@@ -6,7 +6,7 @@ from gen.sam.group_generator import ShipGroupGenerator
class UBoatGroupGenerator(ShipGroupGenerator):
- def generate(self):
+ def generate(self) -> None:
for i in range(random.randint(1, 4)):
self.add_unit(
diff --git a/gen/fleet/ww2lst.py b/gen/fleet/ww2lst.py
index 7ed63fbe..e3ac7de6 100644
--- a/gen/fleet/ww2lst.py
+++ b/gen/fleet/ww2lst.py
@@ -6,7 +6,7 @@ from gen.sam.group_generator import ShipGroupGenerator
class WW2LSTGroupGenerator(ShipGroupGenerator):
- def generate(self):
+ def generate(self) -> None:
# Add LS Samuel Chase
self.add_unit(
diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py
deleted file mode 100644
index 7bbee129..00000000
--- a/gen/flights/ai_flight_planner.py
+++ /dev/null
@@ -1,1069 +0,0 @@
-from __future__ import annotations
-
-import logging
-import math
-import operator
-import random
-from collections import defaultdict
-from dataclasses import dataclass, field
-from datetime import timedelta
-from enum import Enum, auto
-from typing import (
- Dict,
- Iterable,
- Iterator,
- List,
- Optional,
- Set,
- TYPE_CHECKING,
- Tuple,
- TypeVar,
-)
-
-from game.dcs.aircrafttype import AircraftType
-from game.infos.information import Information
-from game.procurement import AircraftProcurementRequest
-from game.profiling import logged_duration, MultiEventTracer
-from game.squadrons import AirWing, Squadron
-from game.theater import (
- Airfield,
- ControlPoint,
- Fob,
- FrontLine,
- MissionTarget,
- OffMapSpawn,
- SamGroundObject,
- TheaterGroundObject,
-)
-from game.theater.theatergroundobject import (
- BuildingGroundObject,
- EwrGroundObject,
- NavalGroundObject,
- VehicleGroupGroundObject,
-)
-from game.transfers import CargoShip, Convoy
-from game.utils import Distance, nautical_miles, meters
-from gen.ato import Package
-from gen.flights.ai_flight_planner_db import aircraft_for_task
-from gen.flights.closestairfields import (
- ClosestAirfields,
- ObjectiveDistanceCache,
-)
-from gen.flights.flight import (
- Flight,
- FlightType,
-)
-from gen.flights.flightplan import FlightPlanBuilder
-from gen.flights.traveltime import TotEstimator
-
-# Avoid importing some types that cause circular imports unless type checking.
-if TYPE_CHECKING:
- from game import Game
- from game.inventory import GlobalAircraftInventory
-
-
-class EscortType(Enum):
- AirToAir = auto()
- Sead = auto()
-
-
-@dataclass(frozen=True)
-class ProposedFlight:
- """A flight outline proposed by the mission planner.
-
- Proposed flights haven't been assigned specific aircraft yet. They have only
- a task, a required number of aircraft, and a maximum distance allowed
- between the objective and the departure airfield.
- """
-
- #: The flight's role.
- task: FlightType
-
- #: The number of aircraft required.
- num_aircraft: int
-
- #: The maximum distance between the objective and the departure airfield.
- max_distance: Distance
-
- #: The type of threat this flight defends against if it is an escort. Escort
- #: flights will be pruned if the rest of the package is not threatened by
- #: the threat they defend against. If this flight is not an escort, this
- #: field is None.
- escort_type: Optional[EscortType] = field(default=None)
-
- def __str__(self) -> str:
- return f"{self.task} {self.num_aircraft} ship"
-
-
-@dataclass(frozen=True)
-class ProposedMission:
- """A mission outline proposed by the mission planner.
-
- Proposed missions haven't been assigned aircraft yet. They have only an
- objective location and a list of proposed flights that are required for the
- mission.
- """
-
- #: The mission objective.
- location: MissionTarget
-
- #: The proposed flights that are required for the mission.
- flights: List[ProposedFlight]
-
- asap: bool = field(default=False)
-
- def __str__(self) -> str:
- flights = ", ".join([str(f) for f in self.flights])
- return f"{self.location.name}: {flights}"
-
-
-class AircraftAllocator:
- """Finds suitable aircraft for proposed missions."""
-
- def __init__(
- self,
- air_wing: AirWing,
- closest_airfields: ClosestAirfields,
- global_inventory: GlobalAircraftInventory,
- is_player: bool,
- ) -> None:
- self.air_wing = air_wing
- self.closest_airfields = closest_airfields
- self.global_inventory = global_inventory
- self.is_player = is_player
-
- def find_squadron_for_flight(
- self, flight: ProposedFlight
- ) -> Optional[Tuple[ControlPoint, Squadron]]:
- """Finds aircraft suitable for the given mission.
-
- Searches for aircraft capable of performing the given mission within the
- maximum allowed range. If insufficient aircraft are available for the
- mission, None is returned.
-
- Airfields are searched ordered nearest to farthest from the target and
- searched twice. The first search looks for aircraft which prefer the
- mission type, and the second search looks for any aircraft which are
- capable of the mission type. For example, an F-14 from a nearby carrier
- will be preferred for the CAP of an airfield that has only F-16s, but if
- the carrier has only F/A-18s the F-16s will be used for CAP instead.
-
- Note that aircraft *will* be removed from the global inventory on
- success. This is to ensure that the same aircraft are not matched twice
- on subsequent calls. If the found aircraft are not used, the caller is
- responsible for returning them to the inventory.
- """
- return self.find_aircraft_for_task(flight, flight.task)
-
- def find_aircraft_for_task(
- self, flight: ProposedFlight, task: FlightType
- ) -> Optional[Tuple[ControlPoint, Squadron]]:
- types = aircraft_for_task(task)
- airfields_in_range = self.closest_airfields.operational_airfields_within(
- flight.max_distance
- )
-
- for airfield in airfields_in_range:
- if not airfield.is_friendly(self.is_player):
- continue
- inventory = self.global_inventory.for_control_point(airfield)
- for aircraft in types:
- if not airfield.can_operate(aircraft):
- continue
- if inventory.available(aircraft) < flight.num_aircraft:
- continue
- # Valid location with enough aircraft available. Find a squadron to fit
- # the role.
- squadrons = self.air_wing.auto_assignable_for_task_with_type(
- aircraft, task
- )
- for squadron in squadrons:
- if squadron.can_provide_pilots(flight.num_aircraft):
- inventory.remove_aircraft(aircraft, flight.num_aircraft)
- return airfield, squadron
- return None
-
-
-class PackageBuilder:
- """Builds a Package for the flights it receives."""
-
- def __init__(
- self,
- location: MissionTarget,
- closest_airfields: ClosestAirfields,
- global_inventory: GlobalAircraftInventory,
- air_wing: AirWing,
- is_player: bool,
- package_country: str,
- start_type: str,
- asap: bool,
- ) -> None:
- self.closest_airfields = closest_airfields
- self.is_player = is_player
- self.package_country = package_country
- self.package = Package(location, auto_asap=asap)
- self.allocator = AircraftAllocator(
- air_wing, closest_airfields, global_inventory, is_player
- )
- self.global_inventory = global_inventory
- self.start_type = start_type
-
- def plan_flight(self, plan: ProposedFlight) -> bool:
- """Allocates aircraft for the given flight and adds them to the package.
-
- If no suitable aircraft are available, False is returned. If the failed
- flight was critical and the rest of the mission will be scrubbed, the
- caller should return any previously planned flights to the inventory
- using release_planned_aircraft.
- """
- assignment = self.allocator.find_squadron_for_flight(plan)
- if assignment is None:
- return False
- airfield, squadron = assignment
- if isinstance(airfield, OffMapSpawn):
- start_type = "In Flight"
- else:
- start_type = self.start_type
-
- flight = Flight(
- self.package,
- self.package_country,
- squadron,
- plan.num_aircraft,
- plan.task,
- start_type,
- departure=airfield,
- arrival=airfield,
- divert=self.find_divert_field(squadron.aircraft, airfield),
- )
- self.package.add_flight(flight)
- return True
-
- def find_divert_field(
- self, aircraft: AircraftType, arrival: ControlPoint
- ) -> Optional[ControlPoint]:
- divert_limit = nautical_miles(150)
- for airfield in self.closest_airfields.operational_airfields_within(
- divert_limit
- ):
- if airfield.captured != self.is_player:
- continue
- if airfield == arrival:
- continue
- if not airfield.can_operate(aircraft):
- continue
- if isinstance(airfield, OffMapSpawn):
- continue
- return airfield
- return None
-
- def build(self) -> Package:
- """Returns the built package."""
- return self.package
-
- def release_planned_aircraft(self) -> None:
- """Returns any planned flights to the inventory."""
- flights = list(self.package.flights)
- for flight in flights:
- self.global_inventory.return_from_flight(flight)
- flight.clear_roster()
- self.package.remove_flight(flight)
-
-
-MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget)
-
-
-class ObjectiveFinder:
- """Identifies potential objectives for the mission planner."""
-
- # TODO: Merge into doctrine.
- AIRFIELD_THREAT_RANGE = nautical_miles(150)
- SAM_THREAT_RANGE = nautical_miles(100)
-
- def __init__(self, game: Game, is_player: bool) -> None:
- self.game = game
- self.is_player = is_player
-
- def enemy_air_defenses(self) -> Iterator[tuple[TheaterGroundObject, Distance]]:
- """Iterates over all enemy SAM sites."""
- doctrine = self.game.faction_for(self.is_player).doctrine
- threat_zones = self.game.threat_zone_for(not self.is_player)
- for cp in self.enemy_control_points():
- for ground_object in cp.ground_objects:
- if ground_object.is_dead:
- continue
-
- if isinstance(ground_object, EwrGroundObject):
- if threat_zones.threatened_by_air_defense(ground_object):
- # This is a very weak heuristic for determining whether the EWR
- # is close enough to be worth targeting before a SAM that is
- # covering it. Ingress distance corresponds to the beginning of
- # the attack range and is sufficient for most standoff weapons,
- # so treating the ingress distance as the threat distance sorts
- # these EWRs such that they will be attacked before SAMs that do
- # not threaten the ingress point, but after those that do.
- target_range = doctrine.ingress_egress_distance
- else:
- # But if the EWR isn't covered then we should only be worrying
- # about its detection range.
- target_range = ground_object.max_detection_range()
- elif isinstance(ground_object, SamGroundObject):
- target_range = ground_object.max_threat_range()
- else:
- continue
-
- yield ground_object, target_range
-
- def threatening_air_defenses(self) -> Iterator[TheaterGroundObject]:
- """Iterates over enemy SAMs in threat range of friendly control points.
-
- SAM sites are sorted by their closest proximity to any friendly control
- point (airfield or fleet).
- """
-
- target_ranges: list[tuple[TheaterGroundObject, Distance]] = []
- for target, threat_range in self.enemy_air_defenses():
- ranges: list[Distance] = []
- for cp in self.friendly_control_points():
- ranges.append(meters(target.distance_to(cp)) - threat_range)
- target_ranges.append((target, min(ranges)))
-
- target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
- for target, _range in target_ranges:
- yield target
-
- def enemy_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]:
- """Iterates over all enemy vehicle groups."""
- for cp in self.enemy_control_points():
- for ground_object in cp.ground_objects:
- if not isinstance(ground_object, VehicleGroupGroundObject):
- continue
-
- if ground_object.is_dead:
- continue
-
- yield ground_object
-
- def threatening_vehicle_groups(self) -> Iterator[MissionTarget]:
- """Iterates over enemy vehicle groups near friendly control points.
-
- Groups are sorted by their closest proximity to any friendly control
- point (airfield or fleet).
- """
- return self._targets_by_range(self.enemy_vehicle_groups())
-
- def enemy_ships(self) -> Iterator[NavalGroundObject]:
- for cp in self.enemy_control_points():
- for ground_object in cp.ground_objects:
- if not isinstance(ground_object, NavalGroundObject):
- continue
-
- if ground_object.is_dead:
- continue
-
- yield ground_object
-
- def threatening_ships(self) -> Iterator[MissionTarget]:
- """Iterates over enemy ships near friendly control points.
-
- Groups are sorted by their closest proximity to any friendly control
- point (airfield or fleet).
- """
- return self._targets_by_range(self.enemy_ships())
-
- def _targets_by_range(
- self, targets: Iterable[MissionTargetType]
- ) -> Iterator[MissionTargetType]:
- target_ranges: List[Tuple[MissionTargetType, int]] = []
- for target in targets:
- ranges: List[int] = []
- for cp in self.friendly_control_points():
- ranges.append(target.distance_to(cp))
- target_ranges.append((target, min(ranges)))
-
- target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
- for target, _range in target_ranges:
- yield target
-
- def strike_targets(self) -> Iterator[TheaterGroundObject]:
- """Iterates over enemy strike targets.
-
- Targets are sorted by their closest proximity to any friendly control
- point (airfield or fleet).
- """
- targets: List[Tuple[TheaterGroundObject, int]] = []
- # Building objectives are made of several individual TGOs (one per
- # building).
- found_targets: Set[str] = set()
- for enemy_cp in self.enemy_control_points():
- for ground_object in enemy_cp.ground_objects:
- # TODO: Reuse ground_object.mission_types.
- # The mission types for ground objects are currently not
- # accurate because we include things like strike and BAI for all
- # targets since they have different planning behavior (waypoint
- # generation is better for players with strike when the targets
- # are stationary, AI behavior against weaker air defenses is
- # better with BAI), so that's not a useful filter. Once we have
- # better control over planning profiles and target dependent
- # loadouts we can clean this up.
- if isinstance(ground_object, VehicleGroupGroundObject):
- # BAI target, not strike target.
- continue
-
- if isinstance(ground_object, NavalGroundObject):
- # Anti-ship target, not strike target.
- continue
-
- if isinstance(ground_object, SamGroundObject):
- # SAMs are targeted by DEAD. No need to double plan.
- continue
-
- is_building = isinstance(ground_object, BuildingGroundObject)
- is_fob = isinstance(enemy_cp, Fob)
- if is_building and is_fob and ground_object.is_control_point:
- # This is the FOB structure itself. Can't be repaired or
- # targeted by the player, so shouldn't be targetable by the
- # AI.
- continue
-
- if ground_object.is_dead:
- continue
- if ground_object.name in found_targets:
- continue
- ranges: List[int] = []
- for friendly_cp in self.friendly_control_points():
- ranges.append(ground_object.distance_to(friendly_cp))
- targets.append((ground_object, min(ranges)))
- found_targets.add(ground_object.name)
- targets = sorted(targets, key=operator.itemgetter(1))
- for target, _range in targets:
- yield target
-
- def front_lines(self) -> Iterator[FrontLine]:
- """Iterates over all active front lines in the theater."""
- yield from self.game.theater.conflicts()
-
- def vulnerable_control_points(self) -> Iterator[ControlPoint]:
- """Iterates over friendly CPs that are vulnerable to enemy CPs.
-
- Vulnerability is defined as any enemy CP within threat range of of the
- CP.
- """
- for cp in self.friendly_control_points():
- if isinstance(cp, OffMapSpawn):
- # Off-map spawn locations don't need protection.
- continue
- airfields_in_proximity = self.closest_airfields_to(cp)
- airfields_in_threat_range = (
- airfields_in_proximity.operational_airfields_within(
- self.AIRFIELD_THREAT_RANGE
- )
- )
- for airfield in airfields_in_threat_range:
- if not airfield.is_friendly(self.is_player):
- yield cp
- break
-
- def oca_targets(self, min_aircraft: int) -> Iterator[MissionTarget]:
- airfields = []
- for control_point in self.enemy_control_points():
- if not isinstance(control_point, Airfield):
- continue
- if control_point.base.total_aircraft >= min_aircraft:
- airfields.append(control_point)
- return self._targets_by_range(airfields)
-
- def convoys(self) -> Iterator[Convoy]:
- for front_line in self.front_lines():
- yield from self.game.transfers.convoys.travelling_to(
- front_line.control_point_hostile_to(self.is_player)
- )
-
- def cargo_ships(self) -> Iterator[CargoShip]:
- for front_line in self.front_lines():
- yield from self.game.transfers.cargo_ships.travelling_to(
- front_line.control_point_hostile_to(self.is_player)
- )
-
- def friendly_control_points(self) -> Iterator[ControlPoint]:
- """Iterates over all friendly control points."""
- return (
- c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player)
- )
-
- def farthest_friendly_control_point(self) -> ControlPoint:
- """Finds the friendly control point that is farthest from any threats."""
- threat_zones = self.game.threat_zone_for(not self.is_player)
-
- farthest = None
- max_distance = meters(0)
- for cp in self.friendly_control_points():
- if isinstance(cp, OffMapSpawn):
- continue
- distance = threat_zones.distance_to_threat(cp.position)
- if distance > max_distance:
- farthest = cp
- max_distance = distance
-
- if farthest is None:
- raise RuntimeError("Found no friendly control points. You probably lost.")
- return farthest
-
- def closest_friendly_control_point(self) -> ControlPoint:
- """Finds the friendly control point that is closest to any threats."""
- threat_zones = self.game.threat_zone_for(not self.is_player)
-
- closest = None
- min_distance = meters(math.inf)
- for cp in self.friendly_control_points():
- if isinstance(cp, OffMapSpawn):
- continue
- distance = threat_zones.distance_to_threat(cp.position)
- if distance < min_distance:
- closest = cp
- min_distance = distance
-
- if closest is None:
- raise RuntimeError("Found no friendly control points. You probably lost.")
- return closest
-
- def enemy_control_points(self) -> Iterator[ControlPoint]:
- """Iterates over all enemy control points."""
- return (
- c
- for c in self.game.theater.controlpoints
- if not c.is_friendly(self.is_player)
- )
-
- def all_possible_targets(self) -> Iterator[MissionTarget]:
- """Iterates over all possible mission targets in the theater.
-
- Valid mission targets are control points (airfields and carriers), front
- lines, and ground objects (SAM sites, factories, resource extraction
- sites, etc).
- """
- for cp in self.game.theater.controlpoints:
- yield cp
- yield from cp.ground_objects
- yield from self.front_lines()
-
- @staticmethod
- def closest_airfields_to(location: MissionTarget) -> ClosestAirfields:
- """Returns the closest airfields to the given location."""
- return ObjectiveDistanceCache.get_closest_airfields(location)
-
-
-class CoalitionMissionPlanner:
- """Coalition flight planning AI.
-
- This class is responsible for automatically planning missions for the
- coalition at the start of the turn.
-
- The primary goal of the mission planner is to protect existing friendly
- assets. Missions will be planned with the following priorities:
-
- 1. CAP for airfields/fleets in close proximity to the enemy to prevent heavy
- losses of friendly aircraft.
- 2. CAP for front line areas to protect ground and CAS units.
- 3. DEAD to reduce necessity of SEAD for future missions.
- 4. CAS to protect friendly ground units.
- 5. Strike missions to reduce the enemy's resources.
-
- TODO: Anti-ship and airfield strikes to reduce enemy sortie rates.
- TODO: BAI to prevent enemy forces from reaching the front line.
- TODO: Should fleets always have a CAP?
-
- TODO: Stance and doctrine-specific planning behavior.
- """
-
- # TODO: Merge into doctrine, also limit by aircraft.
- MAX_CAP_RANGE = nautical_miles(100)
- MAX_CAS_RANGE = nautical_miles(50)
- MAX_ANTISHIP_RANGE = nautical_miles(150)
- MAX_BAI_RANGE = nautical_miles(150)
- MAX_OCA_RANGE = nautical_miles(150)
- MAX_SEAD_RANGE = nautical_miles(150)
- MAX_STRIKE_RANGE = nautical_miles(150)
- MAX_AWEC_RANGE = Distance.inf()
- MAX_TANKER_RANGE = nautical_miles(200)
-
- def __init__(self, game: Game, is_player: bool) -> None:
- self.game = game
- self.is_player = is_player
- self.objective_finder = ObjectiveFinder(self.game, self.is_player)
- self.ato = self.game.blue_ato if is_player else self.game.red_ato
- self.threat_zones = self.game.threat_zone_for(not self.is_player)
- self.procurement_requests = self.game.procurement_requests_for(self.is_player)
- self.faction = self.game.faction_for(self.is_player)
-
- def air_wing_can_plan(self, mission_type: FlightType) -> bool:
- """Returns True if it is possible for the air wing to plan this mission type.
-
- Not all mission types can be fulfilled by all air wings. Many factions do not
- have AEW&C aircraft, so they will never be able to plan those missions. It's
- also possible for the player to exclude mission types from their squadron
- designs.
- """
- return self.game.air_wing_for(self.is_player).can_auto_plan(mission_type)
-
- def critical_missions(self) -> Iterator[ProposedMission]:
- """Identifies the most important missions to plan this turn.
-
- Non-critical missions that cannot be fulfilled will create purchase
- orders for the next turn. Critical missions will create a purchase order
- unless the mission can be doubly fulfilled. In other words, the AI will
- attempt to have *double* the aircraft it needs for these missions to
- ensure that they can be planned again next turn even if all aircraft are
- eliminated this turn.
- """
-
- # Find farthest, friendly CP for AEWC.
- yield ProposedMission(
- self.objective_finder.farthest_friendly_control_point(),
- [ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)],
- # Supports all the early CAP flights, so should be in the air ASAP.
- asap=True,
- )
-
- yield ProposedMission(
- self.objective_finder.closest_friendly_control_point(),
- [ProposedFlight(FlightType.REFUELING, 1, self.MAX_TANKER_RANGE)],
- )
-
- # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP.
- for cp in self.objective_finder.vulnerable_control_points():
- # Plan CAP in such a way, that it is established during the whole desired mission length
- for _ in range(
- 0,
- int(self.game.settings.desired_player_mission_duration.total_seconds()),
- int(self.faction.doctrine.cap_duration.total_seconds()),
- ):
- yield ProposedMission(
- cp,
- [
- ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
- ],
- )
-
- # Find front lines, plan CAS.
- for front_line in self.objective_finder.front_lines():
- yield ProposedMission(
- front_line,
- [
- ProposedFlight(FlightType.CAS, 2, self.MAX_CAS_RANGE),
- # This is *not* an escort because front lines don't create a threat
- # zone. Generating threat zones from front lines causes the front
- # line to push back BARCAPs as it gets closer to the base. While
- # front lines do have the same problem of potentially pulling
- # BARCAPs off bases to engage a front line TARCAP, that's probably
- # the one time where we do want that.
- #
- # TODO: Use intercepts and extra TARCAPs to cover bases near fronts.
- # We don't have intercept missions yet so this isn't something we
- # can do today, but we should probably return to having the front
- # line project a threat zone (so that strike missions will route
- # around it) and instead *not plan* a BARCAP at bases near the
- # front, since there isn't a place to put a barrier. Instead, the
- # aircraft that would have been a BARCAP could be used as additional
- # interceptors and TARCAPs which will defend the base but won't be
- # trying to avoid front line contacts.
- ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE),
- ],
- )
-
- def propose_missions(self) -> Iterator[ProposedMission]:
- """Identifies and iterates over potential mission in priority order."""
- yield from self.critical_missions()
-
- # Find enemy SAM sites with ranges that cover friendly CPs, front lines,
- # or objects, plan DEAD.
- # Find enemy SAM sites with ranges that extend to within 50 nmi of
- # friendly CPs, front, lines, or objects, plan DEAD.
- for sam in self.objective_finder.threatening_air_defenses():
- flights = [ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE)]
-
- # Only include SEAD against SAMs that still have emitters. No need to
- # suppress an EWR, and SEAD isn't useful against a SAM that no longer has a
- # working track radar.
- #
- # For SAMs without track radars and EWRs, we still want a SEAD escort if
- # needed.
- #
- # Note that there is a quirk here: we should potentially be included a SEAD
- # escort *and* SEAD when the target is a radar SAM but the flight path is
- # also threatened by SAMs. We don't want to include a SEAD escort if the
- # package is *only* threatened by the target though. Could be improved, but
- # needs a decent refactor to the escort planning to do so.
- if sam.has_live_radar_sam:
- flights.append(ProposedFlight(FlightType.SEAD, 2, self.MAX_SEAD_RANGE))
- else:
- flights.append(
- ProposedFlight(
- FlightType.SEAD_ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.Sead
- )
- )
- # TODO: Max escort range.
- flights.append(
- ProposedFlight(
- FlightType.ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.AirToAir
- )
- )
- yield ProposedMission(sam, flights)
-
- # These will only rarely get planned. When a convoy is travelling multiple legs,
- # they're targetable after the first leg. The reason for this is that
- # procurement happens *after* mission planning so that the missions that could
- # not be filled will guide the procurement process. Procurement is the stage
- # that convoys are created (because they're created to move ground units that
- # were just purchased), so we haven't created any yet. Any incomplete transfers
- # from the previous turn (multi-leg journeys) will still be present though so
- # they can be targeted.
- #
- # Even after this is fixed, the player's convoys that were created through the
- # UI will never be targeted on the first turn of their journey because the AI
- # stops planning after the start of the turn. We could potentially fix this by
- # moving opfor mission planning until the takeoff button is pushed.
- for convoy in self.objective_finder.convoys():
- yield ProposedMission(
- convoy,
- [
- ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE),
- # TODO: Max escort range.
- ProposedFlight(
- FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
- ),
- ProposedFlight(
- FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead
- ),
- ],
- )
-
- for ship in self.objective_finder.cargo_ships():
- yield ProposedMission(
- ship,
- [
- ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE),
- # TODO: Max escort range.
- ProposedFlight(
- FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
- ),
- ProposedFlight(
- FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead
- ),
- ],
- )
-
- for group in self.objective_finder.threatening_ships():
- yield ProposedMission(
- group,
- [
- ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE),
- # TODO: Max escort range.
- ProposedFlight(
- FlightType.ESCORT,
- 2,
- self.MAX_ANTISHIP_RANGE,
- EscortType.AirToAir,
- ),
- ],
- )
-
- for group in self.objective_finder.threatening_vehicle_groups():
- yield ProposedMission(
- group,
- [
- ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE),
- # TODO: Max escort range.
- ProposedFlight(
- FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
- ),
- ProposedFlight(
- FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead
- ),
- ],
- )
-
- for target in self.objective_finder.oca_targets(min_aircraft=20):
- flights = [
- ProposedFlight(FlightType.OCA_RUNWAY, 2, self.MAX_OCA_RANGE),
- ]
- if self.game.settings.default_start_type == "Cold":
- # Only schedule if the default start type is Cold. If the player
- # has set anything else there are no targets to hit.
- flights.append(
- ProposedFlight(FlightType.OCA_AIRCRAFT, 2, self.MAX_OCA_RANGE)
- )
- flights.extend(
- [
- # TODO: Max escort range.
- ProposedFlight(
- FlightType.ESCORT, 2, self.MAX_OCA_RANGE, EscortType.AirToAir
- ),
- ProposedFlight(
- FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead
- ),
- ]
- )
- yield ProposedMission(target, flights)
-
- # Plan strike missions.
- for target in self.objective_finder.strike_targets():
- yield ProposedMission(
- target,
- [
- ProposedFlight(FlightType.STRIKE, 2, self.MAX_STRIKE_RANGE),
- # TODO: Max escort range.
- ProposedFlight(
- FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE, EscortType.AirToAir
- ),
- ProposedFlight(
- FlightType.SEAD_ESCORT,
- 2,
- self.MAX_STRIKE_RANGE,
- EscortType.Sead,
- ),
- ],
- )
-
- def plan_missions(self) -> None:
- """Identifies and plans mission for the turn."""
- player = "Blue" if self.is_player else "Red"
- with logged_duration(f"{player} mission identification and fulfillment"):
- with MultiEventTracer() as tracer:
- for proposed_mission in self.propose_missions():
- self.plan_mission(proposed_mission, tracer)
-
- with logged_duration(f"{player} reserve mission planning"):
- with MultiEventTracer() as tracer:
- for critical_mission in self.critical_missions():
- self.plan_mission(critical_mission, tracer, reserves=True)
-
- with logged_duration(f"{player} mission scheduling"):
- self.stagger_missions()
-
- for cp in self.objective_finder.friendly_control_points():
- inventory = self.game.aircraft_inventory.for_control_point(cp)
- for aircraft, available in inventory.all_aircraft:
- self.message("Unused aircraft", f"{available} {aircraft} from {cp}")
-
- def plan_flight(
- self,
- mission: ProposedMission,
- flight: ProposedFlight,
- builder: PackageBuilder,
- missing_types: Set[FlightType],
- for_reserves: bool,
- ) -> None:
- if not builder.plan_flight(flight):
- missing_types.add(flight.task)
- purchase_order = AircraftProcurementRequest(
- near=mission.location,
- range=flight.max_distance,
- task_capability=flight.task,
- number=flight.num_aircraft,
- )
- if for_reserves:
- # Reserves are planned for critical missions, so prioritize
- # those orders over aircraft needed for non-critical missions.
- self.procurement_requests.insert(0, purchase_order)
- else:
- self.procurement_requests.append(purchase_order)
-
- def scrub_mission_missing_aircraft(
- self,
- mission: ProposedMission,
- builder: PackageBuilder,
- missing_types: Set[FlightType],
- not_attempted: Iterable[ProposedFlight],
- reserves: bool,
- ) -> None:
- # Try to plan the rest of the mission just so we can count the missing
- # types to buy.
- for flight in not_attempted:
- self.plan_flight(mission, flight, builder, missing_types, reserves)
-
- missing_types_str = ", ".join(sorted([t.name for t in missing_types]))
- builder.release_planned_aircraft()
- desc = "reserve aircraft" if reserves else "aircraft"
- self.message(
- "Insufficient aircraft",
- f"Not enough {desc} in range for {mission.location.name} "
- f"capable of: {missing_types_str}",
- )
-
- def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]:
- threats = defaultdict(bool)
- for flight in builder.package.flights:
- if self.threat_zones.waypoints_threatened_by_aircraft(
- flight.flight_plan.escorted_waypoints()
- ):
- threats[EscortType.AirToAir] = True
- if self.threat_zones.waypoints_threatened_by_radar_sam(
- list(flight.flight_plan.escorted_waypoints())
- ):
- threats[EscortType.Sead] = True
- return threats
-
- def plan_mission(
- self, mission: ProposedMission, tracer: MultiEventTracer, reserves: bool = False
- ) -> None:
- """Allocates aircraft for a proposed mission and adds it to the ATO."""
- builder = PackageBuilder(
- mission.location,
- self.objective_finder.closest_airfields_to(mission.location),
- self.game.aircraft_inventory,
- self.game.air_wing_for(self.is_player),
- self.is_player,
- self.game.country_for(self.is_player),
- self.game.settings.default_start_type,
- mission.asap,
- )
-
- # Attempt to plan all the main elements of the mission first. Escorts
- # will be planned separately so we can prune escorts for packages that
- # are not expected to encounter that type of threat.
- missing_types: Set[FlightType] = set()
- escorts = []
- for proposed_flight in mission.flights:
- if not self.air_wing_can_plan(proposed_flight.task):
- # This air wing can never plan this mission type because they do not
- # have compatible aircraft or squadrons. Skip fulfillment so that we
- # don't place the purchase request.
- continue
- if proposed_flight.escort_type is not None:
- # Escorts are planned after the primary elements of the package.
- # If the package does not need escorts they may be pruned.
- escorts.append(proposed_flight)
- continue
- with tracer.trace("Flight planning"):
- self.plan_flight(
- mission, proposed_flight, builder, missing_types, reserves
- )
-
- if missing_types:
- self.scrub_mission_missing_aircraft(
- mission, builder, missing_types, escorts, reserves
- )
- return
-
- if not builder.package.flights:
- # The non-escort part of this mission is unplannable by this faction. Scrub
- # the mission and do not attempt planning escorts because there's no reason
- # to buy them because this mission will never be planned.
- return
-
- # Create flight plans for the main flights of the package so we can
- # determine threats. This is done *after* creating all of the flights
- # rather than as each flight is added because the flight plan for
- # flights that will rendezvous with their package will be affected by
- # the other flights in the package. Escorts will not be able to
- # contribute to this.
- flight_plan_builder = FlightPlanBuilder(
- self.game, builder.package, self.is_player
- )
- for flight in builder.package.flights:
- with tracer.trace("Flight plan population"):
- flight_plan_builder.populate_flight_plan(flight)
-
- needed_escorts = self.check_needed_escorts(builder)
- for escort in escorts:
- # This list was generated from the not None set, so this should be
- # impossible.
- assert escort.escort_type is not None
- if needed_escorts[escort.escort_type]:
- with tracer.trace("Flight planning"):
- self.plan_flight(mission, escort, builder, missing_types, reserves)
-
- # Check again for unavailable aircraft. If the escort was required and
- # none were found, scrub the mission.
- if missing_types:
- self.scrub_mission_missing_aircraft(
- mission, builder, missing_types, escorts, reserves
- )
- return
-
- if reserves:
- # Mission is planned reserves which will not be used this turn.
- # Return reserves to the inventory.
- builder.release_planned_aircraft()
- return
-
- package = builder.build()
- # Add flight plans for escorts.
- for flight in package.flights:
- if not flight.flight_plan.waypoints:
- with tracer.trace("Flight plan population"):
- flight_plan_builder.populate_flight_plan(flight)
-
- if package.has_players and self.game.settings.auto_ato_player_missions_asap:
- package.auto_asap = True
- package.set_tot_asap()
-
- self.ato.add_package(package)
-
- def stagger_missions(self) -> None:
- def start_time_generator(
- count: int, earliest: int, latest: int, margin: int
- ) -> Iterator[timedelta]:
- interval = (latest - earliest) // count
- for time in range(earliest, latest, interval):
- error = random.randint(-margin, margin)
- yield timedelta(seconds=max(0, time + error))
-
- dca_types = {
- FlightType.BARCAP,
- FlightType.TARCAP,
- }
-
- previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta)
- non_dca_packages = [
- p for p in self.ato.packages if p.primary_task not in dca_types
- ]
-
- start_time = start_time_generator(
- count=len(non_dca_packages),
- earliest=5 * 60,
- latest=int(
- self.game.settings.desired_player_mission_duration.total_seconds()
- ),
- margin=5 * 60,
- )
- for package in self.ato.packages:
- tot = TotEstimator(package).earliest_tot()
- if package.primary_task in dca_types:
- previous_end_time = previous_cap_end_time[package.target]
- if tot > previous_end_time:
- # Can't get there exactly on time, so get there ASAP. This
- # will typically only happen for the first CAP at each
- # target.
- package.time_over_target = tot
- else:
- package.time_over_target = previous_end_time
-
- departure_time = package.mission_departure_time
- # Should be impossible for CAPs
- if departure_time is None:
- logging.error(f"Could not determine mission end time for {package}")
- continue
- previous_cap_end_time[package.target] = departure_time
- elif package.auto_asap:
- package.set_tot_asap()
- else:
- # But other packages should be spread out a bit. Note that take
- # times are delayed, but all aircraft will become active at
- # mission start. This makes it more worthwhile to attack enemy
- # airfields to hit grounded aircraft, since they're more likely
- # to be present. Runway and air started aircraft will be
- # delayed until their takeoff time by AirConflictGenerator.
- package.time_over_target = next(start_time) + tot
-
- def message(self, title, text) -> None:
- """Emits a planning message to the player.
-
- If the mission planner belongs to the players coalition, this emits a
- message to the info panel.
- """
- if self.is_player:
- self.game.informations.append(Information(title, text, self.game.turn))
- else:
- logging.info(f"{title}: {text}")
diff --git a/gen/flights/ai_flight_planner_db.py b/gen/flights/ai_flight_planner_db.py
index 6ed26bd4..59e49406 100644
--- a/gen/flights/ai_flight_planner_db.py
+++ b/gen/flights/ai_flight_planner_db.py
@@ -1,5 +1,6 @@
import logging
-from typing import List, Type
+from collections import Sequence
+from typing import Type
from dcs.helicopters import (
AH_1W,
@@ -124,29 +125,30 @@ from pydcs_extensions.su57.su57 import Su_57
CAP_CAPABLE = [
Su_57,
F_22A,
- MiG_31,
+ F_15C,
F_14B,
F_14A_135_GR,
- MiG_25PD,
Su_33,
+ Su_34,
+ J_11A,
Su_30,
Su_27,
- J_11A,
- F_15C,
MiG_29S,
- MiG_29G,
- MiG_29A,
F_16C_50,
FA_18C_hornet,
+ JF_17,
+ JAS39Gripen,
F_16A,
F_4E,
- JAS39Gripen,
- JF_17,
+ MiG_31,
+ MiG_25PD,
+ MiG_29G,
+ MiG_29A,
MiG_23MLD,
MiG_21Bis,
Mirage_2000_5,
- M_2000C,
F_15E,
+ M_2000C,
F_5E_3,
MiG_19P,
A_4E_C,
@@ -173,6 +175,7 @@ CAS_CAPABLE = [
A_10C_2,
A_10C,
Hercules,
+ Su_34,
Su_25TM,
Su_25T,
Su_25,
@@ -190,17 +193,16 @@ CAS_CAPABLE = [
F_14B,
F_14A_135_GR,
AJS37,
- Su_24MR,
Su_24M,
Su_17M4,
+ Su_33,
F_4E,
S_3B,
- Su_34,
Su_30,
- MiG_19P,
MiG_29S,
MiG_27K,
MiG_29A,
+ MiG_21Bis,
AH_64D,
AH_64A,
AH_1W,
@@ -212,13 +214,14 @@ CAS_CAPABLE = [
Mi_24P,
Mi_24V,
Mi_8MT,
- UH_1H,
+ MiG_19P,
MiG_15bis,
M_2000C,
F_5E_3,
F_86F_Sabre,
C_101CC,
L_39ZA,
+ UH_1H,
A_20G,
Ju_88A4,
P_47D_40,
@@ -299,13 +302,14 @@ STRIKE_CAPABLE = [
Tornado_GR4,
F_16C_50,
FA_18C_hornet,
+ AV8BNA,
+ JF_17,
F_16A,
F_14B,
F_14A_135_GR,
JAS39Gripen_AG,
Tornado_IDS,
Su_17M4,
- Su_24MR,
Su_24M,
Su_25TM,
Su_25T,
@@ -317,11 +321,9 @@ STRIKE_CAPABLE = [
MiG_29S,
MiG_29G,
MiG_29A,
- JF_17,
F_4E,
A_10C_2,
A_10C,
- AV8BNA,
S_3B,
A_4E_C,
M_2000C,
@@ -375,6 +377,7 @@ RUNWAY_ATTACK_CAPABLE = [
Su_34,
Su_30,
Tornado_IDS,
+ M_2000C,
] + STRIKE_CAPABLE
# For any aircraft that isn't necessarily directly involved in strike
@@ -415,7 +418,7 @@ REFUELING_CAPABALE = [
]
-def dcs_types_for_task(task: FlightType) -> list[Type[FlyingType]]:
+def dcs_types_for_task(task: FlightType) -> Sequence[Type[FlyingType]]:
cap_missions = (FlightType.BARCAP, FlightType.TARCAP, FlightType.SWEEP)
if task in cap_missions:
return CAP_CAPABLE
diff --git a/gen/flights/flight.py b/gen/flights/flight.py
index 68a57707..5a7bf855 100644
--- a/gen/flights/flight.py
+++ b/gen/flights/flight.py
@@ -2,14 +2,14 @@ from __future__ import annotations
from datetime import timedelta
from enum import Enum
-from typing import List, Optional, TYPE_CHECKING, Union
+from typing import List, Optional, TYPE_CHECKING, Union, Sequence, Any
from dcs.mapping import Point
from dcs.point import MovingPoint, PointAction
from dcs.unit import Unit
-from game import db
from game.dcs.aircrafttype import AircraftType
+from game.savecompat import has_save_compat_for
from game.squadrons import Pilot, Squadron
from game.theater.controlpoint import ControlPoint, MissionTarget
from game.utils import Distance, meters
@@ -139,7 +139,7 @@ class FlightWaypoint:
Args:
waypoint_type: The waypoint type.
- x: X cooidinate of the waypoint.
+ x: X coordinate of the waypoint.
y: Y coordinate of the waypoint.
alt: Altitude of the waypoint. By default this is AGL, but it can be
changed to MSL by setting alt_type to "RADIO".
@@ -154,11 +154,13 @@ class FlightWaypoint:
# Only used in the waypoint list in the flight edit page. No sense
# having three names. A short and long form is enough.
self.description = ""
- self.targets: List[Union[MissionTarget, Unit]] = []
+ self.targets: Sequence[Union[MissionTarget, Unit]] = []
self.obj_name = ""
self.pretty_name = ""
self.only_for_player = False
self.flyover = False
+ # The minimum amount of fuel remaining at this waypoint in pounds.
+ self.min_fuel: Optional[float] = None
# These are set very late by the air conflict generator (part of mission
# generation). We do it late so that we don't need to propagate changes
@@ -167,6 +169,12 @@ class FlightWaypoint:
self.tot: Optional[timedelta] = None
self.departure_time: Optional[timedelta] = None
+ @has_save_compat_for(5)
+ def __setstate__(self, state: dict[str, Any]) -> None:
+ if "min_fuel" not in state:
+ state["min_fuel"] = None
+ self.__dict__.update(state)
+
@property
def position(self) -> Point:
return Point(self.x, self.y)
@@ -325,12 +333,12 @@ class Flight:
def clear_roster(self) -> None:
self.roster.clear()
- def __repr__(self):
+ def __repr__(self) -> str:
if self.custom_name:
return f"{self.custom_name} {self.count} x {self.unit_type}"
return f"[{self.flight_type}] {self.count} x {self.unit_type}"
- def __str__(self):
+ def __str__(self) -> str:
if self.custom_name:
return f"{self.custom_name} {self.count} x {self.unit_type}"
return f"[{self.flight_type}] {self.count} x {self.unit_type}"
diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py
index 80a7f6e1..463be24d 100644
--- a/gen/flights/flightplan.py
+++ b/gen/flights/flightplan.py
@@ -20,6 +20,8 @@ from dcs.unit import Unit
from shapely.geometry import Point as ShapelyPoint
from game.data.doctrine import Doctrine
+from game.dcs.aircrafttype import FuelConsumption
+from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry
from game.theater import (
Airfield,
ControlPoint,
@@ -28,9 +30,17 @@ from game.theater import (
SamGroundObject,
TheaterGroundObject,
NavalControlPoint,
+ ConflictTheater,
)
-from game.theater.theatergroundobject import EwrGroundObject, NavalGroundObject
-from game.utils import Distance, Speed, feet, meters, nautical_miles, knots
+from game.theater.theatergroundobject import (
+ EwrGroundObject,
+ NavalGroundObject,
+ BuildingGroundObject,
+)
+
+from game.threatzones import ThreatZones
+from game.utils import Distance, Heading, Speed, feet, meters, nautical_miles, knots
+
from .closestairfields import ObjectiveDistanceCache
from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType
from .traveltime import GroundSpeed, TravelTime
@@ -38,8 +48,8 @@ from .waypointbuilder import StrikeTarget, WaypointBuilder
from ..conflictgen import Conflict, FRONTLINE_LENGTH
if TYPE_CHECKING:
- from game import Game
from gen.ato import Package
+ from game.coalition import Coalition
from game.transfers import Convoy
INGRESS_TYPES = {
@@ -131,6 +141,17 @@ class FlightPlan:
@cached_property
def bingo_fuel(self) -> int:
"""Bingo fuel value for the FlightPlan"""
+ if (fuel := self.flight.unit_type.fuel_consumption) is not None:
+ return self._bingo_estimate(fuel)
+ return self._legacy_bingo_estimate()
+
+ def _bingo_estimate(self, fuel: FuelConsumption) -> int:
+ distance_to_arrival = self.max_distance_from(self.flight.arrival)
+ fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles
+ bingo = fuel_consumed + fuel.min_safe
+ return math.ceil(bingo / 100) * 100
+
+ def _legacy_bingo_estimate(self) -> int:
distance_to_arrival = self.max_distance_from(self.flight.arrival)
bingo = 1000.0 # Minimum Emergency Fuel
@@ -219,11 +240,7 @@ class FlightPlan:
tot_waypoint = self.tot_waypoint
if tot_waypoint is None:
return None
-
- time = self.tot
- if time is None:
- return None
- return time - self._travel_time_to_waypoint(tot_waypoint)
+ return self.tot - self._travel_time_to_waypoint(tot_waypoint)
def startup_time(self) -> Optional[timedelta]:
takeoff_time = self.takeoff_time()
@@ -540,7 +557,6 @@ class StrikeFlightPlan(FormationFlightPlan):
join: FlightWaypoint
ingress: FlightWaypoint
targets: List[FlightWaypoint]
- egress: FlightWaypoint
split: FlightWaypoint
nav_from: List[FlightWaypoint]
land: FlightWaypoint
@@ -555,7 +571,6 @@ class StrikeFlightPlan(FormationFlightPlan):
yield self.join
yield self.ingress
yield from self.targets
- yield self.egress
yield self.split
yield from self.nav_from
yield self.land
@@ -567,7 +582,6 @@ class StrikeFlightPlan(FormationFlightPlan):
def package_speed_waypoints(self) -> Set[FlightWaypoint]:
return {
self.ingress,
- self.egress,
self.split,
} | set(self.targets)
@@ -631,8 +645,8 @@ class StrikeFlightPlan(FormationFlightPlan):
@property
def split_time(self) -> timedelta:
- travel_time = self.travel_time_between_waypoints(self.egress, self.split)
- return self.egress_time + travel_time
+ travel_time = self.travel_time_between_waypoints(self.ingress, self.split)
+ return self.ingress_time + travel_time
@property
def ingress_time(self) -> timedelta:
@@ -642,19 +656,9 @@ class StrikeFlightPlan(FormationFlightPlan):
)
return tot - travel_time
- @property
- def egress_time(self) -> timedelta:
- tot = self.tot
- travel_time = self.travel_time_between_waypoints(
- self.target_area_waypoint, self.egress
- )
- return tot + travel_time
-
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]:
if waypoint == self.ingress:
return self.ingress_time
- elif waypoint == self.egress:
- return self.egress_time
elif waypoint in self.targets:
return self.tot
return super().tot_for_waypoint(waypoint)
@@ -868,7 +872,9 @@ class CustomFlightPlan(FlightPlan):
class FlightPlanBuilder:
"""Generates flight plans for flights."""
- def __init__(self, game: Game, package: Package, is_player: bool) -> None:
+ def __init__(
+ self, package: Package, coalition: Coalition, theater: ConflictTheater
+ ) -> None:
# TODO: Plan similar altitudes for the in-country leg of the mission.
# Waypoint altitudes for a given flight *shouldn't* differ too much
# between the join and split points, so we don't need speeds for each
@@ -876,11 +882,21 @@ class FlightPlanBuilder:
# hold too well right now since nothing is stopping each waypoint from
# jumping 20k feet each time, but that's a huge waste of energy we
# should be avoiding anyway.
- self.game = game
self.package = package
- self.is_player = is_player
- self.doctrine: Doctrine = self.game.faction_for(self.is_player).doctrine
- self.threat_zones = self.game.threat_zone_for(not self.is_player)
+ self.coalition = coalition
+ self.theater = theater
+
+ @property
+ def is_player(self) -> bool:
+ return self.coalition.player
+
+ @property
+ def doctrine(self) -> Doctrine:
+ return self.coalition.doctrine
+
+ @property
+ def threat_zones(self) -> ThreatZones:
+ return self.coalition.opponent.threat_zone
def populate_flight_plan(
self,
@@ -945,95 +961,33 @@ class FlightPlanBuilder:
raise PlanningError(f"{task} flight plan generation not implemented")
def regenerate_package_waypoints(self) -> None:
- # The simple case is where the target is greater than the ingress
- # distance into the threat zone and the target is not near the departure
- # airfield. In this case, we can plan the shortest route from the
- # departure airfield to the target, use the last non-threatened point as
- # the join point, and plan the IP inside the threatened area.
- #
- # When the target is near the edge of the threat zone the IP may need to
- # be placed outside the zone.
- #
- # +--------------+ +---------------+
- # | | | |
- # | | IP---+-T |
- # | | | |
- # | | | |
- # +--------------+ +---------------+
- #
- # Here we want to place the IP first and route the flight to the IP
- # rather than routing to the target and placing the IP based on the join
- # point.
- #
- # The other case that we need to handle is when the target is close to
- # the origin airfield. In this case we also need to set up the IP first,
- # but depending on the placement of the IP we may need to place the join
- # point in a retreating position.
- #
- # A messy (and very unlikely) case that we can't do much about:
- #
- # +--------------+ +---------------+
- # | | | |
- # | IP-+---+-T |
- # | | | |
- # | | | |
- # +--------------+ +---------------+
from gen.ato import PackageWaypoints
- target = self.package.target.position
+ package_airfield = self.package_airfield()
- join_point = self.preferred_join_point()
- if join_point is None:
- # The whole path from the origin airfield to the target is
- # threatened. Need to retreat out of the threat area.
- join_point = self.retreat_point(self.package_airfield().position)
+ # Start by picking the best IP for the attack.
+ ingress_point = IpZoneGeometry(
+ self.package.target.position,
+ package_airfield.position,
+ self.coalition,
+ ).find_best_ip()
- attack_heading = join_point.heading_between_point(target)
- ingress_point = self._ingress_point(attack_heading)
- join_distance = meters(join_point.distance_to_point(target))
- ingress_distance = meters(ingress_point.distance_to_point(target))
- if join_distance < ingress_distance:
- # The second case described above. The ingress point is farther from
- # the target than the join point. Use the fallback behavior for now.
- self.legacy_package_waypoints_impl()
- return
+ join_point = JoinZoneGeometry(
+ self.package.target.position,
+ package_airfield.position,
+ ingress_point,
+ self.coalition,
+ ).find_best_join_point()
- # The first case described above. The ingress and join points are placed
- # reasonably relative to each other.
- egress_point = self._egress_point(attack_heading)
+ # And the split point based on the best route from the IP. Since that's no
+ # different than the best route *to* the IP, this is the same as the join point.
+ # TODO: Estimate attack completion point based on the IP and split from there?
self.package.waypoints = PackageWaypoints(
WaypointBuilder.perturb(join_point),
ingress_point,
- egress_point,
WaypointBuilder.perturb(join_point),
)
- def retreat_point(self, origin: Point) -> Point:
- return self.threat_zones.closest_boundary(origin)
-
- def legacy_package_waypoints_impl(self) -> None:
- from gen.ato import PackageWaypoints
-
- ingress_point = self._ingress_point(self._target_heading_to_package_airfield())
- egress_point = self._egress_point(self._target_heading_to_package_airfield())
- join_point = self._rendezvous_point(ingress_point)
- split_point = self._rendezvous_point(egress_point)
- self.package.waypoints = PackageWaypoints(
- join_point,
- ingress_point,
- egress_point,
- split_point,
- )
-
- def preferred_join_point(self) -> Optional[Point]:
- path = self.game.navmesh_for(self.is_player).shortest_path(
- self.package_airfield().position, self.package.target.position
- )
- for point in reversed(path):
- if not self.threat_zones.threatened(point):
- return point
- return None
-
def generate_strike(self, flight: Flight) -> StrikeFlightPlan:
"""Generates a strike flight plan.
@@ -1047,26 +1001,16 @@ class FlightPlanBuilder:
raise InvalidObjectiveLocation(flight.flight_type, location)
targets: List[StrikeTarget] = []
- if len(location.groups) > 0 and location.dcs_identifier == "AA":
+ if isinstance(location, BuildingGroundObject):
+ # A building "group" is implemented as multiple TGOs with the same name.
+ for building in location.strike_targets:
+ targets.append(StrikeTarget(building.category, building))
+ else:
# TODO: Replace with DEAD?
# Strike missions on SEAD targets target units.
for g in location.groups:
for j, u in enumerate(g.units):
targets.append(StrikeTarget(f"{u.type} #{j}", u))
- else:
- # TODO: Does this actually happen?
- # ConflictTheater is built with the belief that multiple ground
- # objects have the same name. If that's the case,
- # TheaterGroundObject needs some refactoring because it behaves very
- # differently for SAM sites than it does for strike targets.
- buildings = self.game.theater.find_ground_objects_by_obj_name(
- location.obj_name
- )
- for building in buildings:
- if building.is_dead:
- continue
-
- targets.append(StrikeTarget(building.category, building))
return self.strike_flightplan(
flight, location, FlightWaypointType.INGRESS_STRIKE, targets
@@ -1087,23 +1031,23 @@ class FlightPlanBuilder:
else:
patrol_alt = feet(25000)
- builder = WaypointBuilder(flight, self.game, self.is_player)
- orbit_location = builder.orbit(orbit_location, patrol_alt)
+ builder = WaypointBuilder(flight, self.coalition)
+ orbit = builder.orbit(orbit_location, patrol_alt)
return AwacsFlightPlan(
package=self.package,
flight=flight,
takeoff=builder.takeoff(flight.departure),
nav_to=builder.nav_path(
- flight.departure.position, orbit_location.position, patrol_alt
+ flight.departure.position, orbit.position, patrol_alt
),
nav_from=builder.nav_path(
- orbit_location.position, flight.arrival.position, patrol_alt
+ orbit.position, flight.arrival.position, patrol_alt
),
land=builder.land(flight.arrival),
divert=builder.divert(flight.divert),
bullseye=builder.bullseye(),
- hold=orbit_location,
+ hold=orbit,
hold_duration=timedelta(hours=4),
)
@@ -1134,7 +1078,7 @@ class FlightPlanBuilder:
)
@staticmethod
- def anti_ship_targets_for_tgo(tgo: TheaterGroundObject) -> List[StrikeTarget]:
+ def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> List[StrikeTarget]:
return [StrikeTarget(f"{g.name} at {tgo.name}", g) for g in tgo.groups]
def generate_anti_ship(self, flight: Flight) -> StrikeFlightPlan:
@@ -1171,16 +1115,17 @@ class FlightPlanBuilder:
if isinstance(location, FrontLine):
raise InvalidObjectiveLocation(flight.flight_type, location)
- start, end = self.racetrack_for_objective(location, barcap=True)
- patrol_alt = meters(
- random.randint(
- int(self.doctrine.min_patrol_altitude.meters),
- int(self.doctrine.max_patrol_altitude.meters),
- )
+ start_pos, end_pos = self.racetrack_for_objective(location, barcap=True)
+
+ preferred_alt = flight.unit_type.preferred_patrol_altitude
+ randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
+ patrol_alt = max(
+ self.doctrine.min_patrol_altitude,
+ min(self.doctrine.max_patrol_altitude, randomized_alt),
)
- builder = WaypointBuilder(flight, self.game, self.is_player)
- start, end = builder.race_track(start, end, patrol_alt)
+ builder = WaypointBuilder(flight, self.coalition)
+ start, end = builder.race_track(start_pos, end_pos, patrol_alt)
return BarCapFlightPlan(
package=self.package,
@@ -1209,12 +1154,15 @@ class FlightPlanBuilder:
"""
assert self.package.waypoints is not None
target = self.package.target.position
+ heading = Heading.from_degrees(
+ self.package.waypoints.join.heading_between_point(target)
+ )
+ start_pos = target.point_from_heading(
+ heading.degrees, -self.doctrine.sweep_distance.meters
+ )
- heading = self.package.waypoints.join.heading_between_point(target)
- start = target.point_from_heading(heading, -self.doctrine.sweep_distance.meters)
-
- builder = WaypointBuilder(flight, self.game, self.is_player)
- start, end = builder.sweep(start, target, self.doctrine.ingress_altitude)
+ builder = WaypointBuilder(flight, self.coalition)
+ start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude)
hold = builder.hold(self._hold_point(flight))
@@ -1253,7 +1201,7 @@ class FlightPlanBuilder:
altitude = feet(1500)
altitude_is_agl = True
- builder = WaypointBuilder(flight, self.game, self.is_player)
+ builder = WaypointBuilder(flight, self.coalition)
pickup = None
nav_to_pickup = []
@@ -1305,7 +1253,9 @@ class FlightPlanBuilder:
else:
raise PlanningError("Could not find any enemy airfields")
- heading = location.position.heading_between_point(closest_airfield.position)
+ heading = Heading.from_degrees(
+ location.position.heading_between_point(closest_airfield.position)
+ )
position = ShapelyPoint(
self.package.target.position.x, self.package.target.position.y
@@ -1341,20 +1291,20 @@ class FlightPlanBuilder:
)
end = location.position.point_from_heading(
- heading,
+ heading.degrees,
random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)),
)
diameter = random.randint(
int(self.doctrine.cap_min_track_length.meters),
int(self.doctrine.cap_max_track_length.meters),
)
- start = end.point_from_heading(heading - 180, diameter)
+ start = end.point_from_heading(heading.opposite.degrees, diameter)
return start, end
def aewc_orbit(self, location: MissionTarget) -> Point:
closest_boundary = self.threat_zones.closest_boundary(location.position)
- heading_to_threat_boundary = location.position.heading_between_point(
- closest_boundary
+ heading_to_threat_boundary = Heading.from_degrees(
+ location.position.heading_between_point(closest_boundary)
)
distance_to_threat = meters(
location.position.distance_to_point(closest_boundary)
@@ -1368,19 +1318,17 @@ class FlightPlanBuilder:
orbit_distance = distance_to_threat - threat_buffer
return location.position.point_from_heading(
- orbit_heading, orbit_distance.meters
+ orbit_heading.degrees, orbit_distance.meters
)
def racetrack_for_frontline(
self, origin: Point, front_line: FrontLine
) -> Tuple[Point, Point]:
# Find targets waypoints
- ingress, heading, distance = Conflict.frontline_vector(
- front_line, self.game.theater
- )
- center = ingress.point_from_heading(heading, distance / 2)
+ ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater)
+ center = ingress.point_from_heading(heading.degrees, distance / 2)
orbit_center = center.point_from_heading(
- heading - 90,
+ heading.left.degrees,
random.randint(
int(nautical_miles(6).meters), int(nautical_miles(15).meters)
),
@@ -1393,8 +1341,8 @@ class FlightPlanBuilder:
combat_width = 35000
radius = combat_width * 1.25
- start = orbit_center.point_from_heading(heading, radius)
- end = orbit_center.point_from_heading(heading + 180, radius)
+ start = orbit_center.point_from_heading(heading.degrees, radius)
+ end = orbit_center.point_from_heading(heading.opposite.degrees, radius)
if end.distance_to_point(origin) < start.distance_to_point(origin):
start, end = end, start
@@ -1408,15 +1356,15 @@ class FlightPlanBuilder:
"""
location = self.package.target
- patrol_alt = meters(
- random.randint(
- int(self.doctrine.min_patrol_altitude.meters),
- int(self.doctrine.max_patrol_altitude.meters),
- )
+ preferred_alt = flight.unit_type.preferred_patrol_altitude
+ randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
+ patrol_alt = max(
+ self.doctrine.min_patrol_altitude,
+ min(self.doctrine.max_patrol_altitude, randomized_alt),
)
# Create points
- builder = WaypointBuilder(flight, self.game, self.is_player)
+ builder = WaypointBuilder(flight, self.coalition)
if isinstance(location, FrontLine):
orbit0p, orbit1p = self.racetrack_for_frontline(
@@ -1547,11 +1495,9 @@ class FlightPlanBuilder:
def generate_escort(self, flight: Flight) -> StrikeFlightPlan:
assert self.package.waypoints is not None
- builder = WaypointBuilder(flight, self.game, self.is_player)
- ingress, target, egress = builder.escort(
- self.package.waypoints.ingress,
- self.package.target,
- self.package.waypoints.egress,
+ builder = WaypointBuilder(flight, self.coalition)
+ ingress, target = builder.escort(
+ self.package.waypoints.ingress, self.package.target
)
hold = builder.hold(self._hold_point(flight))
join = builder.join(self.package.waypoints.join)
@@ -1569,7 +1515,6 @@ class FlightPlanBuilder:
join=join,
ingress=ingress,
targets=[target],
- egress=egress,
split=split,
nav_from=builder.nav_path(
split.position, flight.arrival.position, self.doctrine.ingress_altitude
@@ -1590,18 +1535,16 @@ class FlightPlanBuilder:
if not isinstance(location, FrontLine):
raise InvalidObjectiveLocation(flight.flight_type, location)
- ingress, heading, distance = Conflict.frontline_vector(
- location, self.game.theater
- )
- center = ingress.point_from_heading(heading, distance / 2)
- egress = ingress.point_from_heading(heading, distance)
+ ingress, heading, distance = Conflict.frontline_vector(location, self.theater)
+ center = ingress.point_from_heading(heading.degrees, distance / 2)
+ egress = ingress.point_from_heading(heading.degrees, distance)
ingress_distance = ingress.distance_to_point(flight.departure.position)
egress_distance = egress.distance_to_point(flight.departure.position)
if egress_distance < ingress_distance:
ingress, egress = egress, ingress
- builder = WaypointBuilder(flight, self.game, self.is_player)
+ builder = WaypointBuilder(flight, self.coalition)
return CasFlightPlan(
package=self.package,
@@ -1629,8 +1572,8 @@ class FlightPlanBuilder:
location = self.package.target
closest_boundary = self.threat_zones.closest_boundary(location.position)
- heading_to_threat_boundary = location.position.heading_between_point(
- closest_boundary
+ heading_to_threat_boundary = Heading.from_degrees(
+ location.position.heading_between_point(closest_boundary)
)
distance_to_threat = meters(
location.position.distance_to_point(closest_boundary)
@@ -1645,19 +1588,19 @@ class FlightPlanBuilder:
orbit_distance = distance_to_threat - threat_buffer
racetrack_center = location.position.point_from_heading(
- orbit_heading, orbit_distance.meters
+ orbit_heading.degrees, orbit_distance.meters
)
racetrack_half_distance = Distance.from_nautical_miles(20).meters
racetrack_start = racetrack_center.point_from_heading(
- orbit_heading + 90, racetrack_half_distance
+ orbit_heading.right.degrees, racetrack_half_distance
)
racetrack_end = racetrack_center.point_from_heading(
- orbit_heading - 90, racetrack_half_distance
+ orbit_heading.left.degrees, racetrack_half_distance
)
- builder = WaypointBuilder(flight, self.game, self.is_player)
+ builder = WaypointBuilder(flight, self.coalition)
tanker_type = flight.unit_type
if tanker_type.patrol_altitude is not None:
@@ -1724,49 +1667,10 @@ class FlightPlanBuilder:
origin = flight.departure.position
target = self.package.target.position
join = self.package.waypoints.join
- origin_to_target = origin.distance_to_point(target)
- join_to_target = join.distance_to_point(target)
- if origin_to_target < join_to_target:
- # If the origin airfield is closer to the target than the join
- # point, plan the hold point such that it retreats from the origin
- # airfield.
- return join.point_from_heading(
- target.heading_between_point(origin), self.doctrine.push_distance.meters
- )
-
- heading_to_join = origin.heading_between_point(join)
- hold_point = origin.point_from_heading(
- heading_to_join, self.doctrine.push_distance.meters
- )
- hold_distance = meters(hold_point.distance_to_point(join))
- if hold_distance >= self.doctrine.push_distance:
- # Hold point is between the origin airfield and the join point and
- # spaced sufficiently.
- return hold_point
-
- # The hold point is between the origin airfield and the join point, but
- # the distance between the hold point and the join point is too short.
- # Bend the hold point out to extend the distance while maintaining the
- # minimum distance from the origin airfield to keep the AI flying
- # properly.
- origin_to_join = origin.distance_to_point(join)
- cos_theta = (
- self.doctrine.hold_distance.meters ** 2
- + origin_to_join ** 2
- - self.doctrine.join_distance.meters ** 2
- ) / (2 * self.doctrine.hold_distance.meters * origin_to_join)
- try:
- theta = math.acos(cos_theta)
- except ValueError:
- # No solution that maintains hold and join distances. Extend the
- # hold point away from the target.
- return origin.point_from_heading(
- target.heading_between_point(origin), self.doctrine.hold_distance.meters
- )
-
- return origin.point_from_heading(
- heading_to_join - theta, self.doctrine.hold_distance.meters
- )
+ ip = self.package.waypoints.ingress
+ return HoldZoneGeometry(
+ target, origin, ip, join, self.coalition, self.theater
+ ).find_best_hold_point()
# TODO: Make a model for the waypoint builder and use that in the UI.
def generate_rtb_waypoint(
@@ -1778,7 +1682,7 @@ class FlightPlanBuilder:
flight: The flight to generate the landing waypoint for.
arrival: Arrival airfield or carrier.
"""
- builder = WaypointBuilder(flight, self.game, self.is_player)
+ builder = WaypointBuilder(flight, self.coalition)
return builder.land(arrival)
def strike_flightplan(
@@ -1790,7 +1694,7 @@ class FlightPlanBuilder:
lead_time: timedelta = timedelta(),
) -> StrikeFlightPlan:
assert self.package.waypoints is not None
- builder = WaypointBuilder(flight, self.game, self.is_player, targets)
+ builder = WaypointBuilder(flight, self.coalition, targets)
target_waypoints: List[FlightWaypoint] = []
if targets is not None:
@@ -1819,7 +1723,6 @@ class FlightPlanBuilder:
ingress_type, self.package.waypoints.ingress, location
),
targets=target_waypoints,
- egress=builder.egress(self.package.waypoints.egress, location),
split=split,
nav_from=builder.nav_path(
split.position, flight.arrival.position, self.doctrine.ingress_altitude
@@ -1830,64 +1733,6 @@ class FlightPlanBuilder:
lead_time=lead_time,
)
- def _retreating_rendezvous_point(self, attack_transition: Point) -> Point:
- """Creates a rendezvous point that retreats from the origin airfield."""
- return attack_transition.point_from_heading(
- self.package.target.position.heading_between_point(
- self.package_airfield().position
- ),
- self.doctrine.join_distance.meters,
- )
-
- def _advancing_rendezvous_point(self, attack_transition: Point) -> Point:
- """Creates a rendezvous point that advances toward the target."""
- heading = self._heading_to_package_airfield(attack_transition)
- return attack_transition.point_from_heading(
- heading, -self.doctrine.join_distance.meters
- )
-
- def _rendezvous_should_retreat(self, attack_transition: Point) -> bool:
- transition_target_distance = attack_transition.distance_to_point(
- self.package.target.position
- )
- origin_target_distance = self._distance_to_package_airfield(
- self.package.target.position
- )
-
- # If the origin point is closer to the target than the ingress point,
- # the rendezvous point should be positioned in a position that retreats
- # from the origin airfield.
- return origin_target_distance < transition_target_distance
-
- def _rendezvous_point(self, attack_transition: Point) -> Point:
- """Returns the position of the rendezvous point.
-
- Args:
- attack_transition: The ingress or egress point for this rendezvous.
- """
- if self._rendezvous_should_retreat(attack_transition):
- return self._retreating_rendezvous_point(attack_transition)
- return self._advancing_rendezvous_point(attack_transition)
-
- def _ingress_point(self, heading: int) -> Point:
- return self.package.target.position.point_from_heading(
- heading - 180 + 15, self.doctrine.ingress_egress_distance.meters
- )
-
- def _egress_point(self, heading: int) -> Point:
- return self.package.target.position.point_from_heading(
- heading - 180 - 15, self.doctrine.ingress_egress_distance.meters
- )
-
- def _target_heading_to_package_airfield(self) -> int:
- return self._heading_to_package_airfield(self.package.target.position)
-
- def _heading_to_package_airfield(self, point: Point) -> int:
- return self.package_airfield().position.heading_between_point(point)
-
- def _distance_to_package_airfield(self, point: Point) -> int:
- return self.package_airfield().position.distance_to_point(point)
-
def package_airfield(self) -> ControlPoint:
# We'll always have a package, but if this is being planned via the UI
# it could be the first flight in the package.
diff --git a/gen/flights/loadouts.py b/gen/flights/loadouts.py
index 0a51245a..5e34ec0d 100644
--- a/gen/flights/loadouts.py
+++ b/gen/flights/loadouts.py
@@ -1,9 +1,10 @@
from __future__ import annotations
import datetime
-from typing import Optional, List, Iterator, TYPE_CHECKING, Mapping
+from collections import Iterable
+from typing import Optional, Iterator, TYPE_CHECKING, Mapping
-from game.data.weapons import Weapon, Pylon
+from game.data.weapons import Weapon, Pylon, WeaponType
from game.dcs.aircrafttype import AircraftType
if TYPE_CHECKING:
@@ -19,16 +20,45 @@ class Loadout:
is_custom: bool = False,
) -> None:
self.name = name
- self.pylons = {k: v for k, v in pylons.items() if v is not None}
+ # We clear unused pylon entries on initialization, but UI actions can still
+ # cause a pylon to be emptied, so make the optional type explicit.
+ self.pylons: Mapping[int, Optional[Weapon]] = {
+ k: v for k, v in pylons.items() if v is not None
+ }
self.date = date
self.is_custom = is_custom
def derive_custom(self, name: str) -> Loadout:
return Loadout(name, self.pylons, self.date, is_custom=True)
+ def has_weapon_of_type(self, weapon_type: WeaponType) -> bool:
+ for weapon in self.pylons.values():
+ if weapon is not None and weapon.weapon_group.type is weapon_type:
+ return True
+ return False
+
+ @staticmethod
+ def _fallback_for(
+ weapon: Weapon,
+ pylon: Pylon,
+ date: datetime.date,
+ skip_types: Optional[Iterable[WeaponType]] = None,
+ ) -> Optional[Weapon]:
+ if skip_types is None:
+ skip_types = set()
+ for fallback in weapon.fallbacks:
+ if not pylon.can_equip(fallback):
+ continue
+ if not fallback.available_on(date):
+ continue
+ if fallback.weapon_group.type in skip_types:
+ continue
+ return fallback
+ return None
+
def degrade_for_date(self, unit_type: AircraftType, date: datetime.date) -> Loadout:
if self.date is not None and self.date <= date:
- return Loadout(self.name, self.pylons, self.date)
+ return Loadout(self.name, self.pylons, self.date, self.is_custom)
new_pylons = dict(self.pylons)
for pylon_number, weapon in self.pylons.items():
@@ -37,16 +67,39 @@ class Loadout:
continue
if not weapon.available_on(date):
pylon = Pylon.for_aircraft(unit_type, pylon_number)
- for fallback in weapon.fallbacks:
- if not pylon.can_equip(fallback):
- continue
- if not fallback.available_on(date):
- continue
- new_pylons[pylon_number] = fallback
- break
- else:
+ fallback = self._fallback_for(weapon, pylon, date)
+ if fallback is None:
del new_pylons[pylon_number]
- return Loadout(f"{self.name} ({date.year})", new_pylons, date)
+ else:
+ new_pylons[pylon_number] = fallback
+ loadout = Loadout(self.name, new_pylons, date, self.is_custom)
+ # If this is not a custom loadout, we should replace any LGBs with iron bombs if
+ # the loadout lost its TGP.
+ #
+ # If the loadout was chosen explicitly by the user, assume they know what
+ # they're doing. They may be coordinating buddy-lase.
+ if not loadout.is_custom:
+ loadout.replace_lgbs_if_no_tgp(unit_type, date)
+ return loadout
+
+ def replace_lgbs_if_no_tgp(
+ self, unit_type: AircraftType, date: datetime.date
+ ) -> None:
+ if self.has_weapon_of_type(WeaponType.TGP):
+ return
+
+ new_pylons = dict(self.pylons)
+ for pylon_number, weapon in self.pylons.items():
+ if weapon is not None and weapon.weapon_group.type is WeaponType.LGB:
+ pylon = Pylon.for_aircraft(unit_type, pylon_number)
+ fallback = self._fallback_for(
+ weapon, pylon, date, skip_types={WeaponType.LGB}
+ )
+ if fallback is None:
+ del new_pylons[pylon_number]
+ else:
+ new_pylons[pylon_number] = fallback
+ self.pylons = new_pylons
@classmethod
def iter_for(cls, flight: Flight) -> Iterator[Loadout]:
@@ -64,14 +117,10 @@ class Loadout:
pylons = payload["pylons"]
yield Loadout(
name,
- {p["num"]: Weapon.from_clsid(p["CLSID"]) for p in pylons.values()},
+ {p["num"]: Weapon.with_clsid(p["CLSID"]) for p in pylons.values()},
date=None,
)
- @classmethod
- def all_for(cls, flight: Flight) -> List[Loadout]:
- return list(cls.iter_for(flight))
-
@classmethod
def default_loadout_names_for(cls, flight: Flight) -> Iterator[str]:
from gen.flights.flight import FlightType
@@ -92,6 +141,7 @@ class Loadout:
FlightType.CAS: ("CAS MAVERICK F", "CAS"),
FlightType.STRIKE: ("STRIKE",),
FlightType.ANTISHIP: ("ANTISHIP",),
+ FlightType.DEAD: ("DEAD",),
FlightType.SEAD: ("SEAD",),
FlightType.BAI: ("BAI",),
FlightType.OCA_RUNWAY: ("RUNWAY_ATTACK", "RUNWAY_STRIKE"),
@@ -128,9 +178,13 @@ class Loadout:
if payload is not None:
return Loadout(
name,
- {i: Weapon.from_clsid(d["clsid"]) for i, d in payload},
+ {i: Weapon.with_clsid(d["clsid"]) for i, d in payload},
date=None,
)
# TODO: Try group.load_task_default_loadout(loadout_for_task)
+ return cls.empty_loadout()
+
+ @classmethod
+ def empty_loadout(cls) -> Loadout:
return Loadout("Empty", {}, date=None)
diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py
index f8380897..05ca1d93 100644
--- a/gen/flights/waypointbuilder.py
+++ b/gen/flights/waypointbuilder.py
@@ -10,14 +10,15 @@ from typing import (
TYPE_CHECKING,
Tuple,
Union,
+ Any,
)
from dcs.mapping import Point
from dcs.unit import Unit
-from dcs.unitgroup import Group, VehicleGroup
+from dcs.unitgroup import VehicleGroup, ShipGroup
if TYPE_CHECKING:
- from game import Game
+ from game.coalition import Coalition
from game.transfers import MultiGroupTransport
from game.theater import (
@@ -33,24 +34,24 @@ from .flight import Flight, FlightWaypoint, FlightWaypointType
@dataclass(frozen=True)
class StrikeTarget:
name: str
- target: Union[VehicleGroup, TheaterGroundObject, Unit, Group, MultiGroupTransport]
+ target: Union[
+ VehicleGroup, TheaterGroundObject[Any], Unit, ShipGroup, MultiGroupTransport
+ ]
class WaypointBuilder:
def __init__(
self,
flight: Flight,
- game: Game,
- player: bool,
+ coalition: Coalition,
targets: Optional[List[StrikeTarget]] = None,
) -> None:
self.flight = flight
- self.conditions = game.conditions
- self.doctrine = game.faction_for(player).doctrine
- self.threat_zones = game.threat_zone_for(not player)
- self.navmesh = game.navmesh_for(player)
+ self.doctrine = coalition.doctrine
+ self.threat_zones = coalition.opponent.threat_zone
+ self.navmesh = coalition.nav_mesh
self.targets = targets
- self._bullseye = game.bullseye_for(player)
+ self._bullseye = coalition.bullseye
@property
def is_helo(self) -> bool:
@@ -426,22 +427,19 @@ class WaypointBuilder:
self,
ingress: Point,
target: MissionTarget,
- egress: Point,
- ) -> Tuple[FlightWaypoint, FlightWaypoint, FlightWaypoint]:
+ ) -> Tuple[FlightWaypoint, FlightWaypoint]:
"""Creates the waypoints needed to escort the package.
Args:
ingress: The package ingress point.
target: The mission target.
- egress: The package egress point.
"""
# This would preferably be no points at all, and instead the Escort task
# would begin on the join point and end on the split point, however the
# escort task does not appear to work properly (see the longer
# description in gen.aircraft.JoinPointBuilder), so instead we give
- # the escort flights a flight plan including the ingress point, target
- # area, and egress point.
- ingress = self.ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target)
+ # the escort flights a flight plan including the ingress point and target area.
+ ingress_wp = self.ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target)
waypoint = FlightWaypoint(
FlightWaypointType.TARGET_GROUP_LOC,
@@ -454,9 +452,7 @@ class WaypointBuilder:
waypoint.name = "TARGET"
waypoint.description = "Escort the package"
waypoint.pretty_name = "Target area"
-
- egress = self.egress(egress, target)
- return ingress, waypoint, egress
+ return ingress_wp, waypoint
@staticmethod
def pickup(control_point: ControlPoint) -> FlightWaypoint:
diff --git a/gen/forcedoptionsgen.py b/gen/forcedoptionsgen.py
index d18db095..e4025d48 100644
--- a/gen/forcedoptionsgen.py
+++ b/gen/forcedoptionsgen.py
@@ -38,12 +38,12 @@ class ForcedOptionsGenerator:
self.mission.forced_options.labels = ForcedOptions.Labels.None_
def _set_unrestricted_satnav(self) -> None:
- blue = self.game.player_faction
- red = self.game.enemy_faction
+ blue = self.game.blue.faction
+ red = self.game.red.faction
if blue.unrestricted_satnav or red.unrestricted_satnav:
self.mission.forced_options.unrestricted_satnav = True
- def generate(self):
+ def generate(self) -> None:
self._set_options_view()
self._set_external_views()
self._set_labels()
diff --git a/gen/ground_forces/ai_ground_planner.py b/gen/ground_forces/ai_ground_planner.py
index 045c4b39..45d98c01 100644
--- a/gen/ground_forces/ai_ground_planner.py
+++ b/gen/ground_forces/ai_ground_planner.py
@@ -1,13 +1,18 @@
+from __future__ import annotations
+
import logging
import random
from enum import Enum
-from typing import Dict, List
+from typing import Dict, List, TYPE_CHECKING
from game.data.groundunitclass import GroundUnitClass
from game.dcs.groundunittype import GroundUnitType
from game.theater import ControlPoint
from gen.ground_forces.combat_stance import CombatStance
+if TYPE_CHECKING:
+ from game import Game
+
MAX_COMBAT_GROUP_PER_CP = 10
@@ -52,10 +57,9 @@ class CombatGroup:
self.unit_type = unit_type
self.size = size
self.role = role
- self.assigned_enemy_cp = None
self.start_position = None
- def __str__(self):
+ def __str__(self) -> str:
s = f"ROLE : {self.role}\n"
if self.size:
s += f"UNITS {self.unit_type} * {self.size}"
@@ -63,7 +67,7 @@ class CombatGroup:
class GroundPlanner:
- def __init__(self, cp: ControlPoint, game):
+ def __init__(self, cp: ControlPoint, game: Game) -> None:
self.cp = cp
self.game = game
self.connected_enemy_cp = [
@@ -83,17 +87,15 @@ class GroundPlanner:
self.units_per_cp[cp.id] = []
self.reserve: List[CombatGroup] = []
- def plan_groundwar(self):
+ def plan_groundwar(self) -> None:
ground_unit_limit = self.cp.frontline_unit_count_limit
remaining_available_frontline_units = ground_unit_limit
- if hasattr(self.cp, "stance"):
- group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[self.cp.stance]
- else:
- self.cp.stance = CombatStance.DEFENSIVE
- group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE]
+ # TODO: Fix to handle the per-front stances.
+ # https://github.com/dcs-liberation/dcs_liberation/issues/1417
+ group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE]
# Create combat groups and assign them randomly to each enemy CP
for unit_type in self.cp.base.armor:
@@ -152,20 +154,9 @@ class GroundPlanner:
if len(self.connected_enemy_cp) > 0:
enemy_cp = random.choice(self.connected_enemy_cp).id
self.units_per_cp[enemy_cp].append(group)
- group.assigned_enemy_cp = enemy_cp
else:
self.reserve.append(group)
- group.assigned_enemy_cp = "__reserve__"
collection.append(group)
if remaining_available_frontline_units == 0:
break
-
- print("------------------")
- print("Ground Planner : ")
- print(self.cp.name)
- print("------------------")
- for unit_type in self.units_per_cp.keys():
- print("For : #" + str(unit_type))
- for group in self.units_per_cp[unit_type]:
- print(str(group))
diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py
index 9d307e9c..19907b7e 100644
--- a/gen/groundobjectsgen.py
+++ b/gen/groundobjectsgen.py
@@ -9,7 +9,18 @@ from __future__ import annotations
import logging
import random
-from typing import Dict, Iterator, Optional, TYPE_CHECKING, Type, List
+from typing import (
+ Dict,
+ Iterator,
+ Optional,
+ TYPE_CHECKING,
+ Type,
+ List,
+ TypeVar,
+ Any,
+ Generic,
+ Union,
+)
from dcs import Mission, Point, unitgroup
from dcs.action import SceneryDestructionZone
@@ -25,13 +36,13 @@ from dcs.task import (
)
from dcs.triggers import TriggerStart, TriggerZone
from dcs.unit import Ship, Unit, Vehicle, InvisibleFARP
-from dcs.unitgroup import Group, ShipGroup, StaticGroup, VehicleGroup
-from dcs.unittype import StaticType, UnitType
+from dcs.unitgroup import ShipGroup, StaticGroup, VehicleGroup
+from dcs.unittype import StaticType, ShipType, VehicleType
from dcs.vehicles import vehicle_map
from game import db
from game.data.building_data import FORTIFICATION_UNITS, FORTIFICATION_UNITS_ID
-from game.db import unit_type_from_name
+from game.db import unit_type_from_name, ship_type_from_name, vehicle_type_from_name
from game.theater import ControlPoint, TheaterGroundObject
from game.theater.theatergroundobject import (
BuildingGroundObject,
@@ -44,7 +55,7 @@ from game.theater.theatergroundobject import (
SceneryGroundObject,
)
from game.unitmap import UnitMap
-from game.utils import feet, knots, mps
+from game.utils import Heading, feet, knots, mps
from .radios import RadioFrequency, RadioRegistry
from .runways import RunwayData
from .tacan import TacanBand, TacanChannel, TacanRegistry
@@ -56,7 +67,10 @@ FARP_FRONTLINE_DISTANCE = 10000
AA_CP_MIN_DISTANCE = 40000
-class GenericGroundObjectGenerator:
+TgoT = TypeVar("TgoT", bound=TheaterGroundObject[Any])
+
+
+class GenericGroundObjectGenerator(Generic[TgoT]):
"""An unspecialized ground object generator.
Currently used only for SAM
@@ -64,7 +78,7 @@ class GenericGroundObjectGenerator:
def __init__(
self,
- ground_object: TheaterGroundObject,
+ ground_object: TgoT,
country: Country,
game: Game,
mission: Mission,
@@ -89,10 +103,7 @@ class GenericGroundObjectGenerator:
logging.warning(f"Found empty group in {self.ground_object}")
continue
- unit_type = unit_type_from_name(group.units[0].type)
- if unit_type is None:
- raise RuntimeError(f"Unrecognized unit type: {group.units[0].type}")
-
+ unit_type = vehicle_type_from_name(group.units[0].type)
vg = self.m.vehicle_group(
self.country,
group.name,
@@ -116,24 +127,27 @@ class GenericGroundObjectGenerator:
self._register_unit_group(group, vg)
@staticmethod
- def enable_eplrs(group: Group, unit_type: Type[UnitType]) -> None:
- if hasattr(unit_type, "eplrs"):
- if unit_type.eplrs:
- group.points[0].tasks.append(EPLRS(group.id))
+ def enable_eplrs(group: VehicleGroup, unit_type: Type[VehicleType]) -> None:
+ if unit_type.eplrs:
+ group.points[0].tasks.append(EPLRS(group.id))
- def set_alarm_state(self, group: Group) -> None:
+ def set_alarm_state(self, group: Union[ShipGroup, VehicleGroup]) -> None:
if self.game.settings.perf_red_alert_state:
group.points[0].tasks.append(OptAlarmState(2))
else:
group.points[0].tasks.append(OptAlarmState(1))
- def _register_unit_group(self, persistence_group: Group, miz_group: Group) -> None:
+ def _register_unit_group(
+ self,
+ persistence_group: Union[ShipGroup, VehicleGroup],
+ miz_group: Union[ShipGroup, VehicleGroup],
+ ) -> None:
self.unit_map.add_ground_object_units(
self.ground_object, persistence_group, miz_group
)
-class MissileSiteGenerator(GenericGroundObjectGenerator):
+class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject]):
@property
def culled(self) -> bool:
# Don't cull missile sites - their range is long enough to make them easily
@@ -148,11 +162,11 @@ class MissileSiteGenerator(GenericGroundObjectGenerator):
for group in self.ground_object.groups:
vg = self.m.find_group(group.name)
if vg is not None:
- targets = self.possible_missile_targets(vg)
+ targets = self.possible_missile_targets()
if targets:
target = random.choice(targets)
real_target = target.point_from_heading(
- random.randint(0, 360), random.randint(0, 2500)
+ Heading.random().degrees, random.randint(0, 2500)
)
vg.points[0].add_task(FireAtPoint(real_target))
logging.info("Set up fire task for missile group.")
@@ -165,7 +179,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator):
"Couldn't setup missile site to fire, group was not generated."
)
- def possible_missile_targets(self, vg: Group) -> List[Point]:
+ def possible_missile_targets(self) -> List[Point]:
"""
Find enemy control points in range
:param vg: Vehicle group we are searching a target for (There is always only oe group right now)
@@ -174,7 +188,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator):
targets: List[Point] = []
for cp in self.game.theater.controlpoints:
if cp.captured != self.ground_object.control_point.captured:
- distance = cp.position.distance_to_point(vg.position)
+ distance = cp.position.distance_to_point(self.ground_object.position)
if distance < self.missile_site_range:
targets.append(cp.position)
return targets
@@ -196,7 +210,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator):
return site_range
-class BuildingSiteGenerator(GenericGroundObjectGenerator):
+class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]):
"""Generator for building sites.
Building sites are the primary type of non-airbase objective locations that
@@ -225,14 +239,14 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator):
f"{self.ground_object.dcs_identifier} not found in static maps"
)
- def generate_vehicle_group(self, unit_type: Type[UnitType]) -> None:
+ def generate_vehicle_group(self, unit_type: Type[VehicleType]) -> None:
if not self.ground_object.is_dead:
group = self.m.vehicle_group(
country=self.country,
name=self.ground_object.group_name,
_type=unit_type,
position=self.ground_object.position,
- heading=self.ground_object.heading,
+ heading=self.ground_object.heading.degrees,
)
self._register_fortification(group)
@@ -242,7 +256,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator):
name=self.ground_object.group_name,
_type=static_type,
position=self.ground_object.position,
- heading=self.ground_object.heading,
+ heading=self.ground_object.heading.degrees,
dead=self.ground_object.is_dead,
)
self._register_building(group)
@@ -324,7 +338,7 @@ class SceneryGenerator(BuildingSiteGenerator):
self.unit_map.add_scenery(scenery)
-class GenericCarrierGenerator(GenericGroundObjectGenerator):
+class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundObject]):
"""Base type for carrier group generation.
Used by both CV(N) groups and LHA groups.
@@ -373,16 +387,17 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator):
# time as the recovery window.
brc = self.steam_into_wind(ship_group)
self.activate_beacons(ship_group, tacan, tacan_callsign, icls)
- self.add_runway_data(brc or 0, atc, tacan, tacan_callsign, icls)
+ self.add_runway_data(
+ brc or Heading.from_degrees(0), atc, tacan, tacan_callsign, icls
+ )
self._register_unit_group(group, ship_group)
- def get_carrier_type(self, group: Group) -> Type[UnitType]:
- unit_type = unit_type_from_name(group.units[0].type)
- if unit_type is None:
- raise RuntimeError(f"Unrecognized carrier name: {group.units[0].type}")
- return unit_type
+ def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]:
+ return ship_type_from_name(group.units[0].type)
- def configure_carrier(self, group: Group, atc_channel: RadioFrequency) -> ShipGroup:
+ def configure_carrier(
+ self, group: ShipGroup, atc_channel: RadioFrequency
+ ) -> ShipGroup:
unit_type = self.get_carrier_type(group)
ship_group = self.m.ship_group(
@@ -409,14 +424,14 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator):
ship.set_frequency(atc_channel.hertz)
return ship
- def steam_into_wind(self, group: ShipGroup) -> Optional[int]:
+ def steam_into_wind(self, group: ShipGroup) -> Optional[Heading]:
wind = self.game.conditions.weather.wind.at_0m
- brc = wind.direction + 180
+ brc = Heading.from_degrees(wind.direction).opposite
# Aim for 25kts over the deck.
carrier_speed = knots(25) - mps(wind.speed)
for attempt in range(5):
point = group.points[0].position.point_from_heading(
- brc, 100000 - attempt * 20000
+ brc.degrees, 100000 - attempt * 20000
)
if self.game.theater.is_in_sea(point):
group.points[0].speed = carrier_speed.meters_per_second
@@ -446,7 +461,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator):
def add_runway_data(
self,
- brc: int,
+ brc: Heading,
atc: RadioFrequency,
tacan: TacanChannel,
callsign: str,
@@ -474,7 +489,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator):
class CarrierGenerator(GenericCarrierGenerator):
"""Generator for CV(N) groups."""
- def get_carrier_type(self, group: Group) -> UnitType:
+ def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]:
unit_type = super().get_carrier_type(group)
if self.game.settings.supercarrier:
unit_type = db.upgrade_to_supercarrier(unit_type, self.control_point.name)
@@ -518,7 +533,7 @@ class LhaGenerator(GenericCarrierGenerator):
)
-class ShipObjectGenerator(GenericGroundObjectGenerator):
+class ShipObjectGenerator(GenericGroundObjectGenerator[ShipGroundObject]):
"""Generator for non-carrier naval groups."""
def generate(self) -> None:
@@ -529,14 +544,11 @@ class ShipObjectGenerator(GenericGroundObjectGenerator):
if not group.units:
logging.warning(f"Found empty group in {self.ground_object}")
continue
+ self.generate_group(group, ship_type_from_name(group.units[0].type))
- unit_type = unit_type_from_name(group.units[0].type)
- if unit_type is None:
- raise RuntimeError(f"Unrecognized unit type: {group.units[0].type}")
-
- self.generate_group(group, unit_type)
-
- def generate_group(self, group_def: Group, first_unit_type: Type[UnitType]) -> None:
+ def generate_group(
+ self, group_def: ShipGroup, first_unit_type: Type[ShipType]
+ ) -> None:
group = self.m.ship_group(
self.country,
group_def.name,
@@ -578,21 +590,15 @@ class HelipadGenerator:
def generate(self) -> None:
- if self.cp.captured:
- country_name = self.game.player_country
- else:
- country_name = self.game.enemy_country
- country = self.m.country(country_name)
-
# Note : Helipad are generated as neutral object in order not to interfer with capture triggers
neutral_country = self.m.country(self.game.neutral_country.name)
-
+ country = self.m.country(self.game.coalition_for(self.cp.captured).country_name)
for i, helipad in enumerate(self.cp.helipads):
name = self.cp.name + "_helipad_" + str(i)
logging.info("Generating helipad : " + name)
pad = InvisibleFARP(name=name)
pad.position = Point(helipad.x, helipad.y)
- pad.heading = helipad.heading
+ pad.heading = helipad.heading.degrees
sg = unitgroup.StaticGroup(self.m.next_group_id(), name)
sg.add_unit(pad)
sp = StaticPoint()
@@ -647,19 +653,15 @@ class GroundObjectsGenerator:
self.icls_alloc = iter(range(1, 21))
self.runways: Dict[str, RunwayData] = {}
- def generate(self):
+ def generate(self) -> None:
for cp in self.game.theater.controlpoints:
- if cp.captured:
- country_name = self.game.player_country
- else:
- country_name = self.game.enemy_country
- country = self.m.country(country_name)
-
+ country = self.m.country(self.game.coalition_for(cp.captured).country_name)
HelipadGenerator(
self.m, cp, self.game, self.radio_registry, self.tacan_registry
).generate()
for ground_object in cp.ground_objects:
+ generator: GenericGroundObjectGenerator[Any]
if isinstance(ground_object, FactoryGroundObject):
generator = FactoryGenerator(
ground_object, country, self.game, self.m, self.unit_map
diff --git a/gen/kneeboard.py b/gen/kneeboard.py
index 62fd9d25..a2be2ef6 100644
--- a/gen/kneeboard.py
+++ b/gen/kneeboard.py
@@ -23,6 +23,7 @@ only be added per airframe, so PvP missions where each side have the same
aircraft will be able to see the enemy's kneeboard for the same airframe.
"""
import datetime
+import math
import textwrap
from collections import defaultdict
from dataclasses import dataclass
@@ -40,6 +41,7 @@ from game.dcs.aircrafttype import AircraftType
from game.theater import ConflictTheater, TheaterGroundObject, LatLon
from game.theater.bullseye import Bullseye
from game.utils import meters
+from game.weather import Weather
from .aircraft import FlightData
from .airsupportgen import AwacsInfo, TankerInfo
from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator
@@ -76,7 +78,7 @@ class KneeboardPageWriter:
"arial.ttf", 24, layout_engine=ImageFont.LAYOUT_BASIC
)
self.content_font = ImageFont.truetype(
- "arial.ttf", 20, layout_engine=ImageFont.LAYOUT_BASIC
+ "arial.ttf", 16, layout_engine=ImageFont.LAYOUT_BASIC
)
self.table_font = ImageFont.truetype(
"resources/fonts/Inconsolata.otf", 20, layout_engine=ImageFont.LAYOUT_BASIC
@@ -91,10 +93,15 @@ class KneeboardPageWriter:
return self.x, self.y
def text(
- self, text: str, font=None, fill: Tuple[int, int, int] = (0, 0, 0)
+ self,
+ text: str,
+ font: Optional[ImageFont.FreeTypeFont] = None,
+ fill: Optional[Tuple[int, int, int]] = None,
) -> None:
if font is None:
font = self.content_font
+ if fill is None:
+ fill = self.foreground_fill
self.draw.text(self.position, text, font=font, fill=fill)
width, height = self.draw.textsize(text, font=font)
@@ -107,12 +114,17 @@ class KneeboardPageWriter:
self.text(text, font=self.heading_font, fill=self.foreground_fill)
def table(
- self, cells: List[List[str]], headers: Optional[List[str]] = None
+ self,
+ cells: List[List[str]],
+ headers: Optional[List[str]] = None,
+ font: Optional[ImageFont.FreeTypeFont] = None,
) -> None:
if headers is None:
headers = []
+ if font is None:
+ font = self.table_font
table = tabulate(cells, headers=headers, numalign="right")
- self.text(table, font=self.table_font, fill=self.foreground_fill)
+ self.text(table, font, fill=self.foreground_fill)
def write(self, path: Path) -> None:
self.image.save(path)
@@ -195,6 +207,7 @@ class FlightPlanBuilder:
self._ground_speed(self.target_points[0].waypoint),
self._format_time(self.target_points[0].waypoint.tot),
self._format_time(self.target_points[0].waypoint.departure_time),
+ self._format_min_fuel(self.target_points[0].waypoint.min_fuel),
]
)
self.last_waypoint = self.target_points[-1].waypoint
@@ -212,6 +225,7 @@ class FlightPlanBuilder:
self._ground_speed(waypoint.waypoint),
self._format_time(waypoint.waypoint.tot),
self._format_time(waypoint.waypoint.departure_time),
+ self._format_min_fuel(waypoint.waypoint.min_fuel),
]
)
@@ -250,6 +264,12 @@ class FlightPlanBuilder:
duration = (waypoint.tot - last_time).total_seconds() / 3600
return f"{int(distance.nautical_miles / duration)} kt"
+ @staticmethod
+ def _format_min_fuel(min_fuel: Optional[float]) -> str:
+ if min_fuel is None:
+ return ""
+ return str(math.ceil(min_fuel / 100) * 100)
+
def build(self) -> List[List[str]]:
return self.rows
@@ -262,14 +282,21 @@ class BriefingPage(KneeboardPage):
flight: FlightData,
bullseye: Bullseye,
theater: ConflictTheater,
+ weather: Weather,
start_time: datetime.datetime,
dark_kneeboard: bool,
) -> None:
self.flight = flight
self.bullseye = bullseye
self.theater = theater
+ self.weather = weather
self.start_time = start_time
self.dark_kneeboard = dark_kneeboard
+ self.flight_plan_font = ImageFont.truetype(
+ "resources/fonts/Inconsolata.otf",
+ 16,
+ layout_engine=ImageFont.LAYOUT_BASIC,
+ )
def write(self, path: Path) -> None:
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
@@ -296,11 +323,29 @@ class BriefingPage(KneeboardPage):
flight_plan_builder.add_waypoint(num, waypoint)
writer.table(
flight_plan_builder.build(),
- headers=["#", "Action", "Alt", "Dist", "GSPD", "Time", "Departure"],
+ headers=[
+ "#",
+ "Action",
+ "Alt",
+ "Dist",
+ "GSPD",
+ "Time",
+ "Departure",
+ "Min fuel",
+ ],
+ font=self.flight_plan_font,
)
writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}")
+ qnh_in_hg = f"{self.weather.atmospheric.qnh.inches_hg:.2f}"
+ qnh_mm_hg = f"{self.weather.atmospheric.qnh.mm_hg:.1f}"
+ qnh_hpa = f"{self.weather.atmospheric.qnh.hecto_pascals:.1f}"
+ writer.text(
+ f"Temperature: {round(self.weather.atmospheric.temperature_celsius)} °C at sea level"
+ )
+ writer.text(f"QNH: {qnh_in_hg} inHg / {qnh_mm_hg} mmHg / {qnh_hpa} hPa")
+
writer.table(
[
[
@@ -311,6 +356,12 @@ class BriefingPage(KneeboardPage):
["Bingo", "Joker"],
)
+ if any(self.flight.laser_codes):
+ codes: list[list[str]] = []
+ for idx, code in enumerate(self.flight.laser_codes, start=1):
+ codes.append([str(idx), "" if code is None else str(code)])
+ writer.table(codes, ["#", "Laser Code"])
+
writer.write(path)
def airfield_info_row(
@@ -365,6 +416,8 @@ class BriefingPage(KneeboardPage):
class SupportPage(KneeboardPage):
"""A kneeboard page containing information about support units."""
+ JTAC_REGION_MAX_LEN = 25
+
def __init__(
self,
flight: FlightData,
@@ -408,7 +461,7 @@ class SupportPage(KneeboardPage):
aewc_ladder.append(
[
str(single_aewc.callsign),
- str(single_aewc.freq),
+ self.format_frequency(single_aewc.freq),
str(single_aewc.depature_location),
str(dep),
str(arr),
@@ -444,8 +497,18 @@ class SupportPage(KneeboardPage):
writer.heading("JTAC")
jtacs = []
for jtac in self.jtacs:
- jtacs.append([jtac.callsign, jtac.region, jtac.code])
- writer.table(jtacs, headers=["Callsign", "Region", "Laser Code"])
+ jtacs.append(
+ [
+ jtac.callsign,
+ KneeboardPageWriter.wrap_line(
+ jtac.region,
+ self.JTAC_REGION_MAX_LEN,
+ ),
+ jtac.code,
+ self.format_frequency(jtac.freq),
+ ]
+ )
+ writer.table(jtacs, headers=["Callsign", "Region", "Laser Code", "FREQ"])
writer.write(path)
@@ -554,6 +617,24 @@ class StrikeTaskPage(KneeboardPage):
]
+class NotesPage(KneeboardPage):
+ """A kneeboard page containing the campaign owner's notes."""
+
+ def __init__(
+ self,
+ notes: str,
+ dark_kneeboard: bool,
+ ) -> None:
+ self.notes = notes
+ self.dark_kneeboard = dark_kneeboard
+
+ def write(self, path: Path) -> None:
+ writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
+ writer.title(f"Notes")
+ writer.text(self.notes)
+ writer.write(path)
+
+
class KneeboardGenerator(MissionInfoGenerator):
"""Creates kneeboard pages for each client flight in the mission."""
@@ -609,6 +690,7 @@ class KneeboardGenerator(MissionInfoGenerator):
flight,
self.game.bullseye_for(flight.friendly),
self.game.theater,
+ self.game.conditions.weather,
self.mission.start_time,
self.dark_kneeboard,
),
@@ -623,6 +705,10 @@ class KneeboardGenerator(MissionInfoGenerator):
),
]
+ # Only create the notes page if there are notes to show.
+ if notes := self.game.notes:
+ pages.append(NotesPage(notes, self.dark_kneeboard))
+
if (target_page := self.generate_task_page(flight)) is not None:
pages.append(target_page)
diff --git a/gen/lasercoderegistry.py b/gen/lasercoderegistry.py
new file mode 100644
index 00000000..6872cb30
--- /dev/null
+++ b/gen/lasercoderegistry.py
@@ -0,0 +1,37 @@
+from collections import deque
+from typing import Iterator
+
+
+class OutOfLaserCodesError(RuntimeError):
+ def __init__(self) -> None:
+ super().__init__(
+ f"All JTAC laser codes have been allocated. No available codes."
+ )
+
+
+class LaserCodeRegistry:
+ def __init__(self) -> None:
+ self.allocated_codes: set[int] = set()
+ self.allocator: Iterator[int] = LaserCodeRegistry.__laser_code_generator()
+
+ def get_next_laser_code(self) -> int:
+ try:
+ while (code := next(self.allocator)) in self.allocated_codes:
+ pass
+ self.allocated_codes.add(code)
+ return code
+ except StopIteration:
+ raise OutOfLaserCodesError
+
+ @staticmethod
+ def __laser_code_generator() -> Iterator[int]:
+ # Valid laser codes are as follows
+ # First digit is always 1
+ # Second digit is 5-7
+ # Third and fourth digits are 1 - 8
+ # We iterate backward (reversed()) so that 1687 follows 1688
+ q = deque(int(oct(code)[2:]) + 11 for code in reversed(range(0o1500, 0o2000)))
+
+ # We start with the default of 1688 and wrap around when we reach the end
+ q.rotate(-q.index(1688))
+ return iter(q)
diff --git a/gen/locations/preset_control_point_locations.py b/gen/locations/preset_control_point_locations.py
deleted file mode 100644
index e4be5136..00000000
--- a/gen/locations/preset_control_point_locations.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from dataclasses import dataclass, field
-
-from typing import List
-
-from gen.locations.preset_locations import PresetLocation
-
-
-@dataclass
-class PresetControlPointLocations:
- """A repository of preset locations for a given control point"""
-
- # List of possible ashore locations to generate objects (Represented in miz file by an APC_AAV_7_Amphibious)
- ashore_locations: List[PresetLocation] = field(default_factory=list)
-
- # List of possible offshore locations to generate ship groups (Represented in miz file by an Oliver Hazard Perry)
- offshore_locations: List[PresetLocation] = field(default_factory=list)
-
- # Possible antiship missiles sites locations (Represented in miz file by Iranian Silkworm missiles)
- antiship_locations: List[PresetLocation] = field(default_factory=list)
-
- # List of possible powerplants locations (Represented in miz file by static Workshop A object, USA)
- powerplant_locations: List[PresetLocation] = field(default_factory=list)
diff --git a/gen/locations/preset_locations.py b/gen/locations/preset_locations.py
deleted file mode 100644
index 89bdffbc..00000000
--- a/gen/locations/preset_locations.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from dataclasses import dataclass
-
-from dcs import Point
-
-
-@dataclass
-class PresetLocation:
- """A preset location"""
-
- position: Point
- heading: int
- id: str
-
- def __str__(self):
- return (
- "-" * 10
- + "X: {}\n Y: {}\nHdg: {}°\nId: {}".format(
- self.position.x, self.position.y, self.heading, self.id
- )
- + "-" * 10
- )
diff --git a/gen/missiles/missiles_group_generator.py b/gen/missiles/missiles_group_generator.py
index 72251516..63f1bb80 100644
--- a/gen/missiles/missiles_group_generator.py
+++ b/gen/missiles/missiles_group_generator.py
@@ -1,13 +1,20 @@
import logging
import random
-from game import db
+from typing import Optional
+
+from dcs.unitgroup import VehicleGroup
+
+from game import db, Game
+from game.theater.theatergroundobject import MissileSiteGroundObject
from gen.missiles.scud_site import ScudGenerator
from gen.missiles.v1_group import V1GroupGenerator
MISSILES_MAP = {"V1GroupGenerator": V1GroupGenerator, "ScudGenerator": ScudGenerator}
-def generate_missile_group(game, ground_object, faction_name: str):
+def generate_missile_group(
+ game: Game, ground_object: MissileSiteGroundObject, faction_name: str
+) -> Optional[VehicleGroup]:
"""
This generate a missiles group
:return: Nothing, but put the group reference inside the ground object
diff --git a/gen/missiles/scud_site.py b/gen/missiles/scud_site.py
index 67c9a0ad..c57b43e3 100644
--- a/gen/missiles/scud_site.py
+++ b/gen/missiles/scud_site.py
@@ -2,15 +2,21 @@ import random
from dcs.vehicles import Unarmed, MissilesSS, AirDefence
-from gen.sam.group_generator import GroupGenerator
+from game import Game
+from game.factions.faction import Faction
+from game.theater.theatergroundobject import MissileSiteGroundObject
+from game.utils import Heading
+from gen.sam.group_generator import VehicleGroupGenerator
-class ScudGenerator(GroupGenerator):
- def __init__(self, game, ground_object, faction):
+class ScudGenerator(VehicleGroupGenerator[MissileSiteGroundObject]):
+ def __init__(
+ self, game: Game, ground_object: MissileSiteGroundObject, faction: Faction
+ ) -> None:
super(ScudGenerator, self).__init__(game, ground_object)
self.faction = faction
- def generate(self):
+ def generate(self) -> None:
# Scuds
self.add_unit(
@@ -58,5 +64,5 @@ class ScudGenerator(GroupGenerator):
"STRELA#0",
self.position.x + 200,
self.position.y + 15,
- 90,
+ Heading.from_degrees(90),
)
diff --git a/gen/missiles/v1_group.py b/gen/missiles/v1_group.py
index 60c94db8..e42a94fe 100644
--- a/gen/missiles/v1_group.py
+++ b/gen/missiles/v1_group.py
@@ -2,15 +2,21 @@ import random
from dcs.vehicles import Unarmed, MissilesSS, AirDefence
-from gen.sam.group_generator import GroupGenerator
+from game import Game
+from game.factions.faction import Faction
+from game.theater.theatergroundobject import MissileSiteGroundObject
+from game.utils import Heading
+from gen.sam.group_generator import VehicleGroupGenerator
-class V1GroupGenerator(GroupGenerator):
- def __init__(self, game, ground_object, faction):
+class V1GroupGenerator(VehicleGroupGenerator[MissileSiteGroundObject]):
+ def __init__(
+ self, game: Game, ground_object: MissileSiteGroundObject, faction: Faction
+ ) -> None:
super(V1GroupGenerator, self).__init__(game, ground_object)
self.faction = faction
- def generate(self):
+ def generate(self) -> None:
# Ramps
self.add_unit(
@@ -60,5 +66,5 @@ class V1GroupGenerator(GroupGenerator):
"Blitz#0",
self.position.x + 200,
self.position.y + 15,
- 90,
+ Heading.from_degrees(90),
)
diff --git a/gen/naming.py b/gen/naming.py
index df56ab64..e43d629c 100644
--- a/gen/naming.py
+++ b/gen/naming.py
@@ -1,6 +1,6 @@
import random
import time
-from typing import List
+from typing import List, Any
from dcs.country import Country
@@ -256,7 +256,7 @@ class NameGenerator:
existing_alphas: List[str] = []
@classmethod
- def reset(cls):
+ def reset(cls) -> None:
cls.number = 0
cls.infantry_number = 0
cls.convoy_number = 0
@@ -265,7 +265,7 @@ class NameGenerator:
cls.existing_alphas = []
@classmethod
- def reset_numbers(cls):
+ def reset_numbers(cls) -> None:
cls.number = 0
cls.infantry_number = 0
cls.aircraft_number = 0
@@ -273,7 +273,9 @@ class NameGenerator:
cls.cargo_ship_number = 0
@classmethod
- def next_aircraft_name(cls, country: Country, parent_base_id: int, flight: Flight):
+ def next_aircraft_name(
+ cls, country: Country, parent_base_id: int, flight: Flight
+ ) -> str:
cls.aircraft_number += 1
try:
if flight.custom_name:
@@ -293,7 +295,9 @@ class NameGenerator:
)
@classmethod
- def next_unit_name(cls, country: Country, parent_base_id: int, unit_type: UnitType):
+ def next_unit_name(
+ cls, country: Country, parent_base_id: int, unit_type: UnitType[Any]
+ ) -> str:
cls.number += 1
return "unit|{}|{}|{}|{}|".format(
country.id, cls.number, parent_base_id, unit_type.name
@@ -301,8 +305,8 @@ class NameGenerator:
@classmethod
def next_infantry_name(
- cls, country: Country, parent_base_id: int, unit_type: UnitType
- ):
+ cls, country: Country, parent_base_id: int, unit_type: UnitType[Any]
+ ) -> str:
cls.infantry_number += 1
return "infantry|{}|{}|{}|{}|".format(
country.id,
@@ -312,17 +316,17 @@ class NameGenerator:
)
@classmethod
- def next_awacs_name(cls, country: Country):
+ def next_awacs_name(cls, country: Country) -> str:
cls.number += 1
return "awacs|{}|{}|0|".format(country.id, cls.number)
@classmethod
- def next_tanker_name(cls, country: Country, unit_type: AircraftType):
+ def next_tanker_name(cls, country: Country, unit_type: AircraftType) -> str:
cls.number += 1
return "tanker|{}|{}|0|{}".format(country.id, cls.number, unit_type.name)
@classmethod
- def next_carrier_name(cls, country: Country):
+ def next_carrier_name(cls, country: Country) -> str:
cls.number += 1
return "carrier|{}|{}|0|".format(country.id, cls.number)
@@ -337,7 +341,7 @@ class NameGenerator:
return f"Cargo Ship {cls.cargo_ship_number:03}"
@classmethod
- def random_objective_name(cls):
+ def random_objective_name(cls) -> str:
if cls.animals:
animal = random.choice(cls.animals)
cls.animals.remove(animal)
diff --git a/gen/radios.py b/gen/radios.py
index 22968397..ced4ac9c 100644
--- a/gen/radios.py
+++ b/gen/radios.py
@@ -15,7 +15,7 @@ class RadioFrequency:
#: The frequency in kilohertz.
hertz: int
- def __str__(self):
+ def __str__(self) -> str:
if self.hertz >= 1000000:
return self.format("MHz", 1000000)
return self.format("kHz", 1000)
diff --git a/gen/runways.py b/gen/runways.py
index dfb0cebe..ef9ab52f 100644
--- a/gen/runways.py
+++ b/gen/runways.py
@@ -8,6 +8,7 @@ from typing import Iterator, Optional
from dcs.terrain.terrain import Airport
from game.weather import Conditions
+from game.utils import Heading
from .airfields import AIRFIELD_DATA
from .radios import RadioFrequency
from .tacan import TacanChannel
@@ -16,7 +17,7 @@ from .tacan import TacanChannel
@dataclass(frozen=True)
class RunwayData:
airfield_name: str
- runway_heading: int
+ runway_heading: Heading
runway_name: str
atc: Optional[RadioFrequency] = None
tacan: Optional[TacanChannel] = None
@@ -26,7 +27,7 @@ class RunwayData:
@classmethod
def for_airfield(
- cls, airport: Airport, runway_heading: int, runway_name: str
+ cls, airport: Airport, runway_heading: Heading, runway_name: str
) -> RunwayData:
"""Creates RunwayData for the given runway of an airfield.
@@ -66,12 +67,14 @@ class RunwayData:
runway_number = runway.heading // 10
runway_side = ["", "L", "R"][runway.leftright]
runway_name = f"{runway_number:02}{runway_side}"
- yield cls.for_airfield(airport, runway.heading, runway_name)
+ yield cls.for_airfield(
+ airport, Heading.from_degrees(runway.heading), runway_name
+ )
# pydcs only exposes one runway per physical runway, so to expose
# both sides of the runway we need to generate the other.
- heading = (runway.heading + 180) % 360
- runway_number = heading // 10
+ heading = Heading.from_degrees(runway.heading).opposite
+ runway_number = heading.degrees // 10
runway_side = ["", "R", "L"][runway.leftright]
runway_name = f"{runway_number:02}{runway_side}"
yield cls.for_airfield(airport, heading, runway_name)
@@ -81,10 +84,10 @@ class RunwayAssigner:
def __init__(self, conditions: Conditions):
self.conditions = conditions
- def angle_off_headwind(self, runway: RunwayData) -> int:
- wind = self.conditions.weather.wind.at_0m.direction
- ideal_heading = (wind + 180) % 360
- return abs(runway.runway_heading - ideal_heading)
+ def angle_off_headwind(self, runway: RunwayData) -> Heading:
+ wind = Heading.from_degrees(self.conditions.weather.wind.at_0m.direction)
+ ideal_heading = wind.opposite
+ return runway.runway_heading.angle_between(ideal_heading)
def get_preferred_runway(self, airport: Airport) -> RunwayData:
"""Returns the preferred runway for the given airport.
diff --git a/gen/sam/aaa_bofors.py b/gen/sam/aaa_bofors.py
index 8c76f7f4..f6e21977 100644
--- a/gen/sam/aaa_bofors.py
+++ b/gen/sam/aaa_bofors.py
@@ -14,25 +14,21 @@ class BoforsGenerator(AirDefenseGroupGenerator):
"""
name = "Bofors AAA"
- price = 75
- def generate(self):
- grid_x = random.randint(2, 3)
- grid_y = random.randint(2, 3)
-
- spacing = random.randint(10, 40)
+ def generate(self) -> None:
index = 0
- for i in range(grid_x):
- for j in range(grid_y):
- index = index + 1
- self.add_unit(
- AirDefence.Bofors40,
- "AAA#" + str(index),
- self.position.x + spacing * i,
- self.position.y + spacing * j,
- self.heading,
- )
+ for i in range(4):
+ spacing_x = random.randint(10, 40)
+ spacing_y = random.randint(10, 40)
+ index = index + 1
+ self.add_unit(
+ AirDefence.Bofors40,
+ "AAA#" + str(index),
+ self.position.x + spacing_x * i,
+ self.position.y + spacing_y * i,
+ self.heading,
+ )
@classmethod
def range(cls) -> AirDefenseRange:
diff --git a/gen/sam/aaa_flak.py b/gen/sam/aaa_flak.py
index f918e48a..0e27a8d2 100644
--- a/gen/sam/aaa_flak.py
+++ b/gen/sam/aaa_flak.py
@@ -6,6 +6,7 @@ from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
)
+from game.utils import Heading
GFLAK = [
AirDefence.Flak38,
@@ -23,31 +24,26 @@ class FlakGenerator(AirDefenseGroupGenerator):
"""
name = "Flak Site"
- price = 135
-
- def generate(self):
- grid_x = random.randint(2, 3)
- grid_y = random.randint(2, 3)
-
- spacing = random.randint(20, 35)
+ def generate(self) -> None:
index = 0
mixed = random.choice([True, False])
unit_type = random.choice(GFLAK)
- for i in range(grid_x):
- for j in range(grid_y):
- index = index + 1
- self.add_unit(
- unit_type,
- "AAA#" + str(index),
- self.position.x + spacing * i + random.randint(1, 5),
- self.position.y + spacing * j + random.randint(1, 5),
- self.heading,
- )
+ for i in range(4):
+ index = index + 1
+ spacing_x = random.randint(10, 40)
+ spacing_y = random.randint(10, 40)
+ self.add_unit(
+ unit_type,
+ "AAA#" + str(index),
+ self.position.x + spacing_x * i + random.randint(1, 5),
+ self.position.y + spacing_y * i + random.randint(1, 5),
+ self.heading,
+ )
- if mixed:
- unit_type = random.choice(GFLAK)
+ if mixed:
+ unit_type = random.choice(GFLAK)
# Search lights
search_pos = self.get_circular_position(random.randint(2, 3), 80)
@@ -86,14 +82,14 @@ class FlakGenerator(AirDefenseGroupGenerator):
)
# Some Opel Blitz trucks
- for i in range(int(max(1, grid_x / 2))):
- for j in range(int(max(1, grid_x / 2))):
+ for i in range(int(max(1, 2))):
+ for j in range(int(max(1, 2))):
self.add_unit(
Unarmed.Blitz_36_6700A,
"BLITZ#" + str(index),
self.position.x + 125 + 15 * i + random.randint(1, 5),
self.position.y + 15 * j + random.randint(1, 5),
- 75,
+ Heading.from_degrees(75),
)
@classmethod
diff --git a/gen/sam/aaa_flak18.py b/gen/sam/aaa_flak18.py
index 91f81f15..17725a33 100644
--- a/gen/sam/aaa_flak18.py
+++ b/gen/sam/aaa_flak18.py
@@ -14,9 +14,8 @@ class Flak18Generator(AirDefenseGroupGenerator):
"""
name = "WW2 Flak Site"
- price = 40
- def generate(self):
+ def generate(self) -> None:
spacing = random.randint(30, 60)
index = 0
diff --git a/gen/sam/aaa_ks19.py b/gen/sam/aaa_ks19.py
index 1e3de4ca..7f062bfe 100644
--- a/gen/sam/aaa_ks19.py
+++ b/gen/sam/aaa_ks19.py
@@ -13,12 +13,8 @@ class KS19Generator(AirDefenseGroupGenerator):
"""
name = "KS-19 AAA Site"
- price = 98
-
- def generate(self):
-
- spacing = random.randint(10, 40)
+ def generate(self) -> None:
self.add_unit(
highdigitsams.AAA_SON_9_Fire_Can,
"TR",
@@ -28,16 +24,17 @@ class KS19Generator(AirDefenseGroupGenerator):
)
index = 0
- for i in range(3):
- for j in range(3):
- index = index + 1
- self.add_unit(
- highdigitsams.AAA_100mm_KS_19,
- "AAA#" + str(index),
- self.position.x + spacing * i,
- self.position.y + spacing * j,
- self.heading,
- )
+ for i in range(4):
+ spacing_x = random.randint(10, 40)
+ spacing_y = random.randint(10, 40)
+ index = index + 1
+ self.add_unit(
+ highdigitsams.AAA_100mm_KS_19,
+ "AAA#" + str(index),
+ self.position.x + spacing_x * i,
+ self.position.y + spacing_y * i,
+ self.heading,
+ )
@classmethod
def range(cls) -> AirDefenseRange:
diff --git a/gen/sam/aaa_ww2_ally_flak.py b/gen/sam/aaa_ww2_ally_flak.py
index 415bdab3..4eed42f4 100644
--- a/gen/sam/aaa_ww2_ally_flak.py
+++ b/gen/sam/aaa_ww2_ally_flak.py
@@ -6,6 +6,7 @@ from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
)
+from game.utils import Heading
class AllyWW2FlakGenerator(AirDefenseGroupGenerator):
@@ -14,9 +15,8 @@ class AllyWW2FlakGenerator(AirDefenseGroupGenerator):
"""
name = "WW2 Ally Flak Site"
- price = 140
- def generate(self):
+ def generate(self) -> None:
positions = self.get_circular_position(4, launcher_distance=30, coverage=360)
for i, position in enumerate(positions):
@@ -54,28 +54,28 @@ class AllyWW2FlakGenerator(AirDefenseGroupGenerator):
"CMD#1",
self.position.x,
self.position.y - 20,
- random.randint(0, 360),
+ Heading.random(),
)
self.add_unit(
Unarmed.M30_CC,
"LOG#1",
self.position.x,
self.position.y + 20,
- random.randint(0, 360),
+ Heading.random(),
)
self.add_unit(
Unarmed.M4_Tractor,
"LOG#2",
self.position.x + 20,
self.position.y,
- random.randint(0, 360),
+ Heading.random(),
)
self.add_unit(
Unarmed.Bedford_MWD,
"LOG#3",
self.position.x - 20,
self.position.y,
- random.randint(0, 360),
+ Heading.random(),
)
@classmethod
diff --git a/gen/sam/aaa_zsu57.py b/gen/sam/aaa_zsu57.py
index 4648e90b..909ce549 100644
--- a/gen/sam/aaa_zsu57.py
+++ b/gen/sam/aaa_zsu57.py
@@ -12,10 +12,9 @@ class ZSU57Generator(AirDefenseGroupGenerator):
"""
name = "ZSU-57-2 Group"
- price = 60
- def generate(self):
- num_launchers = 5
+ def generate(self) -> None:
+ num_launchers = 4
positions = self.get_circular_position(
num_launchers, launcher_distance=110, coverage=360
)
diff --git a/gen/sam/aaa_zu23_insurgent.py b/gen/sam/aaa_zu23_insurgent.py
index 5ca97638..ef2ec419 100644
--- a/gen/sam/aaa_zu23_insurgent.py
+++ b/gen/sam/aaa_zu23_insurgent.py
@@ -14,25 +14,20 @@ class ZU23InsurgentGenerator(AirDefenseGroupGenerator):
"""
name = "Zu-23 Site"
- price = 56
-
- def generate(self):
- grid_x = random.randint(2, 3)
- grid_y = random.randint(2, 3)
-
- spacing = random.randint(10, 40)
+ def generate(self) -> None:
index = 0
- for i in range(grid_x):
- for j in range(grid_y):
- index = index + 1
- self.add_unit(
- AirDefence.ZU_23_Closed_Insurgent,
- "AAA#" + str(index),
- self.position.x + spacing * i,
- self.position.y + spacing * j,
- self.heading,
- )
+ for i in range(4):
+ index = index + 1
+ spacing_x = random.randint(10, 40)
+ spacing_y = random.randint(10, 40)
+ self.add_unit(
+ AirDefence.ZU_23_Closed_Insurgent,
+ "AAA#" + str(index),
+ self.position.x + spacing_x * i,
+ self.position.y + spacing_y * i,
+ self.heading,
+ )
@classmethod
def range(cls) -> AirDefenseRange:
diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py
index a62a5f11..f755cafa 100644
--- a/gen/sam/airdefensegroupgenerator.py
+++ b/gen/sam/airdefensegroupgenerator.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from abc import ABC, abstractmethod
from enum import Enum
from typing import Iterator, List
@@ -6,36 +8,69 @@ from dcs.unitgroup import VehicleGroup
from game import Game
from game.theater.theatergroundobject import SamGroundObject
-from gen.sam.group_generator import GroupGenerator
+from gen.sam.group_generator import VehicleGroupGenerator
+
+
+class SkynetRole(Enum):
+ #: A radar SAM that should be controlled by Skynet.
+ Sam = "Sam"
+
+ #: A radar SAM that should be controlled and used as an EWR by Skynet.
+ SamAsEwr = "SamAsEwr"
+
+ #: An air defense unit that should be used as point defense by Skynet.
+ PointDefense = "PD"
+
+ #: All other types of groups that might be present in a SAM TGO. This includes
+ #: SHORADS, AAA, supply trucks, etc. Anything that shouldn't be controlled by Skynet
+ #: should use this role.
+ NoSkynetBehavior = "NoSkynetBehavior"
class AirDefenseRange(Enum):
- AAA = "AAA"
- Short = "short"
- Medium = "medium"
- Long = "long"
+ AAA = ("AAA", SkynetRole.NoSkynetBehavior)
+ Short = ("short", SkynetRole.NoSkynetBehavior)
+ Medium = ("medium", SkynetRole.Sam)
+ Long = ("long", SkynetRole.SamAsEwr)
+
+ def __init__(self, description: str, default_role: SkynetRole) -> None:
+ self.range_name = description
+ self.default_role = default_role
-class AirDefenseGroupGenerator(GroupGenerator, ABC):
+class AirDefenseGroupGenerator(VehicleGroupGenerator[SamGroundObject], ABC):
"""
This is the base for all SAM group generators
"""
- price: int
-
def __init__(self, game: Game, ground_object: SamGroundObject) -> None:
- ground_object.skynet_capable = True
super().__init__(game, ground_object)
+ self.vg.name = self.group_name_for_role(self.vg.id, self.primary_group_role())
self.auxiliary_groups: List[VehicleGroup] = []
+ self.heading = self.heading_to_conflict()
- def add_auxiliary_group(self, name_suffix: str) -> VehicleGroup:
- group = VehicleGroup(
- self.game.next_group_id(), "|".join([self.go.group_name, name_suffix])
- )
+ def add_auxiliary_group(self, role: SkynetRole) -> VehicleGroup:
+ gid = self.game.next_group_id()
+ group = VehicleGroup(gid, self.group_name_for_role(gid, role))
self.auxiliary_groups.append(group)
return group
+ def group_name_for_role(self, gid: int, role: SkynetRole) -> str:
+ if role is SkynetRole.NoSkynetBehavior:
+ # No special naming needed for air defense groups that don't participate in
+ # Skynet.
+ return f"{self.go.group_name}|{gid}"
+
+ # For those that do, we need a prefix of `$COLOR|SAM| so our Skynet config picks
+ # the group up at all. To support PDs we need to append the ID of the TGO so
+ # that the PD will know which group it's protecting. We then append the role so
+ # our config knows what to do with the group, and finally the GID of *this*
+ # group to ensure no conflicts.
+ return "|".join(
+ [self.go.faction_color, "SAM", str(self.go.group_id), role.value, str(gid)]
+ )
+
def get_generated_group(self) -> VehicleGroup:
raise RuntimeError(
"Deprecated call to AirDefenseGroupGenerator.get_generated_group "
@@ -52,3 +87,7 @@ class AirDefenseGroupGenerator(GroupGenerator, ABC):
@abstractmethod
def range(cls) -> AirDefenseRange:
...
+
+ @classmethod
+ def primary_group_role(cls) -> SkynetRole:
+ return cls.range().default_role
diff --git a/gen/sam/cold_war_flak.py b/gen/sam/cold_war_flak.py
index 6c0bdf40..bb538434 100644
--- a/gen/sam/cold_war_flak.py
+++ b/gen/sam/cold_war_flak.py
@@ -17,9 +17,8 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator):
"""
name = "Early Cold War Flak Site"
- price = 74
- def generate(self):
+ def generate(self) -> None:
spacing = random.randint(30, 60)
index = 0
@@ -42,7 +41,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator):
"SHO#1",
self.position.x - 40,
self.position.y - 40,
- self.heading + 180,
+ self.heading.opposite,
),
self.add_unit(
AirDefence.S_60_Type59_Artillery,
@@ -58,7 +57,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator):
"SHO#3",
self.position.x - 80,
self.position.y - 40,
- self.heading + 180,
+ self.heading.opposite,
),
self.add_unit(
AirDefence.ZU_23_Emplacement_Closed,
@@ -90,9 +89,8 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator):
"""
name = "Cold War Flak Site"
- price = 72
- def generate(self):
+ def generate(self) -> None:
spacing = random.randint(30, 60)
index = 0
@@ -115,7 +113,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator):
"SHO#1",
self.position.x - 40,
self.position.y - 40,
- self.heading + 180,
+ self.heading.opposite,
),
self.add_unit(
AirDefence.S_60_Type59_Artillery,
@@ -131,7 +129,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator):
"SHO#3",
self.position.x - 80,
self.position.y - 40,
- self.heading + 180,
+ self.heading.opposite,
),
self.add_unit(
AirDefence.ZU_23_Emplacement_Closed,
diff --git a/gen/sam/ewr_group_generator.py b/gen/sam/ewr_group_generator.py
index 81ede492..32404be4 100644
--- a/gen/sam/ewr_group_generator.py
+++ b/gen/sam/ewr_group_generator.py
@@ -18,6 +18,7 @@ from gen.sam.ewrs import (
StraightFlushGenerator,
TallRackGenerator,
EwrGenerator,
+ TinShieldGenerator,
)
EWR_MAP = {
@@ -31,6 +32,7 @@ EWR_MAP = {
"SnowDriftGenerator": SnowDriftGenerator,
"StraightFlushGenerator": StraightFlushGenerator,
"HawkEwrGenerator": HawkEwrGenerator,
+ "TinShieldGenerator": TinShieldGenerator,
}
diff --git a/gen/sam/ewrs.py b/gen/sam/ewrs.py
index df27e6ad..2ffc93da 100644
--- a/gen/sam/ewrs.py
+++ b/gen/sam/ewrs.py
@@ -1,23 +1,19 @@
from typing import Type
-from dcs.vehicles import AirDefence
from dcs.unittype import VehicleType
+from dcs.vehicles import AirDefence
-from gen.sam.group_generator import GroupGenerator
+from game.theater.theatergroundobject import EwrGroundObject
+from gen.sam.group_generator import VehicleGroupGenerator
-class EwrGenerator(GroupGenerator):
+class EwrGenerator(VehicleGroupGenerator[EwrGroundObject]):
unit_type: Type[VehicleType]
@classmethod
def name(cls) -> str:
return cls.unit_type.name
- @staticmethod
- def price() -> int:
- # TODO: Differentiate sites.
- return 20
-
def generate(self) -> None:
self.add_unit(
self.unit_type, "EWR", self.position.x, self.position.y, self.heading
@@ -106,3 +102,9 @@ class HawkEwrGenerator(EwrGenerator):
"""
unit_type = AirDefence.Hawk_sr
+
+
+class TinShieldGenerator(EwrGenerator):
+ """19ZH6 "Tin Shield" EWR."""
+
+ unit_type = AirDefence.RLS_19J6
diff --git a/gen/sam/freya_ewr.py b/gen/sam/freya_ewr.py
index 917767fb..e484d53e 100644
--- a/gen/sam/freya_ewr.py
+++ b/gen/sam/freya_ewr.py
@@ -4,6 +4,7 @@ from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
)
+from game.utils import Heading
class FreyaGenerator(AirDefenseGroupGenerator):
@@ -12,9 +13,8 @@ class FreyaGenerator(AirDefenseGroupGenerator):
"""
name = "Freya EWR Site"
- price = 60
- def generate(self):
+ def generate(self) -> None:
# TODO : would be better with the Concrete structure that is supposed to protect it
self.add_unit(
@@ -102,7 +102,7 @@ class FreyaGenerator(AirDefenseGroupGenerator):
"Inf#3",
self.position.x + 20,
self.position.y - 24,
- self.heading + 45,
+ self.heading + Heading.from_degrees(45),
)
@classmethod
diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py
index 65eb0b50..bbe6bdb9 100644
--- a/gen/sam/group_generator.py
+++ b/gen/sam/group_generator.py
@@ -1,70 +1,141 @@
from __future__ import annotations
+import logging
import math
+import operator
import random
-from typing import TYPE_CHECKING, Type
+from collections import Iterable
+from typing import TYPE_CHECKING, Type, TypeVar, Generic, Any
from dcs import unitgroup
from dcs.mapping import Point
from dcs.point import PointAction
-from dcs.unit import Ship, Vehicle
-from dcs.unittype import VehicleType
+from dcs.unit import Ship, Vehicle, Unit
+from dcs.unitgroup import ShipGroup, VehicleGroup
+from dcs.unittype import VehicleType, UnitType, ShipType
+from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction
-from game.theater.theatergroundobject import TheaterGroundObject
+from game.theater import MissionTarget
+from game.theater.theatergroundobject import TheaterGroundObject, NavalGroundObject
+from game.utils import Heading
if TYPE_CHECKING:
from game.game import Game
+GroupT = TypeVar("GroupT", VehicleGroup, ShipGroup)
+UnitT = TypeVar("UnitT", bound=Unit)
+UnitTypeT = TypeVar("UnitTypeT", bound=Type[UnitType])
+TgoT = TypeVar("TgoT", bound=TheaterGroundObject[Any])
+
+
# TODO: Generate a group description rather than a pydcs group.
# It appears that all of this work gets redone at miz generation time (see
# groundobjectsgen for an example). We can do less work and include the data we
# care about in the format we want if we just generate our own group description
# types rather than pydcs groups.
-class GroupGenerator:
- def __init__(self, game: Game, ground_object: TheaterGroundObject) -> None:
+class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]):
+ def __init__(self, game: Game, ground_object: TgoT, group: GroupT) -> None:
self.game = game
self.go = ground_object
self.position = ground_object.position
- self.heading = random.randint(0, 359)
- self.vg = unitgroup.VehicleGroup(self.game.next_group_id(), self.go.group_name)
- wp = self.vg.add_waypoint(self.position, PointAction.OffRoad, 0)
- wp.ETA_locked = True
+ self.heading: Heading = Heading.random()
+ self.price = 0
+ self.vg: GroupT = group
- def generate(self):
+ def generate(self) -> None:
raise NotImplementedError
- def get_generated_group(self) -> unitgroup.VehicleGroup:
+ def get_generated_group(self) -> GroupT:
return self.vg
def add_unit(
self,
- unit_type: Type[VehicleType],
+ unit_type: UnitTypeT,
name: str,
pos_x: float,
pos_y: float,
- heading: int,
- ) -> Vehicle:
+ heading: Heading,
+ ) -> UnitT:
return self.add_unit_to_group(
self.vg, unit_type, name, Point(pos_x, pos_y), heading
)
def add_unit_to_group(
self,
- group: unitgroup.VehicleGroup,
+ group: GroupT,
+ unit_type: UnitTypeT,
+ name: str,
+ position: Point,
+ heading: Heading,
+ ) -> UnitT:
+ raise NotImplementedError
+
+ def heading_to_conflict(self) -> Heading:
+ # Heading for a Group to the enemy.
+ # Should be the point between the nearest and the most distant conflict
+ conflicts: dict[MissionTarget, float] = {}
+
+ for conflict in self.game.theater.conflicts():
+ conflicts[conflict] = conflict.distance_to(self.go)
+
+ if len(conflicts) == 0:
+ return self.heading
+
+ closest_conflict = min(conflicts.items(), key=operator.itemgetter(1))[0]
+ most_distant_conflict = max(conflicts.items(), key=operator.itemgetter(1))[0]
+
+ conflict_center = Point(
+ (closest_conflict.position.x + most_distant_conflict.position.x) / 2,
+ (closest_conflict.position.y + most_distant_conflict.position.y) / 2,
+ )
+
+ return Heading.from_degrees(
+ self.go.position.heading_between_point(conflict_center)
+ )
+
+
+class VehicleGroupGenerator(
+ Generic[TgoT], GroupGenerator[VehicleGroup, Vehicle, Type[VehicleType], TgoT]
+):
+ def __init__(self, game: Game, ground_object: TgoT) -> None:
+ super().__init__(
+ game,
+ ground_object,
+ unitgroup.VehicleGroup(game.next_group_id(), ground_object.group_name),
+ )
+ wp = self.vg.add_waypoint(self.position, PointAction.OffRoad, 0)
+ wp.ETA_locked = True
+
+ def generate(self) -> None:
+ raise NotImplementedError
+
+ def add_unit_to_group(
+ self,
+ group: VehicleGroup,
unit_type: Type[VehicleType],
name: str,
position: Point,
- heading: int,
+ heading: Heading,
) -> Vehicle:
unit = Vehicle(self.game.next_unit_id(), f"{group.name}|{name}", unit_type.id)
unit.position = position
- unit.heading = heading
+ unit.heading = heading.degrees
group.add_unit(unit)
+
+ # get price of unit to calculate the real price of the whole group
+ try:
+ ground_unit_type = next(GroundUnitType.for_dcs_type(unit_type))
+ self.price += ground_unit_type.price
+ except StopIteration:
+ logging.error(f"Cannot get price for unit {unit_type.name}")
+
return unit
- def get_circular_position(self, num_units, launcher_distance, coverage=90):
+ def get_circular_position(
+ self, num_units: int, launcher_distance: int, coverage: int = 90
+ ) -> Iterable[tuple[float, float, Heading]]:
"""
Given a position on the map, array a group of units in a circle a uniform distance from the unit
:param num_units:
@@ -86,43 +157,50 @@ class GroupGenerator:
positions = []
if num_units % 2 == 0:
- current_offset = self.heading - ((coverage / (num_units - 1)) / 2)
+ current_offset = self.heading.degrees - ((coverage / (num_units - 1)) / 2)
else:
- current_offset = self.heading
+ current_offset = self.heading.degrees
current_offset -= outer_offset * (math.ceil(num_units / 2) - 1)
- for x in range(1, num_units + 1):
- positions.append(
- (
- self.position.x
- + launcher_distance * math.cos(math.radians(current_offset)),
- self.position.y
- + launcher_distance * math.sin(math.radians(current_offset)),
- current_offset,
- )
+ for _ in range(1, num_units + 1):
+ x: float = self.position.x + launcher_distance * math.cos(
+ math.radians(current_offset)
)
+ y: float = self.position.y + launcher_distance * math.sin(
+ math.radians(current_offset)
+ )
+ positions.append((x, y, Heading.from_degrees(current_offset)))
current_offset += outer_offset
return positions
-class ShipGroupGenerator(GroupGenerator):
+class ShipGroupGenerator(
+ GroupGenerator[ShipGroup, Ship, Type[ShipType], NavalGroundObject]
+):
"""Abstract class for other ship generator classes"""
- def __init__(
- self, game: Game, ground_object: TheaterGroundObject, faction: Faction
- ):
- self.game = game
- self.go = ground_object
- self.position = ground_object.position
- self.heading = random.randint(0, 359)
+ def __init__(self, game: Game, ground_object: NavalGroundObject, faction: Faction):
+ super().__init__(
+ game,
+ ground_object,
+ unitgroup.ShipGroup(game.next_group_id(), ground_object.group_name),
+ )
self.faction = faction
- self.vg = unitgroup.ShipGroup(self.game.next_group_id(), self.go.group_name)
wp = self.vg.add_waypoint(self.position, 0)
wp.ETA_locked = True
- def add_unit(self, unit_type, name, pos_x, pos_y, heading) -> Ship:
+ def generate(self) -> None:
+ raise NotImplementedError
+
+ def add_unit_to_group(
+ self,
+ group: ShipGroup,
+ unit_type: Type[ShipType],
+ name: str,
+ position: Point,
+ heading: Heading,
+ ) -> Ship:
unit = Ship(self.game.next_unit_id(), f"{self.go.group_name}|{name}", unit_type)
- unit.position.x = pos_x
- unit.position.y = pos_y
- unit.heading = heading
- self.vg.add_unit(unit)
+ unit.position = position
+ unit.heading = heading.degrees
+ group.add_unit(unit)
return unit
diff --git a/gen/sam/sam_avenger.py b/gen/sam/sam_avenger.py
index b778cc62..ac72b709 100644
--- a/gen/sam/sam_avenger.py
+++ b/gen/sam/sam_avenger.py
@@ -14,10 +14,9 @@ class AvengerGenerator(AirDefenseGroupGenerator):
"""
name = "Avenger Group"
- price = 62
- def generate(self):
- num_launchers = random.randint(2, 3)
+ def generate(self) -> None:
+ num_launchers = 2
self.add_unit(
Unarmed.M_818,
diff --git a/gen/sam/sam_chaparral.py b/gen/sam/sam_chaparral.py
index 465ba0bd..2a746f95 100644
--- a/gen/sam/sam_chaparral.py
+++ b/gen/sam/sam_chaparral.py
@@ -14,10 +14,9 @@ class ChaparralGenerator(AirDefenseGroupGenerator):
"""
name = "Chaparral Group"
- price = 66
- def generate(self):
- num_launchers = random.randint(2, 4)
+ def generate(self) -> None:
+ num_launchers = 2
self.add_unit(
Unarmed.M_818,
diff --git a/gen/sam/sam_gepard.py b/gen/sam/sam_gepard.py
index 669781df..05b04068 100644
--- a/gen/sam/sam_gepard.py
+++ b/gen/sam/sam_gepard.py
@@ -14,23 +14,20 @@ class GepardGenerator(AirDefenseGroupGenerator):
"""
name = "Gepard Group"
- price = 50
- def generate(self):
- self.add_unit(
- AirDefence.Gepard,
- "SPAAA",
- self.position.x,
- self.position.y,
- self.heading,
+ def generate(self) -> None:
+ num_launchers = 2
+
+ positions = self.get_circular_position(
+ num_launchers, launcher_distance=120, coverage=180
)
- if random.randint(0, 1) == 1:
+ for i, position in enumerate(positions):
self.add_unit(
AirDefence.Gepard,
- "SPAAA2",
- self.position.x,
- self.position.y,
- self.heading,
+ "SPAA#" + str(i),
+ position[0],
+ position[1],
+ position[2],
)
self.add_unit(
Unarmed.M_818,
diff --git a/gen/sam/sam_group_generator.py b/gen/sam/sam_group_generator.py
index 3db1b70a..4bebcd27 100644
--- a/gen/sam/sam_group_generator.py
+++ b/gen/sam/sam_group_generator.py
@@ -28,6 +28,7 @@ from gen.sam.sam_gepard import GepardGenerator
from gen.sam.sam_hawk import HawkGenerator
from gen.sam.sam_hq7 import HQ7Generator
from gen.sam.sam_linebacker import LinebackerGenerator
+from gen.sam.sam_nasams import NasamBGenerator, NasamCGenerator
from gen.sam.sam_patriot import PatriotGenerator
from gen.sam.sam_rapier import RapierGenerator
from gen.sam.sam_roland import RolandGenerator
@@ -100,6 +101,8 @@ SAM_MAP: Dict[str, Type[AirDefenseGroupGenerator]] = {
"SA20Generator": SA20Generator,
"SA20BGenerator": SA20BGenerator,
"SA23Generator": SA23Generator,
+ "NasamBGenerator": NasamBGenerator,
+ "NasamCGenerator": NasamCGenerator,
}
diff --git a/gen/sam/sam_hawk.py b/gen/sam/sam_hawk.py
index 01e463e1..f65faf09 100644
--- a/gen/sam/sam_hawk.py
+++ b/gen/sam/sam_hawk.py
@@ -6,6 +6,7 @@ from dcs.vehicles import AirDefence
from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
+ SkynetRole,
)
@@ -15,9 +16,8 @@ class HawkGenerator(AirDefenseGroupGenerator):
"""
name = "Hawk Site"
- price = 115
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
AirDefence.Hawk_sr,
"SR",
@@ -41,7 +41,7 @@ class HawkGenerator(AirDefenseGroupGenerator):
)
# Triple A for close range defense
- aa_group = self.add_auxiliary_group("AA")
+ aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior)
self.add_unit_to_group(
aa_group,
AirDefence.Vulcan,
@@ -50,7 +50,7 @@ class HawkGenerator(AirDefenseGroupGenerator):
self.heading,
)
- num_launchers = random.randint(3, 6)
+ num_launchers = 6
positions = self.get_circular_position(
num_launchers, launcher_distance=120, coverage=180
)
diff --git a/gen/sam/sam_hq7.py b/gen/sam/sam_hq7.py
index d05aecd8..89a81097 100644
--- a/gen/sam/sam_hq7.py
+++ b/gen/sam/sam_hq7.py
@@ -6,6 +6,7 @@ from dcs.vehicles import AirDefence
from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
+ SkynetRole,
)
@@ -15,9 +16,8 @@ class HQ7Generator(AirDefenseGroupGenerator):
"""
name = "HQ-7 Site"
- price = 120
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
AirDefence.HQ_7_STR_SP,
"STR",
@@ -25,16 +25,9 @@ class HQ7Generator(AirDefenseGroupGenerator):
self.position.y,
self.heading,
)
- self.add_unit(
- AirDefence.HQ_7_LN_SP,
- "LN",
- self.position.x + 20,
- self.position.y,
- self.heading,
- )
# Triple A for close range defense
- aa_group = self.add_auxiliary_group("AA")
+ aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior)
self.add_unit_to_group(
aa_group,
AirDefence.Ural_375_ZU_23,
@@ -50,7 +43,7 @@ class HQ7Generator(AirDefenseGroupGenerator):
self.heading,
)
- num_launchers = random.randint(0, 3)
+ num_launchers = 2
if num_launchers > 0:
positions = self.get_circular_position(
num_launchers, launcher_distance=120, coverage=360
diff --git a/gen/sam/sam_linebacker.py b/gen/sam/sam_linebacker.py
index b140b138..397c38a7 100644
--- a/gen/sam/sam_linebacker.py
+++ b/gen/sam/sam_linebacker.py
@@ -14,10 +14,9 @@ class LinebackerGenerator(AirDefenseGroupGenerator):
"""
name = "Linebacker Group"
- price = 75
- def generate(self):
- num_launchers = random.randint(2, 4)
+ def generate(self) -> None:
+ num_launchers = 2
self.add_unit(
Unarmed.M_818,
diff --git a/gen/sam/sam_nasams.py b/gen/sam/sam_nasams.py
new file mode 100644
index 00000000..62bbf60c
--- /dev/null
+++ b/gen/sam/sam_nasams.py
@@ -0,0 +1,68 @@
+from typing import Type
+
+from dcs.mapping import Point
+from dcs.unittype import VehicleType
+from dcs.vehicles import AirDefence
+
+from game import Game
+from game.theater import SamGroundObject
+from gen.sam.airdefensegroupgenerator import (
+ AirDefenseRange,
+ AirDefenseGroupGenerator,
+)
+
+
+class NasamCGenerator(AirDefenseGroupGenerator):
+ """
+ This generate a Nasams group with AIM-120C missiles
+ """
+
+ name = "NASAMS AIM-120C"
+
+ def __init__(self, game: Game, ground_object: SamGroundObject):
+ super().__init__(game, ground_object)
+ self.launcherType: Type[VehicleType] = AirDefence.NASAMS_LN_C
+
+ def generate(self) -> None:
+ # Command Post
+ self.add_unit(
+ AirDefence.NASAMS_Command_Post,
+ "CP",
+ self.position.x + 30,
+ self.position.y + 30,
+ self.heading,
+ )
+ # Radar
+ self.add_unit(
+ AirDefence.NASAMS_Radar_MPQ64F1,
+ "RADAR",
+ self.position.x - 30,
+ self.position.y - 30,
+ self.heading,
+ )
+
+ positions = self.get_circular_position(4, launcher_distance=120, coverage=360)
+ for i, position in enumerate(positions):
+ self.add_unit(
+ self.launcherType,
+ "LN#" + str(i),
+ position[0],
+ position[1],
+ position[2],
+ )
+
+ @classmethod
+ def range(cls) -> AirDefenseRange:
+ return AirDefenseRange.Medium
+
+
+class NasamBGenerator(NasamCGenerator):
+ """
+ This generate a Nasams group with AIM-120B missiles
+ """
+
+ name = "NASAMS AIM-120B"
+
+ def __init__(self, game: Game, ground_object: SamGroundObject):
+ super().__init__(game, ground_object)
+ self.launcherType: Type[VehicleType] = AirDefence.NASAMS_LN_B
diff --git a/gen/sam/sam_patriot.py b/gen/sam/sam_patriot.py
index 21f6cd18..55c4be2b 100644
--- a/gen/sam/sam_patriot.py
+++ b/gen/sam/sam_patriot.py
@@ -1,11 +1,10 @@
-import random
-
from dcs.mapping import Point
from dcs.vehicles import AirDefence
from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
+ SkynetRole,
)
@@ -15,9 +14,8 @@ class PatriotGenerator(AirDefenseGroupGenerator):
"""
name = "Patriot Battery"
- price = 240
- def generate(self):
+ def generate(self) -> None:
# Command Post
self.add_unit(
AirDefence.Patriot_str,
@@ -55,10 +53,7 @@ class PatriotGenerator(AirDefenseGroupGenerator):
self.heading,
)
- num_launchers = random.randint(3, 4)
- positions = self.get_circular_position(
- num_launchers, launcher_distance=120, coverage=360
- )
+ positions = self.get_circular_position(8, launcher_distance=120, coverage=360)
for i, position in enumerate(positions):
self.add_unit(
AirDefence.Patriot_ln,
@@ -69,11 +64,8 @@ class PatriotGenerator(AirDefenseGroupGenerator):
)
# Short range protection for high value site
- aa_group = self.add_auxiliary_group("AA")
- num_launchers = random.randint(3, 4)
- positions = self.get_circular_position(
- num_launchers, launcher_distance=200, coverage=360
- )
+ aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior)
+ positions = self.get_circular_position(2, launcher_distance=200, coverage=360)
for i, (x, y, heading) in enumerate(positions):
self.add_unit_to_group(
aa_group,
@@ -82,6 +74,15 @@ class PatriotGenerator(AirDefenseGroupGenerator):
Point(x, y),
heading,
)
+ positions = self.get_circular_position(2, launcher_distance=300, coverage=360)
+ for i, (x, y, heading) in enumerate(positions):
+ self.add_unit_to_group(
+ aa_group,
+ AirDefence.M1097_Avenger,
+ f"Avenger#{i}",
+ Point(x, y),
+ heading,
+ )
@classmethod
def range(cls) -> AirDefenseRange:
diff --git a/gen/sam/sam_rapier.py b/gen/sam/sam_rapier.py
index 0e361459..aac88d64 100644
--- a/gen/sam/sam_rapier.py
+++ b/gen/sam/sam_rapier.py
@@ -5,6 +5,7 @@ from dcs.vehicles import AirDefence
from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
+ SkynetRole,
)
@@ -14,9 +15,8 @@ class RapierGenerator(AirDefenseGroupGenerator):
"""
name = "Rapier AA Site"
- price = 50
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
AirDefence.Rapier_fsa_blindfire_radar,
"BT",
@@ -32,7 +32,7 @@ class RapierGenerator(AirDefenseGroupGenerator):
self.heading,
)
- num_launchers = random.randint(3, 6)
+ num_launchers = 2
positions = self.get_circular_position(
num_launchers, launcher_distance=80, coverage=240
)
@@ -49,3 +49,7 @@ class RapierGenerator(AirDefenseGroupGenerator):
@classmethod
def range(cls) -> AirDefenseRange:
return AirDefenseRange.Short
+
+ @classmethod
+ def primary_group_role(cls) -> SkynetRole:
+ return SkynetRole.Sam
diff --git a/gen/sam/sam_roland.py b/gen/sam/sam_roland.py
index 4a88cfd4..57c3ab0e 100644
--- a/gen/sam/sam_roland.py
+++ b/gen/sam/sam_roland.py
@@ -3,6 +3,7 @@ from dcs.vehicles import AirDefence, Unarmed
from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
+ SkynetRole,
)
@@ -12,9 +13,9 @@ class RolandGenerator(AirDefenseGroupGenerator):
"""
name = "Roland Site"
- price = 40
- def generate(self):
+ def generate(self) -> None:
+ num_launchers = 2
self.add_unit(
AirDefence.Roland_Radar,
"EWR",
@@ -22,13 +23,18 @@ class RolandGenerator(AirDefenseGroupGenerator):
self.position.y,
self.heading,
)
- self.add_unit(
- AirDefence.Roland_ADS,
- "ADS",
- self.position.x,
- self.position.y,
- self.heading,
+ positions = self.get_circular_position(
+ num_launchers, launcher_distance=80, coverage=240
)
+
+ for i, position in enumerate(positions):
+ self.add_unit(
+ AirDefence.Roland_ADS,
+ "ADS#" + str(i),
+ position[0],
+ position[1],
+ position[2],
+ )
self.add_unit(
Unarmed.M_818,
"TRUCK",
@@ -40,3 +46,7 @@ class RolandGenerator(AirDefenseGroupGenerator):
@classmethod
def range(cls) -> AirDefenseRange:
return AirDefenseRange.Short
+
+ @classmethod
+ def primary_group_role(cls) -> SkynetRole:
+ return SkynetRole.Sam
diff --git a/gen/sam/sam_sa10.py b/gen/sam/sam_sa10.py
index 6daf8bfb..6b277bfa 100644
--- a/gen/sam/sam_sa10.py
+++ b/gen/sam/sam_sa10.py
@@ -1,6 +1,7 @@
-import random
+from typing import Type
from dcs.mapping import Point
+from dcs.unittype import VehicleType
from dcs.vehicles import AirDefence
from game import Game
@@ -8,6 +9,7 @@ from game.theater import SamGroundObject
from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
+ SkynetRole,
)
from pydcs_extensions.highdigitsams import highdigitsams
@@ -18,19 +20,18 @@ class SA10Generator(AirDefenseGroupGenerator):
"""
name = "SA-10/S-300PS Battery - With ZSU-23"
- price = 550
def __init__(self, game: Game, ground_object: SamGroundObject):
super().__init__(game, ground_object)
- self.sr1 = AirDefence.S_300PS_40B6MD_sr
- self.sr2 = AirDefence.S_300PS_64H6E_sr
- self.cp = AirDefence.S_300PS_54K6_cp
- self.tr1 = AirDefence.S_300PS_40B6M_tr
- self.tr2 = AirDefence.S_300PS_40B6M_tr
- self.ln1 = AirDefence.S_300PS_5P85C_ln
- self.ln2 = AirDefence.S_300PS_5P85D_ln
+ self.sr1: Type[VehicleType] = AirDefence.S_300PS_40B6MD_sr
+ self.sr2: Type[VehicleType] = AirDefence.S_300PS_64H6E_sr
+ self.cp: Type[VehicleType] = AirDefence.S_300PS_54K6_cp
+ self.tr1: Type[VehicleType] = AirDefence.S_300PS_40B6M_tr
+ self.tr2: Type[VehicleType] = AirDefence.S_300PS_40B6M_tr
+ self.ln1: Type[VehicleType] = AirDefence.S_300PS_5P85C_ln
+ self.ln2: Type[VehicleType] = AirDefence.S_300PS_5P85D_ln
- def generate(self):
+ def generate(self) -> None:
# Search Radar
self.add_unit(
self.sr1, "SR1", self.position.x, self.position.y + 40, self.heading
@@ -44,17 +45,13 @@ class SA10Generator(AirDefenseGroupGenerator):
# Command Post
self.add_unit(self.cp, "CP", self.position.x, self.position.y, self.heading)
- # 2 Tracking radars
+ # 1 Tracking radar
self.add_unit(
self.tr1, "TR1", self.position.x - 40, self.position.y - 40, self.heading
)
- self.add_unit(
- self.tr2, "TR2", self.position.x + 40, self.position.y - 40, self.heading
- )
-
# 2 different launcher type (C & D)
- num_launchers = random.randint(6, 8)
+ num_launchers = 6
positions = self.get_circular_position(
num_launchers, launcher_distance=100, coverage=360
)
@@ -76,8 +73,8 @@ class SA10Generator(AirDefenseGroupGenerator):
def generate_defensive_groups(self) -> None:
# AAA for defending against close targets.
- aa_group = self.add_auxiliary_group("AA")
- num_launchers = random.randint(6, 8)
+ aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior)
+ num_launchers = 2
positions = self.get_circular_position(
num_launchers, launcher_distance=210, coverage=360
)
@@ -94,15 +91,14 @@ class SA10Generator(AirDefenseGroupGenerator):
class Tier2SA10Generator(SA10Generator):
name = "SA-10/S-300PS Battery - With SA-15 PD"
- price = 650
def generate_defensive_groups(self) -> None:
# Create AAA the way the main group does.
super().generate_defensive_groups()
# SA-15 for both shorter range targets and point defense.
- pd_group = self.add_auxiliary_group("PD")
- num_launchers = random.randint(2, 4)
+ pd_group = self.add_auxiliary_group(SkynetRole.PointDefense)
+ num_launchers = 2
positions = self.get_circular_position(
num_launchers, launcher_distance=140, coverage=360
)
@@ -119,12 +115,11 @@ class Tier2SA10Generator(SA10Generator):
class Tier3SA10Generator(SA10Generator):
name = "SA-10/S-300PS Battery - With SA-15 PD & SA-19 SHORAD"
- price = 750
def generate_defensive_groups(self) -> None:
# AAA for defending against close targets.
- aa_group = self.add_auxiliary_group("AA")
- num_launchers = random.randint(6, 8)
+ aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior)
+ num_launchers = 2
positions = self.get_circular_position(
num_launchers, launcher_distance=210, coverage=360
)
@@ -138,8 +133,8 @@ class Tier3SA10Generator(SA10Generator):
)
# SA-15 for both shorter range targets and point defense.
- pd_group = self.add_auxiliary_group("PD")
- num_launchers = random.randint(2, 4)
+ pd_group = self.add_auxiliary_group(SkynetRole.PointDefense)
+ num_launchers = 2
positions = self.get_circular_position(
num_launchers, launcher_distance=140, coverage=360
)
@@ -155,7 +150,6 @@ class Tier3SA10Generator(SA10Generator):
class SA10BGenerator(Tier3SA10Generator):
- price = 700
name = "SA-10B/S-300PS Battery"
def __init__(self, game: Game, ground_object: SamGroundObject):
@@ -171,7 +165,6 @@ class SA10BGenerator(Tier3SA10Generator):
class SA12Generator(Tier3SA10Generator):
- price = 750
name = "SA-12/S-300V Battery"
def __init__(self, game: Game, ground_object: SamGroundObject):
@@ -187,7 +180,6 @@ class SA12Generator(Tier3SA10Generator):
class SA20Generator(Tier3SA10Generator):
- price = 800
name = "SA-20/S-300PMU-1 Battery"
def __init__(self, game: Game, ground_object: SamGroundObject):
@@ -203,7 +195,6 @@ class SA20Generator(Tier3SA10Generator):
class SA20BGenerator(Tier3SA10Generator):
- price = 850
name = "SA-20B/S-300PMU-2 Battery"
def __init__(self, game: Game, ground_object: SamGroundObject):
@@ -219,7 +210,6 @@ class SA20BGenerator(Tier3SA10Generator):
class SA23Generator(Tier3SA10Generator):
- price = 950
name = "SA-23/S-300VM Battery"
def __init__(self, game: Game, ground_object: SamGroundObject):
diff --git a/gen/sam/sam_sa11.py b/gen/sam/sam_sa11.py
index 7fec37c2..873ee0d5 100644
--- a/gen/sam/sam_sa11.py
+++ b/gen/sam/sam_sa11.py
@@ -14,9 +14,8 @@ class SA11Generator(AirDefenseGroupGenerator):
"""
name = "SA-11 Buk Battery"
- price = 180
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
AirDefence.SA_11_Buk_SR_9S18M1,
"SR",
@@ -32,7 +31,7 @@ class SA11Generator(AirDefenseGroupGenerator):
self.heading,
)
- num_launchers = random.randint(2, 4)
+ num_launchers = 4
positions = self.get_circular_position(
num_launchers, launcher_distance=140, coverage=180
)
diff --git a/gen/sam/sam_sa13.py b/gen/sam/sam_sa13.py
index 0fbe1af0..0c81e042 100644
--- a/gen/sam/sam_sa13.py
+++ b/gen/sam/sam_sa13.py
@@ -14,9 +14,8 @@ class SA13Generator(AirDefenseGroupGenerator):
"""
name = "SA-13 Strela Group"
- price = 50
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
Unarmed.UAZ_469,
"UAZ",
@@ -32,7 +31,7 @@ class SA13Generator(AirDefenseGroupGenerator):
self.heading,
)
- num_launchers = random.randint(2, 3)
+ num_launchers = 2
positions = self.get_circular_position(
num_launchers, launcher_distance=120, coverage=360
)
diff --git a/gen/sam/sam_sa15.py b/gen/sam/sam_sa15.py
index 3dcb881a..c0a6d852 100644
--- a/gen/sam/sam_sa15.py
+++ b/gen/sam/sam_sa15.py
@@ -12,16 +12,20 @@ class SA15Generator(AirDefenseGroupGenerator):
"""
name = "SA-15 Tor Group"
- price = 55
- def generate(self):
- self.add_unit(
- AirDefence.Tor_9A331,
- "ADS",
- self.position.x,
- self.position.y,
- self.heading,
+ def generate(self) -> None:
+ num_launchers = 2
+ positions = self.get_circular_position(
+ num_launchers, launcher_distance=120, coverage=360
)
+ for i, position in enumerate(positions):
+ self.add_unit(
+ AirDefence.Tor_9A331,
+ "ADS#" + str(i),
+ position[0],
+ position[1],
+ position[2],
+ )
self.add_unit(
Unarmed.UAZ_469,
"EWR",
diff --git a/gen/sam/sam_sa17.py b/gen/sam/sam_sa17.py
index 093044b8..1544a043 100644
--- a/gen/sam/sam_sa17.py
+++ b/gen/sam/sam_sa17.py
@@ -13,9 +13,8 @@ class SA17Generator(AirDefenseGroupGenerator):
"""
name = "SA-17 Grizzly Battery"
- price = 180
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
AirDefence.SA_11_Buk_SR_9S18M1,
"SR",
diff --git a/gen/sam/sam_sa19.py b/gen/sam/sam_sa19.py
index caac1f7c..8611a310 100644
--- a/gen/sam/sam_sa19.py
+++ b/gen/sam/sam_sa19.py
@@ -14,10 +14,9 @@ class SA19Generator(AirDefenseGroupGenerator):
"""
name = "SA-19 Tunguska Group"
- price = 90
- def generate(self):
- num_launchers = random.randint(1, 3)
+ def generate(self) -> None:
+ num_launchers = 2
if num_launchers == 1:
self.add_unit(
diff --git a/gen/sam/sam_sa2.py b/gen/sam/sam_sa2.py
index 4b7341df..0d2546c5 100644
--- a/gen/sam/sam_sa2.py
+++ b/gen/sam/sam_sa2.py
@@ -14,9 +14,8 @@ class SA2Generator(AirDefenseGroupGenerator):
"""
name = "SA-2/S-75 Site"
- price = 74
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
AirDefence.P_19_s_125_sr,
"SR",
@@ -32,7 +31,7 @@ class SA2Generator(AirDefenseGroupGenerator):
self.heading,
)
- num_launchers = random.randint(3, 6)
+ num_launchers = 6
positions = self.get_circular_position(
num_launchers, launcher_distance=120, coverage=180
)
diff --git a/gen/sam/sam_sa3.py b/gen/sam/sam_sa3.py
index 1a95de12..b75555d1 100644
--- a/gen/sam/sam_sa3.py
+++ b/gen/sam/sam_sa3.py
@@ -14,9 +14,8 @@ class SA3Generator(AirDefenseGroupGenerator):
"""
name = "SA-3/S-125 Site"
- price = 80
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
AirDefence.P_19_s_125_sr,
"SR",
@@ -32,7 +31,7 @@ class SA3Generator(AirDefenseGroupGenerator):
self.heading,
)
- num_launchers = random.randint(3, 6)
+ num_launchers = 4
positions = self.get_circular_position(
num_launchers, launcher_distance=120, coverage=180
)
diff --git a/gen/sam/sam_sa6.py b/gen/sam/sam_sa6.py
index fa72b24a..af9a6ffc 100644
--- a/gen/sam/sam_sa6.py
+++ b/gen/sam/sam_sa6.py
@@ -14,9 +14,8 @@ class SA6Generator(AirDefenseGroupGenerator):
"""
name = "SA-6 Kub Site"
- price = 102
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
AirDefence.Kub_1S91_str,
"STR",
@@ -25,7 +24,7 @@ class SA6Generator(AirDefenseGroupGenerator):
self.heading,
)
- num_launchers = random.randint(2, 4)
+ num_launchers = 4
positions = self.get_circular_position(
num_launchers, launcher_distance=120, coverage=360
)
diff --git a/gen/sam/sam_sa8.py b/gen/sam/sam_sa8.py
index 3ab28dfc..35afab86 100644
--- a/gen/sam/sam_sa8.py
+++ b/gen/sam/sam_sa8.py
@@ -12,16 +12,21 @@ class SA8Generator(AirDefenseGroupGenerator):
"""
name = "SA-8 OSA Site"
- price = 55
- def generate(self):
- self.add_unit(
- AirDefence.Osa_9A33_ln,
- "OSA",
- self.position.x,
- self.position.y,
- self.heading,
+ def generate(self) -> None:
+ num_launchers = 2
+ positions = self.get_circular_position(
+ num_launchers, launcher_distance=120, coverage=180
)
+
+ for i, position in enumerate(positions):
+ self.add_unit(
+ AirDefence.Osa_9A33_ln,
+ "OSA" + str(i),
+ position[0],
+ position[1],
+ position[2],
+ )
self.add_unit(
AirDefence.SA_8_Osa_LD_9T217,
"LD",
diff --git a/gen/sam/sam_sa9.py b/gen/sam/sam_sa9.py
index fccc7973..6ee35518 100644
--- a/gen/sam/sam_sa9.py
+++ b/gen/sam/sam_sa9.py
@@ -14,9 +14,8 @@ class SA9Generator(AirDefenseGroupGenerator):
"""
name = "SA-9 Group"
- price = 40
- def generate(self):
+ def generate(self) -> None:
self.add_unit(
Unarmed.UAZ_469,
"UAZ",
@@ -32,7 +31,7 @@ class SA9Generator(AirDefenseGroupGenerator):
self.heading,
)
- num_launchers = random.randint(2, 3)
+ num_launchers = 2
positions = self.get_circular_position(
num_launchers, launcher_distance=120, coverage=360
)
diff --git a/gen/sam/sam_vulcan.py b/gen/sam/sam_vulcan.py
index 2d057dc0..9a458db0 100644
--- a/gen/sam/sam_vulcan.py
+++ b/gen/sam/sam_vulcan.py
@@ -14,23 +14,20 @@ class VulcanGenerator(AirDefenseGroupGenerator):
"""
name = "Vulcan Group"
- price = 25
- def generate(self):
- self.add_unit(
- AirDefence.Vulcan,
- "SPAAA",
- self.position.x,
- self.position.y,
- self.heading,
+ def generate(self) -> None:
+ num_launchers = 2
+
+ positions = self.get_circular_position(
+ num_launchers, launcher_distance=120, coverage=180
)
- if random.randint(0, 1) == 1:
+ for i, position in enumerate(positions):
self.add_unit(
AirDefence.Vulcan,
- "SPAAA2",
- self.position.x,
- self.position.y,
- self.heading,
+ "SPAA#" + str(i),
+ position[0],
+ position[1],
+ position[2],
)
self.add_unit(
Unarmed.M_818,
diff --git a/gen/sam/sam_zsu23.py b/gen/sam/sam_zsu23.py
index 708ae5c6..5e64d5df 100644
--- a/gen/sam/sam_zsu23.py
+++ b/gen/sam/sam_zsu23.py
@@ -1,6 +1,6 @@
import random
-from dcs.vehicles import AirDefence
+from dcs.vehicles import AirDefence, Unarmed
from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
@@ -14,10 +14,9 @@ class ZSU23Generator(AirDefenseGroupGenerator):
"""
name = "ZSU-23 Group"
- price = 50
- def generate(self):
- num_launchers = random.randint(4, 5)
+ def generate(self) -> None:
+ num_launchers = 4
positions = self.get_circular_position(
num_launchers, launcher_distance=120, coverage=180
@@ -30,6 +29,13 @@ class ZSU23Generator(AirDefenseGroupGenerator):
position[1],
position[2],
)
+ self.add_unit(
+ Unarmed.M_818,
+ "TRUCK",
+ self.position.x + 80,
+ self.position.y,
+ self.heading,
+ )
@classmethod
def range(cls) -> AirDefenseRange:
diff --git a/gen/sam/sam_zu23.py b/gen/sam/sam_zu23.py
index 6a1b41cb..2a2e2f4b 100644
--- a/gen/sam/sam_zu23.py
+++ b/gen/sam/sam_zu23.py
@@ -1,6 +1,6 @@
import random
-from dcs.vehicles import AirDefence
+from dcs.vehicles import AirDefence, Unarmed
from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
@@ -14,25 +14,27 @@ class ZU23Generator(AirDefenseGroupGenerator):
"""
name = "ZU-23 Group"
- price = 54
-
- def generate(self):
- grid_x = random.randint(2, 3)
- grid_y = random.randint(2, 3)
-
- spacing = random.randint(10, 40)
+ def generate(self) -> None:
index = 0
- for i in range(grid_x):
- for j in range(grid_y):
- index = index + 1
- self.add_unit(
- AirDefence.ZU_23_Emplacement_Closed,
- "AAA#" + str(index),
- self.position.x + spacing * i,
- self.position.y + spacing * j,
- self.heading,
- )
+ for i in range(4):
+ index = index + 1
+ spacing_x = random.randint(10, 40)
+ spacing_y = random.randint(10, 40)
+ self.add_unit(
+ AirDefence.ZU_23_Emplacement_Closed,
+ "AAA#" + str(index),
+ self.position.x + spacing_x * i,
+ self.position.y + spacing_y * i,
+ self.heading,
+ )
+ self.add_unit(
+ Unarmed.M_818,
+ "TRUCK",
+ self.position.x + 80,
+ self.position.y,
+ self.heading,
+ )
@classmethod
def range(cls) -> AirDefenseRange:
diff --git a/gen/sam/sam_zu23_ural.py b/gen/sam/sam_zu23_ural.py
index 4f97d6f3..85ca1d20 100644
--- a/gen/sam/sam_zu23_ural.py
+++ b/gen/sam/sam_zu23_ural.py
@@ -14,10 +14,9 @@ class ZU23UralGenerator(AirDefenseGroupGenerator):
"""
name = "ZU-23 Ural Group"
- price = 64
- def generate(self):
- num_launchers = random.randint(2, 8)
+ def generate(self) -> None:
+ num_launchers = 4
positions = self.get_circular_position(
num_launchers, launcher_distance=80, coverage=360
diff --git a/gen/sam/sam_zu23_ural_insurgent.py b/gen/sam/sam_zu23_ural_insurgent.py
index d0ab8405..7d70300a 100644
--- a/gen/sam/sam_zu23_ural_insurgent.py
+++ b/gen/sam/sam_zu23_ural_insurgent.py
@@ -14,10 +14,13 @@ class ZU23UralInsurgentGenerator(AirDefenseGroupGenerator):
"""
name = "ZU-23 Ural Insurgent Group"
- price = 64
- def generate(self):
- num_launchers = random.randint(2, 8)
+ @classmethod
+ def range(cls) -> AirDefenseRange:
+ return AirDefenseRange.AAA
+
+ def generate(self) -> None:
+ num_launchers = 4
positions = self.get_circular_position(
num_launchers, launcher_distance=80, coverage=360
@@ -30,7 +33,3 @@ class ZU23UralInsurgentGenerator(AirDefenseGroupGenerator):
position[1],
position[2],
)
-
- @classmethod
- def range(cls) -> AirDefenseRange:
- return AirDefenseRange.AAA
diff --git a/gen/triggergen.py b/gen/triggergen.py
index a8e29a42..2a70d204 100644
--- a/gen/triggergen.py
+++ b/gen/triggergen.py
@@ -51,11 +51,11 @@ class TriggersGenerator:
capture_zone_types = (Fob,)
capture_zone_flag = 600
- def __init__(self, mission: Mission, game: Game):
+ def __init__(self, mission: Mission, game: Game) -> None:
self.mission = mission
self.game = game
- def _set_allegiances(self, player_coalition: str, enemy_coalition: str):
+ def _set_allegiances(self, player_coalition: str, enemy_coalition: str) -> None:
"""
Set airbase initial coalition
"""
@@ -83,11 +83,16 @@ class TriggersGenerator:
for cp in self.game.theater.controlpoints:
if isinstance(cp, Airfield):
- self.mission.terrain.airport_by_id(cp.at.id).set_coalition(
+ cp_airport = self.mission.terrain.airport_by_id(cp.airport.id)
+ if cp_airport is None:
+ raise RuntimeError(
+ f"Could not find {cp.airport.name} in the mission"
+ )
+ cp_airport.set_coalition(
cp.captured and player_coalition or enemy_coalition
)
- def _set_skill(self, player_coalition: str, enemy_coalition: str):
+ def _set_skill(self, player_coalition: str, enemy_coalition: str) -> None:
"""
Set skill level for all aircraft in the mission
"""
@@ -103,7 +108,7 @@ class TriggersGenerator:
for vehicle_group in country.vehicle_group:
vehicle_group.set_skill(skill_level)
- def _gen_markers(self):
+ def _gen_markers(self) -> None:
"""
Generate markers on F10 map for each existing objective
"""
@@ -188,7 +193,7 @@ class TriggersGenerator:
recapture_trigger.add_action(ClearFlag(flag=flag))
self.mission.triggerrules.triggers.append(recapture_trigger)
- def generate(self):
+ def generate(self) -> None:
player_coalition = "blue"
enemy_coalition = "red"
@@ -198,7 +203,7 @@ class TriggersGenerator:
self._generate_capture_triggers(player_coalition, enemy_coalition)
@classmethod
- def get_capture_zone_flag(cls):
+ def get_capture_zone_flag(cls) -> int:
flag = cls.capture_zone_flag
cls.capture_zone_flag += 1
return flag
diff --git a/gen/units.py b/gen/units.py
deleted file mode 100644
index cfd16ab8..00000000
--- a/gen/units.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Unit conversions."""
-
-
-def meters_to_feet(meters: float) -> float:
- """Convers meters to feet."""
- return meters * 3.28084
diff --git a/gen/visualgen.py b/gen/visualgen.py
index 0fa9c335..3a11652e 100644
--- a/gen/visualgen.py
+++ b/gen/visualgen.py
@@ -1,9 +1,8 @@
from __future__ import annotations
import random
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
-from dcs.mapping import Point
from dcs.mission import Mission
from dcs.unit import Static
from dcs.unittype import StaticType
@@ -11,22 +10,22 @@ from dcs.unittype import StaticType
if TYPE_CHECKING:
from game import Game
-from .conflictgen import Conflict, FRONTLINE_LENGTH
+from .conflictgen import Conflict
class MarkerSmoke(StaticType):
id = "big_smoke"
category = "Effects"
name = "big_smoke"
- shape_name = 5
- rate = 0.1
+ shape_name = 5 # type: ignore
+ rate = 0.1 # type: ignore
class Smoke(StaticType):
id = "big_smoke"
category = "Effects"
name = "big_smoke"
- shape_name = 2
+ shape_name = 2 # type: ignore
rate = 1
@@ -34,7 +33,7 @@ class BigSmoke(StaticType):
id = "big_smoke"
category = "Effects"
name = "big_smoke"
- shape_name = 3
+ shape_name = 3 # type: ignore
rate = 1
@@ -42,17 +41,11 @@ class MassiveSmoke(StaticType):
id = "big_smoke"
category = "Effects"
name = "big_smoke"
- shape_name = 4
+ shape_name = 4 # type: ignore
rate = 1
-class Outpost(StaticType):
- id = "outpost"
- name = "outpost"
- category = "Fortifications"
-
-
-def __monkey_static_dict(self: Static):
+def __monkey_static_dict(self: Static) -> dict[str, Any]:
global __original_static_dict
d = __original_static_dict(self)
@@ -63,9 +56,8 @@ def __monkey_static_dict(self: Static):
__original_static_dict = Static.dict
-Static.dict = __monkey_static_dict
+Static.dict = __monkey_static_dict # type: ignore
-FRONT_SMOKE_SPACING = 800
FRONT_SMOKE_RANDOM_SPREAD = 4000
FRONT_SMOKE_TYPE_CHANCES = {
2: MassiveSmoke,
@@ -74,29 +66,13 @@ FRONT_SMOKE_TYPE_CHANCES = {
100: Smoke,
}
-DESTINATION_SMOKE_AMOUNT_FACTOR = 0.03
-DESTINATION_SMOKE_DISTANCE_FACTOR = 1
-DESTINATION_SMOKE_TYPE_CHANCES = {
- 5: BigSmoke,
- 100: Smoke,
-}
-
-
-def turn_heading(heading, fac):
- heading += fac
- if heading > 359:
- heading = heading - 359
- if heading < 0:
- heading = 359 + heading
- return heading
-
class VisualGenerator:
- def __init__(self, mission: Mission, game: Game):
+ def __init__(self, mission: Mission, game: Game) -> None:
self.mission = mission
self.game = game
- def _generate_frontline_smokes(self):
+ def _generate_frontline_smokes(self) -> None:
for front_line in self.game.theater.conflicts():
from_cp = front_line.blue_cp
to_cp = front_line.red_cp
@@ -110,7 +86,7 @@ class VisualGenerator:
continue
for offset in range(0, distance, self.game.settings.perf_smoke_spacing):
- position = plane_start.point_from_heading(heading, offset)
+ position = plane_start.point_from_heading(heading.degrees, offset)
for k, v in FRONT_SMOKE_TYPE_CHANCES.items():
if random.randint(0, 100) <= k:
@@ -121,68 +97,12 @@ class VisualGenerator:
break
self.mission.static_group(
- self.mission.country(self.game.enemy_country),
+ self.mission.country(self.game.red.country_name),
"",
_type=v,
position=pos,
)
break
- def _generate_stub_planes(self):
- pass
- """
- mission_units = set()
- for coalition_name, coalition in self.mission.coalition.items():
- for country in coalition.countries.values():
- for group in country.plane_group + country.helicopter_group + country.vehicle_group:
- for unit in group.units:
- mission_units.add(db.unit_type_of(unit))
-
- for unit_type in mission_units:
- self.mission.static_group(self.mission.country(self.game.player_country), "a", unit_type, Point(0, 300000), hidden=True)"""
-
- def generate_target_smokes(self, target):
- spread = target.size * DESTINATION_SMOKE_DISTANCE_FACTOR
- for _ in range(
- 0,
- int(
- target.size
- * DESTINATION_SMOKE_AMOUNT_FACTOR
- * (1.1 - target.base.strength)
- ),
- ):
- for k, v in DESTINATION_SMOKE_TYPE_CHANCES.items():
- if random.randint(0, 100) <= k:
- position = target.position.random_point_within(0, spread)
- if not self.game.theater.is_on_land(position):
- break
-
- self.mission.static_group(
- self.mission.country(self.game.enemy_country),
- "",
- _type=v,
- position=position,
- hidden=True,
- )
- break
-
- def generate_transportation_marker(self, at: Point):
- self.mission.static_group(
- self.mission.country(self.game.player_country),
- "",
- _type=MarkerSmoke,
- position=at,
- )
-
- def generate_transportation_destination(self, at: Point):
- self.generate_transportation_marker(at.point_from_heading(0, 20))
- self.mission.static_group(
- self.mission.country(self.game.player_country),
- "",
- _type=Outpost,
- position=at,
- )
-
- def generate(self):
+ def generate(self) -> None:
self._generate_frontline_smokes()
- self._generate_stub_planes()
diff --git a/mypy.ini b/mypy.ini
index b8bb3a89..da81307c 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -1,18 +1,24 @@
[mypy]
+# TODO: Cleanup so we can enable the checks commented out here.
+check_untyped_defs = True
+# disallow_any_decorated = True
+# disallow_any_expr = True
+disallow_any_generics = True
+# disallow_any_unimported = True
+disallow_untyped_decorators = True
+disallow_untyped_defs = True
+follow_imports = silent
+# implicit_reexport = False
namespace_packages = True
-
-[mypy-dcs.*]
-follow_imports=silent
-ignore_missing_imports = True
+no_implicit_optional = True
+warn_redundant_casts = True
+# warn_return_any = True
+warn_unreachable = True
+warn_unused_ignores = True
[mypy-faker.*]
ignore_missing_imports = True
-[mypy-PIL.*]
-ignore_missing_imports = True
-
-[mypy-winreg.*]
-ignore_missing_imports = True
-
[mypy-shapely.*]
+# https://github.com/Toblerity/Shapely/issues/721
ignore_missing_imports = True
\ No newline at end of file
diff --git a/pydcs_extensions/jas39/jas39.py b/pydcs_extensions/jas39/jas39.py
index a9940b2d..8ce7d2c9 100644
--- a/pydcs_extensions/jas39/jas39.py
+++ b/pydcs_extensions/jas39/jas39.py
@@ -1,3 +1,5 @@
+from typing import Set
+
from dcs import task
from dcs.planes import PlaneType
from dcs.weapons_data import Weapons
@@ -6,111 +8,177 @@ from pydcs_extensions.weapon_injector import inject_weapons
class JAS39GripenWeapons:
- JAS_ARAKM70BAP = {
- "clsid": "JAS_ARAKM70BAP",
- "name": "ARAK M70B AP",
- "weight": 372.2,
+ EWS_39_Integrated_ECM = {
+ "clsid": "{JAS39_EWS39}",
+ "name": "EWS 39 Integrated ECM",
+ "weight": 1,
}
- JAS_ARAKM70BHE = {
- "clsid": "JAS_ARAKM70BHE",
- "name": "ARAK M70B HE",
- "weight": 372.2,
+ Integrated_ELINT = {
+ "clsid": "{JAS39_ELINT}",
+ "name": "Integrated ELINT",
+ "weight": 1,
}
- JAS_BK90 = {
- "clsid": "JAS_BK90",
- "name": "BK-90 Unguided Cluster Munition",
- "weight": 605,
+ JAS39_AIM120B = {
+ "clsid": "JAS39_AIM120B",
+ "name": "AIM-120B AMRAAM Active Rdr AAM",
+ "weight": 157,
}
- JAS_BRIMSTONE = {
- "clsid": "JAS_BRIMSTONE",
+ JAS39_AIM120C5 = {
+ "clsid": "JAS39_AIM120C5",
+ "name": "AIM-120C-5 AMRAAM Active Rdr AAM",
+ "weight": 162.5,
+ }
+ JAS39_AIM120C7 = {
+ "clsid": "JAS39_AIM120C7",
+ "name": "AIM-120C-7 AMRAAM Active Rdr AAM",
+ "weight": 162.5,
+ }
+ JAS39_AIM_9L = {
+ "clsid": "JAS39_AIM-9L",
+ "name": "AIM-9L Sidewinder IR AAM",
+ "weight": 86,
+ }
+ JAS39_AIM_9M = {
+ "clsid": "JAS39_AIM-9M",
+ "name": "AIM-9M Sidewinder IR AAM",
+ "weight": 86,
+ }
+ JAS39_AIM_9X = {
+ "clsid": "JAS39_AIM-9X",
+ "name": "AIM-9X Sidewinder IR AAM",
+ "weight": 86.5,
+ }
+ JAS39_ASRAAM = {
+ "clsid": "JAS39_ASRAAM",
+ "name": "AIM-132 ASRAAM IR AAM",
+ "weight": 89,
+ }
+ JAS39_A_DARTER = {
+ "clsid": "JAS39_A-DARTER",
+ "name": "A-Darter IR AAM",
+ "weight": 90,
+ }
+ JAS39_BRIMSTONE = {
+ "clsid": "JAS39_BRIMSTONE",
"name": "Brimstone Laser Guided Missile",
"weight": 195.5,
}
- JAS_GBU10_TV = {
- "clsid": "JAS_GBU10_TV",
- "name": "GBU-10 2000 lb TV-guided Bomb",
+ JAS39_Derby = {
+ "clsid": "JAS39_Derby",
+ "name": "I-Derby ER BVRAAM Active Rdr AAM",
+ "weight": 119,
+ }
+ JAS39_DWS39 = {
+ "clsid": "JAS39_DWS39",
+ "name": "DWS39 Unguided Cluster Munition",
+ "weight": 605,
+ }
+ JAS39_GBU10 = {
+ "clsid": "JAS39_GBU10",
+ "name": "GBU-10 2000 lb Laser-guided Bomb",
"weight": 934,
}
- JAS_GBU12 = {
- "clsid": "JAS_GBU12",
+ JAS39_GBU12 = {
+ "clsid": "JAS39_GBU12",
"name": "GBU-12 500 lb Laser-guided Bomb",
"weight": 275,
}
- JAS_GBU16_TV = {
- "clsid": "JAS_GBU16_TV",
- "name": "GBU-16 1000lb TV Guided Bomb",
- "weight": 934,
+ JAS39_GBU16 = {
+ "clsid": "JAS39_GBU16",
+ "name": "GBU-16 1000 lb Laser-guided Bomb",
+ "weight": 454,
}
- JAS_GBU31 = {
- "clsid": "JAS_GBU31",
+ JAS39_GBU31 = {
+ "clsid": "JAS39_GBU31",
"name": "GBU-31 2000lb TV Guided Glide-Bomb",
"weight": 934,
}
- JAS_GBU49_TV = {
- "clsid": "JAS_GBU49_TV",
+ JAS39_GBU32 = {
+ "clsid": "JAS39_GBU32",
+ "name": "GBU-32 1000lb TV Guided Glide-Bomb",
+ "weight": 454,
+ }
+ JAS39_GBU38 = {
+ "clsid": "JAS39_GBU38",
+ "name": "GBU-38 500lb TV Guided Glide-Bomb",
+ "weight": 241,
+ }
+ JAS39_GBU49 = {
+ "clsid": "JAS39_GBU49",
"name": "GBU-49 500lb TV Guided Bomb",
- "weight": 275,
+ "weight": 241,
}
- JAS_IRIS_T = {
- "clsid": "JAS_IRIS-T",
- "name": "Rb98 IRIS-T Sidewinder IR AAM",
- "weight": 88.4,
+ JAS39_IRIS_T = {"clsid": "JAS39_IRIS-T", "name": "IRIS-T IR AAM", "weight": 88.4}
+ JAS39_Litening = {
+ "clsid": "JAS39_Litening",
+ "name": "Litening III Targeting Pod",
+ "weight": 208,
}
- JAS_Litening = {
- "clsid": "JAS_Litening",
- "name": "Litening III POD (LLTV)",
- "weight": 295,
+ JAS39_M70BAP = {
+ "clsid": "JAS39_M70BAP",
+ "name": "M70B AP Unguided rocket",
+ "weight": 372.2,
}
- JAS_MAR_1 = {
- "clsid": "JAS_MAR-1",
+ JAS39_M70BHE = {
+ "clsid": "JAS39_M70BHE",
+ "name": "M70B HE Unguided rocket",
+ "weight": 372.2,
+ }
+ JAS39_M71LD = {
+ "clsid": "JAS39_M71LD",
+ "name": "4x M/71 120kg GP Bomb Low-drag",
+ "weight": 605,
+ }
+ JAS39_MAR_1 = {
+ "clsid": "JAS39_MAR-1",
"name": "MAR-1 High Speed Anti-Radiation Missile",
"weight": 350,
}
- JAS_Meteor = {
- "clsid": "JAS_Meteor",
- "name": "Rb101 Meteor BVRAAM Active Rdr AAM",
+ JAS39_Meteor = {
+ "clsid": "JAS39_Meteor",
+ "name": "Meteor BVRAAM Active Rdr AAM",
"weight": 191,
}
- JAS_RB15F = {
- "clsid": "JAS_RB15F",
- "name": "RBS-15 Mk. IV Gungnir Radiation Seeking Anti-ship Missile ",
- "weight": None,
+ JAS39_PYTHON_5 = {
+ "clsid": "JAS39_PYTHON-5",
+ "name": "Python-5 IR AAM",
+ "weight": 106,
}
- JAS_RB75T = {
- "clsid": "JAS_RB75T",
- "name": "Rb-75T (AGM-65E Maverick) (Laser ASM Lg Whd)",
- "weight": 210,
+ JAS39_RBS15 = {
+ "clsid": "JAS39_RBS15",
+ "name": "RBS-15 Mk4 Gungnir Anti-ship Missile",
+ "weight": 650,
}
- JAS_Rb74 = {
- "clsid": "JAS_Rb74",
- "name": "Rb74 AIM-9L Sidewinder IR AAM",
- "weight": 90,
+ JAS39_RBS15AI = {
+ "clsid": "JAS39_RBS15AI",
+ "name": "RBS-15 Mk4 Gungnir Anti-ship Missile (AI)",
+ "weight": 650,
}
- JAS_Rb99 = {
- "clsid": "JAS_Rb99",
- "name": "Rb99 AIM-120B AMRAAM Active Rdr AAM",
- "weight": 157,
+ JAS39_SDB = {
+ "clsid": "JAS39_SDB",
+ "name": "GBU-39 SDB 285lb TV Guided Glide-Bomb",
+ "weight": 661,
}
- JAS_Rb99_DUAL = {
- "clsid": "JAS_Rb99_DUAL",
- "name": "Rb99 AIM-120B AMRAAM Active Rdr AAM x 2",
- "weight": 313,
- }
- JAS_Stormshadow = {
- "clsid": "JAS_Stormshadow",
+ JAS39_STORMSHADOW = {
+ "clsid": "JAS39_STORMSHADOW",
"name": "Storm Shadow Long Range Anti-Radiation Cruise-missile",
- "weight": None,
+ "weight": 1300,
}
- JAS_TANK1100 = {
- "clsid": "JAS_TANK1100",
- "name": "External drop tank 1100 litre",
+ JAS39_TANK1100 = {
+ "clsid": "JAS39_TANK1100",
+ "name": "Drop tank 1100 litre",
"weight": 1019,
}
- JAS_TANK1700 = {
- "clsid": "JAS_TANK1700",
- "name": "External drop tank 1700 litre",
+ JAS39_TANK1700 = {
+ "clsid": "JAS39_TANK1700",
+ "name": "Drop tank 1700 litre",
"weight": 1533,
}
+ Litening_III_Targeting_Pod_FLIR = {
+ "clsid": "{JAS39_FLIR}",
+ "name": "Litening III Targeting Pod FLIR",
+ "weight": 2,
+ }
inject_weapons(JAS39GripenWeapons)
@@ -124,17 +192,22 @@ class JAS39Gripen(PlaneType):
length = 14.1
fuel_max = 2550
max_speed = 2649.996
- chaff = 90
- flare = 45
- charge_total = 180
+ chaff = 80
+ flare = 40
+ charge_total = 120
chaff_charge_size = 1
- flare_charge_size = 2
+ flare_charge_size = 1
category = "Interceptor" # {78EFB7A2-FD52-4b57-A6A6-3BF0E1D6555F}
radio_frequency = 127.5
class Pylon1:
- JAS_IRIS_T = (1, JAS39GripenWeapons.JAS_IRIS_T)
- JAS_Rb74 = (1, JAS39GripenWeapons.JAS_Rb74)
+ JAS39_IRIS_T = (1, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_AIM_9L = (1, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_A_DARTER = (1, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (1, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (1, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (1, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (1, JAS39GripenWeapons.JAS39_ASRAAM)
AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (1, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod)
Smokewinder___red = (1, Weapons.Smokewinder___red)
Smokewinder___green = (1, Weapons.Smokewinder___green)
@@ -144,92 +217,100 @@ class JAS39Gripen(PlaneType):
Smokewinder___orange = (1, Weapons.Smokewinder___orange)
class Pylon2:
- JAS_IRIS_T = (2, JAS39GripenWeapons.JAS_IRIS_T)
- JAS_Rb74 = (2, JAS39GripenWeapons.JAS_Rb74)
- JAS_Meteor = (2, JAS39GripenWeapons.JAS_Meteor)
- JAS_Rb99 = (2, JAS39GripenWeapons.JAS_Rb99)
- JAS_Rb99_DUAL = (2, JAS39GripenWeapons.JAS_Rb99_DUAL)
- LAU_115_2_LAU_127_AIM_120C = (2, Weapons.LAU_115_2_LAU_127_AIM_120C)
- AIM_120C_5_AMRAAM___Active_Rdr_AAM = (
- 2,
- Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM,
- )
-
- # ERRR
+ JAS39_IRIS_T = (2, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_AIM_9L = (2, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_A_DARTER = (2, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (2, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (2, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (2, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (2, JAS39GripenWeapons.JAS39_ASRAAM)
+ JAS39_Meteor = (2, JAS39GripenWeapons.JAS39_Meteor)
+ JAS39_AIM120B = (2, JAS39GripenWeapons.JAS39_AIM120B)
+ JAS39_AIM120C5 = (2, JAS39GripenWeapons.JAS39_AIM120C5)
+ JAS39_AIM120C7 = (2, JAS39GripenWeapons.JAS39_AIM120C7)
+ JAS39_Derby = (2, JAS39GripenWeapons.JAS39_Derby)
class Pylon3:
- JAS_Meteor = (3, JAS39GripenWeapons.JAS_Meteor)
- JAS_Rb99 = (3, JAS39GripenWeapons.JAS_Rb99)
- AIM_120C_5_AMRAAM___Active_Rdr_AAM = (
- 3,
- Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM,
- )
- JAS_TANK1100 = (3, JAS39GripenWeapons.JAS_TANK1100)
- JAS_TANK1700 = (3, JAS39GripenWeapons.JAS_TANK1700)
-
- # ERRR
+ JAS39_AIM_9L = (3, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_IRIS_T = (3, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_A_DARTER = (3, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (3, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (3, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (3, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (3, JAS39GripenWeapons.JAS39_ASRAAM)
+ JAS39_Meteor = (3, JAS39GripenWeapons.JAS39_Meteor)
+ JAS39_AIM120B = (3, JAS39GripenWeapons.JAS39_AIM120B)
+ JAS39_AIM120C5 = (3, JAS39GripenWeapons.JAS39_AIM120C5)
+ JAS39_AIM120C7 = (3, JAS39GripenWeapons.JAS39_AIM120C7)
+ JAS39_Derby = (3, JAS39GripenWeapons.JAS39_Derby)
+ JAS39_TANK1100 = (3, JAS39GripenWeapons.JAS39_TANK1100)
+ JAS39_TANK1700 = (3, JAS39GripenWeapons.JAS39_TANK1700)
class Pylon4:
- L_081_Fantasmagoria_ELINT_pod = (4, Weapons.L_081_Fantasmagoria_ELINT_pod)
+ JAS39_TANK1100 = (4, JAS39GripenWeapons.JAS39_TANK1100)
class Pylon5:
- JAS_TANK1100 = (5, JAS39GripenWeapons.JAS_TANK1100)
- JAS_Meteor = (5, JAS39GripenWeapons.JAS_Meteor)
- AIM_120C_5_AMRAAM___Active_Rdr_AAM = (
- 5,
- Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM,
- )
- JAS_Rb99 = (5, JAS39GripenWeapons.JAS_Rb99)
- JAS_Rb99_DUAL = (5, JAS39GripenWeapons.JAS_Rb99_DUAL)
-
- # ERRR
+ JAS39_Litening = (5, JAS39GripenWeapons.JAS39_Litening)
class Pylon6:
- L005_Sorbtsiya_ECM_pod__left_ = (6, Weapons.L005_Sorbtsiya_ECM_pod__left_)
+ JAS39_AIM_9L = (6, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_IRIS_T = (6, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_A_DARTER = (6, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (6, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (6, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (6, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (6, JAS39GripenWeapons.JAS39_ASRAAM)
+ JAS39_Meteor = (6, JAS39GripenWeapons.JAS39_Meteor)
+ JAS39_AIM120B = (6, JAS39GripenWeapons.JAS39_AIM120B)
+ JAS39_AIM120C5 = (6, JAS39GripenWeapons.JAS39_AIM120C5)
+ JAS39_AIM120C7 = (6, JAS39GripenWeapons.JAS39_AIM120C7)
+ JAS39_Derby = (6, JAS39GripenWeapons.JAS39_Derby)
+ JAS39_TANK1100 = (6, JAS39GripenWeapons.JAS39_TANK1100)
+ JAS39_TANK1700 = (6, JAS39GripenWeapons.JAS39_TANK1700)
class Pylon7:
- JAS_Litening = (7, JAS39GripenWeapons.JAS_Litening)
-
- # ERRR
+ JAS39_IRIS_T = (7, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_AIM_9L = (7, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_A_DARTER = (7, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (7, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (7, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (7, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (7, JAS39GripenWeapons.JAS39_ASRAAM)
+ JAS39_Meteor = (7, JAS39GripenWeapons.JAS39_Meteor)
+ JAS39_AIM120B = (7, JAS39GripenWeapons.JAS39_AIM120B)
+ JAS39_AIM120C5 = (7, JAS39GripenWeapons.JAS39_AIM120C5)
+ JAS39_AIM120C7 = (7, JAS39GripenWeapons.JAS39_AIM120C7)
+ JAS39_Derby = (7, JAS39GripenWeapons.JAS39_Derby)
class Pylon8:
- JAS_Meteor = (8, JAS39GripenWeapons.JAS_Meteor)
- JAS_Rb99 = (8, JAS39GripenWeapons.JAS_Rb99)
- AIM_120C_5_AMRAAM___Active_Rdr_AAM = (
- 8,
- Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM,
- )
- JAS_TANK1100 = (8, JAS39GripenWeapons.JAS_TANK1100)
- JAS_TANK1700 = (8, JAS39GripenWeapons.JAS_TANK1700)
-
- # ERRR
+ JAS39_IRIS_T = (8, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_AIM_9L = (8, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_A_DARTER = (8, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (8, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (8, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (8, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (8, JAS39GripenWeapons.JAS39_ASRAAM)
+ AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (8, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod)
+ Smokewinder___red = (8, Weapons.Smokewinder___red)
+ Smokewinder___green = (8, Weapons.Smokewinder___green)
+ Smokewinder___blue = (8, Weapons.Smokewinder___blue)
+ Smokewinder___white = (8, Weapons.Smokewinder___white)
+ Smokewinder___yellow = (8, Weapons.Smokewinder___yellow)
+ Smokewinder___orange = (8, Weapons.Smokewinder___orange)
class Pylon9:
- JAS_IRIS_T = (9, JAS39GripenWeapons.JAS_IRIS_T)
- JAS_Rb74 = (9, JAS39GripenWeapons.JAS_Rb74)
- JAS_Meteor = (9, JAS39GripenWeapons.JAS_Meteor)
- JAS_Rb99 = (9, JAS39GripenWeapons.JAS_Rb99)
- JAS_Rb99_DUAL = (9, JAS39GripenWeapons.JAS_Rb99_DUAL)
- LAU_115_2_LAU_127_AIM_120C = (9, Weapons.LAU_115_2_LAU_127_AIM_120C)
- AIM_120C_5_AMRAAM___Active_Rdr_AAM = (
+ Litening_III_Targeting_Pod_FLIR = (
9,
- Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM,
+ JAS39GripenWeapons.Litening_III_Targeting_Pod_FLIR,
)
- # ERRR
-
class Pylon10:
- JAS_IRIS_T = (10, JAS39GripenWeapons.JAS_IRIS_T)
- JAS_Rb74 = (10, JAS39GripenWeapons.JAS_Rb74)
- AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (10, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod)
- Smokewinder___red = (10, Weapons.Smokewinder___red)
- Smokewinder___green = (10, Weapons.Smokewinder___green)
- Smokewinder___blue = (10, Weapons.Smokewinder___blue)
- Smokewinder___white = (10, Weapons.Smokewinder___white)
- Smokewinder___yellow = (10, Weapons.Smokewinder___yellow)
- Smokewinder___orange = (10, Weapons.Smokewinder___orange)
+ Integrated_ELINT = (10, JAS39GripenWeapons.Integrated_ELINT)
- pylons = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
+ class Pylon11:
+ EWS_39_Integrated_ECM = (11, JAS39GripenWeapons.EWS_39_Integrated_ECM)
+
+ pylons: Set[int] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}
tasks = [
task.Intercept,
@@ -249,17 +330,22 @@ class JAS39Gripen_AG(PlaneType):
length = 14.1
fuel_max = 2550
max_speed = 2649.996
- chaff = 90
- flare = 45
- charge_total = 180
+ chaff = 80
+ flare = 40
+ charge_total = 120
chaff_charge_size = 1
flare_charge_size = 1
category = "Interceptor" # {78EFB7A2-FD52-4b57-A6A6-3BF0E1D6555F}
radio_frequency = 127.5
class Pylon1:
- JAS_IRIS_T = (1, JAS39GripenWeapons.JAS_IRIS_T)
- JAS_Rb74 = (1, JAS39GripenWeapons.JAS_Rb74)
+ JAS39_IRIS_T = (1, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_AIM_9L = (1, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_A_DARTER = (1, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (1, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (1, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (1, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (1, JAS39GripenWeapons.JAS39_ASRAAM)
AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (1, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod)
Smokewinder___red = (1, Weapons.Smokewinder___red)
Smokewinder___green = (1, Weapons.Smokewinder___green)
@@ -269,56 +355,65 @@ class JAS39Gripen_AG(PlaneType):
Smokewinder___orange = (1, Weapons.Smokewinder___orange)
class Pylon2:
- JAS_IRIS_T = (2, JAS39GripenWeapons.JAS_IRIS_T)
- JAS_Rb74 = (2, JAS39GripenWeapons.JAS_Rb74)
- JAS_RB75T = (2, JAS39GripenWeapons.JAS_RB75T)
- AGM_65K___Maverick_K__CCD_Imp_ASM_ = (
- 2,
- Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_,
- )
- JAS_BK90 = (2, JAS39GripenWeapons.JAS_BK90)
- JAS_RB15F = (2, JAS39GripenWeapons.JAS_RB15F)
- JAS_MAR_1 = (2, JAS39GripenWeapons.JAS_MAR_1)
- JAS_GBU12 = (2, JAS39GripenWeapons.JAS_GBU12)
- JAS_GBU49_TV = (2, JAS39GripenWeapons.JAS_GBU49_TV)
- # ERRR JAS_GBU16
- JAS_GBU16_TV = (2, JAS39GripenWeapons.JAS_GBU16_TV)
- # ERRR GBU12_TEST
+ JAS39_IRIS_T = (2, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_AIM_9L = (2, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_A_DARTER = (2, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (2, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (2, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (2, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (2, JAS39GripenWeapons.JAS39_ASRAAM)
+ JAS39_RBS15 = (2, JAS39GripenWeapons.JAS39_RBS15)
+ JAS39_RBS15AI = (2, JAS39GripenWeapons.JAS39_RBS15AI)
+ JAS39_MAR_1 = (2, JAS39GripenWeapons.JAS39_MAR_1)
+ JAS39_GBU49 = (2, JAS39GripenWeapons.JAS39_GBU49)
+ JAS39_GBU32 = (2, JAS39GripenWeapons.JAS39_GBU32)
+ JAS39_GBU38 = (2, JAS39GripenWeapons.JAS39_GBU38)
+ JAS39_SDB = (2, JAS39GripenWeapons.JAS39_SDB)
+ JAS39_GBU12 = (2, JAS39GripenWeapons.JAS39_GBU12)
+ JAS39_GBU16 = (2, JAS39GripenWeapons.JAS39_GBU16)
+ JAS39_DWS39 = (2, JAS39GripenWeapons.JAS39_DWS39)
Mk_82___500lb_GP_Bomb_LD = (2, Weapons.Mk_82___500lb_GP_Bomb_LD)
Mk_83___1000lb_GP_Bomb_LD = (2, Weapons.Mk_83___1000lb_GP_Bomb_LD)
BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = (
2,
Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
)
- _4x_SB_M_71_120kg_GP_Bomb_Low_drag = (
+ JAS39_M71LD = (2, JAS39GripenWeapons.JAS39_M71LD)
+ JAS39_M70BHE = (2, JAS39GripenWeapons.JAS39_M70BHE)
+ JAS39_M70BAP = (2, JAS39GripenWeapons.JAS39_M70BAP)
+ JAS39_BRIMSTONE = (2, JAS39GripenWeapons.JAS39_BRIMSTONE)
+ LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = (
2,
- Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag,
+ Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_,
)
- JAS_ARAKM70BHE = (2, JAS39GripenWeapons.JAS_ARAKM70BHE)
- JAS_ARAKM70BAP = (2, JAS39GripenWeapons.JAS_ARAKM70BAP)
- JAS_BRIMSTONE = (2, JAS39GripenWeapons.JAS_BRIMSTONE)
-
- # ERRR
+ LAU_117_AGM_65H = (2, Weapons.LAU_117_AGM_65H)
class Pylon3:
- JAS_RB75T = (3, JAS39GripenWeapons.JAS_RB75T)
- AGM_65K___Maverick_K__CCD_Imp_ASM_ = (
+ JAS39_AIM_9L = (3, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_IRIS_T = (3, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_A_DARTER = (3, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (3, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (3, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (3, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (3, JAS39GripenWeapons.JAS39_ASRAAM)
+ LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = (
3,
- Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_,
- )
- JAS_Stormshadow = (3, JAS39GripenWeapons.JAS_Stormshadow)
- JAS_BK90 = (3, JAS39GripenWeapons.JAS_BK90)
- JAS_GBU31 = (3, JAS39GripenWeapons.JAS_GBU31)
- JAS_RB15F = (3, JAS39GripenWeapons.JAS_RB15F)
- JAS_MAR_1 = (3, JAS39GripenWeapons.JAS_MAR_1)
- JAS_GBU12 = (3, JAS39GripenWeapons.JAS_GBU12)
- JAS_GBU49_TV = (3, JAS39GripenWeapons.JAS_GBU49_TV)
- # ERRR JAS_GBU16
- JAS_GBU16_TV = (3, JAS39GripenWeapons.JAS_GBU16_TV)
- GBU_10___2000lb_Laser_Guided_Bomb = (
- 3,
- Weapons.GBU_10___2000lb_Laser_Guided_Bomb,
+ Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_,
)
+ LAU_117_AGM_65H = (3, Weapons.LAU_117_AGM_65H)
+ JAS39_BRIMSTONE = (3, JAS39GripenWeapons.JAS39_BRIMSTONE)
+ JAS39_RBS15 = (3, JAS39GripenWeapons.JAS39_RBS15)
+ JAS39_RBS15AI = (3, JAS39GripenWeapons.JAS39_RBS15AI)
+ JAS39_MAR_1 = (3, JAS39GripenWeapons.JAS39_MAR_1)
+ JAS39_GBU49 = (3, JAS39GripenWeapons.JAS39_GBU49)
+ JAS39_GBU31 = (3, JAS39GripenWeapons.JAS39_GBU31)
+ JAS39_GBU32 = (3, JAS39GripenWeapons.JAS39_GBU32)
+ JAS39_GBU38 = (3, JAS39GripenWeapons.JAS39_GBU38)
+ JAS39_SDB = (3, JAS39GripenWeapons.JAS39_SDB)
+ JAS39_GBU12 = (3, JAS39GripenWeapons.JAS39_GBU12)
+ JAS39_GBU10 = (3, JAS39GripenWeapons.JAS39_GBU10)
+ JAS39_GBU16 = (3, JAS39GripenWeapons.JAS39_GBU16)
+ JAS39_DWS39 = (3, JAS39GripenWeapons.JAS39_DWS39)
Mk_82___500lb_GP_Bomb_LD = (3, Weapons.Mk_82___500lb_GP_Bomb_LD)
Mk_83___1000lb_GP_Bomb_LD = (3, Weapons.Mk_83___1000lb_GP_Bomb_LD)
Mk_84___2000lb_GP_Bomb_LD = (3, Weapons.Mk_84___2000lb_GP_Bomb_LD)
@@ -326,144 +421,140 @@ class JAS39Gripen_AG(PlaneType):
3,
Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
)
- _4x_SB_M_71_120kg_GP_Bomb_Low_drag = (
- 3,
- Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag,
- )
- JAS_TANK1100 = (3, JAS39GripenWeapons.JAS_TANK1100)
- JAS_TANK1700 = (3, JAS39GripenWeapons.JAS_TANK1700)
- JAS_ARAKM70BHE = (3, JAS39GripenWeapons.JAS_ARAKM70BHE)
- JAS_ARAKM70BAP = (3, JAS39GripenWeapons.JAS_ARAKM70BAP)
- JAS_BRIMSTONE = (3, JAS39GripenWeapons.JAS_BRIMSTONE)
-
- # ERRR
+ JAS39_M71LD = (3, JAS39GripenWeapons.JAS39_M71LD)
+ JAS39_TANK1100 = (3, JAS39GripenWeapons.JAS39_TANK1100)
+ JAS39_TANK1700 = (3, JAS39GripenWeapons.JAS39_TANK1700)
+ JAS39_M70BHE = (3, JAS39GripenWeapons.JAS39_M70BHE)
+ JAS39_M70BAP = (3, JAS39GripenWeapons.JAS39_M70BAP)
+ JAS39_STORMSHADOW = (3, JAS39GripenWeapons.JAS39_STORMSHADOW)
class Pylon4:
- L_081_Fantasmagoria_ELINT_pod = (4, Weapons.L_081_Fantasmagoria_ELINT_pod)
+ JAS39_BRIMSTONE = (4, JAS39GripenWeapons.JAS39_BRIMSTONE)
+ JAS39_STORMSHADOW = (4, JAS39GripenWeapons.JAS39_STORMSHADOW)
+ JAS39_GBU49 = (4, JAS39GripenWeapons.JAS39_GBU49)
+ JAS39_GBU31 = (4, JAS39GripenWeapons.JAS39_GBU31)
+ JAS39_GBU32 = (4, JAS39GripenWeapons.JAS39_GBU32)
+ JAS39_GBU38 = (4, JAS39GripenWeapons.JAS39_GBU38)
+ JAS39_SDB = (4, JAS39GripenWeapons.JAS39_SDB)
+ JAS39_GBU10 = (4, JAS39GripenWeapons.JAS39_GBU10)
+ JAS39_GBU12 = (4, JAS39GripenWeapons.JAS39_GBU12)
+ JAS39_GBU16 = (4, JAS39GripenWeapons.JAS39_GBU16)
+ Mk_82___500lb_GP_Bomb_LD = (4, Weapons.Mk_82___500lb_GP_Bomb_LD)
+ Mk_83___1000lb_GP_Bomb_LD = (4, Weapons.Mk_83___1000lb_GP_Bomb_LD)
+ Mk_84___2000lb_GP_Bomb_LD = (4, Weapons.Mk_84___2000lb_GP_Bomb_LD)
+ BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = (
+ 4,
+ Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
+ )
+ JAS39_M71LD = (4, JAS39GripenWeapons.JAS39_M71LD)
+ JAS39_TANK1100 = (4, JAS39GripenWeapons.JAS39_TANK1100)
class Pylon5:
- JAS_Stormshadow = (5, JAS39GripenWeapons.JAS_Stormshadow)
- JAS_GBU12 = (5, JAS39GripenWeapons.JAS_GBU12)
- JAS_GBU49_TV = (5, JAS39GripenWeapons.JAS_GBU49_TV)
- # ERRR JAS_GBU16
- JAS_GBU16_TV = (5, JAS39GripenWeapons.JAS_GBU16_TV)
- GBU_10___2000lb_Laser_Guided_Bomb = (
- 5,
- Weapons.GBU_10___2000lb_Laser_Guided_Bomb,
- )
- Mk_82___500lb_GP_Bomb_LD = (5, Weapons.Mk_82___500lb_GP_Bomb_LD)
- Mk_83___1000lb_GP_Bomb_LD = (5, Weapons.Mk_83___1000lb_GP_Bomb_LD)
- Mk_84___2000lb_GP_Bomb_LD = (5, Weapons.Mk_84___2000lb_GP_Bomb_LD)
- BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = (
- 5,
- Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
- )
- _4x_SB_M_71_120kg_GP_Bomb_Low_drag = (
- 5,
- Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag,
- )
- JAS_TANK1100 = (5, JAS39GripenWeapons.JAS_TANK1100)
- # ERRR JAS_WMD7
- JAS_BRIMSTONE = (5, JAS39GripenWeapons.JAS_BRIMSTONE)
-
- # ERRR {INV-SMOKE-RED}
- # ERRR {INV-SMOKE-GREEN}
- # ERRR {INV-SMOKE-BLUE}
- # ERRR {INV-SMOKE-WHITE}
- # ERRR {INV-SMOKE-YELLOW}
- # ERRR {INV-SMOKE-ORANGE}
- # ERRR
+ JAS39_Litening = (5, JAS39GripenWeapons.JAS39_Litening)
class Pylon6:
- L005_Sorbtsiya_ECM_pod__left_ = (6, Weapons.L005_Sorbtsiya_ECM_pod__left_)
+ JAS39_AIM_9L = (6, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_IRIS_T = (6, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_A_DARTER = (6, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (6, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (6, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (6, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (6, JAS39GripenWeapons.JAS39_ASRAAM)
+ LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = (
+ 6,
+ Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_,
+ )
+ LAU_117_AGM_65H = (6, Weapons.LAU_117_AGM_65H)
+ JAS39_BRIMSTONE = (6, JAS39GripenWeapons.JAS39_BRIMSTONE)
+ JAS39_RBS15 = (6, JAS39GripenWeapons.JAS39_RBS15)
+ JAS39_RBS15AI = (6, JAS39GripenWeapons.JAS39_RBS15AI)
+ JAS39_MAR_1 = (6, JAS39GripenWeapons.JAS39_MAR_1)
+ JAS39_GBU49 = (6, JAS39GripenWeapons.JAS39_GBU49)
+ JAS39_GBU31 = (6, JAS39GripenWeapons.JAS39_GBU31)
+ JAS39_GBU32 = (6, JAS39GripenWeapons.JAS39_GBU32)
+ JAS39_GBU38 = (6, JAS39GripenWeapons.JAS39_GBU38)
+ JAS39_SDB = (6, JAS39GripenWeapons.JAS39_SDB)
+ JAS39_GBU12 = (6, JAS39GripenWeapons.JAS39_GBU12)
+ JAS39_GBU10 = (6, JAS39GripenWeapons.JAS39_GBU10)
+ JAS39_GBU16 = (6, JAS39GripenWeapons.JAS39_GBU16)
+ JAS39_DWS39 = (6, JAS39GripenWeapons.JAS39_DWS39)
+ Mk_82___500lb_GP_Bomb_LD = (6, Weapons.Mk_82___500lb_GP_Bomb_LD)
+ Mk_83___1000lb_GP_Bomb_LD = (6, Weapons.Mk_83___1000lb_GP_Bomb_LD)
+ Mk_84___2000lb_GP_Bomb_LD = (6, Weapons.Mk_84___2000lb_GP_Bomb_LD)
+ BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = (
+ 6,
+ Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
+ )
+ JAS39_M71LD = (6, JAS39GripenWeapons.JAS39_M71LD)
+ JAS39_TANK1100 = (6, JAS39GripenWeapons.JAS39_TANK1100)
+ JAS39_TANK1700 = (6, JAS39GripenWeapons.JAS39_TANK1700)
+ JAS39_M70BHE = (6, JAS39GripenWeapons.JAS39_M70BHE)
+ JAS39_M70BAP = (6, JAS39GripenWeapons.JAS39_M70BAP)
+ JAS39_STORMSHADOW = (6, JAS39GripenWeapons.JAS39_STORMSHADOW)
class Pylon7:
- JAS_Litening = (7, JAS39GripenWeapons.JAS_Litening)
-
- # ERRR
+ JAS39_IRIS_T = (7, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_AIM_9L = (7, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_A_DARTER = (7, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (7, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (7, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (7, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (7, JAS39GripenWeapons.JAS39_ASRAAM)
+ JAS39_RBS15 = (7, JAS39GripenWeapons.JAS39_RBS15)
+ JAS39_RBS15AI = (7, JAS39GripenWeapons.JAS39_RBS15AI)
+ JAS39_MAR_1 = (7, JAS39GripenWeapons.JAS39_MAR_1)
+ JAS39_GBU49 = (7, JAS39GripenWeapons.JAS39_GBU49)
+ JAS39_GBU32 = (7, JAS39GripenWeapons.JAS39_GBU32)
+ JAS39_GBU38 = (7, JAS39GripenWeapons.JAS39_GBU38)
+ JAS39_SDB = (7, JAS39GripenWeapons.JAS39_SDB)
+ JAS39_GBU12 = (7, JAS39GripenWeapons.JAS39_GBU12)
+ JAS39_GBU16 = (7, JAS39GripenWeapons.JAS39_GBU16)
+ JAS39_DWS39 = (7, JAS39GripenWeapons.JAS39_DWS39)
+ Mk_82___500lb_GP_Bomb_LD = (7, Weapons.Mk_82___500lb_GP_Bomb_LD)
+ Mk_83___1000lb_GP_Bomb_LD = (7, Weapons.Mk_83___1000lb_GP_Bomb_LD)
+ BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = (
+ 7,
+ Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
+ )
+ JAS39_M71LD = (7, JAS39GripenWeapons.JAS39_M71LD)
+ JAS39_M70BHE = (7, JAS39GripenWeapons.JAS39_M70BHE)
+ JAS39_M70BAP = (7, JAS39GripenWeapons.JAS39_M70BAP)
+ JAS39_BRIMSTONE = (7, JAS39GripenWeapons.JAS39_BRIMSTONE)
+ LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = (
+ 7,
+ Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_,
+ )
+ LAU_117_AGM_65H = (7, Weapons.LAU_117_AGM_65H)
class Pylon8:
- JAS_RB75T = (8, JAS39GripenWeapons.JAS_RB75T)
- AGM_65K___Maverick_K__CCD_Imp_ASM_ = (
- 8,
- Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_,
- )
- JAS_Stormshadow = (8, JAS39GripenWeapons.JAS_Stormshadow)
- JAS_BK90 = (8, JAS39GripenWeapons.JAS_BK90)
- JAS_GBU31 = (8, JAS39GripenWeapons.JAS_GBU31)
- JAS_RB15F = (8, JAS39GripenWeapons.JAS_RB15F)
- JAS_MAR_1 = (8, JAS39GripenWeapons.JAS_MAR_1)
- JAS_GBU12 = (8, JAS39GripenWeapons.JAS_GBU12)
- JAS_GBU49_TV = (8, JAS39GripenWeapons.JAS_GBU49_TV)
- # ERRR JAS_GBU16
- JAS_GBU16_TV = (8, JAS39GripenWeapons.JAS_GBU16_TV)
- GBU_10___2000lb_Laser_Guided_Bomb = (
- 8,
- Weapons.GBU_10___2000lb_Laser_Guided_Bomb,
- )
- Mk_82___500lb_GP_Bomb_LD = (8, Weapons.Mk_82___500lb_GP_Bomb_LD)
- Mk_83___1000lb_GP_Bomb_LD = (8, Weapons.Mk_83___1000lb_GP_Bomb_LD)
- Mk_84___2000lb_GP_Bomb_LD = (8, Weapons.Mk_84___2000lb_GP_Bomb_LD)
- BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = (
- 8,
- Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
- )
- _4x_SB_M_71_120kg_GP_Bomb_Low_drag = (
- 8,
- Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag,
- )
- JAS_TANK1100 = (8, JAS39GripenWeapons.JAS_TANK1100)
- JAS_TANK1700 = (8, JAS39GripenWeapons.JAS_TANK1700)
- JAS_ARAKM70BHE = (8, JAS39GripenWeapons.JAS_ARAKM70BHE)
- JAS_ARAKM70BAP = (8, JAS39GripenWeapons.JAS_ARAKM70BAP)
- JAS_BRIMSTONE = (8, JAS39GripenWeapons.JAS_BRIMSTONE)
-
- # ERRR
+ JAS39_IRIS_T = (8, JAS39GripenWeapons.JAS39_IRIS_T)
+ JAS39_AIM_9L = (8, JAS39GripenWeapons.JAS39_AIM_9L)
+ JAS39_A_DARTER = (8, JAS39GripenWeapons.JAS39_A_DARTER)
+ JAS39_AIM_9M = (8, JAS39GripenWeapons.JAS39_AIM_9M)
+ JAS39_AIM_9X = (8, JAS39GripenWeapons.JAS39_AIM_9X)
+ JAS39_PYTHON_5 = (8, JAS39GripenWeapons.JAS39_PYTHON_5)
+ JAS39_ASRAAM = (8, JAS39GripenWeapons.JAS39_ASRAAM)
+ AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (8, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod)
+ Smokewinder___red = (8, Weapons.Smokewinder___red)
+ Smokewinder___green = (8, Weapons.Smokewinder___green)
+ Smokewinder___blue = (8, Weapons.Smokewinder___blue)
+ Smokewinder___white = (8, Weapons.Smokewinder___white)
+ Smokewinder___yellow = (8, Weapons.Smokewinder___yellow)
+ Smokewinder___orange = (8, Weapons.Smokewinder___orange)
class Pylon9:
- JAS_IRIS_T = (9, JAS39GripenWeapons.JAS_IRIS_T)
- JAS_Rb74 = (9, JAS39GripenWeapons.JAS_Rb74)
- JAS_RB75T = (9, JAS39GripenWeapons.JAS_RB75T)
- AGM_65K___Maverick_K__CCD_Imp_ASM_ = (
+ Litening_III_Targeting_Pod_FLIR = (
9,
- Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_,
+ JAS39GripenWeapons.Litening_III_Targeting_Pod_FLIR,
)
- JAS_BK90 = (9, JAS39GripenWeapons.JAS_BK90)
- JAS_RB15F = (9, JAS39GripenWeapons.JAS_RB15F)
- JAS_MAR_1 = (9, JAS39GripenWeapons.JAS_MAR_1)
- JAS_GBU12 = (9, JAS39GripenWeapons.JAS_GBU12)
- JAS_GBU49_TV = (9, JAS39GripenWeapons.JAS_GBU49_TV)
- # ERRR JAS_GBU16
- JAS_GBU16_TV = (9, JAS39GripenWeapons.JAS_GBU16_TV)
- # ERRR GBU12_TEST
- Mk_82___500lb_GP_Bomb_LD = (9, Weapons.Mk_82___500lb_GP_Bomb_LD)
- Mk_83___1000lb_GP_Bomb_LD = (9, Weapons.Mk_83___1000lb_GP_Bomb_LD)
- BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = (
- 9,
- Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
- )
- _4x_SB_M_71_120kg_GP_Bomb_Low_drag = (
- 9,
- Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag,
- )
- JAS_ARAKM70BHE = (9, JAS39GripenWeapons.JAS_ARAKM70BHE)
- JAS_ARAKM70BAP = (9, JAS39GripenWeapons.JAS_ARAKM70BAP)
- JAS_BRIMSTONE = (9, JAS39GripenWeapons.JAS_BRIMSTONE)
-
- # ERRR
class Pylon10:
- JAS_IRIS_T = (10, JAS39GripenWeapons.JAS_IRIS_T)
- JAS_Rb74 = (10, JAS39GripenWeapons.JAS_Rb74)
- AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (10, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod)
- Smokewinder___red = (10, Weapons.Smokewinder___red)
- Smokewinder___green = (10, Weapons.Smokewinder___green)
- Smokewinder___blue = (10, Weapons.Smokewinder___blue)
- Smokewinder___white = (10, Weapons.Smokewinder___white)
- Smokewinder___yellow = (10, Weapons.Smokewinder___yellow)
- Smokewinder___orange = (10, Weapons.Smokewinder___orange)
+ Integrated_ELINT = (10, JAS39GripenWeapons.Integrated_ELINT)
- pylons = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
+ class Pylon11:
+ EWS_39_Integrated_ECM = (11, JAS39GripenWeapons.EWS_39_Integrated_ECM)
+
+ pylons: Set[int] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}
tasks = [
task.SEAD,
diff --git a/pyinstaller.spec b/pyinstaller.spec
index 839c641f..88436110 100644
--- a/pyinstaller.spec
+++ b/pyinstaller.spec
@@ -36,7 +36,7 @@ exe = EXE(
bootloader_ignore_signals=False,
strip=False,
upx=True,
- console=True,
+ console=False,
)
coll = COLLECT(
exe,
diff --git a/qt_ui/liberation_install.py b/qt_ui/liberation_install.py
index 0bbcb0b0..341cd45a 100644
--- a/qt_ui/liberation_install.py
+++ b/qt_ui/liberation_install.py
@@ -112,7 +112,7 @@ def replace_mission_scripting_file():
)
liberation_scripting_path = "./resources/scripts/MissionScripting.lua"
backup_scripting_path = "./resources/scripts/MissionScripting.original.lua"
- if os.path.isfile(mission_scripting_path):
+ if install_dir != "" and os.path.isfile(mission_scripting_path):
with open(mission_scripting_path, "r") as ms:
current_file_content = ms.read()
with open(liberation_scripting_path, "r") as libe_ms:
@@ -133,5 +133,9 @@ def restore_original_mission_scripting():
)
backup_scripting_path = "./resources/scripts/MissionScripting.original.lua"
- if os.path.isfile(backup_scripting_path) and os.path.isfile(mission_scripting_path):
+ if (
+ install_dir != ""
+ and os.path.isfile(backup_scripting_path)
+ and os.path.isfile(mission_scripting_path)
+ ):
copyfile(backup_scripting_path, mission_scripting_path)
diff --git a/qt_ui/logging_config.py b/qt_ui/logging_config.py
index ea08efe3..8d7a26af 100644
--- a/qt_ui/logging_config.py
+++ b/qt_ui/logging_config.py
@@ -3,6 +3,8 @@ import logging
import os
from logging.handlers import RotatingFileHandler
+from qt_ui.logging_handler import HookableInMemoryHandler
+
def init_logging(version: str) -> None:
"""Initializes the logging configuration."""
@@ -10,13 +12,22 @@ def init_logging(version: str) -> None:
os.mkdir("logs")
fmt = "%(asctime)s :: %(levelname)s :: %(message)s"
+ formatter = logging.Formatter(fmt)
+
logging.basicConfig(level=logging.DEBUG, format=fmt)
logger = logging.getLogger()
- handler = RotatingFileHandler("./logs/liberation.log", "a", 5000000, 1)
- handler.setLevel(logging.DEBUG)
- handler.setFormatter(logging.Formatter(fmt))
+ rotating_file_handler = RotatingFileHandler(
+ "./logs/liberation.log", "a", 5000000, 1
+ )
+ rotating_file_handler.setLevel(logging.DEBUG)
+ rotating_file_handler.setFormatter(formatter)
- logger.addHandler(handler)
+ hookable_in_memory_handler = HookableInMemoryHandler()
+ hookable_in_memory_handler.setLevel(logging.DEBUG)
+ hookable_in_memory_handler.setFormatter(formatter)
+
+ logger.addHandler(rotating_file_handler)
+ logger.addHandler(hookable_in_memory_handler)
logger.info(f"DCS Liberation {version}")
diff --git a/qt_ui/logging_handler.py b/qt_ui/logging_handler.py
new file mode 100644
index 00000000..465e4cb1
--- /dev/null
+++ b/qt_ui/logging_handler.py
@@ -0,0 +1,38 @@
+import logging
+import typing
+
+LogHook = typing.Callable[[str], None]
+
+
+class HookableInMemoryHandler(logging.Handler):
+ """Hookable in-memory logging handler for logs window"""
+
+ _log: str
+ _hook: typing.Optional[typing.Callable[[str], None]]
+
+ def __init__(self, *args, **kwargs):
+ super(HookableInMemoryHandler, self).__init__(*args, **kwargs)
+ self._log = ""
+ self._hook = None
+
+ @property
+ def log(self) -> str:
+ return self._log
+
+ def emit(self, record):
+ msg = self.format(record)
+ self._log += msg + "\n"
+ if self._hook is not None:
+ self._hook(msg)
+
+ def write(self, m):
+ pass
+
+ def clearLog(self) -> None:
+ self._log = ""
+
+ def setHook(self, hook: typing.Callable[[str], None]) -> None:
+ self._hook = hook
+
+ def clearHook(self) -> None:
+ self._hook = None
diff --git a/qt_ui/main.py b/qt_ui/main.py
index 744f90de..70d4dd5b 100644
--- a/qt_ui/main.py
+++ b/qt_ui/main.py
@@ -7,18 +7,15 @@ from pathlib import Path
from typing import Optional
from PySide2 import QtWidgets
+from PySide2.QtCore import Qt
from PySide2.QtGui import QPixmap
from PySide2.QtWidgets import QApplication, QSplashScreen
from dcs.payloads import PayloadDirectories
-from dcs.weapons_data import weapon_ids
from game import Game, VERSION, persistency
-from game.data.weapons import (
- WEAPON_FALLBACK_MAP,
- WEAPON_INTRODUCTION_YEARS,
- Weapon,
-)
+from game.data.weapons import WeaponGroup, Pylon, Weapon
from game.db import FACTIONS
+from game.dcs.aircrafttype import AircraftType
from game.profiling import logged_duration
from game.settings import Settings
from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings
@@ -62,6 +59,8 @@ def run_ui(game: Optional[Game]) -> None:
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens
app = QApplication(sys.argv)
+ app.setAttribute(Qt.AA_DisableWindowContextHelpButton)
+
# init the theme and load the stylesheet based on the theme index
liberation_theme.init()
with open(
@@ -97,6 +96,22 @@ def run_ui(game: Optional[Game]) -> None:
uiconstants.load_aircraft_banners()
uiconstants.load_vehicle_banners()
+ # Show warning if no DCS Installation directory was set
+ if liberation_install.get_dcs_install_directory() == "":
+ QtWidgets.QMessageBox.warning(
+ splash,
+ "No DCS installation directory.",
+ "The DCS Installation directory is not set correctly. "
+ "This will prevent DCS Liberation to work properly as the MissionScripting "
+ "file will not be modified."
+ "
To solve this problem, you can set the Installation directory "
+ "within the preferences menu. You can also manually edit or replace the "
+ "following file:"
+ "
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.
"
+ "To accept your changes and continue, close this window. "
+ " "
+ "To remove a squadron from the game, uncheck the box in the title. New "
+ "squadrons cannot be added via the UI at this time. To add a custom "
+ "squadron, "
+ f'see the wiki.'
+ )
+
+ doc_label.setOpenExternalLinks(True)
+ layout.addWidget(doc_label)
+
+ tab_widget = QTabWidget()
+ layout.addWidget(tab_widget)
+
+ self.tabs = []
+ for coalition in game.coalitions:
+ coalition_tab = AirWingConfigurationTab(coalition.air_wing)
+ name = "Blue" if coalition.player else "Red"
+ tab_widget.addTab(coalition_tab, name)
+ self.tabs.append(coalition_tab)
+
+ def reject(self) -> None:
+ for tab in self.tabs:
+ tab.apply()
+ super().reject()
diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py
index ac666e0e..df0cf81c 100644
--- a/qt_ui/windows/AirWingDialog.py
+++ b/qt_ui/windows/AirWingDialog.py
@@ -3,12 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Iterator
-from PySide2.QtCore import (
- QItemSelectionModel,
- QModelIndex,
- Qt,
- QSize,
-)
+from PySide2.QtCore import QItemSelectionModel, QModelIndex, QSize
from PySide2.QtWidgets import (
QAbstractItemView,
QCheckBox,
@@ -183,7 +178,7 @@ class AirInventoryView(QWidget):
self.table.setSortingEnabled(True)
def iter_allocated_aircraft(self) -> Iterator[AircraftInventoryData]:
- for package in self.game_model.game.blue_ato.packages:
+ for package in self.game_model.game.blue.ato.packages:
for flight in package.flights:
yield from AircraftInventoryData.from_flight(flight)
diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py
index e4d7f403..a2c984d2 100644
--- a/qt_ui/windows/QLiberationWindow.py
+++ b/qt_ui/windows/QLiberationWindow.py
@@ -36,6 +36,8 @@ from qt_ui.windows.preferences.QLiberationPreferencesWindow import (
)
from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow
from qt_ui.windows.stats.QStatsWindow import QStatsWindow
+from qt_ui.windows.notes.QNotesWindow import QNotesWindow
+from qt_ui.windows.logs.QLogsWindow import QLogsWindow
class QLiberationWindow(QMainWindow):
@@ -150,6 +152,9 @@ class QLiberationWindow(QMainWindow):
)
)
+ self.openLogsAction = QAction("Show &logs", self)
+ self.openLogsAction.triggered.connect(self.showLogsDialog)
+
self.openSettingsAction = QAction("Settings", self)
self.openSettingsAction.setIcon(CONST.ICONS["Settings"])
self.openSettingsAction.triggered.connect(self.showSettingsDialog)
@@ -158,6 +163,10 @@ class QLiberationWindow(QMainWindow):
self.openStatsAction.setIcon(CONST.ICONS["Statistics"])
self.openStatsAction.triggered.connect(self.showStatsDialog)
+ self.openNotesAction = QAction("Notes", self)
+ self.openNotesAction.setIcon(CONST.ICONS["Notes"])
+ self.openNotesAction.triggered.connect(self.showNotesDialog)
+
def initToolbar(self):
self.tool_bar = self.addToolBar("File")
self.tool_bar.addAction(self.newGameAction)
@@ -171,6 +180,7 @@ class QLiberationWindow(QMainWindow):
self.actions_bar = self.addToolBar("Actions")
self.actions_bar.addAction(self.openSettingsAction)
self.actions_bar.addAction(self.openStatsAction)
+ self.actions_bar.addAction(self.openNotesAction)
def initMenuBar(self):
self.menu = self.menuBar()
@@ -204,6 +214,7 @@ class QLiberationWindow(QMainWindow):
help_menu.addAction(
"Report an &issue", lambda: webbrowser.open_new_tab(URLS["Issues"])
)
+ help_menu.addAction(self.openLogsAction)
help_menu.addSeparator()
help_menu.addAction(self.showAboutDialogAction)
@@ -351,6 +362,14 @@ class QLiberationWindow(QMainWindow):
self.dialog = QStatsWindow(self.game)
self.dialog.show()
+ def showNotesDialog(self):
+ self.dialog = QNotesWindow(self.game)
+ self.dialog.show()
+
+ def showLogsDialog(self):
+ self.dialog = QLogsWindow()
+ self.dialog.show()
+
def onDebriefing(self, debrief: Debriefing):
logging.info("On Debriefing")
self.debriefing = QDebriefingWindow(debrief)
diff --git a/qt_ui/windows/QUnitInfoWindow.py b/qt_ui/windows/QUnitInfoWindow.py
index a87ce597..e5503544 100644
--- a/qt_ui/windows/QUnitInfoWindow.py
+++ b/qt_ui/windows/QUnitInfoWindow.py
@@ -94,6 +94,9 @@ class QUnitInfoWindow(QDialog):
self.details_text = QTextBrowser()
self.details_text.setProperty("style", "info-desc")
self.details_text.setText(unit_type.description)
+ self.details_text.setOpenExternalLinks(
+ True
+ ) # in aircrafttype.py and groundunittype.py, for the descriptions, if No Data. including a google search link
self.gridLayout.addWidget(self.details_text, 3, 0)
self.layout.addLayout(self.gridLayout, 1, 0)
diff --git a/qt_ui/windows/basemenu/DepartingConvoysMenu.py b/qt_ui/windows/basemenu/DepartingConvoysMenu.py
index d858539e..c334f0bb 100644
--- a/qt_ui/windows/basemenu/DepartingConvoysMenu.py
+++ b/qt_ui/windows/basemenu/DepartingConvoysMenu.py
@@ -73,11 +73,15 @@ class DepartingConvoysList(QFrame):
task_box_layout = QGridLayout()
scroll_content.setLayout(task_box_layout)
- for convoy in game_model.game.transfers.convoys.departing_from(cp):
+ for convoy in game_model.game.coalition_for(
+ cp.captured
+ ).transfers.convoys.departing_from(cp):
group_info = DepartingConvoyInfo(convoy)
task_box_layout.addWidget(group_info)
- for cargo_ship in game_model.game.transfers.cargo_ships.departing_from(cp):
+ for cargo_ship in game_model.game.coalition_for(
+ cp.captured
+ ).transfers.cargo_ships.departing_from(cp):
group_info = DepartingConvoyInfo(cargo_ship)
task_box_layout.addWidget(group_info)
diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py
index 8a913280..d10e5bc7 100644
--- a/qt_ui/windows/basemenu/QBaseMenu2.py
+++ b/qt_ui/windows/basemenu/QBaseMenu2.py
@@ -108,7 +108,7 @@ class QBaseMenu2(QDialog):
capture_button.clicked.connect(self.cheat_capture)
self.budget_display = QLabel(
- QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.budget)
+ QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.blue.budget)
)
self.budget_display.setAlignment(Qt.AlignRight | Qt.AlignBottom)
self.budget_display.setProperty("style", "budget-label")
@@ -124,7 +124,6 @@ class QBaseMenu2(QDialog):
self.cp.capture(self.game_model.game, for_player=not self.cp.captured)
# Reinitialized ground planners and the like. The ATO needs to be reset because
# missions planned against the flipped base are no longer valid.
- self.game_model.game.reset_ato()
self.game_model.game.initialize_turn()
GameUpdateSignal.get_instance().updateGame(self.game_model.game)
@@ -140,7 +139,7 @@ class QBaseMenu2(QDialog):
@property
def can_afford_runway_repair(self) -> bool:
- return self.game_model.game.budget >= db.RUNWAY_REPAIR_COST
+ return self.game_model.game.blue.budget >= db.RUNWAY_REPAIR_COST
def begin_runway_repair(self) -> None:
if not self.can_afford_runway_repair:
@@ -148,7 +147,7 @@ class QBaseMenu2(QDialog):
self,
"Cannot repair runway",
f"Runway repair costs ${db.RUNWAY_REPAIR_COST}M but you have "
- f"only ${self.game_model.game.budget}M available.",
+ f"only ${self.game_model.game.blue.budget}M available.",
QMessageBox.Ok,
)
return
@@ -162,7 +161,7 @@ class QBaseMenu2(QDialog):
return
self.cp.begin_runway_repair()
- self.game_model.game.budget -= db.RUNWAY_REPAIR_COST
+ self.game_model.game.blue.budget -= db.RUNWAY_REPAIR_COST
self.update_repair_button()
self.update_intel_summary()
GameUpdateSignal.get_instance().updateGame(self.game_model.game)
@@ -196,7 +195,9 @@ class QBaseMenu2(QDialog):
ground_unit_limit = self.cp.frontline_unit_count_limit
deployable_unit_info = ""
- allocated = self.cp.allocated_ground_units(self.game_model.game.transfers)
+ allocated = self.cp.allocated_ground_units(
+ self.game_model.game.coalition_for(self.cp.captured).transfers
+ )
unit_overage = max(
allocated.total_present - self.cp.frontline_unit_count_limit, 0
)
@@ -256,4 +257,6 @@ class QBaseMenu2(QDialog):
NewUnitTransferDialog(self.game_model, self.cp, parent=self.window()).show()
def update_budget(self, game: Game) -> None:
- self.budget_display.setText(QRecruitBehaviour.BUDGET_FORMAT.format(game.budget))
+ self.budget_display.setText(
+ QRecruitBehaviour.BUDGET_FORMAT.format(game.blue.budget)
+ )
diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py
index 5eb7534a..77b0258b 100644
--- a/qt_ui/windows/basemenu/QRecruitBehaviour.py
+++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py
@@ -103,11 +103,11 @@ class QRecruitBehaviour:
@property
def budget(self) -> float:
- return self.game_model.game.budget
+ return self.game_model.game.blue.budget
@budget.setter
def budget(self, value: int) -> None:
- self.game_model.game.budget = value
+ self.game_model.game.blue.budget = value
def add_purchase_row(
self,
@@ -209,8 +209,6 @@ class QRecruitBehaviour:
if self.pending_deliveries.available_next_turn(unit_type) > 0:
self.budget += unit_type.price
self.pending_deliveries.sell({unit_type: 1})
- if self.pending_deliveries.units[unit_type] == 0:
- del self.pending_deliveries.units[unit_type]
self.update_purchase_controls()
self.update_available_budget()
return True
diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
index 2df51537..9e24e082 100644
--- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
+++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
@@ -45,7 +45,7 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
row = 0
unit_types: Set[AircraftType] = set()
- for unit_type in self.game_model.game.player_faction.aircrafts:
+ for unit_type in self.game_model.game.blue.faction.aircrafts:
if self.cp.is_carrier and not unit_type.carrier_capable:
continue
if self.cp.is_lha and not unit_type.lha_capable:
diff --git a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py
index ec467b92..166f7b4b 100644
--- a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py
+++ b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py
@@ -56,6 +56,5 @@ class QGroundForcesStrategy(QGroupBox):
self.cp.base.affect_strength(amount)
enemy_point.base.affect_strength(-amount)
# Clear the ATO to replan missions affected by the front line.
- self.game.reset_ato()
self.game.initialize_turn()
GameUpdateSignal.get_instance().updateGame(self.game)
diff --git a/qt_ui/windows/finances/QFinancesMenu.py b/qt_ui/windows/finances/QFinancesMenu.py
index 4ef8b281..c1eec23e 100644
--- a/qt_ui/windows/finances/QFinancesMenu.py
+++ b/qt_ui/windows/finances/QFinancesMenu.py
@@ -57,10 +57,7 @@ class FinancesLayout(QGridLayout):
middle=f"Income multiplier: {income.multiplier:.1f}",
right=f"{income.total}M",
)
- if player:
- budget = game.budget
- else:
- budget = game.enemy_budget
+ budget = game.coalition_for(player).budget
self.add_row(middle="Balance", right=f"{budget}M")
self.setRowStretch(next(self.row), 1)
diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py
index 96debe14..5622682f 100644
--- a/qt_ui/windows/groundobject/QGroundObjectMenu.py
+++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py
@@ -1,7 +1,6 @@
import logging
from typing import List, Optional
-from PySide2 import QtCore
from PySide2.QtGui import Qt
from PySide2.QtWidgets import (
QComboBox,
@@ -238,8 +237,8 @@ class QGroundObjectMenu(QDialog):
self.total_value = total_value
def repair_unit(self, group, unit, price):
- if self.game.budget > price:
- self.game.budget -= price
+ if self.game.blue.budget > price:
+ self.game.blue.budget -= price
group.units_losts = [u for u in group.units_losts if u.id != unit.id]
group.units.append(unit)
GameUpdateSignal.get_instance().updateGame(self.game)
@@ -257,8 +256,16 @@ class QGroundObjectMenu(QDialog):
def sell_all(self):
self.update_total_value()
- self.game.budget = self.game.budget + self.total_value
+ self.game.blue.budget = self.game.blue.budget + self.total_value
self.ground_object.groups = []
+
+ # Replan if the tgo was a target of the redfor
+ if any(
+ package.target == self.ground_object
+ for package in self.game.ato_for(player=False).packages
+ ):
+ self.game.initialize_turn(for_red=True, for_blue=False)
+
self.do_refresh_layout()
GameUpdateSignal.get_instance().updateGame(self.game)
@@ -299,14 +306,17 @@ class QBuyGroupForGroundObjectDialog(QDialog):
self.buySamBox = QGroupBox("Buy SAM site :")
self.buyArmorBox = QGroupBox("Buy defensive position :")
- faction = self.game.player_faction
+ faction = self.game.blue.faction
# Sams
possible_sams = get_faction_possible_sams_generator(faction)
for sam in possible_sams:
+ # Pre Generate SAM to get the real price
+ generator = sam(self.game, self.ground_object)
+ generator.generate()
self.samCombo.addItem(
- sam.name + " [$" + str(sam.price) + "M]", userData=sam
+ generator.name + " [$" + str(generator.price) + "M]", userData=generator
)
self.samCombo.currentIndexChanged.connect(self.samComboChanged)
@@ -331,8 +341,12 @@ class QBuyGroupForGroundObjectDialog(QDialog):
buy_ewr_layout.addWidget(self.ewr_selector, 0, 1, alignment=Qt.AlignRight)
ewr_types = get_faction_possible_ewrs_generator(faction)
for ewr_type in ewr_types:
+ # Pre Generate to get the real price
+ generator = ewr_type(self.game, self.ground_object)
+ generator.generate()
self.ewr_selector.addItem(
- f"{ewr_type.name()} [${ewr_type.price()}M]", ewr_type
+ generator.name() + " [$" + str(generator.price) + "M]",
+ userData=generator,
)
self.ewr_selector.currentIndexChanged.connect(self.on_ewr_selection_changed)
@@ -402,7 +416,7 @@ class QBuyGroupForGroundObjectDialog(QDialog):
def on_ewr_selection_changed(self, index):
ewr = self.ewr_selector.itemData(index)
self.buy_ewr_button.setText(
- f"Buy [${ewr.price()}M][-${self.current_group_value}M]"
+ f"Buy [${ewr.price}M][-${self.current_group_value}M]"
)
def armorComboChanged(self, index):
@@ -419,12 +433,12 @@ class QBuyGroupForGroundObjectDialog(QDialog):
logging.info("Buying Armor ")
utype = self.buyArmorCombo.itemData(self.buyArmorCombo.currentIndex())
price = utype.price * self.amount.value() - self.current_group_value
- if price > self.game.budget:
+ if price > self.game.blue.budget:
self.error_money()
self.close()
return
else:
- self.game.budget -= price
+ self.game.blue.budget -= price
# Generate Armor
group = generate_armor_group_of_type_and_size(
@@ -432,36 +446,40 @@ class QBuyGroupForGroundObjectDialog(QDialog):
)
self.ground_object.groups = [group]
+ # Replan redfor missions
+ self.game.initialize_turn(for_red=True, for_blue=False)
+
GameUpdateSignal.get_instance().updateGame(self.game)
def buySam(self):
sam_generator = self.samCombo.itemData(self.samCombo.currentIndex())
price = sam_generator.price - self.current_group_value
- if price > self.game.budget:
+ if price > self.game.blue.budget:
self.error_money()
return
else:
- self.game.budget -= price
+ self.game.blue.budget -= price
- # Generate SAM
- generator = sam_generator(self.game, self.ground_object)
- generator.generate()
- self.ground_object.groups = list(generator.groups)
+ self.ground_object.groups = list(sam_generator.groups)
+
+ # Replan redfor missions
+ self.game.initialize_turn(for_red=True, for_blue=False)
GameUpdateSignal.get_instance().updateGame(self.game)
def buy_ewr(self):
ewr_generator = self.ewr_selector.itemData(self.ewr_selector.currentIndex())
- price = ewr_generator.price() - self.current_group_value
- if price > self.game.budget:
+ price = ewr_generator.price - self.current_group_value
+ if price > self.game.blue.budget:
self.error_money()
return
else:
- self.game.budget -= price
+ self.game.blue.budget -= price
- generator = ewr_generator(self.game, self.ground_object)
- generator.generate()
- self.ground_object.groups = [generator.vg]
+ self.ground_object.groups = [ewr_generator.vg]
+
+ # Replan redfor missions
+ self.game.initialize_turn(for_red=True, for_blue=False)
GameUpdateSignal.get_instance().updateGame(self.game)
diff --git a/qt_ui/windows/logs/QLogsWindow.py b/qt_ui/windows/logs/QLogsWindow.py
new file mode 100644
index 00000000..936261a1
--- /dev/null
+++ b/qt_ui/windows/logs/QLogsWindow.py
@@ -0,0 +1,67 @@
+import logging
+import typing
+
+from PySide2.QtWidgets import (
+ QDialog,
+ QPlainTextEdit,
+ QVBoxLayout,
+ QPushButton,
+)
+from PySide2.QtGui import QTextCursor
+
+from qt_ui.logging_handler import HookableInMemoryHandler
+
+
+class QLogsWindow(QDialog):
+ vbox: QVBoxLayout
+ textbox: QPlainTextEdit
+ clear_button: QPushButton
+ _logging_handler: typing.Optional[HookableInMemoryHandler]
+
+ def __init__(self):
+ super().__init__()
+
+ self.setWindowTitle("Logs")
+ self.setMinimumSize(400, 100)
+ self.resize(1000, 450)
+
+ self.vbox = QVBoxLayout()
+ self.setLayout(self.vbox)
+
+ self.textbox = QPlainTextEdit(self)
+ self.textbox.setReadOnly(True)
+ self.textbox.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
+ self.textbox.move(10, 10)
+ self.textbox.resize(1000, 450)
+ self.textbox.setStyleSheet(
+ "font-family: 'Courier New', monospace; background: #1D2731;"
+ )
+ self.vbox.addWidget(self.textbox)
+
+ self.clear_button = QPushButton(self)
+ self.clear_button.setText("CLEAR")
+ self.clear_button.setProperty("style", "btn-primary")
+ self.clear_button.clicked.connect(self.clearLogs)
+ self.vbox.addWidget(self.clear_button)
+
+ self._logging_handler = None
+ logger = logging.getLogger()
+ for handler in logger.handlers:
+ if isinstance(handler, HookableInMemoryHandler):
+ self._logging_handler = handler
+ break
+ if self._logging_handler is not None:
+ self.textbox.setPlainText(self._logging_handler.log)
+ self.textbox.moveCursor(QTextCursor.End)
+ self._logging_handler.setHook(self.appendLog)
+ else:
+ self.textbox.setPlainText("WARNING: logging not initialized!")
+
+ def clearLogs(self) -> None:
+ if self._logging_handler is not None:
+ self._logging_handler.clearLog()
+ self.textbox.setPlainText("")
+
+ def appendLog(self, msg: str):
+ self.textbox.appendPlainText(msg)
+ self.textbox.moveCursor(QTextCursor.End)
diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py
index 19634847..c86987ae 100644
--- a/qt_ui/windows/mission/QPackageDialog.py
+++ b/qt_ui/windows/mission/QPackageDialog.py
@@ -180,7 +180,7 @@ class QPackageDialog(QDialog):
self.game.aircraft_inventory.claim_for_flight(flight)
self.package_model.add_flight(flight)
planner = FlightPlanBuilder(
- self.game, self.package_model.package, is_player=True
+ self.package_model.package, self.game.blue, self.game.theater
)
try:
planner.populate_flight_plan(flight)
diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py
index 7f5c6cc4..3c0a1e74 100644
--- a/qt_ui/windows/mission/flight/QFlightCreator.py
+++ b/qt_ui/windows/mission/flight/QFlightCreator.py
@@ -38,7 +38,7 @@ class QFlightCreator(QDialog):
self.game = game
self.package = package
self.custom_name_text = None
- self.country = self.game.player_country
+ self.country = self.game.blue.country_name
self.setWindowTitle("Create flight")
self.setWindowIcon(EVENT_ICONS["strike"])
@@ -52,7 +52,6 @@ class QFlightCreator(QDialog):
self.aircraft_selector = QAircraftTypeSelector(
self.game.aircraft_inventory.available_types_for_player,
- self.game.player_country,
self.task_selector.currentData(),
)
self.aircraft_selector.setCurrentIndex(0)
diff --git a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py
index 5cf5b370..b1eb809e 100644
--- a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py
+++ b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py
@@ -47,8 +47,20 @@ class QFlightPayloadTab(QFrame):
def reload_from_flight(self) -> None:
self.loadout_selector.setCurrentText(self.flight.loadout.name)
+ def loadout_at(self, index: int) -> Loadout:
+ loadout = self.loadout_selector.itemData(index)
+ if loadout is None:
+ return Loadout.empty_loadout()
+ return loadout
+
+ def current_loadout(self) -> Loadout:
+ loadout = self.loadout_selector.currentData()
+ if loadout is None:
+ return Loadout.empty_loadout()
+ return loadout
+
def on_new_loadout(self, index: int) -> None:
- self.flight.loadout = self.loadout_selector.itemData(index)
+ self.flight.loadout = self.loadout_at(index)
self.payload_editor.reset_pylons()
def on_custom_toggled(self, use_custom: bool) -> None:
@@ -56,5 +68,5 @@ class QFlightPayloadTab(QFrame):
if use_custom:
self.flight.loadout = self.flight.loadout.derive_custom("Custom")
else:
- self.flight.loadout = self.loadout_selector.currentData()
+ self.flight.loadout = self.current_loadout()
self.payload_editor.reset_pylons()
diff --git a/qt_ui/windows/mission/flight/payload/QPylonEditor.py b/qt_ui/windows/mission/flight/payload/QPylonEditor.py
index 3cb22c19..e6eeaa24 100644
--- a/qt_ui/windows/mission/flight/payload/QPylonEditor.py
+++ b/qt_ui/windows/mission/flight/payload/QPylonEditor.py
@@ -56,7 +56,7 @@ class QPylonEditor(QComboBox):
#
# A similar hack exists in Pylon to support forcibly equipping this even when
# it's not known to be compatible.
- if weapon.cls_id == "":
+ if weapon.clsid == "":
if not self.has_added_clean_item:
self.addItem("Clean", weapon)
self.has_added_clean_item = True
diff --git a/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py b/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py
index 282df1ce..2cca2425 100644
--- a/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py
+++ b/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py
@@ -100,6 +100,6 @@ class FlightAirfieldDisplay(QGroupBox):
def update_flight_plan(self) -> None:
planner = FlightPlanBuilder(
- self.game, self.package_model.package, is_player=True
+ self.package_model.package, self.game.blue, self.game.theater
)
planner.populate_flight_plan(self.flight)
diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py
index 440d3f9b..bc4a7d51 100644
--- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py
+++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py
@@ -37,7 +37,7 @@ class QFlightWaypointTab(QFrame):
self.game = game
self.package = package
self.flight = flight
- self.planner = FlightPlanBuilder(self.game, package, is_player=True)
+ self.planner = FlightPlanBuilder(package, game.blue, game.theater)
self.flight_waypoint_list: Optional[QFlightWaypointList] = None
self.rtb_waypoint: Optional[QPushButton] = None
diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py
index 264f73cf..b29a4806 100644
--- a/qt_ui/windows/newgame/QNewGameWizard.py
+++ b/qt_ui/windows/newgame/QNewGameWizard.py
@@ -15,6 +15,7 @@ from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSe
from game.factions.faction import Faction
from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar
from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner
+from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog
from qt_ui.windows.newgame.QCampaignList import (
Campaign,
QCampaignList,
@@ -125,6 +126,10 @@ class NewGameWizard(QtWidgets.QWizard):
)
self.generatedGame = generator.generate()
+ AirWingConfigurationDialog(self.generatedGame, self).exec_()
+
+ self.generatedGame.begin_turn_0()
+
super(NewGameWizard, self).accept()
diff --git a/qt_ui/windows/notes/QNotesWindow.py b/qt_ui/windows/notes/QNotesWindow.py
new file mode 100644
index 00000000..cb1419b5
--- /dev/null
+++ b/qt_ui/windows/notes/QNotesWindow.py
@@ -0,0 +1,67 @@
+from PySide2.QtWidgets import (
+ QDialog,
+ QPlainTextEdit,
+ QVBoxLayout,
+ QHBoxLayout,
+ QPushButton,
+ QLabel,
+)
+from PySide2.QtGui import QTextCursor
+from PySide2.QtCore import QTimer
+
+import qt_ui.uiconstants as CONST
+from game.game import Game
+
+from time import sleep
+
+
+class QNotesWindow(QDialog):
+ def __init__(self, game: Game):
+ super(QNotesWindow, self).__init__()
+
+ self.game = game
+ self.setWindowTitle("Notes")
+ self.setWindowIcon(CONST.ICONS["Notes"])
+ self.setMinimumSize(400, 100)
+ self.resize(600, 450)
+
+ self.vbox = QVBoxLayout()
+ self.setLayout(self.vbox)
+
+ self.vbox.addWidget(
+ QLabel("Saved notes are available as a page in your kneeboard.")
+ )
+
+ self.textbox = QPlainTextEdit(self)
+ try:
+ self.textbox.setPlainText(self.game.notes)
+ self.textbox.moveCursor(QTextCursor.End)
+ except AttributeError: # old save may not have game.notes
+ pass
+ self.textbox.move(10, 10)
+ self.textbox.resize(600, 450)
+ self.textbox.setStyleSheet("background: #1D2731;")
+ self.vbox.addWidget(self.textbox)
+
+ self.button_row = QHBoxLayout()
+ self.vbox.addLayout(self.button_row)
+
+ self.clear_button = QPushButton(self)
+ self.clear_button.setText("CLEAR")
+ self.clear_button.setProperty("style", "btn-primary")
+ self.clear_button.clicked.connect(self.clearNotes)
+ self.button_row.addWidget(self.clear_button)
+
+ self.save_button = QPushButton(self)
+ self.save_button.setText("SAVE")
+ self.save_button.setProperty("style", "btn-success")
+ self.save_button.clicked.connect(self.saveNotes)
+ self.button_row.addWidget(self.save_button)
+
+ def clearNotes(self) -> None:
+ self.textbox.setPlainText("")
+
+ def saveNotes(self) -> None:
+ self.game.notes = self.textbox.toPlainText()
+ self.save_button.setText("SAVED")
+ QTimer.singleShot(5000, lambda: self.save_button.setText("SAVE"))
diff --git a/qt_ui/windows/preferences/QLiberationFirstStartWindow.py b/qt_ui/windows/preferences/QLiberationFirstStartWindow.py
index 4a300f35..78b898a1 100644
--- a/qt_ui/windows/preferences/QLiberationFirstStartWindow.py
+++ b/qt_ui/windows/preferences/QLiberationFirstStartWindow.py
@@ -58,6 +58,12 @@ class QLiberationFirstStartWindow(QDialog):
As you click on the button below, the file will be replaced in your DCS installation directory.
+
+
If you leave the DCS Installation Directory empty, DCS Liberation can not automatically replace the MissionScripting.lua and will therefore not work correctly!
+ In this case, you need to edit the file yourself. The easiest way to do it is to replace the original file with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua).
+
You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub.
+
+
Thank you for reading !
diff --git a/qt_ui/windows/preferences/QLiberationPreferences.py b/qt_ui/windows/preferences/QLiberationPreferences.py
index 0d41b298..fbfa6770 100644
--- a/qt_ui/windows/preferences/QLiberationPreferences.py
+++ b/qt_ui/windows/preferences/QLiberationPreferences.py
@@ -22,6 +22,7 @@ class QLiberationPreferences(QFrame):
super(QLiberationPreferences, self).__init__()
self.saved_game_dir = ""
self.dcs_install_dir = ""
+ self.install_dir_ignore_warning = False
self.dcs_install_dir = liberation_install.get_dcs_install_directory()
self.saved_game_dir = liberation_install.get_saved_game_dir()
@@ -102,17 +103,38 @@ class QLiberationPreferences(QFrame):
error_dialog.exec_()
return False
- if not os.path.isdir(self.dcs_install_dir):
+ if self.install_dir_ignore_warning and self.dcs_install_dir == "":
+ warning_dialog = QMessageBox.warning(
+ self,
+ "The DCS Installation directory was not set",
+ "You set an empty DCS Installation directory! "
+ "
Without this directory, DCS Liberation can not replace the MissionScripting.lua for you and will not work properly. "
+ "In this case, you need to edit the MissionScripting.lua yourself. The easiest way to do it is to replace the original file (<dcs_installation_directory>/Scripts/MissionScripting.lua) with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua)."
+ "
You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub."
+ "
Are you sure that you want to leave the installation directory empty?"
+ "
This is only recommended for expert users!",
+ QMessageBox.StandardButton.Yes,
+ QMessageBox.StandardButton.No,
+ )
+ if warning_dialog == QMessageBox.No:
+ return False
+ elif not os.path.isdir(self.dcs_install_dir):
error_dialog = QMessageBox.critical(
self,
"Wrong DCS installation directory.",
- self.dcs_install_dir + " is not a valid directory",
+ self.dcs_install_dir
+ + " is not a valid directory. DCS Liberation requires the installation directory to replace the MissionScripting.lua"
+ "
If you ignore this Error, DCS Liberation can not work properly and needs your attention. "
+ "In this case, you need to edit the MissionScripting.lua yourself. The easiest way to do it is to replace the original file (<dcs_installation_directory>/Scripts/MissionScripting.lua) with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua)."
+ "
You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub."
+ "
This is only recommended for expert users!",
+ QMessageBox.StandardButton.Ignore,
QMessageBox.StandardButton.Ok,
)
- error_dialog.exec_()
+ if error_dialog == QMessageBox.Ignore:
+ self.install_dir_ignore_warning = True
return False
-
- if not os.path.isdir(
+ elif not os.path.isdir(
os.path.join(self.dcs_install_dir, "Scripts")
) and os.path.isfile(os.path.join(self.dcs_install_dir, "bin", "DCS.exe")):
error_dialog = QMessageBox.critical(
diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py
index 67cc0e3b..188963d7 100644
--- a/qt_ui/windows/settings/QSettingsWindow.py
+++ b/qt_ui/windows/settings/QSettingsWindow.py
@@ -101,7 +101,7 @@ class HqAutomationSettingsBox(QGroupBox):
front_line = QCheckBox()
front_line.setChecked(self.game.settings.automate_front_line_reinforcements)
- front_line.toggled.connect(self.set_front_line_automation)
+ front_line.toggled.connect(self.set_front_line_reinforcement_automation)
layout.addWidget(QLabel("Automate front-line purchases"), 1, 0)
layout.addWidget(front_line, 1, 1, Qt.AlignRight)
@@ -147,12 +147,30 @@ class HqAutomationSettingsBox(QGroupBox):
)
layout.addWidget(self.auto_ato_player_missions_asap, 4, 1, Qt.AlignRight)
+ self.automate_front_line_stance = QCheckBox()
+ self.automate_front_line_stance.setChecked(
+ self.game.settings.automate_front_line_stance
+ )
+ self.automate_front_line_stance.toggled.connect(
+ self.set_front_line_stance_automation
+ )
+
+ layout.addWidget(
+ QLabel("Automatically manage front line stances"),
+ 5,
+ 0,
+ )
+ layout.addWidget(self.automate_front_line_stance, 5, 1, Qt.AlignRight)
+
def set_runway_automation(self, value: bool) -> None:
self.game.settings.automate_runway_repair = value
- def set_front_line_automation(self, value: bool) -> None:
+ def set_front_line_reinforcement_automation(self, value: bool) -> None:
self.game.settings.automate_front_line_reinforcements = value
+ def set_front_line_stance_automation(self, value: bool) -> None:
+ self.game.settings.automate_front_line_stance = value
+
def set_aircraft_automation(self, value: bool) -> None:
self.game.settings.automate_aircraft_reinforcements = value
@@ -855,7 +873,7 @@ class QSettingsWindow(QDialog):
def cheatMoney(self, amount):
logging.info("CHEATING FOR AMOUNT : " + str(amount) + "M")
- self.game.budget += amount
+ self.game.blue.budget += amount
if amount > 0:
self.game.informations.append(
Information(
diff --git a/qt_ui/windows/stats/QAircraftChart.py b/qt_ui/windows/stats/QAircraftChart.py
index 6c8d1db9..6516ec58 100644
--- a/qt_ui/windows/stats/QAircraftChart.py
+++ b/qt_ui/windows/stats/QAircraftChart.py
@@ -42,10 +42,16 @@ class QAircraftChart(QFrame):
self.chart.setTitle("Aircraft forces over time")
self.chart.createDefaultAxes()
+ self.chart.axisX().setTitleText("Turn")
+ self.chart.axisX().setLabelFormat("%i")
self.chart.axisX().setRange(0, len(self.alliedAircraft))
+ self.chart.axisX().applyNiceNumbers()
+
+ self.chart.axisY().setLabelFormat("%i")
self.chart.axisY().setRange(
0, max(max(self.alliedAircraft), max(self.enemyAircraft)) + 10
)
+ self.chart.axisY().applyNiceNumbers()
self.chartView = QtCharts.QChartView(self.chart)
self.chartView.setRenderHint(QPainter.Antialiasing)
diff --git a/qt_ui/windows/stats/QArmorChart.py b/qt_ui/windows/stats/QArmorChart.py
index 09c272fa..e952c717 100644
--- a/qt_ui/windows/stats/QArmorChart.py
+++ b/qt_ui/windows/stats/QArmorChart.py
@@ -42,10 +42,16 @@ class QArmorChart(QFrame):
self.chart.setTitle("Combat vehicles over time")
self.chart.createDefaultAxes()
+ self.chart.axisX().setTitleText("Turn")
+ self.chart.axisX().setLabelFormat("%i")
self.chart.axisX().setRange(0, len(self.alliedArmor))
+ self.chart.axisX().applyNiceNumbers()
+
+ self.chart.axisY().setLabelFormat("%i")
self.chart.axisY().setRange(
0, max(max(self.alliedArmor), max(self.enemyArmor)) + 10
)
+ self.chart.axisY().applyNiceNumbers()
self.chartView = QtCharts.QChartView(self.chart)
self.chartView.setRenderHint(QPainter.Antialiasing)
diff --git a/qt_ui/windows/stats/QStatsWindow.py b/qt_ui/windows/stats/QStatsWindow.py
index 7d4fda07..14817d18 100644
--- a/qt_ui/windows/stats/QStatsWindow.py
+++ b/qt_ui/windows/stats/QStatsWindow.py
@@ -14,7 +14,7 @@ class QStatsWindow(QDialog):
self.setModal(True)
self.setWindowTitle("Stats")
self.setWindowIcon(CONST.ICONS["Statistics"])
- self.setMinimumSize(600, 250)
+ self.setMinimumSize(600, 300)
self.layout = QGridLayout()
self.aircraft_charts = QAircraftChart(self.game)
diff --git a/requirements.txt b/requirements.txt
index 75bf846a..e42f5a80 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -19,7 +19,7 @@ pathspec==0.8.1
pefile==2019.4.18
Pillow==8.2.0
pre-commit==2.10.1
--e git://github.com/pydcs/dcs@7dea4f516d943c1f48454a46043b5f38d42a35f0#egg=pydcs
+-e git://github.com/pydcs/dcs@eb0b9f2de660393ccd6ba17b2d82371d44e0d27b#egg=pydcs
pyinstaller==4.3
pyinstaller-hooks-contrib==2021.1
pyparsing==2.4.7
@@ -36,5 +36,6 @@ tabulate==0.8.7
text-unidecode==1.3
toml==0.10.2
typed-ast==1.4.2
+types-Pillow==8.3.1
typing-extensions==3.7.4.3
virtualenv==20.4.2
diff --git a/resources/briefing/templates/briefingtemplate_CN.j2 b/resources/briefing/templates/briefingtemplate_CN.j2
index 5b4e38cc..a5a0dc01 100644
--- a/resources/briefing/templates/briefingtemplate_CN.j2
+++ b/resources/briefing/templates/briefingtemplate_CN.j2
@@ -67,6 +67,7 @@ DCS Liberation 第 {{ game.turn }} 回合
{% for flight in flights if flight.client_units %}
--------------------------------------------------
{{ flight.flight_type }} {{ flight.units[0].type }} x {{flight.size}}, departing in {{ flight.departure_delay }}, {{ flight.package.target.name}}
+频率 : {{ flight|intra_flight_channel }}
{% for waypoint in flight.waypoints %}
{{ loop.index0 }} {{waypoint|waypoint_timing("Depart ")}}-- {{waypoint.name}} : {{ waypoint.description}}
{% endfor %}
@@ -108,6 +109,6 @@ AWACS:
{%- if jtacs|length > 0 %}
JTACS [F-10 菜单] :
====================
-{% for jtac in jtacs %}前线 {{ jtac.region }} -- 激光编码 : {{ jtac.code }}
+{% for jtac in jtacs %}前线 {{ jtac.region }} -- 激光编码 : {{ jtac.code }}, 频率 : {{ jtac.freq.mhz }}
{% endfor %}
{% endif %}
diff --git a/resources/briefing/templates/briefingtemplate_EN.j2 b/resources/briefing/templates/briefingtemplate_EN.j2
index ce5b7220..67fc74eb 100644
--- a/resources/briefing/templates/briefingtemplate_EN.j2
+++ b/resources/briefing/templates/briefingtemplate_EN.j2
@@ -67,6 +67,7 @@ Your flights:
{% for flight in flights if flight.client_units %}
--------------------------------------------------
{{ flight.flight_type }} {{ flight.units[0].type }} x {{ flight.size }}, departing in {{ flight.departure_delay }}, {{ flight.package.target.name}}
+Freq : {{ flight|intra_flight_channel }}
{% for waypoint in flight.waypoints %}
{{ loop.index0 }} {{waypoint|waypoint_timing("Depart ")}}-- {{waypoint.name}} : {{ waypoint.description}}
{% endfor %}
@@ -108,6 +109,6 @@ AWACS:
{%- if jtacs|length > 0 %}
JTACS [F-10 Menu] :
====================
-{% for jtac in jtacs %}Frontline {{ jtac.region }} -- Code : {{ jtac.code }}
+{% for jtac in jtacs %}Frontline {{ jtac.region }} -- Code : {{ jtac.code }}, Freq : {{ jtac.freq.mhz }}
{% endfor %}
{% endif %}
\ No newline at end of file
diff --git a/resources/briefing/templates/briefingtemplate_FR.j2 b/resources/briefing/templates/briefingtemplate_FR.j2
index 61cf6b2e..2672cb44 100644
--- a/resources/briefing/templates/briefingtemplate_FR.j2
+++ b/resources/briefing/templates/briefingtemplate_FR.j2
@@ -67,6 +67,7 @@ Vols :
{% for flight in flights if flight.client_units %}
--------------------------------------------------
{{ flight.flight_type }} {{ flight.units[0].type }} x {{flight.size}}, départ dans {{ flight.departure_delay }}, {{ flight.package.target.name}}
+Fréq : {{ flight|intra_flight_channel }}
{% for waypoint in flight.waypoints %}
{{ loop.index0 }} {{waypoint|waypoint_timing("Départ dans ")}}-- {{waypoint.name}} : {{ waypoint.description}}
{% endfor %}
@@ -108,6 +109,6 @@ AWACS:
{%- if jtacs|length > 0 %}
JTACS [Menu F-10] :
====================
-{% for jtac in jtacs %}Ligne de front {{ jtac.region }} -- Code : {{ jtac.code }}
+{% for jtac in jtacs %}Ligne de front {{ jtac.region }} -- Code : {{ jtac.code }}, Fréq : {{ jtac.freq.mhz }}
{% endfor %}
{% endif %}
\ No newline at end of file
diff --git a/resources/campaigns/Battle_for_the_UAE.json b/resources/campaigns/Battle_for_the_UAE.json
index 0c5086e2..2eabc899 100644
--- a/resources/campaigns/Battle_for_the_UAE.json
+++ b/resources/campaigns/Battle_for_the_UAE.json
@@ -5,7 +5,7 @@
"recommended_player_faction": "Bluefor Modern",
"recommended_enemy_faction": "Iran 2015",
"description": "
Following the Battle of Abu Dhabi, Iran's invasion of the UAE has been halted approximately 20 miles Northeast of Liwa Airbase by coalition forces.
After weeks of stalemate, coalition forces have consolidated their position and are ready to launch their counterattack to push Iranian forces off the peninsula.
This is a complete map of every airbase in the Caucasus Region, all bases are fully defended by Air, Land and/or Sea. The player starts by invading southern Georgia and works their way through Russia. The Strike and SAM targets are limited for performance reasons. If this Scenario is too taxing for your computer you may use the Multi-Part Scenarios. They are copied from this Campaign and are catered toward less powerful machines.
",
- "version": "7.0",
+ "version": "8.0",
"miz": "Caucasus_Multi_Full.miz",
"performance": 3
}
\ No newline at end of file
diff --git a/resources/campaigns/Caucasus_Multi_Georgia.json b/resources/campaigns/Caucasus_Multi_Georgia.json
index 63c1dba0..c0e55f2d 100644
--- a/resources/campaigns/Caucasus_Multi_Georgia.json
+++ b/resources/campaigns/Caucasus_Multi_Georgia.json
@@ -5,7 +5,7 @@
"recommended_player_faction": "Bluefor Modern",
"recommended_enemy_faction": "Georgia 2008",
"description": "
This is Part 1 of the Caucasus Multi-Part Campaign. This is the invasion of Georgia starting from the southwest (Batumi) and ending in both Gudauta and Tiblisi. This is a straightforward campaign that is smaller and simpler than most. However, it acts great as either a stand alone campaign for beginners, or as a lead into the Caucasus Multi-Part Russia campaign.
",
- "version": "7.0",
+ "version": "8.0",
"miz": "Caucasus_Multi_Georgia.miz",
"performance": 1
}
\ No newline at end of file
diff --git a/resources/campaigns/Caucasus_Multi_Russia.json b/resources/campaigns/Caucasus_Multi_Russia.json
index e9df1997..a35edea4 100644
--- a/resources/campaigns/Caucasus_Multi_Russia.json
+++ b/resources/campaigns/Caucasus_Multi_Russia.json
@@ -5,7 +5,7 @@
"recommended_player_faction": "Bluefor Modern",
"recommended_enemy_faction": "Russia 2010",
"description": "
This is part 2 of the Caucasus Multi-part campaign. After completing Multi-Part Georgia, play this campaign to invade Russia and finish the theater. As this is now Russia the recommended enemy faction has changed. To simulate still owning Georgia the player income has been supplemented through an increased number of blue strike targets at the starting bases. This is a more difficult scenario with a higher concentration of Redfor SAMs and Strike targets than usual.
",
- "version": "7.0",
+ "version": "8.0",
"miz": "Caucasus_Multi_Russia.miz",
"performance": 2
}
\ No newline at end of file
diff --git a/resources/campaigns/First_Lebanon_War.json b/resources/campaigns/First_Lebanon_War.json
index ecb56691..6534643a 100644
--- a/resources/campaigns/First_Lebanon_War.json
+++ b/resources/campaigns/First_Lebanon_War.json
@@ -6,6 +6,6 @@
"recommended_enemy_faction": "Syria 1982",
"description": "
1100HRS, 06 June 1982: H-hour for Operation Peace for Galilee.
Objective: Push North towards Beirut and into the Bekaa Valley, eliminating or displacing any PLO and Syrian resistance. Airbases and their surrounding infrastructure in Syria are not the main objective but are still viable strategic targets.
Background: Years of PLO encroachment into the UN neutral zone and their resulting terror attacks against Israelis have pushed tension along the border to a breaking point. On June 3, the attempted assassination of Israeli Ambassador, Shlomo Argov by gunmen with ties to the PLO have finally pushed the Israelis to action.
Recommended Starting Budget:
$1500m for recommended factions, $$2000m for modern scenarios
In a scenario reminescent of the First Lebanon War, hostile Syrian-backed forces have flooded into the Bekaa Valley.
The objective of this operation is twofold: drive the enemy out of the Bekaa Valley and push past the Golan Heights into Syrian territory to capture Tiyas Airbase.
This is an asymmetrical Red Flag Exercise scenario for the NTTR comprising 4 control points. You start off in control of the two Tonopah airports, and will push south to capture Groom Lake and Nellis AFBs. Taking down Nellis AFB's IADS and striking their resource sites ASAP once Groom Lake has been captured is recommended to offset their resource advantage.
",
- "version": "7.0",
+ "version": "8.0",
"miz": "exercise_vegas_nerve.miz",
"performance": 0
}
\ No newline at end of file
diff --git a/resources/campaigns/exercise_vegas_nerve.miz b/resources/campaigns/exercise_vegas_nerve.miz
index 4cc74abd..7d280d6e 100644
Binary files a/resources/campaigns/exercise_vegas_nerve.miz and b/resources/campaigns/exercise_vegas_nerve.miz differ
diff --git a/resources/campaigns/golan_heights_lite.json b/resources/campaigns/golan_heights_lite.json
index 051205da..ba618c9b 100644
--- a/resources/campaigns/golan_heights_lite.json
+++ b/resources/campaigns/golan_heights_lite.json
@@ -7,5 +7,5 @@
"description": "
In this scenario, you start in Israel and the conflict is focused around the golan heights, an historically disputed territory.
This scenario is designed to be performance friendly.
A small theater in Russia, progress from Mozdok to Maykop.
This scenario is pretty simple, and is ideal if you want to run a short campaign to try liberation. If your PC is not powerful, this is also the less performance heavy scenario.
This campaign is designed to be beginner friendly in that the number of aircraft slot have been limited. Other than the starting point and the 'boss' base the max slots in each of the airbases have a mere 3-5 slots.
This should prevent the airpower rush escperienced in most of the other larger campaign.
",
- "version": "7.0",
+ "version": "8.0",
"miz": "nevada_limited_air.miz",
"performance": 1
}
\ No newline at end of file
diff --git a/resources/campaigns/northern_russia.json b/resources/campaigns/northern_russia.json
index f76276c1..a2c66c50 100644
--- a/resources/campaigns/northern_russia.json
+++ b/resources/campaigns/northern_russia.json
@@ -7,5 +7,5 @@
"description": "
A medium campaign through the north eastern part of the Caucasus map. Play vs 1975 Russia for an low-medium difficulty campaign, play vs russia 1990 for a hard difficulty campaign.
Russia has invaded Georgia through the eastern mountains. Mount a counter offense and push them back!",
"miz": "northern_russia.miz",
"performance": 2,
- "version": "7.0"
+ "version": "8.0"
}
\ No newline at end of file
diff --git a/resources/campaigns/operation_allied_sword.json b/resources/campaigns/operation_allied_sword.json
index 3f26c95a..027864e7 100644
--- a/resources/campaigns/operation_allied_sword.json
+++ b/resources/campaigns/operation_allied_sword.json
@@ -5,7 +5,7 @@
"recommended_player_faction": "Israel-USN 2005 (Allied Sword)",
"recommended_enemy_faction": "Syria-Lebanon 2005 (Allied Sword)",
"description": "
In this fictional scenario, a US/Israeli coalition must push north from the Israeli border, through Syria and Lebanon to Aleppo.
Backstory: A Syrian-Lebanese joint force (with Russian materiel support) has attacked Israel, attmepting to cross the northern border. With the arrival of a US carrier group, Israel prepares its counterattack. The US Navy will handle the Beirut region's coastal arena, while the IAF will push through Damascus and the inland mountain ranges.
",
- "version": "7.0",
+ "version": "8.0",
"miz": "operation_allied_sword.miz",
"performance": 2
-}
\ No newline at end of file
+}
diff --git a/resources/campaigns/operation_blackball.json b/resources/campaigns/operation_blackball.json
index 14ee2863..9b0483f3 100644
--- a/resources/campaigns/operation_blackball.json
+++ b/resources/campaigns/operation_blackball.json
@@ -5,7 +5,7 @@
"recommended_player_faction": "US Navy 2005",
"recommended_enemy_faction": "Russia 2010",
"description": "
Warning: This campaign will not work if the attacking faction does not have a carrier.
A lightweight, fictional showcase of Cyprus for the Syria terrain. A US Navy force must deploy from a FOB and carrier group to push from the north-east down through the island.
Backstory: The world is at war. With the help of her eastern allies, Russia has taken the Suez Canal and deployed a large naval force to the Mediterranean, trapping a US carrier group near the Turkish-Syrian border. Now, they must break out by taking Cyprus back.
",
- "version": "7.0",
+ "version": "8.0",
"miz": "operation_blackball.miz",
"performance": 1
-}
\ No newline at end of file
+}
diff --git a/resources/campaigns/operation_dynamo.json b/resources/campaigns/operation_dynamo.json
index 63c147a3..e0445cc3 100644
--- a/resources/campaigns/operation_dynamo.json
+++ b/resources/campaigns/operation_dynamo.json
@@ -5,7 +5,7 @@
"recommended_player_faction": "Allies 1940",
"recommended_enemy_faction": "Germany 1940",
"description": "
The Battle of Dunkirk (French: Bataille de Dunkerque) was fought around the French port of Dunkirk (Dunkerque) during the Second World War, between the Allies and Nazi Germany. As the Allies were losing the Battle of France on the Western Front, the Battle of Dunkirk was the defence and evacuation of British and other Allied forces to Britain from 26 May to 4 June 1940..
This is a semi-fictional what-if scenario for Operation Peace Spring, during which Turkish forces that crossed into Syria on an offensive against Kurdish militias were emboldened by early successes to continue pushing further southward. Attempts to broker a ceasefire have failed. Members of Operation Inherent Resolve have gathered at Ramat David Airbase in Israel to launch a counter-offensive. Campaign inversion is available if you wish to play as Turkey.
",
- "version": "7.0",
+ "version": "8.0",
"miz": "operation_peace_spring.miz",
"performance": 1
}
\ No newline at end of file
diff --git a/resources/campaigns/operation_peace_spring.miz b/resources/campaigns/operation_peace_spring.miz
index 7d1b5c7c..251e9aa4 100644
Binary files a/resources/campaigns/operation_peace_spring.miz and b/resources/campaigns/operation_peace_spring.miz differ
diff --git a/resources/campaigns/operation_vectrons_claw.json b/resources/campaigns/operation_vectrons_claw.json
index ac0e176f..51d5cb60 100644
--- a/resources/campaigns/operation_vectrons_claw.json
+++ b/resources/campaigns/operation_vectrons_claw.json
@@ -5,7 +5,7 @@
"recommended_player_faction": "USA 2005",
"recommended_enemy_faction": "Russia 1990",
"description": "
United Nations Observer Mission in Georgia (UNOMIG) observers stationed in Georgia to monitor the ceasefire between Georgia and Abkhazia have been cut off from friendly forces by Russian troops backing the separatist state. The UNOMIG HQ at Sukhumi has been taken, and a small contingent of observers and troops at the Zugdidi Sector HQ will have to make a run for the coast, supported by offshore US naval aircraft. The contingent is aware that their best shot at survival is to swiftly retake Sukhumi before Russian forces have a chance to dig in, so that friendly ground forces can land and reinforce them.
Note: Ground unit purchase will not be available past Turn 0 until Sukhumi is retaken, so it is imperative you reach Sukhumi with at least one surviving ground unit to capture it. The player can either play the first leg of the scenario as an evacuation with a couple of light vehicles (e.g. Humvees) set on breakthrough (modifying waypoints in the mission editor so they are not charging head-on into enemy ground forces is suggested), or purchase heavier ground units if they wish to experience a more traditional ground war.
",
- "version": "7.0",
+ "version": "8.0",
"miz": "operation_vectrons_claw.miz",
"performance": 1
}
\ No newline at end of file
diff --git a/resources/campaigns/operation_vectrons_claw.miz b/resources/campaigns/operation_vectrons_claw.miz
index 5862883f..a415ba8b 100644
Binary files a/resources/campaigns/operation_vectrons_claw.miz and b/resources/campaigns/operation_vectrons_claw.miz differ
diff --git a/resources/campaigns/scenic_route.json b/resources/campaigns/scenic_route.json
index d79ae493..b6dc194c 100644
--- a/resources/campaigns/scenic_route.json
+++ b/resources/campaigns/scenic_route.json
@@ -5,7 +5,7 @@
"recommended_player_faction": "US Navy 2005",
"recommended_enemy_faction": "Iran 2015",
"description": "
A lightweight naval campaign involving a US Navy carrier group pushing across the coast of Iran. Note that the ground units purchased on turn zero must sustain you until you've taken the first hostile FOB. The starting point does not have a factory to simulate a Marine Expeditionary Force deploying from the carrier group.
Backstory: Iran has declared war on all US forces in the Gulf, resulting in all local allies withdrawing their support for American troops. A lone carrier group must pacify the southern coast of Iran and hold out until backup can arrive, lest the US and her interests be ejected from the region permanently.