diff --git a/game/data/doctrine.py b/game/data/doctrine.py
index 866ae897..d81c5484 100644
--- a/game/data/doctrine.py
+++ b/game/data/doctrine.py
@@ -1,95 +1,101 @@
+from dataclasses import dataclass
+
from game.utils import nm_to_meter, feet_to_meter
-MODERN_DOCTRINE = {
- "GENERATORS": {
- "CAS": True,
- "CAP": True,
- "SEAD": True,
- "STRIKE": True,
- "ANTISHIP": True,
- },
+@dataclass(frozen=True)
+class Doctrine:
+ cas: bool
+ cap: bool
+ sead: bool
+ strike: bool
+ antiship: bool
- "STRIKE_MAX_RANGE": 1500000,
- "SEAD_MAX_RANGE": 1500000,
+ strike_max_range: int
+ sead_max_range: int
- "CAP_EVERY_X_MINUTES": 20,
- "CAS_EVERY_X_MINUTES": 30,
- "SEAD_EVERY_X_MINUTES": 40,
- "STRIKE_EVERY_X_MINUTES": 40,
+ rendezvous_altitude: int
+ join_distance: int
+ split_distance: int
+ ingress_egress_distance: int
+ ingress_altitude: int
+ egress_altitude: int
- "INGRESS_EGRESS_DISTANCE": nm_to_meter(45),
- "INGRESS_ALT": feet_to_meter(20000),
- "EGRESS_ALT": feet_to_meter(20000),
- "PATROL_ALT_RANGE": (feet_to_meter(15000), feet_to_meter(33000)),
- "PATTERN_ALTITUDE": feet_to_meter(5000),
+ min_patrol_altitude: int
+ max_patrol_altitude: int
+ pattern_altitude: int
- "CAP_PATTERN_LENGTH": (nm_to_meter(15), nm_to_meter(40)),
- "FRONTLINE_CAP_DISTANCE_FROM_FRONTLINE": (nm_to_meter(6), nm_to_meter(15)),
- "CAP_DISTANCE_FROM_CP": (nm_to_meter(10), nm_to_meter(40)),
+ cap_min_track_length: int
+ cap_max_track_length: int
+ cap_min_distance_from_cp: int
+ cap_max_distance_from_cp: int
- "MAX_NUMBER_OF_INTERCEPTION_GROUP": 3,
-}
-COLDWAR_DOCTRINE = {
+MODERN_DOCTRINE = Doctrine(
+ cap=True,
+ cas=True,
+ sead=True,
+ strike=True,
+ antiship=True,
+ strike_max_range=1500000,
+ sead_max_range=1500000,
+ rendezvous_altitude=feet_to_meter(25000),
+ join_distance=nm_to_meter(20),
+ split_distance=nm_to_meter(20),
+ ingress_egress_distance=nm_to_meter(45),
+ ingress_altitude=feet_to_meter(20000),
+ egress_altitude=feet_to_meter(20000),
+ min_patrol_altitude=feet_to_meter(15000),
+ max_patrol_altitude=feet_to_meter(33000),
+ pattern_altitude=feet_to_meter(5000),
+ cap_min_track_length=nm_to_meter(15),
+ cap_max_track_length=nm_to_meter(40),
+ cap_min_distance_from_cp=nm_to_meter(10),
+ cap_max_distance_from_cp=nm_to_meter(40),
+)
- "GENERATORS": {
- "CAS": True,
- "CAP": True,
- "SEAD": True,
- "STRIKE": True,
- "ANTISHIP": True,
- },
+COLDWAR_DOCTRINE = Doctrine(
+ cap=True,
+ cas=True,
+ sead=True,
+ strike=True,
+ antiship=True,
+ strike_max_range=1500000,
+ sead_max_range=1500000,
+ rendezvous_altitude=feet_to_meter(22000),
+ join_distance=nm_to_meter(10),
+ split_distance=nm_to_meter(10),
+ ingress_egress_distance=nm_to_meter(30),
+ ingress_altitude=feet_to_meter(18000),
+ egress_altitude=feet_to_meter(18000),
+ min_patrol_altitude=feet_to_meter(10000),
+ max_patrol_altitude=feet_to_meter(24000),
+ pattern_altitude=feet_to_meter(5000),
+ cap_min_track_length=nm_to_meter(12),
+ cap_max_track_length=nm_to_meter(24),
+ cap_min_distance_from_cp=nm_to_meter(8),
+ cap_max_distance_from_cp=nm_to_meter(25),
+)
- "STRIKE_MAX_RANGE": 1500000,
- "SEAD_MAX_RANGE": 1500000,
-
- "CAP_EVERY_X_MINUTES": 20,
- "CAS_EVERY_X_MINUTES": 30,
- "SEAD_EVERY_X_MINUTES": 40,
- "STRIKE_EVERY_X_MINUTES": 40,
-
- "INGRESS_EGRESS_DISTANCE": nm_to_meter(30),
- "INGRESS_ALT": feet_to_meter(18000),
- "EGRESS_ALT": feet_to_meter(18000),
- "PATROL_ALT_RANGE": (feet_to_meter(10000), feet_to_meter(24000)),
- "PATTERN_ALTITUDE": feet_to_meter(5000),
-
- "CAP_PATTERN_LENGTH": (nm_to_meter(12), nm_to_meter(24)),
- "FRONTLINE_CAP_DISTANCE_FROM_FRONTLINE": (nm_to_meter(2), nm_to_meter(8)),
- "CAP_DISTANCE_FROM_CP": (nm_to_meter(8), nm_to_meter(25)),
-
- "MAX_NUMBER_OF_INTERCEPTION_GROUP": 3,
-}
-
-WWII_DOCTRINE = {
-
- "GENERATORS": {
- "CAS": True,
- "CAP": True,
- "SEAD": False,
- "STRIKE": True,
- "ANTISHIP": True,
- },
-
- "STRIKE_MAX_RANGE": 1500000,
- "SEAD_MAX_RANGE": 1500000,
-
- "CAP_EVERY_X_MINUTES": 20,
- "CAS_EVERY_X_MINUTES": 30,
- "SEAD_EVERY_X_MINUTES": 40,
- "STRIKE_EVERY_X_MINUTES": 40,
-
- "INGRESS_EGRESS_DISTANCE": nm_to_meter(7),
- "INGRESS_ALT": feet_to_meter(8000),
- "EGRESS_ALT": feet_to_meter(8000),
- "PATROL_ALT_RANGE": (feet_to_meter(4000), feet_to_meter(15000)),
- "PATTERN_ALTITUDE": feet_to_meter(5000),
-
- "CAP_PATTERN_LENGTH": (nm_to_meter(8), nm_to_meter(18)),
- "FRONTLINE_CAP_DISTANCE_FROM_FRONTLINE": (nm_to_meter(1), nm_to_meter(6)),
- "CAP_DISTANCE_FROM_CP": (nm_to_meter(0), nm_to_meter(5)),
-
- "MAX_NUMBER_OF_INTERCEPTION_GROUP": 3,
-
-}
+WWII_DOCTRINE = Doctrine(
+ cap=True,
+ cas=True,
+ sead=False,
+ strike=True,
+ antiship=True,
+ strike_max_range=1500000,
+ sead_max_range=1500000,
+ join_distance=nm_to_meter(5),
+ split_distance=nm_to_meter(5),
+ rendezvous_altitude=feet_to_meter(10000),
+ ingress_egress_distance=nm_to_meter(7),
+ ingress_altitude=feet_to_meter(8000),
+ egress_altitude=feet_to_meter(8000),
+ min_patrol_altitude=feet_to_meter(4000),
+ max_patrol_altitude=feet_to_meter(15000),
+ pattern_altitude=feet_to_meter(5000),
+ cap_min_track_length=nm_to_meter(8),
+ cap_max_track_length=nm_to_meter(18),
+ cap_min_distance_from_cp=nm_to_meter(0),
+ cap_max_distance_from_cp=nm_to_meter(5),
+)
diff --git a/game/db.py b/game/db.py
index 003bbcc4..45ddfdf7 100644
--- a/game/db.py
+++ b/game/db.py
@@ -807,7 +807,7 @@ CARRIER_TAKEOFF_BAN = [
Units separated by country.
country : DCS Country name
"""
-FACTIONS = {
+FACTIONS: typing.Dict[str, typing.Dict[str, typing.Any]] = {
"Bluefor Modern": BLUEFOR_MODERN,
"Bluefor Cold War 1970s": BLUEFOR_COLDWAR,
diff --git a/game/game.py b/game/game.py
index 5fa8c050..fc3e2305 100644
--- a/game/game.py
+++ b/game/game.py
@@ -4,11 +4,13 @@ from game.db import REWARDS, PLAYER_BUDGET_BASE, sys
from game.inventory import GlobalAircraftInventory
from game.models.game_stats import GameStats
from gen.ato import AirTaskingOrder
-from gen.flights.ai_flight_planner import FlightPlanner
+from gen.flights.ai_flight_planner import CoalitionMissionPlanner
+from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.ground_forces.ai_ground_planner import GroundPlanner
from .event import *
from .settings import Settings
+
COMMISION_UNIT_VARIETY = 4
COMMISION_LIMITS_SCALE = 1.5
COMMISION_LIMITS_FACTORS = {
@@ -70,7 +72,6 @@ class Game:
self.date = datetime(start_date.year, start_date.month, start_date.day)
self.game_stats = GameStats()
self.game_stats.update(self)
- self.planners = {}
self.ground_planners = {}
self.informations = []
self.informations.append(Information("Game Start", "-" * 40, 0))
@@ -88,6 +89,7 @@ class Game:
)
self.sanitize_sides()
+ self.on_load()
def sanitize_sides(self):
@@ -104,11 +106,11 @@ class Game:
self.enemy_country = "Russia"
@property
- def player_faction(self):
+ def player_faction(self) -> Dict[str, Any]:
return db.FACTIONS[self.player_name]
@property
- def enemy_faction(self):
+ def enemy_faction(self) -> Dict[str, Any]:
return db.FACTIONS[self.enemy_name]
def _roll(self, prob, mult):
@@ -203,8 +205,10 @@ class Game:
else:
return event and event.name and event.name == self.player_name
- def pass_turn(self, no_action=False, ignored_cps: typing.Collection[ControlPoint] = None):
+ def on_load(self) -> None:
+ ObjectiveDistanceCache.set_theater(self.theater)
+ def pass_turn(self, no_action=False, ignored_cps: typing.Collection[ControlPoint] = None):
logging.info("Pass turn")
self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0))
self.turn = self.turn + 1
@@ -244,16 +248,12 @@ class Game:
# Plan flights & combat for next turn
self.__culling_points = self.compute_conflicts_position()
- self.planners = {}
self.ground_planners = {}
self.blue_ato.clear()
self.red_ato.clear()
+ CoalitionMissionPlanner(self, is_player=True).plan_missions()
+ CoalitionMissionPlanner(self, is_player=False).plan_missions()
for cp in self.theater.controlpoints:
- if cp.has_runway():
- planner = FlightPlanner(cp, self)
- planner.plan_flights()
- self.planners[cp.id] = planner
-
if cp.has_frontline:
gplanner = GroundPlanner(cp, self)
gplanner.plan_groundwar()
diff --git a/game/operation/operation.py b/game/operation/operation.py
index b23c219e..fe50f2ef 100644
--- a/game/operation/operation.py
+++ b/game/operation/operation.py
@@ -182,22 +182,17 @@ class Operation:
airsupportgen.generate(self.is_awacs_enabled)
# Generate Activity on the map
- airgen = AircraftConflictGenerator(
- self.current_mission, self.conflict, self.game.settings, self.game,
- radio_registry)
- for cp in self.game.theater.controlpoints:
- side = cp.captured
- if side:
- country = self.current_mission.country(self.game.player_country)
- else:
- country = self.current_mission.country(self.game.enemy_country)
- if cp.id in self.game.planners.keys():
- airgen.generate_flights(
- cp,
- country,
- self.game.planners[cp.id],
- groundobjectgen.runways
- )
+ self.airgen.generate_flights(
+ self.current_mission.country(self.game.player_country),
+ self.game.blue_ato,
+ self.groundobjectgen.runways
+ )
+ self.airgen.generate_flights(
+ self.current_mission.country(self.game.enemy_country),
+ self.game.red_ato,
+ self.groundobjectgen.runways
+ )
+
# Generate ground units on frontline everywhere
jtacs: List[JtacInfo] = []
diff --git a/gen/aircraft.py b/gen/aircraft.py
index fb485e0a..9eeb1836 100644
--- a/gen/aircraft.py
+++ b/gen/aircraft.py
@@ -14,8 +14,8 @@ from game.settings import Settings
from game.utils import nm_to_meter
from gen.airfields import RunwayData
from gen.airsupportgen import AirSupport
+from gen.ato import AirTaskingOrder
from gen.callsigns import create_group_callsign_from_unit
-from gen.flights.ai_flight_planner import FlightPlanner
from gen.flights.flight import (
Flight,
FlightType,
@@ -751,31 +751,28 @@ class AircraftConflictGenerator:
else:
logging.warning("Pylon not found ! => Pylon" + key + " on " + str(flight.unit_type))
-
- def generate_flights(self, cp, country, flight_planner: FlightPlanner,
- dynamic_runways: Dict[str, RunwayData]):
- # Clear pydcs parking slots
- if cp.airport is not None:
- logging.info("CLEARING SLOTS @ " + cp.airport.name)
- logging.info("===============")
+ def clear_parking_slots(self) -> None:
+ for cp in self.game.theater.controlpoints:
if cp.airport is not None:
- for ps in cp.airport.parking_slots:
- logging.info("SLOT : " + str(ps.unit_id))
- ps.unit_id = None
- logging.info("----------------")
- logging.info("===============")
+ for parking_slot in cp.airport.parking_slots:
+ parking_slot.unit_id = None
- for flight in flight_planner.flights:
-
- if flight.client_count == 0 and self.game.position_culled(flight.from_cp.position):
- logging.info("Flight not generated : culled")
- continue
- logging.info("Generating flight : " + str(flight.unit_type))
- group = self.generate_planned_flight(cp, country, flight)
- self.setup_flight_group(group, flight, flight.flight_type,
- dynamic_runways)
- self.setup_group_activation_trigger(flight, group)
+ def generate_flights(self, country, ato: AirTaskingOrder,
+ dynamic_runways: Dict[str, RunwayData]) -> None:
+ self.clear_parking_slots()
+ for package in ato.packages:
+ for flight in package.flights:
+ culled = self.game.position_culled(flight.from_cp.position)
+ if flight.client_count == 0 and culled:
+ logging.info("Flight not generated: culled")
+ continue
+ logging.info(f"Generating flight: {flight.unit_type}")
+ group = self.generate_planned_flight(flight.from_cp, country,
+ flight)
+ self.setup_flight_group(group, flight, flight.flight_type,
+ dynamic_runways)
+ self.setup_group_activation_trigger(flight, group)
def setup_group_activation_trigger(self, flight, group):
if flight.scheduled_in > 0 and flight.client_count == 0:
@@ -932,6 +929,14 @@ class AircraftConflictGenerator:
group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire))
group.points[0].tasks.append(OptRestrictJettison(True))
+ elif flight_type == FlightType.ESCORT:
+ group.task = Escort.name
+ self._setup_group(group, Escort, flight, dynamic_runways)
+ # TODO: Cleanup duplication...
+ group.points[0].tasks.clear()
+ group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
+ group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire))
+ group.points[0].tasks.append(OptRestrictJettison(True))
group.points[0].tasks.append(OptRTBOnBingoFuel(True))
group.points[0].tasks.append(OptRestrictAfterburner(True))
diff --git a/gen/ato.py b/gen/ato.py
index e82930fe..a2e4a8a1 100644
--- a/gen/ato.py
+++ b/gen/ato.py
@@ -11,8 +11,9 @@ the single CAP flight.
from collections import defaultdict
from dataclasses import dataclass, field
import logging
-from typing import Dict, List
+from typing import Dict, Iterator, List, Optional
+from dcs.mapping import Point
from .flights.flight import Flight, FlightType
from theater.missiontarget import MissionTarget
@@ -39,6 +40,11 @@ class Package:
#: The set of flights in the package.
flights: List[Flight] = field(default_factory=list)
+ join_point: Optional[Point] = field(default=None, init=False, hash=False)
+ split_point: Optional[Point] = field(default=None, init=False, hash=False)
+ ingress_point: Optional[Point] = field(default=None, init=False, hash=False)
+ egress_point: Optional[Point] = field(default=None, init=False, hash=False)
+
def add_flight(self, flight: Flight) -> None:
"""Adds a flight to the package."""
self.flights.append(flight)
@@ -46,12 +52,14 @@ class Package:
def remove_flight(self, flight: Flight) -> None:
"""Removes a flight from the package."""
self.flights.remove(flight)
+ if not self.flights:
+ self.ingress_point = None
+ self.egress_point = None
@property
- def package_description(self) -> str:
- """Generates a package description based on flight composition."""
+ def primary_task(self) -> Optional[FlightType]:
if not self.flights:
- return "No mission"
+ return None
flight_counts: Dict[FlightType, int] = defaultdict(lambda: 0)
for flight in self.flights:
@@ -84,13 +92,21 @@ class Package:
]
for task in task_priorities:
if flight_counts[task]:
- return task.name
+ return task
# If we get here, our task_priorities list above is incomplete. Log the
# issue and return the type of *any* flight in the package.
some_mission = next(iter(self.flights)).flight_type
logging.warning(f"Unhandled mission type: {some_mission}")
- return some_mission.name
+ return some_mission
+
+ @property
+ def package_description(self) -> str:
+ """Generates a package description based on flight composition."""
+ task = self.primary_task
+ if task is None:
+ return "No mission"
+ return task.name
def __hash__(self) -> int:
# TODO: Far from perfect. Number packages?
diff --git a/gen/briefinggen.py b/gen/briefinggen.py
index 10e07001..82744a8a 100644
--- a/gen/briefinggen.py
+++ b/gen/briefinggen.py
@@ -106,7 +106,7 @@ class BriefingGenerator(MissionInfoGenerator):
aircraft = flight.aircraft_type
flight_unit_name = db.unit_type_name(aircraft)
self.description += "-" * 50 + "\n"
- self.description += f"{flight_unit_name} x {flight.size + 2}\n\n"
+ self.description += f"{flight_unit_name} x {flight.size}\n\n"
for i, wpt in enumerate(flight.waypoints):
self.description += f"#{i + 1} -- {wpt.name} : {wpt.description}\n"
diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py
index 29deb1f4..6b584e68 100644
--- a/gen/flights/ai_flight_planner.py
+++ b/gen/flights/ai_flight_planner.py
@@ -1,705 +1,433 @@
-import math
-import operator
-import random
-from typing import Iterable, Iterator, List, Tuple
+from __future__ import annotations
-from dcs.unittype import FlyingType
+import logging
+import operator
+from dataclasses import dataclass
+from typing import Dict, Iterator, List, Optional, Set, TYPE_CHECKING, Tuple
+
+from dcs.unittype import UnitType
from game import db
-from game.data.doctrine import MODERN_DOCTRINE
from game.data.radar_db import UNITS_WITH_RADAR
+from game.infos.information import Information
from game.utils import nm_to_meter
from gen import Conflict
from gen.ato import Package
from gen.flights.ai_flight_planner_db import (
CAP_CAPABLE,
CAS_CAPABLE,
- DRONES,
SEAD_CAPABLE,
STRIKE_CAPABLE,
)
+from gen.flights.closestairfields import (
+ ClosestAirfields,
+ ObjectiveDistanceCache,
+)
from gen.flights.flight import (
Flight,
FlightType,
- FlightWaypoint,
- FlightWaypointType,
)
-from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject
+from gen.flights.flightplan import FlightPlanBuilder
+from theater import (
+ ControlPoint,
+ FrontLine,
+ MissionTarget,
+ TheaterGroundObject,
+)
-MISSION_DURATION = 80
+# Avoid importing some types that cause circular imports unless type checking.
+if TYPE_CHECKING:
+ from game import Game
+ from game.inventory import GlobalAircraftInventory
-# TODO: Should not be per-control point.
-# Packages can frag flights from individual airfields, so we should be planning
-# coalition wide rather than per airfield.
-class FlightPlanner:
+@dataclass(frozen=True)
+class ProposedFlight:
+ """A flight outline proposed by the mission planner.
- def __init__(self, from_cp: ControlPoint, game: "Game") -> None:
- # TODO : have the flight planner depend on a 'stance' setting : [Defensive, Aggresive... etc] and faction doctrine
- # TODO : the flight planner should plan package and operations
- self.from_cp = from_cp
- self.game = game
- self.flights: List[Flight] = []
- self.potential_sead_targets: List[Tuple[TheaterGroundObject, int]] = []
- self.potential_strike_targets: List[Tuple[TheaterGroundObject, int]] = []
+ 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.
+ """
- if from_cp.captured:
- self.faction = self.game.player_faction
+ #: 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: int
+
+ def __str__(self) -> str:
+ return f"{self.task.name} {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]
+
+ 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, closest_airfields: ClosestAirfields,
+ global_inventory: GlobalAircraftInventory,
+ is_player: bool) -> None:
+ self.closest_airfields = closest_airfields
+ self.global_inventory = global_inventory
+ self.is_player = is_player
+
+ def find_aircraft_for_flight(
+ self, flight: ProposedFlight
+ ) -> Optional[Tuple[ControlPoint, UnitType]]:
+ """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.
+
+ 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.
+ """
+ cap_missions = (FlightType.BARCAP, FlightType.CAP, FlightType.TARCAP)
+ if flight.task in cap_missions:
+ types = CAP_CAPABLE
+ elif flight.task == FlightType.CAS:
+ types = CAS_CAPABLE
+ elif flight.task in (FlightType.DEAD, FlightType.SEAD):
+ types = SEAD_CAPABLE
+ elif flight.task == FlightType.STRIKE:
+ types = STRIKE_CAPABLE
+ elif flight.task == FlightType.ESCORT:
+ types = CAP_CAPABLE
else:
- self.faction = self.game.enemy_faction
+ logging.error(f"Unplannable flight type: {flight.task}")
+ return None
- if "doctrine" in self.faction.keys():
- self.doctrine = self.faction["doctrine"]
- else:
- self.doctrine = MODERN_DOCTRINE
-
- @property
- def aircraft_inventory(self) -> "GlobalAircraftInventory":
- return self.game.aircraft_inventory
-
- def reset(self) -> None:
- """Reset the planned flights and available units."""
- self.flights = []
- self.potential_sead_targets = []
- self.potential_strike_targets = []
-
- def plan_flights(self) -> None:
- self.reset()
- self.compute_sead_targets()
- self.compute_strike_targets()
-
- self.commission_cap()
- self.commission_cas()
- self.commission_sead()
- self.commission_strike()
- # TODO: Commission anti-ship and intercept.
-
- def plan_legacy_mission(self, flight: Flight,
- location: MissionTarget) -> None:
- package = Package(location)
- package.add_flight(flight)
- if flight.from_cp.captured:
- self.game.blue_ato.add_package(package)
- else:
- self.game.red_ato.add_package(package)
- self.flights.append(flight)
- self.aircraft_inventory.claim_for_flight(flight)
-
- def get_compatible_aircraft(self, candidates: Iterable[FlyingType],
- minimum: int) -> List[FlyingType]:
- inventory = self.aircraft_inventory.for_control_point(self.from_cp)
- return [k for k, v in inventory.all_aircraft if
- k in candidates and v >= minimum]
-
- def alloc_aircraft(
- self, num_flights: int, flight_size: int,
- allowed_types: Iterable[FlyingType]) -> Iterator[FlyingType]:
- aircraft = self.get_compatible_aircraft(allowed_types, flight_size)
- if not aircraft:
- return
-
- for _ in range(num_flights):
- yield random.choice(aircraft)
- aircraft = self.get_compatible_aircraft(allowed_types, flight_size)
- if not aircraft:
- return
-
- def commission_cap(self) -> None:
- """Pick some aircraft to assign them to defensive CAP roles (BARCAP)."""
- offset = random.randint(0, 5)
- num_caps = MISSION_DURATION // self.doctrine["CAP_EVERY_X_MINUTES"]
- for i, aircraft in enumerate(self.alloc_aircraft(num_caps, 2, CAP_CAPABLE)):
- flight = Flight(aircraft, 2, self.from_cp, FlightType.CAP)
-
- flight.scheduled_in = offset + i * random.randint(
- self.doctrine["CAP_EVERY_X_MINUTES"] - 5,
- self.doctrine["CAP_EVERY_X_MINUTES"] + 5
- )
-
- if len(self._get_cas_locations()) > 0:
- location = random.choice(self._get_cas_locations())
- self.generate_frontline_cap(flight, location)
- else:
- location = flight.from_cp
- self.generate_barcap(flight, flight.from_cp)
-
- self.plan_legacy_mission(flight, location)
-
- def commission_cas(self) -> None:
- """Pick some aircraft to assign them to CAS."""
- cas_locations = self._get_cas_locations()
- if not cas_locations:
- return
-
- offset = random.randint(0,5)
- num_cas = MISSION_DURATION // self.doctrine["CAS_EVERY_X_MINUTES"]
- for i, aircraft in enumerate(self.alloc_aircraft(num_cas, 2, CAS_CAPABLE)):
- flight = Flight(aircraft, 2, self.from_cp, FlightType.CAS)
- flight.scheduled_in = offset + i * random.randint(
- self.doctrine["CAS_EVERY_X_MINUTES"] - 5,
- self.doctrine["CAS_EVERY_X_MINUTES"] + 5)
- location = random.choice(cas_locations)
-
- self.generate_cas(flight, location)
- self.plan_legacy_mission(flight, location)
-
- def commission_sead(self) -> None:
- """Pick some aircraft to assign them to SEAD tasks."""
-
- if not self.potential_sead_targets:
- return
-
- offset = random.randint(0, 5)
- num_sead = max(
- MISSION_DURATION // self.doctrine["SEAD_EVERY_X_MINUTES"],
- len(self.potential_sead_targets))
- for i, aircraft in enumerate(self.alloc_aircraft(num_sead, 2, SEAD_CAPABLE)):
- flight = Flight(aircraft, 2, self.from_cp,
- random.choice([FlightType.SEAD, FlightType.DEAD]))
- flight.scheduled_in = offset + i * random.randint(
- self.doctrine["SEAD_EVERY_X_MINUTES"] - 5,
- self.doctrine["SEAD_EVERY_X_MINUTES"] + 5)
-
- location = self.potential_sead_targets[0][0]
- self.potential_sead_targets.pop()
-
- self.generate_sead(flight, location, [])
- self.plan_legacy_mission(flight, location)
-
- def commission_strike(self) -> None:
- """Pick some aircraft to assign them to STRIKE tasks."""
- if not self.potential_strike_targets:
- return
-
- offset = random.randint(0,5)
- num_strike = max(
- MISSION_DURATION / self.doctrine["STRIKE_EVERY_X_MINUTES"],
- len(self.potential_strike_targets)
+ # TODO: Implement mission type weighting for aircraft.
+ # We should avoid assigning F/A-18s to CAP missions when there are F-15s
+ # available, since the F/A-18 is capable of performing other tasks that
+ # the F-15 is not capable of.
+ airfields_in_range = self.closest_airfields.airfields_within(
+ flight.max_distance
)
- for i, aircraft in enumerate(self.alloc_aircraft(num_strike, 2, STRIKE_CAPABLE)):
- if aircraft in DRONES:
- count = 1
- else:
- count = 2
+ 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, available in inventory.all_aircraft:
+ if aircraft in types and available >= flight.num_aircraft:
+ inventory.remove_aircraft(aircraft, flight.num_aircraft)
+ return airfield, aircraft
- flight = Flight(aircraft, count, self.from_cp, FlightType.STRIKE)
- flight.scheduled_in = offset + i * random.randint(
- self.doctrine["STRIKE_EVERY_X_MINUTES"] - 5,
- self.doctrine["STRIKE_EVERY_X_MINUTES"] + 5)
+ return None
- location = self.potential_strike_targets[0][0]
- self.potential_strike_targets.pop(0)
- self.generate_strike(flight, location)
- self.plan_legacy_mission(flight, location)
+class PackageBuilder:
+ """Builds a Package for the flights it receives."""
- def _get_cas_locations(self) -> List[FrontLine]:
- return self._get_cas_locations_for_cp(self.from_cp)
+ def __init__(self, location: MissionTarget,
+ closest_airfields: ClosestAirfields,
+ global_inventory: GlobalAircraftInventory,
+ is_player: bool) -> None:
+ self.package = Package(location)
+ self.allocator = AircraftAllocator(closest_airfields, global_inventory,
+ is_player)
+ self.global_inventory = global_inventory
+
+ 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_aircraft_for_flight(plan)
+ if assignment is None:
+ return False
+ airfield, aircraft = assignment
+ flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task)
+ self.package.add_flight(flight)
+ return True
+
+ 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)
+ self.package.remove_flight(flight)
+
+
+class ObjectiveFinder:
+ """Identifies potential objectives for the mission planner."""
+
+ # TODO: Merge into doctrine.
+ AIRFIELD_THREAT_RANGE = nm_to_meter(150)
+ SAM_THREAT_RANGE = nm_to_meter(100)
+
+ def __init__(self, game: Game, is_player: bool) -> None:
+ self.game = game
+ self.is_player = is_player
+
+ def enemy_sams(self) -> Iterator[TheaterGroundObject]:
+ """Iterates over all enemy SAM sites."""
+ # Control points might have the same ground object several times, for
+ # some reason.
+ found_targets: Set[str] = set()
+ for cp in self.enemy_control_points():
+ for ground_object in cp.ground_objects:
+ if ground_object.name in found_targets:
+ continue
+
+ if ground_object.dcs_identifier != "AA":
+ continue
+
+ if not self.object_has_radar(ground_object):
+ continue
+
+ # TODO: Yield in order of most threatening.
+ # Need to sort in order of how close their defensive range comes
+ # to friendly assets. To do that we need to add effective range
+ # information to the database.
+ yield ground_object
+ found_targets.add(ground_object.name)
+
+ def threatening_sams(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).
+ """
+ sams: List[Tuple[TheaterGroundObject, int]] = []
+ for sam in self.enemy_sams():
+ ranges: List[int] = []
+ for cp in self.friendly_control_points():
+ ranges.append(sam.distance_to(cp))
+ sams.append((sam, min(ranges)))
+
+ sams = sorted(sams, key=operator.itemgetter(1))
+ for sam, _range in sams:
+ yield sam
+
+ 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]] = []
+ # Control points might have the same ground object several times, for
+ # some reason.
+ found_targets: Set[str] = set()
+ for enemy_cp in self.enemy_control_points():
+ for ground_object in enemy_cp.ground_objects:
+ 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
@staticmethod
- def _get_cas_locations_for_cp(for_cp: ControlPoint) -> List[FrontLine]:
- cas_locations = []
- for cp in for_cp.connected_points:
- if cp.captured != for_cp.captured:
- cas_locations.append(FrontLine(for_cp, cp))
- return cas_locations
+ def object_has_radar(ground_object: TheaterGroundObject) -> bool:
+ """Returns True if the ground object contains a unit with radar."""
+ for group in ground_object.groups:
+ for unit in group.units:
+ if db.unit_type_from_name(unit.type) in UNITS_WITH_RADAR:
+ return True
+ return False
- def compute_strike_targets(self):
+ def front_lines(self) -> Iterator[FrontLine]:
+ """Iterates over all active front lines in the theater."""
+ for cp in self.friendly_control_points():
+ for connected in cp.connected_points:
+ if connected.is_friendly(self.is_player):
+ continue
+
+ if Conflict.has_frontline_between(cp, connected):
+ yield FrontLine(cp, connected)
+
+ 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.
"""
- @return a list of potential strike targets in range
- """
-
- # target, distance
- self.potential_strike_targets = []
-
- for cp in [c for c in self.game.theater.controlpoints if c.captured != self.from_cp.captured]:
-
- # Compute distance to current cp
- distance = math.hypot(cp.position.x - self.from_cp.position.x,
- cp.position.y - self.from_cp.position.y)
-
- if distance > 2*self.doctrine["STRIKE_MAX_RANGE"]:
- # Then it's unlikely any child ground object is in range
- return
-
- added_group = []
- for g in cp.ground_objects:
- if g.group_id in added_group or g.is_dead: continue
-
- # Compute distance to current cp
- distance = math.hypot(cp.position.x - self.from_cp.position.x,
- cp.position.y - self.from_cp.position.y)
-
- if distance < self.doctrine["SEAD_MAX_RANGE"]:
- self.potential_strike_targets.append((g, distance))
- added_group.append(g)
-
- self.potential_strike_targets.sort(key=operator.itemgetter(1))
-
- def compute_sead_targets(self):
- """
- @return a list of potential sead targets in range
- """
-
- # target, distance
- self.potential_sead_targets = []
-
- for cp in [c for c in self.game.theater.controlpoints if c.captured != self.from_cp.captured]:
-
- # Compute distance to current cp
- distance = math.hypot(cp.position.x - self.from_cp.position.x,
- cp.position.y - self.from_cp.position.y)
-
- # Then it's unlikely any ground object is range
- if distance > 2*self.doctrine["SEAD_MAX_RANGE"]:
- return
-
- for g in cp.ground_objects:
-
- if g.dcs_identifier == "AA":
-
- # Check that there is at least one unit with a radar in the ground objects unit groups
- number_of_units = sum([len([r for r in group.units if db.unit_type_from_name(r.type) in UNITS_WITH_RADAR]) for group in g.groups])
- if number_of_units <= 0:
- continue
-
- # Compute distance to current cp
- distance = math.hypot(cp.position.x - self.from_cp.position.x,
- cp.position.y - self.from_cp.position.y)
-
- if distance < self.doctrine["SEAD_MAX_RANGE"]:
- self.potential_sead_targets.append((g, distance))
-
- self.potential_sead_targets.sort(key=operator.itemgetter(1))
-
- def __repr__(self):
- return "-"*40 + "\n" + self.from_cp.name + " planned flights :\n"\
- + "-"*40 + "\n" + "\n".join([repr(f) for f in self.flights]) + "\n" + "-"*40
-
- def generate_strike(self, flight: Flight, location: TheaterGroundObject):
- flight.flight_type = FlightType.STRIKE
- ascend = self.generate_ascend_point(flight.from_cp)
- flight.points.append(ascend)
-
- heading = flight.from_cp.position.heading_between_point(location.position)
- ingress_heading = heading - 180 + 25
- egress_heading = heading - 180 - 25
-
- ingress_pos = location.position.point_from_heading(ingress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"])
- ingress_point = FlightWaypoint(
- FlightWaypointType.INGRESS_STRIKE,
- ingress_pos.x,
- ingress_pos.y,
- self.doctrine["INGRESS_ALT"]
- )
- ingress_point.pretty_name = "INGRESS on " + location.obj_name
- ingress_point.description = "INGRESS on " + location.obj_name
- ingress_point.name = "INGRESS"
- flight.points.append(ingress_point)
-
- if len(location.groups) > 0 and location.dcs_identifier == "AA":
- for g in location.groups:
- for j, u in enumerate(g.units):
- point = FlightWaypoint(
- FlightWaypointType.TARGET_POINT,
- u.position.x,
- u.position.y,
- 0
- )
- point.description = "STRIKE " + "[" + str(location.obj_name) + "] : " + u.type + " #" + str(j)
- point.pretty_name = "STRIKE " + "[" + str(location.obj_name) + "] : " + u.type + " #" + str(j)
- point.name = location.obj_name + "#" + str(j)
- point.only_for_player = True
- ingress_point.targets.append(location)
- flight.points.append(point)
- else:
- if hasattr(location, "obj_name"):
- buildings = self.game.theater.find_ground_objects_by_obj_name(location.obj_name)
- print(buildings)
- for building in buildings:
- print("BUILDING " + str(building.is_dead) + " " + str(building.dcs_identifier))
- if building.is_dead:
- continue
-
- point = FlightWaypoint(
- FlightWaypointType.TARGET_POINT,
- building.position.x,
- building.position.y,
- 0
- )
- point.description = "STRIKE on " + building.obj_name + " " + building.category + " [" + str(building.dcs_identifier) + " ]"
- point.pretty_name = "STRIKE on " + building.obj_name + " " + building.category + " [" + str(building.dcs_identifier) + " ]"
- point.name = building.obj_name
- point.only_for_player = True
- ingress_point.targets.append(building)
- flight.points.append(point)
- else:
- point = FlightWaypoint(
- FlightWaypointType.TARGET_GROUP_LOC,
- location.position.x,
- location.position.y,
- 0
- )
- point.description = "STRIKE on " + location.obj_name
- point.pretty_name = "STRIKE on " + location.obj_name
- point.name = location.obj_name
- point.only_for_player = True
- ingress_point.targets.append(location)
- flight.points.append(point)
-
- egress_pos = location.position.point_from_heading(egress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"])
- egress_point = FlightWaypoint(
- FlightWaypointType.EGRESS,
- egress_pos.x,
- egress_pos.y,
- self.doctrine["EGRESS_ALT"]
- )
- egress_point.name = "EGRESS"
- egress_point.pretty_name = "EGRESS from " + location.obj_name
- egress_point.description = "EGRESS from " + location.obj_name
- flight.points.append(egress_point)
-
- descend = self.generate_descend_point(flight.from_cp)
- flight.points.append(descend)
-
- rtb = self.generate_rtb_waypoint(flight.from_cp)
- flight.points.append(rtb)
-
- def generate_barcap(self, flight, for_cp):
- """
- Generate a barcap flight at a given location
- :param flight: Flight to setup
- :param for_cp: CP to protect
- """
- flight.flight_type = FlightType.BARCAP if for_cp.is_carrier else FlightType.CAP
- patrol_alt = random.randint(self.doctrine["PATROL_ALT_RANGE"][0], self.doctrine["PATROL_ALT_RANGE"][1])
-
- if len(for_cp.ground_objects) > 0:
- loc = random.choice(for_cp.ground_objects)
- hdg = for_cp.position.heading_between_point(loc.position)
- radius = random.randint(self.doctrine["CAP_PATTERN_LENGTH"][0], self.doctrine["CAP_PATTERN_LENGTH"][1])
- orbit0p = loc.position.point_from_heading(hdg - 90, radius)
- orbit1p = loc.position.point_from_heading(hdg + 90, radius)
- else:
- loc = for_cp.position.point_from_heading(random.randint(0, 360), random.randint(self.doctrine["CAP_DISTANCE_FROM_CP"][0], self.doctrine["CAP_DISTANCE_FROM_CP"][1]))
- hdg = for_cp.position.heading_between_point(loc)
- radius = random.randint(self.doctrine["CAP_PATTERN_LENGTH"][0], self.doctrine["CAP_PATTERN_LENGTH"][1])
- orbit0p = loc.point_from_heading(hdg - 90, radius)
- orbit1p = loc.point_from_heading(hdg + 90, radius)
-
- # Create points
- ascend = self.generate_ascend_point(flight.from_cp)
- flight.points.append(ascend)
-
- orbit0 = FlightWaypoint(
- FlightWaypointType.PATROL_TRACK,
- orbit0p.x,
- orbit0p.y,
- patrol_alt
- )
- orbit0.name = "ORBIT 0"
- orbit0.description = "Standby between this point and the next one"
- orbit0.pretty_name = "Race-track start"
- flight.points.append(orbit0)
-
- orbit1 = FlightWaypoint(
- FlightWaypointType.PATROL,
- orbit1p.x,
- orbit1p.y,
- patrol_alt
- )
- orbit1.name = "ORBIT 1"
- orbit1.description = "Standby between this point and the previous one"
- orbit1.pretty_name = "Race-track end"
- flight.points.append(orbit1)
-
- orbit0.targets.append(for_cp)
- obj_added = []
- for ground_object in for_cp.ground_objects:
- if ground_object.obj_name not in obj_added and not ground_object.airbase_group:
- orbit0.targets.append(ground_object)
- obj_added.append(ground_object.obj_name)
-
- descend = self.generate_descend_point(flight.from_cp)
- flight.points.append(descend)
-
- rtb = self.generate_rtb_waypoint(flight.from_cp)
- flight.points.append(rtb)
-
- def generate_frontline_cap(self, flight: Flight,
- front_line: FrontLine) -> None:
- """Generate a CAP flight plan for the given front line.
-
- :param flight: Flight to setup
- :param front_line: Front line to protect.
- """
- ally_cp, enemy_cp = front_line.control_points
- flight.flight_type = FlightType.CAP
- patrol_alt = random.randint(self.doctrine["PATROL_ALT_RANGE"][0],
- self.doctrine["PATROL_ALT_RANGE"][1])
-
- # Find targets waypoints
- ingress, heading, distance = Conflict.frontline_vector(ally_cp, enemy_cp, self.game.theater)
- center = ingress.point_from_heading(heading, distance / 2)
- orbit_center = center.point_from_heading(heading - 90, random.randint(nm_to_meter(6), nm_to_meter(15)))
-
- combat_width = distance / 2
- if combat_width > 500000:
- combat_width = 500000
- if combat_width < 35000:
- combat_width = 35000
-
- radius = combat_width*1.25
- orbit0p = orbit_center.point_from_heading(heading, radius)
- orbit1p = orbit_center.point_from_heading(heading + 180, radius)
-
- # Create points
- ascend = self.generate_ascend_point(flight.from_cp)
- flight.points.append(ascend)
-
- orbit0 = FlightWaypoint(
- FlightWaypointType.PATROL_TRACK,
- orbit0p.x,
- orbit0p.y,
- patrol_alt
- )
- orbit0.name = "ORBIT 0"
- orbit0.description = "Standby between this point and the next one"
- orbit0.pretty_name = "Race-track start"
- flight.points.append(orbit0)
-
- orbit1 = FlightWaypoint(
- FlightWaypointType.PATROL,
- orbit1p.x,
- orbit1p.y,
- patrol_alt
- )
- orbit1.name = "ORBIT 1"
- orbit1.description = "Standby between this point and the previous one"
- orbit1.pretty_name = "Race-track end"
- flight.points.append(orbit1)
-
- # Note : Targets of a PATROL TRACK waypoints are the points to be defended
- orbit0.targets.append(flight.from_cp)
- orbit0.targets.append(center)
-
- descend = self.generate_descend_point(flight.from_cp)
- flight.points.append(descend)
-
- rtb = self.generate_rtb_waypoint(flight.from_cp)
- flight.points.append(rtb)
-
-
- def generate_sead(self, flight, location, custom_targets = []):
- """
- Generate a sead flight at a given location
- :param flight: Flight to setup
- :param location: Location of the SEAD target
- :param custom_targets: Custom targets if any
- """
- flight.points = []
- flight.flight_type = random.choice([FlightType.SEAD, FlightType.DEAD])
-
- ascend = self.generate_ascend_point(flight.from_cp)
- flight.points.append(ascend)
-
- heading = flight.from_cp.position.heading_between_point(location.position)
- ingress_heading = heading - 180 + 25
- egress_heading = heading - 180 - 25
-
- ingress_pos = location.position.point_from_heading(ingress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"])
- ingress_point = FlightWaypoint(
- FlightWaypointType.INGRESS_SEAD,
- ingress_pos.x,
- ingress_pos.y,
- self.doctrine["INGRESS_ALT"]
- )
- ingress_point.name = "INGRESS"
- ingress_point.pretty_name = "INGRESS on " + location.obj_name
- ingress_point.description = "INGRESS on " + location.obj_name
- flight.points.append(ingress_point)
-
- if len(custom_targets) > 0:
- for target in custom_targets:
- point = FlightWaypoint(
- FlightWaypointType.TARGET_POINT,
- target.position.x,
- target.position.y,
- 0
- )
- point.alt_type = "RADIO"
- if flight.flight_type == FlightType.DEAD:
- point.description = "DEAD on " + target.type
- point.pretty_name = "DEAD on " + location.obj_name
- point.only_for_player = True
- else:
- point.description = "SEAD on " + location.obj_name
- point.pretty_name = "SEAD on " + location.obj_name
- point.only_for_player = True
- flight.points.append(point)
- ingress_point.targets.append(location)
- ingress_point.targetGroup = location
- else:
- point = FlightWaypoint(
- FlightWaypointType.TARGET_GROUP_LOC,
- location.position.x,
- location.position.y,
- 0
+ for cp in self.friendly_control_points():
+ airfields_in_proximity = self.closest_airfields_to(cp)
+ airfields_in_threat_range = airfields_in_proximity.airfields_within(
+ self.AIRFIELD_THREAT_RANGE
)
- point.alt_type = "RADIO"
- if flight.flight_type == FlightType.DEAD:
- point.description = "DEAD on " + location.obj_name
- point.pretty_name = "DEAD on " + location.obj_name
- point.only_for_player = True
- else:
- point.description = "SEAD on " + location.obj_name
- point.pretty_name = "SEAD on " + location.obj_name
- point.only_for_player = True
- ingress_point.targets.append(location)
- ingress_point.targetGroup = location
- flight.points.append(point)
+ for airfield in airfields_in_threat_range:
+ if not airfield.is_friendly(self.is_player):
+ yield cp
+ break
- egress_pos = location.position.point_from_heading(egress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"])
- egress_point = FlightWaypoint(
- FlightWaypointType.EGRESS,
- egress_pos.x,
- egress_pos.y,
- self.doctrine["EGRESS_ALT"]
- )
- egress_point.name = "EGRESS"
- egress_point.pretty_name = "EGRESS from " + location.obj_name
- egress_point.description = "EGRESS from " + location.obj_name
- flight.points.append(egress_point)
+ 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))
- descend = self.generate_descend_point(flight.from_cp)
- flight.points.append(descend)
+ 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))
- rtb = self.generate_rtb_waypoint(flight.from_cp)
- flight.points.append(rtb)
+ def all_possible_targets(self) -> Iterator[MissionTarget]:
+ """Iterates over all possible mission targets in the theater.
- def generate_cas(self, flight: Flight, front_line: FrontLine) -> None:
- """Generate a CAS flight plan for the given target.
-
- :param flight: Flight to setup
- :param front_line: Front line containing CAS targets.
+ Valid mission targets are control points (airfields and carriers), front
+ lines, and ground objects (SAM sites, factories, resource extraction
+ sites, etc).
"""
- from_cp, location = front_line.control_points
- is_helo = hasattr(flight.unit_type, "helicopter") and flight.unit_type.helicopter
- cap_alt = 1000
- flight.points = []
- flight.flight_type = FlightType.CAS
+ for cp in self.game.theater.controlpoints:
+ yield cp
+ yield from cp.ground_objects
+ yield from self.front_lines()
- ingress, heading, distance = Conflict.frontline_vector(
- from_cp, location, self.game.theater
+ def closest_airfields_to(self, 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 = nm_to_meter(100)
+ MAX_CAS_RANGE = nm_to_meter(50)
+ MAX_SEAD_RANGE = nm_to_meter(150)
+ MAX_STRIKE_RANGE = nm_to_meter(150)
+
+ 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
+
+ def propose_missions(self) -> Iterator[ProposedMission]:
+ """Identifies and iterates over potential mission in priority order."""
+ # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP.
+ for cp in self.objective_finder.vulnerable_control_points():
+ yield ProposedMission(cp, [
+ ProposedFlight(FlightType.CAP, 2, self.MAX_CAP_RANGE),
+ ])
+
+ # Find front lines, plan CAP.
+ for front_line in self.objective_finder.front_lines():
+ yield ProposedMission(front_line, [
+ ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE),
+ ProposedFlight(FlightType.CAS, 2, self.MAX_CAS_RANGE),
+ ])
+
+ # 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_sams():
+ yield ProposedMission(sam, [
+ ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE),
+ # TODO: Max escort range.
+ ProposedFlight(FlightType.ESCORT, 2, self.MAX_SEAD_RANGE),
+ ])
+
+ # 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.SEAD, 2, self.MAX_STRIKE_RANGE),
+ ProposedFlight(FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE),
+ ])
+
+ def plan_missions(self) -> None:
+ """Identifies and plans mission for the turn."""
+ for proposed_mission in self.propose_missions():
+ self.plan_mission(proposed_mission)
+
+ 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.id} from {cp}")
+
+ def plan_mission(self, mission: ProposedMission) -> 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.is_player
)
- center = ingress.point_from_heading(heading, distance / 2)
- egress = ingress.point_from_heading(heading, distance)
+ for flight in mission.flights:
+ if not builder.plan_flight(flight):
+ builder.release_planned_aircraft()
+ self.message("Insufficient aircraft",
+ f"Not enough aircraft in range for {mission}")
+ return
- ascend = self.generate_ascend_point(flight.from_cp)
- if is_helo:
- cap_alt = 500
- ascend.alt = 500
- flight.points.append(ascend)
+ package = builder.build()
+ builder = FlightPlanBuilder(self.game, package, self.is_player)
+ for flight in package.flights:
+ builder.populate_flight_plan(flight)
+ self.ato.add_package(package)
- ingress_point = FlightWaypoint(
- FlightWaypointType.INGRESS_CAS,
- ingress.x,
- ingress.y,
- cap_alt
- )
- ingress_point.alt_type = "RADIO"
- ingress_point.name = "INGRESS"
- ingress_point.pretty_name = "INGRESS"
- ingress_point.description = "Ingress into CAS area"
- flight.points.append(ingress_point)
+ def message(self, title, text) -> None:
+ """Emits a planning message to the player.
- center_point = FlightWaypoint(
- FlightWaypointType.CAS,
- center.x,
- center.y,
- cap_alt
- )
- center_point.alt_type = "RADIO"
- center_point.description = "Provide CAS"
- center_point.name = "CAS"
- center_point.pretty_name = "CAS"
- flight.points.append(center_point)
-
- egress_point = FlightWaypoint(
- FlightWaypointType.EGRESS,
- egress.x,
- egress.y,
- cap_alt
- )
- egress_point.alt_type = "RADIO"
- egress_point.description = "Egress from CAS area"
- egress_point.name = "EGRESS"
- egress_point.pretty_name = "EGRESS"
- flight.points.append(egress_point)
-
- descend = self.generate_descend_point(flight.from_cp)
- if is_helo:
- descend.alt = 300
- flight.points.append(descend)
-
- rtb = self.generate_rtb_waypoint(flight.from_cp)
- flight.points.append(rtb)
-
- def generate_ascend_point(self, from_cp):
+ If the mission planner belongs to the players coalition, this emits a
+ message to the info panel.
"""
- Generate ascend point
- :param from_cp: Airport you're taking off from
- :return:
- """
- ascend_heading = from_cp.heading
- pos_ascend = from_cp.position.point_from_heading(ascend_heading, 10000)
- ascend = FlightWaypoint(
- FlightWaypointType.ASCEND_POINT,
- pos_ascend.x,
- pos_ascend.y,
- self.doctrine["PATTERN_ALTITUDE"]
- )
- ascend.name = "ASCEND"
- ascend.alt_type = "RADIO"
- ascend.description = "Ascend"
- ascend.pretty_name = "Ascend"
- return ascend
-
- def generate_descend_point(self, from_cp):
- """
- Generate approach/descend point
- :param from_cp: Airport you're landing at
- :return:
- """
- ascend_heading = from_cp.heading
- descend = from_cp.position.point_from_heading(ascend_heading - 180, 10000)
- descend = FlightWaypoint(
- FlightWaypointType.DESCENT_POINT,
- descend.x,
- descend.y,
- self.doctrine["PATTERN_ALTITUDE"]
- )
- descend.name = "DESCEND"
- descend.alt_type = "RADIO"
- descend.description = "Descend to pattern alt"
- descend.pretty_name = "Descend to pattern alt"
- return descend
-
- def generate_rtb_waypoint(self, from_cp):
- """
- Generate RTB landing point
- :param from_cp: Airport you're landing at
- :return:
- """
- rtb = from_cp.position
- rtb = FlightWaypoint(
- FlightWaypointType.LANDING_POINT,
- rtb.x,
- rtb.y,
- 0
- )
- rtb.name = "LANDING"
- rtb.alt_type = "RADIO"
- rtb.description = "RTB"
- rtb.pretty_name = "RTB"
- return rtb
+ 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/closestairfields.py b/gen/flights/closestairfields.py
new file mode 100644
index 00000000..a6045dde
--- /dev/null
+++ b/gen/flights/closestairfields.py
@@ -0,0 +1,51 @@
+"""Objective adjacency lists."""
+from typing import Dict, Iterator, List, Optional
+
+from theater import ConflictTheater, ControlPoint, MissionTarget
+
+
+class ClosestAirfields:
+ """Precalculates which control points are closes to the given target."""
+
+ def __init__(self, target: MissionTarget,
+ all_control_points: List[ControlPoint]) -> None:
+ self.target = target
+ self.closest_airfields: List[ControlPoint] = sorted(
+ all_control_points, key=lambda c: self.target.distance_to(c)
+ )
+
+ def airfields_within(self, meters: int) -> Iterator[ControlPoint]:
+ """Iterates over all airfields within the given range of the target.
+
+ Note that this iterates over *all* airfields, not just friendly
+ airfields.
+ """
+ for cp in self.closest_airfields:
+ if cp.distance_to(self.target) < meters:
+ yield cp
+ else:
+ break
+
+
+class ObjectiveDistanceCache:
+ theater: Optional[ConflictTheater] = None
+ closest_airfields: Dict[str, ClosestAirfields] = {}
+
+ @classmethod
+ def set_theater(cls, theater: ConflictTheater) -> None:
+ if cls.theater is not None:
+ cls.closest_airfields = {}
+ cls.theater = theater
+
+ @classmethod
+ def get_closest_airfields(cls, location: MissionTarget) -> ClosestAirfields:
+ if cls.theater is None:
+ raise RuntimeError(
+ "Call ObjectiveDistanceCache.set_theater before using"
+ )
+
+ if location.name not in cls.closest_airfields:
+ cls.closest_airfields[location.name] = ClosestAirfields(
+ location, cls.theater.controlpoints
+ )
+ return cls.closest_airfields[location.name]
diff --git a/gen/flights/flight.py b/gen/flights/flight.py
index 0c5c7956..676b6bd8 100644
--- a/gen/flights/flight.py
+++ b/gen/flights/flight.py
@@ -47,6 +47,8 @@ class FlightWaypointType(Enum):
TARGET_GROUP_LOC = 13 # A target group approximate location
TARGET_SHIP = 14 # A target ship known location
CUSTOM = 15 # User waypoint (no specific behaviour)
+ JOIN = 16
+ SPLIT = 17
class PredefinedWaypointCategory(Enum):
diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py
new file mode 100644
index 00000000..2e595b31
--- /dev/null
+++ b/gen/flights/flightplan.py
@@ -0,0 +1,440 @@
+"""Flight plan generation.
+
+Flights are first planned generically by either the player or by the
+MissionPlanner. Those only plan basic information like the objective, aircraft
+type, and the size of the flight. The FlightPlanBuilder is responsible for
+generating the waypoints for the mission.
+"""
+from __future__ import annotations
+
+import logging
+import random
+from typing import List, Optional, TYPE_CHECKING
+
+from dcs.mapping import Point
+from dcs.unit import Unit
+
+from game.data.doctrine import Doctrine, MODERN_DOCTRINE
+from game.utils import nm_to_meter
+from gen.ato import Package
+from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject
+from .closestairfields import ObjectiveDistanceCache
+from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType
+from .waypointbuilder import WaypointBuilder
+from ..conflictgen import Conflict
+
+if TYPE_CHECKING:
+ from game import Game
+
+
+class InvalidObjectiveLocation(RuntimeError):
+ """Raised when the objective location is invalid for the mission type."""
+ def __init__(self, task: FlightType, location: MissionTarget) -> None:
+ super().__init__(
+ f"{location.name} is not valid for {task.name} missions."
+ )
+
+
+class FlightPlanBuilder:
+ """Generates flight plans for flights."""
+
+ def __init__(self, game: Game, package: Package, is_player: bool) -> None:
+ self.game = game
+ self.package = package
+ self.is_player = is_player
+ if is_player:
+ faction = self.game.player_faction
+ else:
+ faction = self.game.enemy_faction
+ self.doctrine: Doctrine = faction.get("doctrine", MODERN_DOCTRINE)
+
+ def populate_flight_plan(
+ self, flight: Flight,
+ # TODO: Custom targets should be an attribute of the flight.
+ custom_targets: Optional[List[Unit]] = None) -> None:
+ """Creates a default flight plan for the given mission."""
+ if flight not in self.package.flights:
+ raise RuntimeError("Flight must be a part of the package")
+ self.generate_missing_package_waypoints()
+
+ # TODO: Flesh out mission types.
+ try:
+ task = flight.flight_type
+ if task == FlightType.ANTISHIP:
+ logging.error(
+ "Anti-ship flight plan generation not implemented"
+ )
+ elif task == FlightType.BAI:
+ logging.error("BAI flight plan generation not implemented")
+ elif task == FlightType.BARCAP:
+ self.generate_barcap(flight)
+ elif task == FlightType.CAP:
+ self.generate_barcap(flight)
+ elif task == FlightType.CAS:
+ self.generate_cas(flight)
+ elif task == FlightType.DEAD:
+ self.generate_sead(flight, custom_targets)
+ elif task == FlightType.ELINT:
+ logging.error("ELINT flight plan generation not implemented")
+ elif task == FlightType.ESCORT:
+ self.generate_escort(flight)
+ elif task == FlightType.EVAC:
+ logging.error("Evac flight plan generation not implemented")
+ elif task == FlightType.EWAR:
+ logging.error("EWar flight plan generation not implemented")
+ elif task == FlightType.INTERCEPTION:
+ logging.error(
+ "Intercept flight plan generation not implemented"
+ )
+ elif task == FlightType.LOGISTICS:
+ logging.error(
+ "Logistics flight plan generation not implemented"
+ )
+ elif task == FlightType.RECON:
+ logging.error("Recon flight plan generation not implemented")
+ elif task == FlightType.SEAD:
+ self.generate_sead(flight, custom_targets)
+ elif task == FlightType.STRIKE:
+ self.generate_strike(flight)
+ elif task == FlightType.TARCAP:
+ self.generate_frontline_cap(flight)
+ elif task == FlightType.TROOP_TRANSPORT:
+ logging.error(
+ "Troop transport flight plan generation not implemented"
+ )
+ except InvalidObjectiveLocation as ex:
+ logging.error(f"Could not create flight plan: {ex}")
+
+ def generate_missing_package_waypoints(self) -> None:
+ if self.package.ingress_point is None:
+ self.package.ingress_point = self._ingress_point()
+ if self.package.egress_point is None:
+ self.package.egress_point = self._egress_point()
+ if self.package.join_point is None:
+ self.package.join_point = self._join_point()
+ if self.package.split_point is None:
+ self.package.split_point = self._split_point()
+
+ def generate_strike(self, flight: Flight) -> None:
+ """Generates a strike flight plan.
+
+ Args:
+ flight: The flight to generate the flight plan for.
+ """
+ location = self.package.target
+
+ # TODO: Support airfield strikes.
+ if not isinstance(location, TheaterGroundObject):
+ raise InvalidObjectiveLocation(flight.flight_type, location)
+
+ builder = WaypointBuilder(self.doctrine)
+ builder.ascent(flight.from_cp)
+ builder.join(self.package.join_point)
+ builder.ingress_strike(self.package.ingress_point, location)
+
+ if len(location.groups) > 0 and location.dcs_identifier == "AA":
+ # TODO: Replace with DEAD?
+ # Strike missions on SEAD targets target units.
+ for g in location.groups:
+ for j, u in enumerate(g.units):
+ builder.strike_point(u, f"{u.type} #{j}", location)
+ 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
+
+ builder.strike_point(
+ building,
+ f"{building.obj_name} {building.category}",
+ location
+ )
+
+ builder.egress(self.package.egress_point, location)
+ builder.split(self.package.split_point)
+ builder.rtb(flight.from_cp)
+
+ flight.points = builder.build()
+
+ def generate_barcap(self, flight: Flight) -> None:
+ """Generate a BARCAP flight at a given location.
+
+ Args:
+ flight: The flight to generate the flight plan for.
+ """
+ location = self.package.target
+
+ if isinstance(location, FrontLine):
+ raise InvalidObjectiveLocation(flight.flight_type, location)
+
+ patrol_alt = random.randint(
+ self.doctrine.min_patrol_altitude,
+ self.doctrine.max_patrol_altitude
+ )
+
+ closest_cache = ObjectiveDistanceCache.get_closest_airfields(location)
+ for airfield in closest_cache.closest_airfields:
+ if airfield.captured != self.is_player:
+ closest_airfield = airfield
+ break
+ else:
+ logging.error("Could not find any enemy airfields")
+ return
+
+ heading = location.position.heading_between_point(
+ closest_airfield.position
+ )
+
+ end = location.position.point_from_heading(
+ heading,
+ random.randint(self.doctrine.cap_min_distance_from_cp,
+ self.doctrine.cap_max_distance_from_cp)
+ )
+ diameter = random.randint(
+ self.doctrine.cap_min_track_length,
+ self.doctrine.cap_max_track_length
+ )
+ start = end.point_from_heading(heading - 180, diameter)
+
+ builder = WaypointBuilder(self.doctrine)
+ builder.ascent(flight.from_cp)
+ builder.race_track(start, end, patrol_alt)
+ builder.rtb(flight.from_cp)
+ flight.points = builder.build()
+
+ def generate_frontline_cap(self, flight: Flight) -> None:
+ """Generate a CAP flight plan for the given front line.
+
+ Args:
+ flight: The flight to generate the flight plan for.
+ """
+ location = self.package.target
+
+ if not isinstance(location, FrontLine):
+ raise InvalidObjectiveLocation(flight.flight_type, location)
+
+ ally_cp, enemy_cp = location.control_points
+ patrol_alt = random.randint(self.doctrine.min_patrol_altitude,
+ self.doctrine.max_patrol_altitude)
+
+ # Find targets waypoints
+ ingress, heading, distance = Conflict.frontline_vector(
+ ally_cp, enemy_cp, self.game.theater
+ )
+ center = ingress.point_from_heading(heading, distance / 2)
+ orbit_center = center.point_from_heading(
+ heading - 90, random.randint(nm_to_meter(6), nm_to_meter(15))
+ )
+
+ combat_width = distance / 2
+ if combat_width > 500000:
+ combat_width = 500000
+ if combat_width < 35000:
+ combat_width = 35000
+
+ radius = combat_width*1.25
+ orbit0p = orbit_center.point_from_heading(heading, radius)
+ orbit1p = orbit_center.point_from_heading(heading + 180, radius)
+
+ # Create points
+ builder = WaypointBuilder(self.doctrine)
+ builder.ascent(flight.from_cp)
+ builder.race_track(orbit0p, orbit1p, patrol_alt)
+ builder.rtb(flight.from_cp)
+ flight.points = builder.build()
+
+ def generate_sead(self, flight: Flight,
+ custom_targets: Optional[List[Unit]]) -> None:
+ """Generate a SEAD/DEAD flight at a given location.
+
+ Args:
+ flight: The flight to generate the flight plan for.
+ custom_targets: Specific radar equipped units selected by the user.
+ """
+ location = self.package.target
+
+ if not isinstance(location, TheaterGroundObject):
+ raise InvalidObjectiveLocation(flight.flight_type, location)
+
+ if custom_targets is None:
+ custom_targets = []
+
+ builder = WaypointBuilder(self.doctrine)
+ builder.ascent(flight.from_cp)
+ builder.join(self.package.join_point)
+ builder.ingress_sead(self.package.ingress_point, location)
+
+ # TODO: Unify these.
+ # There doesn't seem to be any reason to treat the UI fragged missions
+ # different from the automatic missions.
+ if custom_targets:
+ for target in custom_targets:
+ point = FlightWaypoint(
+ FlightWaypointType.TARGET_POINT,
+ target.position.x,
+ target.position.y,
+ 0
+ )
+ point.alt_type = "RADIO"
+ if flight.flight_type == FlightType.DEAD:
+ builder.dead_point(target, location.name, location)
+ else:
+ builder.sead_point(target, location.name, location)
+ else:
+ if flight.flight_type == FlightType.DEAD:
+ builder.dead_area(location)
+ else:
+ builder.sead_area(location)
+
+ builder.egress(self.package.egress_point, location)
+ builder.split(self.package.split_point)
+ builder.rtb(flight.from_cp)
+
+ flight.points = builder.build()
+
+ def generate_escort(self, flight: Flight) -> None:
+ # TODO: Decide common waypoints for the package ahead of time.
+ # Packages should determine some common points like push, ingress,
+ # egress, and split points ahead of time so they can be shared by all
+ # flights.
+
+ patrol_alt = random.randint(
+ self.doctrine.min_patrol_altitude,
+ self.doctrine.max_patrol_altitude
+ )
+
+ builder = WaypointBuilder(self.doctrine)
+ builder.ascent(flight.from_cp)
+ builder.join(self.package.join_point)
+ builder.race_track(
+ self.package.ingress_point,
+ self.package.egress_point,
+ patrol_alt
+ )
+ builder.split(self.package.split_point)
+ builder.rtb(flight.from_cp)
+
+ flight.points = builder.build()
+
+ def generate_cas(self, flight: Flight) -> None:
+ """Generate a CAS flight plan for the given target.
+
+ Args:
+ flight: The flight to generate the flight plan for.
+ """
+ location = self.package.target
+
+ if not isinstance(location, FrontLine):
+ raise InvalidObjectiveLocation(flight.flight_type, location)
+
+ is_helo = getattr(flight.unit_type, "helicopter", False)
+ cap_alt = 500 if is_helo else 1000
+
+ ingress, heading, distance = Conflict.frontline_vector(
+ location.control_points[0], location.control_points[1],
+ self.game.theater
+ )
+ center = ingress.point_from_heading(heading, distance / 2)
+ egress = ingress.point_from_heading(heading, distance)
+
+ builder = WaypointBuilder(self.doctrine)
+ builder.ascent(flight.from_cp, is_helo)
+ builder.join(self.package.join_point)
+ builder.ingress_cas(ingress, location)
+ builder.cas(center, cap_alt)
+ builder.egress(egress, location)
+ builder.split(self.package.split_point)
+ builder.rtb(flight.from_cp, is_helo)
+
+ flight.points = builder.build()
+
+ # TODO: Make a model for the waypoint builder and use that in the UI.
+ def generate_ascend_point(self, departure: ControlPoint) -> FlightWaypoint:
+ """Generate ascend point.
+
+ Args:
+ departure: Departure airfield or carrier.
+ """
+ builder = WaypointBuilder(self.doctrine)
+ builder.ascent(departure)
+ return builder.build()[0]
+
+ def generate_descend_point(self, arrival: ControlPoint) -> FlightWaypoint:
+ """Generate approach/descend point.
+
+ Args:
+ arrival: Arrival airfield or carrier.
+ """
+ builder = WaypointBuilder(self.doctrine)
+ builder.descent(arrival)
+ return builder.build()[0]
+
+ def generate_rtb_waypoint(self, arrival: ControlPoint) -> FlightWaypoint:
+ """Generate RTB landing point.
+
+ Args:
+ arrival: Arrival airfield or carrier.
+ """
+ builder = WaypointBuilder(self.doctrine)
+ builder.land(arrival)
+ return builder.build()[0]
+
+ def _join_point(self) -> Point:
+ ingress_point = self.package.ingress_point
+ heading = self._heading_to_package_airfield(ingress_point)
+ return ingress_point.point_from_heading(heading,
+ -self.doctrine.join_distance)
+
+ def _split_point(self) -> Point:
+ egress_point = self.package.egress_point
+ heading = self._heading_to_package_airfield(egress_point)
+ return egress_point.point_from_heading(heading,
+ -self.doctrine.split_distance)
+
+ def _ingress_point(self) -> Point:
+ heading = self._target_heading_to_package_airfield()
+ return self.package.target.position.point_from_heading(
+ heading - 180 + 25, self.doctrine.ingress_egress_distance
+ )
+
+ def _egress_point(self) -> Point:
+ heading = self._target_heading_to_package_airfield()
+ return self.package.target.position.point_from_heading(
+ heading - 180 - 25, self.doctrine.ingress_egress_distance
+ )
+
+ 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)
+
+ # TODO: Set ingress/egress/join/split points in the Package.
+ 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.
+ if not self.package.flights:
+ raise RuntimeError(
+ "Cannot determine source airfield for package with no flights"
+ )
+
+ # The package airfield is either the flight's airfield (when there is no
+ # package) or the closest airfield to the objective that is the
+ # departure airfield for some flight in the package.
+ cache = ObjectiveDistanceCache.get_closest_airfields(
+ self.package.target
+ )
+ for airfield in cache.closest_airfields:
+ for flight in self.package.flights:
+ if flight.from_cp == airfield:
+ return airfield
+ raise RuntimeError(
+ "Could not find any airfield assigned to this package"
+ )
diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py
new file mode 100644
index 00000000..8481b6ba
--- /dev/null
+++ b/gen/flights/waypointbuilder.py
@@ -0,0 +1,294 @@
+from __future__ import annotations
+
+from typing import List, Optional, Union
+
+from dcs.mapping import Point
+from dcs.unit import Unit
+
+from game.data.doctrine import Doctrine
+from game.utils import nm_to_meter
+from theater import ControlPoint, MissionTarget, TheaterGroundObject
+from .flight import FlightWaypoint, FlightWaypointType
+
+
+class WaypointBuilder:
+ def __init__(self, doctrine: Doctrine) -> None:
+ self.doctrine = doctrine
+ self.waypoints: List[FlightWaypoint] = []
+ self.ingress_point: Optional[FlightWaypoint] = None
+
+ def build(self) -> List[FlightWaypoint]:
+ return self.waypoints
+
+ def ascent(self, departure: ControlPoint, is_helo: bool = False) -> None:
+ """Create ascent waypoint for the given departure airfield or carrier.
+
+ Args:
+ departure: Departure airfield or carrier.
+ """
+ # TODO: Pick runway based on wind direction.
+ heading = departure.heading
+ position = departure.position.point_from_heading(
+ heading, nm_to_meter(5)
+ )
+ waypoint = FlightWaypoint(
+ FlightWaypointType.ASCEND_POINT,
+ position.x,
+ position.y,
+ 500 if is_helo else self.doctrine.pattern_altitude
+ )
+ waypoint.name = "ASCEND"
+ waypoint.alt_type = "RADIO"
+ waypoint.description = "Ascend"
+ waypoint.pretty_name = "Ascend"
+ self.waypoints.append(waypoint)
+
+ def descent(self, arrival: ControlPoint, is_helo: bool = False) -> None:
+ """Create descent waypoint for the given arrival airfield or carrier.
+
+ Args:
+ arrival: Arrival airfield or carrier.
+ """
+ # TODO: Pick runway based on wind direction.
+ # ControlPoint.heading is the departure heading.
+ heading = (arrival.heading + 180) % 360
+ position = arrival.position.point_from_heading(
+ heading, nm_to_meter(5)
+ )
+ waypoint = FlightWaypoint(
+ FlightWaypointType.DESCENT_POINT,
+ position.x,
+ position.y,
+ 300 if is_helo else self.doctrine.pattern_altitude
+ )
+ waypoint.name = "DESCEND"
+ waypoint.alt_type = "RADIO"
+ waypoint.description = "Descend to pattern altitude"
+ waypoint.pretty_name = "Ascend"
+ self.waypoints.append(waypoint)
+
+ def land(self, arrival: ControlPoint) -> None:
+ """Create descent waypoint for the given arrival airfield or carrier.
+
+ Args:
+ arrival: Arrival airfield or carrier.
+ """
+ position = arrival.position
+ waypoint = FlightWaypoint(
+ FlightWaypointType.LANDING_POINT,
+ position.x,
+ position.y,
+ 0
+ )
+ waypoint.name = "LANDING"
+ waypoint.alt_type = "RADIO"
+ waypoint.description = "Land"
+ waypoint.pretty_name = "Land"
+ self.waypoints.append(waypoint)
+
+ def join(self, position: Point) -> None:
+ waypoint = FlightWaypoint(
+ FlightWaypointType.JOIN,
+ position.x,
+ position.y,
+ self.doctrine.ingress_altitude
+ )
+ waypoint.pretty_name = "Join"
+ waypoint.description = "Rendezvous with package"
+ waypoint.name = "JOIN"
+ self.waypoints.append(waypoint)
+
+ def split(self, position: Point) -> None:
+ waypoint = FlightWaypoint(
+ FlightWaypointType.SPLIT,
+ position.x,
+ position.y,
+ self.doctrine.ingress_altitude
+ )
+ waypoint.pretty_name = "Split"
+ waypoint.description = "Depart from package"
+ waypoint.name = "SPLIT"
+ self.waypoints.append(waypoint)
+
+ def ingress_cas(self, position: Point, objective: MissionTarget) -> None:
+ self._ingress(FlightWaypointType.INGRESS_CAS, position, objective)
+
+ def ingress_sead(self, position: Point, objective: MissionTarget) -> None:
+ self._ingress(FlightWaypointType.INGRESS_SEAD, position, objective)
+
+ def ingress_strike(self, position: Point, objective: MissionTarget) -> None:
+ self._ingress(FlightWaypointType.INGRESS_STRIKE, position, objective)
+
+ def _ingress(self, ingress_type: FlightWaypointType, position: Point,
+ objective: MissionTarget) -> None:
+ if self.ingress_point is not None:
+ raise RuntimeError("A flight plan can have only one ingress point.")
+
+ waypoint = FlightWaypoint(
+ ingress_type,
+ position.x,
+ position.y,
+ self.doctrine.ingress_altitude
+ )
+ waypoint.pretty_name = "INGRESS on " + objective.name
+ waypoint.description = "INGRESS on " + objective.name
+ waypoint.name = "INGRESS"
+ self.waypoints.append(waypoint)
+ self.ingress_point = waypoint
+
+ def egress(self, position: Point, target: MissionTarget) -> None:
+ waypoint = FlightWaypoint(
+ FlightWaypointType.EGRESS,
+ position.x,
+ position.y,
+ self.doctrine.ingress_altitude
+ )
+ waypoint.pretty_name = "EGRESS from " + target.name
+ waypoint.description = "EGRESS from " + target.name
+ waypoint.name = "EGRESS"
+ self.waypoints.append(waypoint)
+
+ def dead_point(self, target: Union[TheaterGroundObject, Unit], name: str,
+ location: MissionTarget) -> None:
+ self._target_point(target, name, f"STRIKE [{location.name}]: {name}",
+ location)
+ # TODO: Seems fishy.
+ self.ingress_point.targetGroup = location
+
+ def sead_point(self, target: Union[TheaterGroundObject, Unit], name: str,
+ location: MissionTarget) -> None:
+ self._target_point(target, name, f"STRIKE [{location.name}]: {name}",
+ location)
+ # TODO: Seems fishy.
+ self.ingress_point.targetGroup = location
+
+ def strike_point(self, target: Union[TheaterGroundObject, Unit], name: str,
+ location: MissionTarget) -> None:
+ self._target_point(target, name, f"STRIKE [{location.name}]: {name}",
+ location)
+
+ def _target_point(self, target: Union[TheaterGroundObject, Unit], name: str,
+ description: str, location: MissionTarget) -> None:
+ if self.ingress_point is None:
+ raise RuntimeError(
+ "An ingress point must be added before target points."
+ )
+
+ waypoint = FlightWaypoint(
+ FlightWaypointType.TARGET_POINT,
+ target.position.x,
+ target.position.y,
+ 0
+ )
+ waypoint.description = description
+ waypoint.pretty_name = description
+ waypoint.name = name
+ waypoint.only_for_player = True
+ self.waypoints.append(waypoint)
+ # TODO: This seems wrong, but it's what was there before.
+ self.ingress_point.targets.append(location)
+
+ def sead_area(self, target: MissionTarget) -> None:
+ self._target_area(f"SEAD on {target.name}", target)
+ # TODO: Seems fishy.
+ self.ingress_point.targetGroup = target
+
+ def dead_area(self, target: MissionTarget) -> None:
+ self._target_area(f"DEAD on {target.name}", target)
+ # TODO: Seems fishy.
+ self.ingress_point.targetGroup = target
+
+ def _target_area(self, name: str, location: MissionTarget) -> None:
+ if self.ingress_point is None:
+ raise RuntimeError(
+ "An ingress point must be added before target points."
+ )
+
+ waypoint = FlightWaypoint(
+ FlightWaypointType.TARGET_GROUP_LOC,
+ location.position.x,
+ location.position.y,
+ 0
+ )
+ waypoint.description = name
+ waypoint.pretty_name = name
+ waypoint.name = name
+ waypoint.only_for_player = True
+ self.waypoints.append(waypoint)
+ # TODO: This seems wrong, but it's what was there before.
+ self.ingress_point.targets.append(location)
+
+ def cas(self, position: Point, altitude: int) -> None:
+ waypoint = FlightWaypoint(
+ FlightWaypointType.CAS,
+ position.x,
+ position.y,
+ altitude
+ )
+ waypoint.alt_type = "RADIO"
+ waypoint.description = "Provide CAS"
+ waypoint.name = "CAS"
+ waypoint.pretty_name = "CAS"
+ self.waypoints.append(waypoint)
+
+ def race_track_start(self, position: Point, altitude: int) -> None:
+ """Creates a racetrack start waypoint.
+
+ Args:
+ position: Position of the waypoint.
+ altitude: Altitude of the racetrack in meters.
+ """
+ waypoint = FlightWaypoint(
+ FlightWaypointType.PATROL_TRACK,
+ position.x,
+ position.y,
+ altitude
+ )
+ waypoint.name = "RACETRACK START"
+ waypoint.description = "Orbit between this point and the next point"
+ waypoint.pretty_name = "Race-track start"
+ self.waypoints.append(waypoint)
+
+ # TODO: Does this actually do anything?
+ # orbit0.targets.append(location)
+ # Note: Targets of PATROL TRACK waypoints are the points to be defended.
+ # orbit0.targets.append(flight.from_cp)
+ # orbit0.targets.append(center)
+
+ def race_track_end(self, position: Point, altitude: int) -> None:
+ """Creates a racetrack end waypoint.
+
+ Args:
+ position: Position of the waypoint.
+ altitude: Altitude of the racetrack in meters.
+ """
+ waypoint = FlightWaypoint(
+ FlightWaypointType.PATROL,
+ position.x,
+ position.y,
+ altitude
+ )
+ waypoint.name = "RACETRACK END"
+ waypoint.description = "Orbit between this point and the previous point"
+ waypoint.pretty_name = "Race-track end"
+ self.waypoints.append(waypoint)
+
+ def race_track(self, start: Point, end: Point, altitude: int) -> None:
+ """Creates two waypoint for a racetrack orbit.
+
+ Args:
+ start: The beginning racetrack waypoint.
+ end: The ending racetrack waypoint.
+ altitude: The racetrack altitude.
+ """
+ self.race_track_start(start, altitude)
+ self.race_track_end(end, altitude)
+
+ def rtb(self, arrival: ControlPoint, is_helo: bool = False) -> None:
+ """Creates descent ant landing waypoints for the given control point.
+
+ Args:
+ arrival: Arrival airfield or carrier.
+ """
+ self.descent(arrival, is_helo)
+ self.land(arrival)
diff --git a/qt_ui/dialogs.py b/qt_ui/dialogs.py
index 16920a15..e09dd92a 100644
--- a/qt_ui/dialogs.py
+++ b/qt_ui/dialogs.py
@@ -54,7 +54,12 @@ class Dialog:
cls.edit_package_dialog.show()
@classmethod
- def open_edit_flight_dialog(cls, flight: Flight):
+ def open_edit_flight_dialog(cls, package_model: PackageModel,
+ flight: Flight) -> None:
"""Opens the dialog to edit the given flight."""
- cls.edit_flight_dialog = QEditFlightDialog(cls.game_model.game, flight)
+ cls.edit_flight_dialog = QEditFlightDialog(
+ cls.game_model.game,
+ package_model.package,
+ flight
+ )
cls.edit_flight_dialog.show()
diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py
index f2f73b4f..fae3a11d 100644
--- a/qt_ui/widgets/QTopPanel.py
+++ b/qt_ui/widgets/QTopPanel.py
@@ -1,3 +1,5 @@
+from typing import Optional
+
from PySide2.QtWidgets import QFrame, QGroupBox, QHBoxLayout, QPushButton
import qt_ui.uiconstants as CONST
@@ -74,7 +76,7 @@ class QTopPanel(QFrame):
self.layout.setContentsMargins(0,0,0,0)
self.setLayout(self.layout)
- def setGame(self, game:Game):
+ def setGame(self, game: Optional[Game]):
self.game = game
if game is not None:
self.turnCounter.setCurrentTurn(self.game.turn, self.game.current_day)
diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py
index bee61a43..e4c178c4 100644
--- a/qt_ui/widgets/ato.py
+++ b/qt_ui/widgets/ato.py
@@ -16,6 +16,7 @@ from PySide2.QtWidgets import (
from gen.ato import Package
from gen.flights.flight import Flight
from ..models import AtoModel, GameModel, NullListModel, PackageModel
+from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
class QFlightList(QListView):
@@ -122,7 +123,7 @@ class QFlightPanel(QGroupBox):
return
from qt_ui.dialogs import Dialog
Dialog.open_edit_flight_dialog(
- self.package_model.flight_at_index(index)
+ self.package_model, self.package_model.flight_at_index(index)
)
def on_delete(self) -> None:
@@ -134,6 +135,7 @@ class QFlightPanel(QGroupBox):
self.game_model.game.aircraft_inventory.return_from_flight(
self.flight_list.selected_item)
self.package_model.delete_flight_at_index(index)
+ GameUpdateSignal.get_instance().redraw_flight_paths()
class QPackageList(QListView):
@@ -217,6 +219,7 @@ class QPackagePanel(QGroupBox):
logging.error(f"Cannot delete package when no package is selected.")
return
self.ato_model.delete_package_at_index(index)
+ GameUpdateSignal.get_instance().redraw_flight_paths()
class QAirTaskingOrderPanel(QSplitter):
diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py
index a36382b5..8654a364 100644
--- a/qt_ui/widgets/map/QLiberationMap.py
+++ b/qt_ui/widgets/map/QLiberationMap.py
@@ -1,10 +1,10 @@
-import typing
-from typing import Dict, Tuple
+from typing import Dict, List, Optional, Tuple
from PySide2.QtCore import Qt
from PySide2.QtGui import QBrush, QColor, QPen, QPixmap, QWheelEvent
from PySide2.QtWidgets import (
QFrame,
+ QGraphicsItem,
QGraphicsOpacityEffect,
QGraphicsScene,
QGraphicsView,
@@ -43,6 +43,9 @@ class QLiberationMap(QGraphicsView):
super(QLiberationMap, self).__init__()
QLiberationMap.instance = self
self.game_model = game_model
+ self.game: Optional[Game] = game_model.game
+
+ self.flight_path_items: List[QGraphicsItem] = []
self.setMinimumSize(800,600)
self.setMaximumHeight(2160)
@@ -53,6 +56,10 @@ class QLiberationMap(QGraphicsView):
self.connectSignals()
self.setGame(game_model.game)
+ GameUpdateSignal.get_instance().flight_paths_changed.connect(
+ lambda: self.draw_flight_plans(self.scene())
+ )
+
def init_scene(self):
scene = QLiberationScene(self)
self.setScene(scene)
@@ -65,7 +72,7 @@ class QLiberationMap(QGraphicsView):
def connectSignals(self):
GameUpdateSignal.get_instance().gameupdated.connect(self.setGame)
- def setGame(self, game: Game):
+ def setGame(self, game: Optional[Game]):
self.game = game
print("Reloading Map Canvas")
if self.game is not None:
@@ -176,8 +183,7 @@ class QLiberationMap(QGraphicsView):
if self.get_display_rule("lines"):
self.scene_create_lines_for_cp(cp, playerColor, enemyColor)
- if self.get_display_rule("flight_paths"):
- self.draw_flight_plans(scene)
+ self.draw_flight_plans(scene)
for cp in self.game.theater.controlpoints:
pos = self._transform_point(cp.position)
@@ -188,6 +194,15 @@ class QLiberationMap(QGraphicsView):
text.setPos(pos[0] + CONST.CP_SIZE + 1, pos[1] - CONST.CP_SIZE / 2 + 1)
def draw_flight_plans(self, scene) -> None:
+ for item in self.flight_path_items:
+ try:
+ scene.removeItem(item)
+ except RuntimeError:
+ # Something may have caused those items to already be removed.
+ pass
+ self.flight_path_items.clear()
+ if not self.get_display_rule("flight_paths"):
+ return
for package in self.game_model.ato_model.packages:
for flight in package.flights:
self.draw_flight_plan(scene, flight)
@@ -209,17 +224,21 @@ class QLiberationMap(QGraphicsView):
player: bool) -> None:
waypoint_pen = self.waypoint_pen(player)
waypoint_brush = self.waypoint_brush(player)
- scene.addEllipse(position[0], position[1], self.WAYPOINT_SIZE,
- self.WAYPOINT_SIZE, waypoint_pen, waypoint_brush)
+ self.flight_path_items.append(scene.addEllipse(
+ position[0], position[1], self.WAYPOINT_SIZE,
+ self.WAYPOINT_SIZE, waypoint_pen, waypoint_brush
+ ))
def draw_flight_path(self, scene: QGraphicsScene, pos0: Tuple[int, int],
pos1: Tuple[int, int], player: bool):
flight_path_pen = self.flight_path_pen(player)
# Draw the line to the *middle* of the waypoint.
offset = self.WAYPOINT_SIZE // 2
- scene.addLine(pos0[0] + offset, pos0[1] + offset,
- pos1[0] + offset, pos1[1] + offset,
- flight_path_pen)
+ self.flight_path_items.append(scene.addLine(
+ pos0[0] + offset, pos0[1] + offset,
+ pos1[0] + offset, pos1[1] + offset,
+ flight_path_pen
+ ))
def scene_create_lines_for_cp(self, cp: ControlPoint, playerColor, enemyColor):
scene = self.scene()
diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py
index dd32dd58..8a52d555 100644
--- a/qt_ui/windows/GameUpdateSignal.py
+++ b/qt_ui/windows/GameUpdateSignal.py
@@ -1,3 +1,7 @@
+from __future__ import annotations
+
+from typing import Optional
+
from PySide2.QtCore import QObject, Signal
from game import Game
@@ -19,21 +23,31 @@ class GameUpdateSignal(QObject):
budgetupdated = Signal(Game)
debriefingReceived = Signal(DebriefingSignal)
+ flight_paths_changed = Signal()
+
def __init__(self):
super(GameUpdateSignal, self).__init__()
GameUpdateSignal.instance = self
- def updateGame(self, game: Game):
+ def redraw_flight_paths(self) -> None:
+ # noinspection PyUnresolvedReferences
+ self.flight_paths_changed.emit()
+
+ def updateGame(self, game: Optional[Game]):
+ # noinspection PyUnresolvedReferences
self.gameupdated.emit(game)
def updateBudget(self, game: Game):
+ # noinspection PyUnresolvedReferences
self.budgetupdated.emit(game)
def sendDebriefing(self, game: Game, gameEvent: Event, debriefing: Debriefing):
sig = DebriefingSignal(game, gameEvent, debriefing)
+ # noinspection PyUnresolvedReferences
self.gameupdated.emit(game)
+ # noinspection PyUnresolvedReferences
self.debriefingReceived.emit(sig)
@staticmethod
- def get_instance():
+ def get_instance() -> GameUpdateSignal:
return GameUpdateSignal.instance
diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py
index 9543ab66..50a4b120 100644
--- a/qt_ui/windows/QLiberationWindow.py
+++ b/qt_ui/windows/QLiberationWindow.py
@@ -232,6 +232,8 @@ class QLiberationWindow(QMainWindow):
sys.exit(0)
def setGame(self, game: Optional[Game]):
+ if game is not None:
+ game.on_load()
self.game = game
if self.info_panel:
self.info_panel.setGame(game)
diff --git a/qt_ui/windows/mission/QChooseAirbase.py b/qt_ui/windows/mission/QChooseAirbase.py
deleted file mode 100644
index 50a86538..00000000
--- a/qt_ui/windows/mission/QChooseAirbase.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from PySide2.QtCore import Signal
-from PySide2.QtWidgets import QGroupBox, QHBoxLayout, QComboBox, QLabel
-
-from game import Game
-
-
-class QChooseAirbase(QGroupBox):
-
- selected_airbase_changed = Signal(str)
-
- def __init__(self, game:Game, title=""):
- super(QChooseAirbase, self).__init__(title)
- self.game = game
-
- self.layout = QHBoxLayout()
- self.depart_from_label = QLabel("Airbase : ")
- self.depart_from = QComboBox()
-
- for i, cp in enumerate([b for b in self.game.theater.controlpoints if b.captured and b.id in self.game.planners]):
- self.depart_from.addItem(str(cp.name), cp)
- self.depart_from.setCurrentIndex(0)
- self.depart_from.currentTextChanged.connect(self._on_airbase_selected)
- self.layout.addWidget(self.depart_from_label)
- self.layout.addWidget(self.depart_from)
- self.setLayout(self.layout)
-
- def _on_airbase_selected(self):
- selected = self.depart_from.currentText()
- self.selected_airbase_changed.emit(selected)
-
-
-
diff --git a/qt_ui/windows/mission/QEditFlightDialog.py b/qt_ui/windows/mission/QEditFlightDialog.py
index 29e4eafb..9f795b79 100644
--- a/qt_ui/windows/mission/QEditFlightDialog.py
+++ b/qt_ui/windows/mission/QEditFlightDialog.py
@@ -5,15 +5,17 @@ from PySide2.QtWidgets import (
)
from game import Game
+from gen.ato import Package
from gen.flights.flight import Flight
from qt_ui.uiconstants import EVENT_ICONS
+from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner
class QEditFlightDialog(QDialog):
"""Dialog window for editing flight plans and loadouts."""
- def __init__(self, game: Game, flight: Flight) -> None:
+ def __init__(self, game: Game, package: Package, flight: Flight) -> None:
super().__init__()
self.game = game
@@ -23,7 +25,12 @@ class QEditFlightDialog(QDialog):
layout = QVBoxLayout()
- self.flight_planner = QFlightPlanner(flight, game)
+ self.flight_planner = QFlightPlanner(package, flight, game)
layout.addWidget(self.flight_planner)
self.setLayout(layout)
+ self.finished.connect(self.on_close)
+
+ @staticmethod
+ def on_close(_result) -> None:
+ GameUpdateSignal.get_instance().redraw_flight_paths()
diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py
index 2b27a035..21a44aa3 100644
--- a/qt_ui/windows/mission/QPackageDialog.py
+++ b/qt_ui/windows/mission/QPackageDialog.py
@@ -14,9 +14,11 @@ from PySide2.QtWidgets import (
from game.game import Game
from gen.ato import Package
from gen.flights.flight import Flight
+from gen.flights.flightplan import FlightPlanBuilder
from qt_ui.models import AtoModel, PackageModel
from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.widgets.ato import QFlightList
+from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.mission.flight.QFlightCreator import QFlightCreator
from theater.missiontarget import MissionTarget
@@ -86,6 +88,12 @@ class QPackageDialog(QDialog):
self.setLayout(self.layout)
+ self.finished.connect(self.on_close)
+
+ @staticmethod
+ def on_close(_result) -> None:
+ GameUpdateSignal.get_instance().redraw_flight_paths()
+
def on_selection_changed(self, selected: QItemSelection,
_deselected: QItemSelection) -> None:
"""Updates the state of the delete button."""
@@ -93,15 +101,17 @@ class QPackageDialog(QDialog):
def on_add_flight(self) -> None:
"""Opens the new flight dialog."""
- self.add_flight_dialog = QFlightCreator(
- self.game, self.package_model.package
- )
+ self.add_flight_dialog = QFlightCreator(self.game,
+ self.package_model.package)
self.add_flight_dialog.created.connect(self.add_flight)
self.add_flight_dialog.show()
def add_flight(self, flight: Flight) -> None:
"""Adds the new flight to the package."""
self.package_model.add_flight(flight)
+ planner = FlightPlanBuilder(self.game, self.package_model.package,
+ is_player=True)
+ planner.populate_flight_plan(flight)
# noinspection PyUnresolvedReferences
self.package_changed.emit()
# noinspection PyUnresolvedReferences
diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py
index f1041071..fc3f9415 100644
--- a/qt_ui/windows/mission/flight/QFlightCreator.py
+++ b/qt_ui/windows/mission/flight/QFlightCreator.py
@@ -1,4 +1,3 @@
-import logging
from typing import Optional
from PySide2.QtCore import Qt, Signal
@@ -11,15 +10,14 @@ from dcs.planes import PlaneType
from game import Game
from gen.ato import Package
-from gen.flights.ai_flight_planner import FlightPlanner
-from gen.flights.flight import Flight, FlightType
+from gen.flights.flight import Flight
from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.widgets.QFlightSizeSpinner import QFlightSizeSpinner
from qt_ui.widgets.QLabeledWidget import QLabeledWidget
from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector
from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox
from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector
-from theater import ControlPoint, FrontLine, TheaterGroundObject
+from theater import ControlPoint
class QFlightCreator(QDialog):
@@ -29,7 +27,6 @@ class QFlightCreator(QDialog):
super().__init__()
self.game = game
- self.package = package
self.setWindowTitle("Create flight")
self.setWindowIcon(EVENT_ICONS["strike"])
@@ -37,7 +34,7 @@ class QFlightCreator(QDialog):
layout = QVBoxLayout()
self.task_selector = QFlightTypeComboBox(
- self.game.theater, self.package.target
+ self.game.theater, package.target
)
self.task_selector.setCurrentIndex(0)
layout.addLayout(QLabeledWidget("Task:", self.task_selector))
@@ -93,7 +90,6 @@ class QFlightCreator(QDialog):
size = self.flight_size_spinner.value()
flight = Flight(aircraft, size, origin, task)
- self.populate_flight_plan(flight, task)
# noinspection PyUnresolvedReferences
self.created.emit(flight)
@@ -102,77 +98,3 @@ class QFlightCreator(QDialog):
def on_aircraft_changed(self, index: int) -> None:
new_aircraft = self.aircraft_selector.itemData(index)
self.airfield_selector.change_aircraft(new_aircraft)
-
- @property
- def planner(self) -> FlightPlanner:
- return self.game.planners[self.airfield_selector.currentData().id]
-
- def populate_flight_plan(self, flight: Flight, task: FlightType) -> None:
- # TODO: Flesh out mission types.
- if task == FlightType.ANTISHIP:
- logging.error("Anti-ship flight plan generation not implemented")
- elif task == FlightType.BAI:
- logging.error("BAI flight plan generation not implemented")
- elif task == FlightType.BARCAP:
- self.generate_cap(flight)
- elif task == FlightType.CAP:
- self.generate_cap(flight)
- elif task == FlightType.CAS:
- self.generate_cas(flight)
- elif task == FlightType.DEAD:
- self.generate_sead(flight)
- elif task == FlightType.ELINT:
- logging.error("ELINT flight plan generation not implemented")
- elif task == FlightType.EVAC:
- logging.error("Evac flight plan generation not implemented")
- elif task == FlightType.EWAR:
- logging.error("EWar flight plan generation not implemented")
- elif task == FlightType.INTERCEPTION:
- logging.error("Intercept flight plan generation not implemented")
- elif task == FlightType.LOGISTICS:
- logging.error("Logistics flight plan generation not implemented")
- elif task == FlightType.RECON:
- logging.error("Recon flight plan generation not implemented")
- elif task == FlightType.SEAD:
- self.generate_sead(flight)
- elif task == FlightType.STRIKE:
- self.generate_strike(flight)
- elif task == FlightType.TARCAP:
- self.generate_cap(flight)
- elif task == FlightType.TROOP_TRANSPORT:
- logging.error(
- "Troop transport flight plan generation not implemented"
- )
-
- def generate_cas(self, flight: Flight) -> None:
- if not isinstance(self.package.target, FrontLine):
- logging.error(
- "Could not create flight plan: CAS missions only valid for "
- "front lines"
- )
- return
- self.planner.generate_cas(flight, self.package.target)
-
- def generate_cap(self, flight: Flight) -> None:
- if isinstance(self.package.target, TheaterGroundObject):
- logging.error(
- "Could not create flight plan: CAP missions for strike targets "
- "not implemented"
- )
- return
- if isinstance(self.package.target, FrontLine):
- self.planner.generate_frontline_cap(flight, self.package.target)
- else:
- self.planner.generate_barcap(flight, self.package.target)
-
- def generate_sead(self, flight: Flight) -> None:
- self.planner.generate_sead(flight, self.package.target)
-
- def generate_strike(self, flight: Flight) -> None:
- if not isinstance(self.package.target, TheaterGroundObject):
- logging.error(
- "Could not create flight plan: strike missions for capture "
- "points not implemented"
- )
- return
- self.planner.generate_strike(flight, self.package.target)
diff --git a/qt_ui/windows/mission/flight/QFlightPlanner.py b/qt_ui/windows/mission/flight/QFlightPlanner.py
index 4eed4754..af48219c 100644
--- a/qt_ui/windows/mission/flight/QFlightPlanner.py
+++ b/qt_ui/windows/mission/flight/QFlightPlanner.py
@@ -2,6 +2,7 @@ from PySide2.QtCore import Signal
from PySide2.QtWidgets import QTabWidget
from game import Game
+from gen.ato import Package
from gen.flights.flight import Flight
from qt_ui.windows.mission.flight.payload.QFlightPayloadTab import \
QFlightPayloadTab
@@ -15,14 +16,14 @@ class QFlightPlanner(QTabWidget):
on_planned_flight_changed = Signal()
- def __init__(self, flight: Flight, game: Game):
+ def __init__(self, package: Package, flight: Flight, game: Game):
super().__init__()
self.general_settings_tab = QGeneralFlightSettingsTab(game, flight)
self.general_settings_tab.on_flight_settings_changed.connect(
lambda: self.on_planned_flight_changed.emit())
self.payload_tab = QFlightPayloadTab(flight, game)
- self.waypoint_tab = QFlightWaypointTab(game, flight)
+ self.waypoint_tab = QFlightWaypointTab(game, package, flight)
self.waypoint_tab.on_flight_changed.connect(
lambda: self.on_planned_flight_changed.emit())
self.addTab(self.general_settings_tab, "General Flight settings")
diff --git a/qt_ui/windows/mission/flight/generator/QAbstractMissionGenerator.py b/qt_ui/windows/mission/flight/generator/QAbstractMissionGenerator.py
deleted file mode 100644
index 8a69d4cd..00000000
--- a/qt_ui/windows/mission/flight/generator/QAbstractMissionGenerator.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from PySide2.QtCore import Qt
-from PySide2.QtWidgets import QDialog, QPushButton
-
-from game import Game
-from gen.flights.flight import Flight
-from qt_ui.uiconstants import EVENT_ICONS
-from qt_ui.windows.mission.flight.waypoints.QFlightWaypointInfoBox import QFlightWaypointInfoBox
-
-
-class QAbstractMissionGenerator(QDialog):
-
- def __init__(self, game: Game, flight: Flight, flight_waypoint_list, title):
- super(QAbstractMissionGenerator, self).__init__()
- self.game = game
- self.flight = flight
- self.setWindowFlags(Qt.WindowStaysOnTopHint)
- self.setMinimumSize(400, 250)
- self.setModal(True)
- self.setWindowTitle(title)
- self.setWindowIcon(EVENT_ICONS["strike"])
- self.flight_waypoint_list = flight_waypoint_list
- self.planner = self.game.planners[self.flight.from_cp.id]
-
- self.selected_waypoints = []
- self.wpt_info = QFlightWaypointInfoBox()
-
- self.ok_button = QPushButton("Ok")
- self.ok_button.clicked.connect(self.apply)
-
- def on_select_wpt_changed(self):
- self.selected_waypoints = self.wpt_selection_box.get_selected_waypoints(False)
- if self.selected_waypoints is None or len(self.selected_waypoints) <= 0:
- self.ok_button.setDisabled(True)
- else:
- self.wpt_info.set_flight_waypoint(self.selected_waypoints[0])
- self.ok_button.setDisabled(False)
-
- def apply(self):
- raise NotImplementedError()
-
-
-
-
diff --git a/qt_ui/windows/mission/flight/generator/QCAPMissionGenerator.py b/qt_ui/windows/mission/flight/generator/QCAPMissionGenerator.py
deleted file mode 100644
index c1f5591e..00000000
--- a/qt_ui/windows/mission/flight/generator/QCAPMissionGenerator.py
+++ /dev/null
@@ -1,52 +0,0 @@
-from PySide2.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout
-
-from game import Game
-from gen.flights.flight import Flight, PredefinedWaypointCategory
-from qt_ui.widgets.combos.QPredefinedWaypointSelectionComboBox import QPredefinedWaypointSelectionComboBox
-from qt_ui.windows.mission.flight.generator.QAbstractMissionGenerator import QAbstractMissionGenerator
-
-
-class QCAPMissionGenerator(QAbstractMissionGenerator):
-
- def __init__(self, game: Game, flight: Flight, flight_waypoint_list):
- super(QCAPMissionGenerator, self).__init__(game, flight, flight_waypoint_list, "CAP Generator")
-
- self.wpt_selection_box = QPredefinedWaypointSelectionComboBox(self.game, self, False, True, True, False, False, True)
- self.wpt_selection_box.setMinimumWidth(200)
- self.wpt_selection_box.currentTextChanged.connect(self.on_select_wpt_changed)
-
- self.init_ui()
- self.on_select_wpt_changed()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- wpt_layout = QHBoxLayout()
- wpt_layout.addWidget(QLabel("CAP mission on : "))
- wpt_layout.addWidget(self.wpt_selection_box)
- wpt_layout.addStretch()
-
- layout.addLayout(wpt_layout)
- layout.addWidget(self.wpt_info)
- layout.addStretch()
- layout.addWidget(self.ok_button)
-
- self.setLayout(layout)
-
- def apply(self):
- self.flight.points = []
-
- wpt = self.selected_waypoints[0]
- if wpt.category == PredefinedWaypointCategory.FRONTLINE:
- self.planner.generate_frontline_cap(self.flight, wpt.data[0], wpt.data[1])
- elif wpt.category == PredefinedWaypointCategory.ALLY_CP:
- self.planner.generate_barcap(self.flight, wpt.data)
- else:
- return
-
- self.flight_waypoint_list.update_list()
- self.close()
-
-
-
-
diff --git a/qt_ui/windows/mission/flight/generator/QCASMissionGenerator.py b/qt_ui/windows/mission/flight/generator/QCASMissionGenerator.py
deleted file mode 100644
index cfae4e52..00000000
--- a/qt_ui/windows/mission/flight/generator/QCASMissionGenerator.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from PySide2.QtGui import Qt
-from PySide2.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout, QGroupBox
-from dcs import Point
-
-from game import Game
-from game.utils import meter_to_nm
-from gen.flights.flight import Flight
-from qt_ui.widgets.combos.QPredefinedWaypointSelectionComboBox import QPredefinedWaypointSelectionComboBox
-from qt_ui.windows.mission.flight.generator.QAbstractMissionGenerator import QAbstractMissionGenerator
-
-
-class QCASMissionGenerator(QAbstractMissionGenerator):
-
- def __init__(self, game: Game, flight: Flight, flight_waypoint_list):
- super(QCASMissionGenerator, self).__init__(game, flight, flight_waypoint_list, "CAS Generator")
-
- self.wpt_selection_box = QPredefinedWaypointSelectionComboBox(self.game, self, False, False, True, False, False)
- self.wpt_selection_box.setMinimumWidth(200)
- self.wpt_selection_box.currentTextChanged.connect(self.on_select_wpt_changed)
-
- self.distanceToTargetLabel = QLabel("0 nm")
- self.init_ui()
- self.on_select_wpt_changed()
-
- def on_select_wpt_changed(self):
- super(QCASMissionGenerator, self).on_select_wpt_changed()
- wpts = self.wpt_selection_box.get_selected_waypoints()
-
- if len(wpts) > 0:
- self.distanceToTargetLabel.setText("~" + str(meter_to_nm(self.flight.from_cp.position.distance_to_point(Point(wpts[0].x, wpts[0].y)))) + " nm")
- else:
- self.distanceToTargetLabel.setText("??? nm")
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- wpt_layout = QHBoxLayout()
- wpt_layout.addWidget(QLabel("CAS : "))
- wpt_layout.addWidget(self.wpt_selection_box)
- wpt_layout.addStretch()
-
- distToTargetBox = QGroupBox("Infos :")
- distToTarget = QHBoxLayout()
- distToTarget.addWidget(QLabel("Distance to target : "))
- distToTarget.addStretch()
- distToTarget.addWidget(self.distanceToTargetLabel, alignment=Qt.AlignRight)
- distToTargetBox.setLayout(distToTarget)
-
- layout.addLayout(wpt_layout)
- layout.addWidget(self.wpt_info)
- layout.addWidget(distToTargetBox)
- layout.addStretch()
- layout.addWidget(self.ok_button)
-
- self.setLayout(layout)
-
- def apply(self):
- self.flight.points = []
- self.planner.generate_cas(self.flight, self.selected_waypoints[0].data[0], self.selected_waypoints[0].data[1])
- self.flight_waypoint_list.update_list()
- self.close()
-
-
-
-
diff --git a/qt_ui/windows/mission/flight/generator/QSEADMissionGenerator.py b/qt_ui/windows/mission/flight/generator/QSEADMissionGenerator.py
deleted file mode 100644
index 7221844c..00000000
--- a/qt_ui/windows/mission/flight/generator/QSEADMissionGenerator.py
+++ /dev/null
@@ -1,84 +0,0 @@
-from PySide2.QtGui import Qt
-from PySide2.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout, QGroupBox
-
-from game import Game
-from game.utils import meter_to_nm
-from gen.flights.flight import Flight
-from qt_ui.widgets.combos.QSEADTargetSelectionComboBox import QSEADTargetSelectionComboBox
-from qt_ui.widgets.views.QSeadTargetInfoView import QSeadTargetInfoView
-from qt_ui.windows.mission.flight.generator.QAbstractMissionGenerator import QAbstractMissionGenerator
-
-
-class QSEADMissionGenerator(QAbstractMissionGenerator):
-
- def __init__(self, game: Game, flight: Flight, flight_waypoint_list):
- super(QSEADMissionGenerator, self).__init__(game, flight, flight_waypoint_list, "SEAD/DEAD Generator")
-
- self.tgt_selection_box = QSEADTargetSelectionComboBox(self.game)
- self.tgt_selection_box.setMinimumWidth(200)
- self.tgt_selection_box.currentTextChanged.connect(self.on_selected_target_changed)
-
- self.distanceToTargetLabel = QLabel("0 nm")
- self.threatRangeLabel = QLabel("0 nm")
- self.detectionRangeLabel = QLabel("0 nm")
- self.seadTargetInfoView = QSeadTargetInfoView(None)
- self.init_ui()
- self.on_selected_target_changed()
-
- def on_selected_target_changed(self):
- target = self.tgt_selection_box.get_selected_target()
- if target is not None:
- self.distanceToTargetLabel.setText("~" + str(meter_to_nm(self.flight.from_cp.position.distance_to_point(target.location.position))) + " nm")
- self.threatRangeLabel.setText(str(meter_to_nm(target.threat_range)) + " nm")
- self.detectionRangeLabel.setText(str(meter_to_nm(target.detection_range)) + " nm")
- self.seadTargetInfoView.setTarget(target)
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- wpt_layout = QHBoxLayout()
- wpt_layout.addWidget(QLabel("SEAD/DEAD target : "))
- wpt_layout.addStretch()
- wpt_layout.addWidget(self.tgt_selection_box, alignment=Qt.AlignRight)
-
- distThreatBox = QGroupBox("Infos :")
- threatLayout = QVBoxLayout()
-
- distToTarget = QHBoxLayout()
- distToTarget.addWidget(QLabel("Distance to site : "))
- distToTarget.addStretch()
- distToTarget.addWidget(self.distanceToTargetLabel, alignment=Qt.AlignRight)
-
- threatRangeLayout = QHBoxLayout()
- threatRangeLayout.addWidget(QLabel("Site threat range : "))
- threatRangeLayout.addStretch()
- threatRangeLayout.addWidget(self.threatRangeLabel, alignment=Qt.AlignRight)
-
- detectionRangeLayout = QHBoxLayout()
- detectionRangeLayout.addWidget(QLabel("Site radar detection range: "))
- detectionRangeLayout.addStretch()
- detectionRangeLayout.addWidget(self.detectionRangeLabel, alignment=Qt.AlignRight)
-
- threatLayout.addLayout(distToTarget)
- threatLayout.addLayout(threatRangeLayout)
- threatLayout.addLayout(detectionRangeLayout)
- distThreatBox.setLayout(threatLayout)
-
- layout.addLayout(wpt_layout)
- layout.addWidget(self.seadTargetInfoView)
- layout.addWidget(distThreatBox)
- layout.addStretch()
- layout.addWidget(self.ok_button)
-
- self.setLayout(layout)
-
- def apply(self):
- self.flight.points = []
- target = self.tgt_selection_box.get_selected_target()
- self.planner.generate_sead(self.flight, target.location, target.radars)
- self.flight_waypoint_list.update_list()
- self.close()
-
-
-
-
diff --git a/qt_ui/windows/mission/flight/generator/QSTRIKEMissionGenerator.py b/qt_ui/windows/mission/flight/generator/QSTRIKEMissionGenerator.py
deleted file mode 100644
index 6da88e0b..00000000
--- a/qt_ui/windows/mission/flight/generator/QSTRIKEMissionGenerator.py
+++ /dev/null
@@ -1,64 +0,0 @@
-from PySide2.QtGui import Qt
-from PySide2.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout, QGroupBox
-
-from game import Game
-from game.utils import meter_to_nm
-from gen.flights.flight import Flight
-from qt_ui.widgets.combos.QStrikeTargetSelectionComboBox import QStrikeTargetSelectionComboBox
-from qt_ui.widgets.views.QStrikeTargetInfoView import QStrikeTargetInfoView
-from qt_ui.windows.mission.flight.generator.QAbstractMissionGenerator import QAbstractMissionGenerator
-
-
-class QSTRIKEMissionGenerator(QAbstractMissionGenerator):
-
- def __init__(self, game: Game, flight: Flight, flight_waypoint_list):
- super(QSTRIKEMissionGenerator, self).__init__(game, flight, flight_waypoint_list, "Strike Generator")
-
- self.tgt_selection_box = QStrikeTargetSelectionComboBox(self.game)
- self.tgt_selection_box.setMinimumWidth(200)
- self.tgt_selection_box.currentTextChanged.connect(self.on_selected_target_changed)
-
-
- self.distanceToTargetLabel = QLabel("0 nm")
- self.strike_infos = QStrikeTargetInfoView(None)
- self.init_ui()
- self.on_selected_target_changed()
-
- def on_selected_target_changed(self):
- target = self.tgt_selection_box.get_selected_target()
- self.distanceToTargetLabel.setText("~" + str(meter_to_nm(self.flight.from_cp.position.distance_to_point(target.location.position))) + " nm")
- self.strike_infos.setTarget(target)
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- wpt_layout = QHBoxLayout()
- wpt_layout.addWidget(QLabel("Target : "))
- wpt_layout.addStretch()
- wpt_layout.addWidget(self.tgt_selection_box, alignment=Qt.AlignRight)
-
- distToTargetBox = QGroupBox("Infos :")
- distToTarget = QHBoxLayout()
- distToTarget.addWidget(QLabel("Distance to target : "))
- distToTarget.addStretch()
- distToTarget.addWidget(self.distanceToTargetLabel, alignment=Qt.AlignRight)
- distToTargetBox.setLayout(distToTarget)
-
- layout.addLayout(wpt_layout)
- layout.addWidget(self.strike_infos)
- layout.addWidget(distToTargetBox)
- layout.addStretch()
- layout.addWidget(self.ok_button)
-
- self.setLayout(layout)
-
- def apply(self):
- self.flight.points = []
- target = self.tgt_selection_box.get_selected_target()
- self.planner.generate_strike(self.flight, target.location)
- self.flight_waypoint_list.update_list()
- self.close()
-
-
-
-
diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py
index 69870d1c..2879c356 100644
--- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py
+++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py
@@ -1,75 +1,98 @@
+from typing import List, Optional
+
from PySide2.QtCore import Signal
-from PySide2.QtWidgets import QFrame, QGridLayout, QLabel, QPushButton, QVBoxLayout
+from PySide2.QtWidgets import (
+ QFrame,
+ QGridLayout,
+ QLabel,
+ QMessageBox,
+ QPushButton,
+ QVBoxLayout,
+)
from game import Game
-from gen.flights.flight import Flight
-from qt_ui.windows.mission.flight.generator.QCAPMissionGenerator import QCAPMissionGenerator
-from qt_ui.windows.mission.flight.generator.QCASMissionGenerator import QCASMissionGenerator
-from qt_ui.windows.mission.flight.generator.QSEADMissionGenerator import QSEADMissionGenerator
-from qt_ui.windows.mission.flight.generator.QSTRIKEMissionGenerator import QSTRIKEMissionGenerator
-from qt_ui.windows.mission.flight.waypoints.QFlightWaypointList import QFlightWaypointList
-from qt_ui.windows.mission.flight.waypoints.QPredefinedWaypointSelectionWindow import QPredefinedWaypointSelectionWindow
+from gen.ato import Package
+from gen.flights.flight import Flight, FlightType
+from gen.flights.flightplan import FlightPlanBuilder
+from qt_ui.windows.mission.flight.waypoints.QFlightWaypointList import \
+ QFlightWaypointList
+from qt_ui.windows.mission.flight.waypoints.QPredefinedWaypointSelectionWindow import \
+ QPredefinedWaypointSelectionWindow
+from theater import ControlPoint, FrontLine
class QFlightWaypointTab(QFrame):
on_flight_changed = Signal()
- def __init__(self, game: Game, flight: Flight):
+ def __init__(self, game: Game, package: Package, flight: Flight):
super(QFlightWaypointTab, self).__init__()
- self.flight = flight
self.game = game
- self.planner = self.game.planners[self.flight.from_cp.id]
+ self.package = package
+ self.flight = flight
+ self.planner = FlightPlanBuilder(self.game, package, is_player=True)
+
+ self.flight_waypoint_list: Optional[QFlightWaypointList] = None
+ self.ascend_waypoint: Optional[QPushButton] = None
+ self.descend_waypoint: Optional[QPushButton] = None
+ self.rtb_waypoint: Optional[QPushButton] = None
+ self.delete_selected: Optional[QPushButton] = None
+ self.open_fast_waypoint_button: Optional[QPushButton] = None
+ self.recreate_buttons: List[QPushButton] = []
self.init_ui()
def init_ui(self):
layout = QGridLayout()
- rlayout = QVBoxLayout()
+
self.flight_waypoint_list = QFlightWaypointList(self.flight)
- self.open_fast_waypoint_button = QPushButton("Add Waypoint")
- self.open_fast_waypoint_button.clicked.connect(self.on_fast_waypoint)
-
- self.cas_generator = QPushButton("Gen. CAS")
- self.cas_generator.clicked.connect(self.on_cas_generator)
-
- self.cap_generator = QPushButton("Gen. CAP")
- self.cap_generator.clicked.connect(self.on_cap_generator)
-
- self.sead_generator = QPushButton("Gen. SEAD/DEAD")
- self.sead_generator.clicked.connect(self.on_sead_generator)
-
- self.strike_generator = QPushButton("Gen. STRIKE")
- self.strike_generator.clicked.connect(self.on_strike_generator)
-
- self.rtb_waypoint = QPushButton("Add RTB Waypoint")
- self.rtb_waypoint.clicked.connect(self.on_rtb_waypoint)
-
- self.ascend_waypoint = QPushButton("Add Ascend Waypoint")
- self.ascend_waypoint.clicked.connect(self.on_ascend_waypoint)
-
- self.descend_waypoint = QPushButton("Add Descend Waypoint")
- self.descend_waypoint.clicked.connect(self.on_descend_waypoint)
-
- self.delete_selected = QPushButton("Delete Selected")
- self.delete_selected.clicked.connect(self.on_delete_waypoint)
-
layout.addWidget(self.flight_waypoint_list, 0, 0)
+ rlayout = QVBoxLayout()
+ layout.addLayout(rlayout, 0, 1)
+
rlayout.addWidget(QLabel("Generator :"))
rlayout.addWidget(QLabel("AI compatible"))
- rlayout.addWidget(self.cas_generator)
- rlayout.addWidget(self.cap_generator)
- rlayout.addWidget(self.sead_generator)
- rlayout.addWidget(self.strike_generator)
+
+ self.recreate_buttons.clear()
+ recreate_types = [
+ FlightType.CAS,
+ FlightType.CAP,
+ FlightType.SEAD,
+ FlightType.STRIKE
+ ]
+ for task in recreate_types:
+ def make_closure(arg):
+ def closure():
+ return self.confirm_recreate(arg)
+ return closure
+ button = QPushButton(f"Recreate as {task.name}")
+ button.clicked.connect(make_closure(task))
+ rlayout.addWidget(button)
+ self.recreate_buttons.append(button)
+
rlayout.addWidget(QLabel("Advanced : "))
rlayout.addWidget(QLabel("Do not use for AI flights"))
+
+ self.ascend_waypoint = QPushButton("Add Ascend Waypoint")
+ self.ascend_waypoint.clicked.connect(self.on_ascend_waypoint)
rlayout.addWidget(self.ascend_waypoint)
+
+ self.descend_waypoint = QPushButton("Add Descend Waypoint")
+ self.descend_waypoint.clicked.connect(self.on_descend_waypoint)
rlayout.addWidget(self.descend_waypoint)
+
+ self.rtb_waypoint = QPushButton("Add RTB Waypoint")
+ self.rtb_waypoint.clicked.connect(self.on_rtb_waypoint)
rlayout.addWidget(self.rtb_waypoint)
- rlayout.addWidget(self.open_fast_waypoint_button)
+
+ self.delete_selected = QPushButton("Delete Selected")
+ self.delete_selected.clicked.connect(self.on_delete_waypoint)
rlayout.addWidget(self.delete_selected)
+
+ self.open_fast_waypoint_button = QPushButton("Add Waypoint")
+ self.open_fast_waypoint_button.clicked.connect(self.on_fast_waypoint)
+ rlayout.addWidget(self.open_fast_waypoint_button)
rlayout.addStretch()
- layout.addLayout(rlayout, 0, 1)
self.setLayout(layout)
def on_delete_waypoint(self):
@@ -102,25 +125,27 @@ class QFlightWaypointTab(QFrame):
self.flight_waypoint_list.update_list()
self.on_change()
- def on_cas_generator(self):
- self.subwindow = QCASMissionGenerator(self.game, self.flight, self.flight_waypoint_list)
- self.subwindow.finished.connect(self.on_change)
- self.subwindow.show()
-
- def on_cap_generator(self):
- self.subwindow = QCAPMissionGenerator(self.game, self.flight, self.flight_waypoint_list)
- self.subwindow.finished.connect(self.on_change)
- self.subwindow.show()
-
- def on_sead_generator(self):
- self.subwindow = QSEADMissionGenerator(self.game, self.flight, self.flight_waypoint_list)
- self.subwindow.finished.connect(self.on_change)
- self.subwindow.show()
-
- def on_strike_generator(self):
- self.subwindow = QSTRIKEMissionGenerator(self.game, self.flight, self.flight_waypoint_list)
- self.subwindow.finished.connect(self.on_change)
- self.subwindow.show()
+ def confirm_recreate(self, task: FlightType) -> None:
+ result = QMessageBox.question(
+ self,
+ "Regenerate flight?",
+ ("Changing the flight type will reset its flight plan. Do you want "
+ "to continue?"),
+ QMessageBox.No,
+ QMessageBox.Yes
+ )
+ if result == QMessageBox.Yes:
+ # TODO: These should all be just CAP.
+ if task == FlightType.CAP:
+ if isinstance(self.package.target, FrontLine):
+ task = FlightType.TARCAP
+ elif isinstance(self.package.target, ControlPoint):
+ if self.package.target.is_fleet:
+ task = FlightType.BARCAP
+ self.flight.flight_type = task
+ self.planner.populate_flight_plan(self.flight)
+ self.flight_waypoint_list.update_list()
+ self.on_change()
def on_change(self):
self.flight_waypoint_list.update_list()
diff --git a/theater/controlpoint.py b/theater/controlpoint.py
index a35cd698..fa211f47 100644
--- a/theater/controlpoint.py
+++ b/theater/controlpoint.py
@@ -52,7 +52,7 @@ class ControlPoint(MissionTarget):
self.id = id
self.name = " ".join(re.split(r" |-", name)[:2])
self.full_name = name
- self.position = position
+ self.position: Point = position
self.at = at
self.ground_objects = []
self.ships = []
@@ -212,3 +212,6 @@ class ControlPoint(MissionTarget):
if g.obj_name == obj_name:
found.append(g)
return found
+
+ def is_friendly(self, to_player: bool) -> bool:
+ return self.captured == to_player
diff --git a/theater/frontline.py b/theater/frontline.py
index 6350e1ab..c71ec4e3 100644
--- a/theater/frontline.py
+++ b/theater/frontline.py
@@ -1,9 +1,14 @@
"""Battlefield front lines."""
from typing import Tuple
+from dcs.mapping import Point
from . import ControlPoint, MissionTarget
+# TODO: Dedup by moving everything to using this class.
+FRONTLINE_MIN_CP_DISTANCE = 5000
+
+
class FrontLine(MissionTarget):
"""Defines a front line location between two control points.
@@ -25,3 +30,16 @@ class FrontLine(MissionTarget):
a = self.control_point_a.name
b = self.control_point_b.name
return f"Front line {a}/{b}"
+
+ @property
+ def position(self) -> Point:
+ a = self.control_point_a.position
+ b = self.control_point_b.position
+ attack_heading = a.heading_between_point(b)
+ attack_distance = a.distance_to_point(b)
+ middle_point = a.point_from_heading(attack_heading, attack_distance / 2)
+
+ strength_delta = (self.control_point_a.base.strength - self.control_point_b.base.strength) / 1.0
+ position = middle_point.point_from_heading(attack_heading,
+ strength_delta * attack_distance / 2 - FRONTLINE_MIN_CP_DISTANCE)
+ return position
diff --git a/theater/missiontarget.py b/theater/missiontarget.py
index 41c90ef9..b0a30aa0 100644
--- a/theater/missiontarget.py
+++ b/theater/missiontarget.py
@@ -1,4 +1,7 @@
+from __future__ import annotations
+
from abc import ABC, abstractmethod
+from dcs.mapping import Point
class MissionTarget(ABC):
@@ -9,3 +12,12 @@ class MissionTarget(ABC):
@abstractmethod
def name(self) -> str:
"""The name of the mission target."""
+
+ @property
+ @abstractmethod
+ def position(self) -> Point:
+ """The location of the mission target."""
+
+ def distance_to(self, other: MissionTarget) -> int:
+ """Computes the distance to the given mission target."""
+ return self.position.distance_to_point(other.position)