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)