From 1e041b6249aed8fc7d02013c2469acf43137c9ea Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 26 Sep 2020 13:17:34 -0700 Subject: [PATCH 01/10] Perform coalition-wide mission planning. Mission planning on a per-control point basis lacked the context it needed to make good decisions, and the ability to make larger missions that pulled aircraft from multiple airfields. The per-CP planners have been replaced in favor of a global planner per coalition. The planner generates a list of potential missions in order of priority and then allocates aircraft to the proposed flights until no missions remain. Mission planning behavior has changed: * CAP flights will now only be generated for airfields within a predefined threat range of an enemy airfield. * CAS, SEAD, and strike missions get escorts. Strike missions get a SEAD flight. * CAS, SEAD, and strike missions will not be planned unless they have an escort available. * Missions may originate from multiple airfields. There's more to do: * The range limitations imposed on the mission planner should take aircraft range limitations into account. * Air superiority aircraft like the F-15 should be preferred for CAP over multi-role aircraft like the F/A-18 since otherwise we run the risk of running out of ground attack capable aircraft even though there are still unused aircraft. * Mission priorities may need tuning. * Target areas could be analyzed for potential threats, allowing escort flights to be optional or omitted if there is no threat to defend against. For example, late game a SEAD flight for a strike mission probably is not necessary. * SAM threat should be judged by how close the extent of the SAM's range is to friendly locations, not the distance to the site itself. An SA-10 30 nm away is more threatening than an SA-6 25 nm away. * Much of the planning behavior should be factored out into the coalition's doctrine. But as-is this is an improvement over the existing behavior, so those things can be follow ups. The potential regression in behavior here is that we're no longer planning multiple cycles of missions. Each objective will get one CAP. I think this fits better with the turn cycle of the game, as a CAP flight should be able to remain on station for the duration of the turn (especially with refueling). Note that this does break save compatibility as the old planner was a part of the game object, and since that class is now gone it can't be unpickled. --- game/db.py | 2 +- game/game.py | 16 +- game/operation/operation.py | 15 +- gen/aircraft.py | 42 +- gen/flights/ai_flight_planner.py | 1074 +++++++---------- gen/flights/flightplan.py | 582 +++++++++ qt_ui/windows/mission/QChooseAirbase.py | 32 - .../windows/mission/flight/QFlightCreator.py | 80 +- .../generator/QAbstractMissionGenerator.py | 3 +- .../flight/waypoints/QFlightWaypointTab.py | 3 +- theater/controlpoint.py | 5 +- theater/frontline.py | 18 + theater/missiontarget.py | 12 + 13 files changed, 1070 insertions(+), 814 deletions(-) create mode 100644 gen/flights/flightplan.py delete mode 100644 qt_ui/windows/mission/QChooseAirbase.py 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..0226de89 100644 --- a/game/game.py +++ b/game/game.py @@ -4,11 +4,12 @@ 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.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 +71,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)) @@ -104,11 +104,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): @@ -244,16 +244,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 c0ce5e67..c86efacb 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -189,15 +189,16 @@ class Operation: side = cp.captured if side: country = self.current_mission.country(self.game.player_country) + ato = self.game.blue_ato else: country = self.current_mission.country(self.game.enemy_country) - if cp.id in self.game.planners.keys(): - self.airgen.generate_flights( - cp, - country, - self.game.planners[cp.id], - self.groundobjectgen.runways - ) + ato = self.game.red_ato + self.airgen.generate_flights( + cp, + country, + 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..04301de7 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,27 @@ 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, cp, 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(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: diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 29deb1f4..6e97e91d 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -1,705 +1,455 @@ -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.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: +class ClosestAirfields: + """Precalculates which control points are closes to the given target.""" - 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]] = [] - - if from_cp.captured: - self.faction = self.game.player_faction - else: - self.faction = self.game.enemy_faction - - 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) + 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) ) - for i, aircraft in enumerate(self.alloc_aircraft(num_strike, 2, STRIKE_CAPABLE)): - if aircraft in DRONES: - count = 1 + + 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: - count = 2 + break - 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) - location = self.potential_strike_targets[0][0] - self.potential_strike_targets.pop(0) +@dataclass(frozen=True) +class ProposedFlight: + """A flight outline proposed by the mission planner. - self.generate_strike(flight, location) - self.plan_legacy_mission(flight, location) + 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. + """ - def _get_cas_locations(self) -> List[FrontLine]: - return self._get_cas_locations_for_cp(self.from_cp) + #: 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 + else: + logging.error(f"Unplannable flight type: {flight.task}") + return None + + # 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 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 + + return None + + +class PackageBuilder: + """Builds a Package for the flights it receives.""" + + 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 + # TODO: Cache globally at startup to avoid generating twice per turn? + self.closest_airfields: Dict[str, ClosestAirfields] = { + t.name: ClosestAirfields(t, self.game.theater.controlpoints) + for t in self.all_possible_targets() + } + + 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[cp.name] + 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 self.closest_airfields[location.name] + + +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.CAP, 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.CAP, 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() + for flight in package.flights: + builder = FlightPlanBuilder(self.game, self.is_player) + builder.populate_flight_plan(flight, package.target) + 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/flightplan.py b/gen/flights/flightplan.py new file mode 100644 index 00000000..c8bd988a --- /dev/null +++ b/gen/flights/flightplan.py @@ -0,0 +1,582 @@ +"""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 game.data.doctrine import MODERN_DOCTRINE +from .flight import Flight, FlightType, FlightWaypointType, FlightWaypoint +from ..conflictgen import Conflict +from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject +from game.utils import nm_to_meter +from dcs.unit import Unit + +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, is_player: bool) -> None: + self.game = game + if is_player: + faction = self.game.player_faction + else: + faction = self.game.enemy_faction + self.doctrine = faction.get("doctrine", MODERN_DOCTRINE) + + def populate_flight_plan(self, flight: Flight, + objective_location: MissionTarget) -> None: + """Creates a default flight plan for the given mission.""" + # 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, objective_location) + elif task == FlightType.CAP: + self.generate_barcap(flight, objective_location) + elif task == FlightType.CAS: + self.generate_cas(flight, objective_location) + elif task == FlightType.DEAD: + self.generate_sead(flight, objective_location) + 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, objective_location) + elif task == FlightType.STRIKE: + self.generate_strike(flight, objective_location) + elif task == FlightType.TARCAP: + self.generate_frontline_cap(flight, objective_location) + 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_strike(self, flight: Flight, location: MissionTarget) -> None: + """Generates a strike flight plan. + + Args: + flight: The flight to generate the flight plan for. + location: The strike target location. + """ + # TODO: Support airfield strikes. + if not isinstance(location, TheaterGroundObject): + raise InvalidObjectiveLocation(flight.flight_type, location) + + # TODO: Stop clobbering flight type. + 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.name + ingress_point.description = "INGRESS on " + location.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 = ( + f"STRIKE [{location.name}] : {u.type} #{j}" + ) + point.pretty_name = ( + f"STRIKE [{location.name}] : {u.type} #{j}" + ) + point.name = f"{location.name} #{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 + ) + for building in buildings: + if building.is_dead: + continue + + point = FlightWaypoint( + FlightWaypointType.TARGET_POINT, + building.position.x, + building.position.y, + 0 + ) + point.description = ( + f"STRIKE on {building.obj_name} {building.category} " + f"[{building.dcs_identifier}]" + ) + point.pretty_name = ( + f"STRIKE on {building.obj_name} {building.category} " + f"[{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.name + point.pretty_name = "STRIKE on " + location.name + point.name = location.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.name + egress_point.description = "EGRESS from " + location.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: Flight, location: MissionTarget) -> None: + """Generate a BARCAP flight at a given location. + + Args: + flight: The flight to generate the flight plan for. + location: The control point to protect. + """ + if isinstance(location, FrontLine): + raise InvalidObjectiveLocation(flight.flight_type, location) + + if isinstance(location, ControlPoint) and location.is_carrier: + flight.flight_type = FlightType.BARCAP + else: + flight.flight_type = FlightType.CAP + + patrol_alt = random.randint( + self.doctrine["PATROL_ALT_RANGE"][0], + self.doctrine["PATROL_ALT_RANGE"][1] + ) + + loc = location.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 = location.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(location) + + 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, + location: MissionTarget) -> None: + """Generate a CAP flight plan for the given front line. + + Args: + flight: The flight to generate the flight plan for. + location: Front line to protect. + """ + if not isinstance(location, FrontLine): + raise InvalidObjectiveLocation(flight.flight_type, location) + + ally_cp, enemy_cp = location.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 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: Flight, location: MissionTarget, + custom_targets: Optional[List[Unit]] = None) -> None: + """Generate a SEAD/DEAD flight at a given location. + + Args: + flight: The flight to generate the flight plan for. + location: Location of the SAM site. + custom_targets: Specific radar equipped units selected by the user. + """ + if not isinstance(location, TheaterGroundObject): + raise InvalidObjectiveLocation(flight.flight_type, location) + + if custom_targets is None: + custom_targets = [] + + 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.name + ingress_point.description = "INGRESS on " + location.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.name + point.only_for_player = True + else: + point.description = "SEAD on " + location.name + point.pretty_name = "SEAD on " + location.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 + ) + point.alt_type = "RADIO" + if flight.flight_type == FlightType.DEAD: + point.description = "DEAD on " + location.name + point.pretty_name = "DEAD on " + location.name + point.only_for_player = True + else: + point.description = "SEAD on " + location.name + point.pretty_name = "SEAD on " + location.name + point.only_for_player = True + ingress_point.targets.append(location) + ingress_point.targetGroup = 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.name + egress_point.description = "EGRESS from " + location.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_cas(self, flight: Flight, location: MissionTarget) -> None: + """Generate a CAS flight plan for the given target. + + Args: + flight: The flight to generate the flight plan for. + location: Front line with CAS targets. + """ + if not isinstance(location, FrontLine): + raise InvalidObjectiveLocation(flight.flight_type, location) + + from_cp, location = location.control_points + is_helo = getattr(flight.unit_type, "helicopter", False) + cap_alt = 1000 + flight.points = [] + flight.flight_type = FlightType.CAS + + ingress, heading, distance = Conflict.frontline_vector( + from_cp, location, self.game.theater + ) + center = ingress.point_from_heading(heading, distance / 2) + egress = ingress.point_from_heading(heading, distance) + + ascend = self.generate_ascend_point(flight.from_cp) + if is_helo: + cap_alt = 500 + ascend.alt = 500 + flight.points.append(ascend) + + 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) + + 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, departure: ControlPoint) -> FlightWaypoint: + """Generate ascend point. + + Args: + departure: Departure airfield or carrier. + """ + ascend_heading = departure.heading + pos_ascend = departure.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, arrival: ControlPoint) -> FlightWaypoint: + """Generate approach/descend point. + + Args: + arrival: Arrival airfield or carrier. + """ + ascend_heading = arrival.heading + descend = arrival.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 + + @staticmethod + def generate_rtb_waypoint(arrival: ControlPoint) -> FlightWaypoint: + """Generate RTB landing point. + + Args: + arrival: Arrival airfield or carrier. + """ + rtb = arrival.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 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/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index f1041071..24fb684f 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -11,7 +11,7 @@ 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.flightplan import FlightPlanBuilder from gen.flights.flight import Flight, FlightType from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.QFlightSizeSpinner import QFlightSizeSpinner @@ -31,6 +31,8 @@ class QFlightCreator(QDialog): self.game = game self.package = package + self.planner = FlightPlanBuilder(self.game, is_player=True) + self.setWindowTitle("Create flight") self.setWindowIcon(EVENT_ICONS["strike"]) @@ -93,7 +95,7 @@ class QFlightCreator(QDialog): size = self.flight_size_spinner.value() flight = Flight(aircraft, size, origin, task) - self.populate_flight_plan(flight, task) + self.planner.populate_flight_plan(flight, self.package.target) # noinspection PyUnresolvedReferences self.created.emit(flight) @@ -102,77 +104,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/generator/QAbstractMissionGenerator.py b/qt_ui/windows/mission/flight/generator/QAbstractMissionGenerator.py index 8a69d4cd..c18729c6 100644 --- a/qt_ui/windows/mission/flight/generator/QAbstractMissionGenerator.py +++ b/qt_ui/windows/mission/flight/generator/QAbstractMissionGenerator.py @@ -3,6 +3,7 @@ from PySide2.QtWidgets import QDialog, QPushButton from game import Game from gen.flights.flight import Flight +from gen.flights.flightplan import FlightPlanBuilder from qt_ui.uiconstants import EVENT_ICONS from qt_ui.windows.mission.flight.waypoints.QFlightWaypointInfoBox import QFlightWaypointInfoBox @@ -19,7 +20,7 @@ class QAbstractMissionGenerator(QDialog): 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.planner = FlightPlanBuilder(self.game, is_player=True) self.selected_waypoints = [] self.wpt_info = QFlightWaypointInfoBox() diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 69870d1c..9db48a09 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -3,6 +3,7 @@ from PySide2.QtWidgets import QFrame, QGridLayout, QLabel, QPushButton, QVBoxLay from game import Game from gen.flights.flight import Flight +from gen.flights.flightplan import FlightPlanBuilder 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 @@ -19,7 +20,7 @@ class QFlightWaypointTab(QFrame): super(QFlightWaypointTab, self).__init__() self.flight = flight self.game = game - self.planner = self.game.planners[self.flight.from_cp.id] + self.planner = FlightPlanBuilder(self.game, is_player=True) self.init_ui() def init_ui(self): 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) From aa309af0151dca72efd56ace1040286a461a589b Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 27 Sep 2020 18:35:16 -0700 Subject: [PATCH 02/10] Redraw flight plans when they change. --- qt_ui/widgets/ato.py | 3 ++ qt_ui/widgets/map/QLiberationMap.py | 36 ++++++++++++++++------ qt_ui/windows/GameUpdateSignal.py | 14 ++++++++- qt_ui/windows/mission/QEditFlightDialog.py | 6 ++++ qt_ui/windows/mission/QPackageDialog.py | 7 +++++ 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index bee61a43..ae9a5db3 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): @@ -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..3f5b026a 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, Tuple from PySide2.QtCore import Qt from PySide2.QtGui import QBrush, QColor, QPen, QPixmap, QWheelEvent from PySide2.QtWidgets import ( QFrame, + QGraphicsItem, QGraphicsOpacityEffect, QGraphicsScene, QGraphicsView, @@ -44,6 +44,8 @@ class QLiberationMap(QGraphicsView): QLiberationMap.instance = self self.game_model = game_model + self.flight_path_items: List[QGraphicsItem] = [] + self.setMinimumSize(800,600) self.setMaximumHeight(2160) self._zoom = 0 @@ -53,6 +55,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) @@ -176,8 +182,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 +193,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 +223,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..3e855149 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from PySide2.QtCore import QObject, Signal from game import Game @@ -19,21 +21,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 redraw_flight_paths(self) -> None: + # noinspection PyUnresolvedReferences + self.flight_paths_changed.emit() + def updateGame(self, game: 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/mission/QEditFlightDialog.py b/qt_ui/windows/mission/QEditFlightDialog.py index 29e4eafb..24fdfae2 100644 --- a/qt_ui/windows/mission/QEditFlightDialog.py +++ b/qt_ui/windows/mission/QEditFlightDialog.py @@ -7,6 +7,7 @@ from PySide2.QtWidgets import ( from game import Game 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 @@ -27,3 +28,8 @@ class QEditFlightDialog(QDialog): 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..37b73d04 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -17,6 +17,7 @@ from gen.flights.flight import Flight 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 +87,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.""" From 8b717c4f4c2ed29281f637b5ae9c406701c6d25d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 27 Sep 2020 19:55:40 -0700 Subject: [PATCH 03/10] Replace doctrine dict with a real type. --- game/data/doctrine.py | 163 ++++++++++++++++++-------------------- gen/flights/flightplan.py | 40 +++++----- 2 files changed, 98 insertions(+), 105 deletions(-) diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 866ae897..e7333096 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,95 +1,88 @@ +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, + ingress_egress_distance: int + ingress_altitude: int + egress_altitude: int + min_patrol_altitude: int + max_patrol_altitude: int + pattern_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), + cap_min_track_length: int + cap_max_track_length: int + cap_min_distance_from_cp: int + cap_max_distance_from_cp: 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)), - "MAX_NUMBER_OF_INTERCEPTION_GROUP": 3, -} +MODERN_DOCTRINE = Doctrine( + cap=True, + cas=True, + sead=True, + strike=True, + antiship=True, + strike_max_range=1500000, + sead_max_range=1500000, + 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), +) -COLDWAR_DOCTRINE = { +COLDWAR_DOCTRINE = Doctrine( + cap=True, + cas=True, + sead=True, + strike=True, + antiship=True, + strike_max_range=1500000, + sead_max_range=1500000, + 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), +) - "GENERATORS": { - "CAS": True, - "CAP": True, - "SEAD": True, - "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(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, + 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/gen/flights/flightplan.py b/gen/flights/flightplan.py index c8bd988a..0c631b31 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -11,7 +11,7 @@ import logging import random from typing import List, Optional, TYPE_CHECKING -from game.data.doctrine import MODERN_DOCTRINE +from game.data.doctrine import Doctrine, MODERN_DOCTRINE from .flight import Flight, FlightType, FlightWaypointType, FlightWaypoint from ..conflictgen import Conflict from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject @@ -39,7 +39,7 @@ class FlightPlanBuilder: faction = self.game.player_faction else: faction = self.game.enemy_faction - self.doctrine = faction.get("doctrine", MODERN_DOCTRINE) + self.doctrine: Doctrine = faction.get("doctrine", MODERN_DOCTRINE) def populate_flight_plan(self, flight: Flight, objective_location: MissionTarget) -> None: @@ -113,13 +113,13 @@ class FlightPlanBuilder: egress_heading = heading - 180 - 25 ingress_pos = location.position.point_from_heading( - ingress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"] + ingress_heading, self.doctrine.ingress_egress_distance ) ingress_point = FlightWaypoint( FlightWaypointType.INGRESS_STRIKE, ingress_pos.x, ingress_pos.y, - self.doctrine["INGRESS_ALT"] + self.doctrine.ingress_altitude ) ingress_point.pretty_name = "INGRESS on " + location.name ingress_point.description = "INGRESS on " + location.name @@ -187,13 +187,13 @@ class FlightPlanBuilder: flight.points.append(point) egress_pos = location.position.point_from_heading( - egress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"] + egress_heading, self.doctrine.ingress_egress_distance ) egress_point = FlightWaypoint( FlightWaypointType.EGRESS, egress_pos.x, egress_pos.y, - self.doctrine["EGRESS_ALT"] + self.doctrine.egress_altitude ) egress_point.name = "EGRESS" egress_point.pretty_name = "EGRESS from " + location.name @@ -222,19 +222,19 @@ class FlightPlanBuilder: flight.flight_type = FlightType.CAP patrol_alt = random.randint( - self.doctrine["PATROL_ALT_RANGE"][0], - self.doctrine["PATROL_ALT_RANGE"][1] + self.doctrine.min_patrol_altitude, + self.doctrine.max_patrol_altitude ) loc = location.position.point_from_heading( random.randint(0, 360), - random.randint(self.doctrine["CAP_DISTANCE_FROM_CP"][0], - self.doctrine["CAP_DISTANCE_FROM_CP"][1]) + random.randint(self.doctrine.cap_min_distance_from_cp, + self.doctrine.cap_max_distance_from_cp) ) hdg = location.position.heading_between_point(loc) radius = random.randint( - self.doctrine["CAP_PATTERN_LENGTH"][0], - self.doctrine["CAP_PATTERN_LENGTH"][1] + self.doctrine.cap_min_track_length, + self.doctrine.cap_max_track_length ) orbit0p = loc.point_from_heading(hdg - 90, radius) orbit1p = loc.point_from_heading(hdg + 90, radius) @@ -286,8 +286,8 @@ class FlightPlanBuilder: ally_cp, enemy_cp = location.control_points flight.flight_type = FlightType.CAP - patrol_alt = random.randint(self.doctrine["PATROL_ALT_RANGE"][0], - self.doctrine["PATROL_ALT_RANGE"][1]) + patrol_alt = random.randint(self.doctrine.min_patrol_altitude, + self.doctrine.max_patrol_altitude) # Find targets waypoints ingress, heading, distance = Conflict.frontline_vector( @@ -372,13 +372,13 @@ class FlightPlanBuilder: egress_heading = heading - 180 - 25 ingress_pos = location.position.point_from_heading( - ingress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"] + ingress_heading, self.doctrine.ingress_egress_distance ) ingress_point = FlightWaypoint( FlightWaypointType.INGRESS_SEAD, ingress_pos.x, ingress_pos.y, - self.doctrine["INGRESS_ALT"] + self.doctrine.ingress_altitude ) ingress_point.name = "INGRESS" ingress_point.pretty_name = "INGRESS on " + location.name @@ -426,13 +426,13 @@ class FlightPlanBuilder: flight.points.append(point) egress_pos = location.position.point_from_heading( - egress_heading, self.doctrine["INGRESS_EGRESS_DISTANCE"] + egress_heading, self.doctrine.ingress_egress_distance ) egress_point = FlightWaypoint( FlightWaypointType.EGRESS, egress_pos.x, egress_pos.y, - self.doctrine["EGRESS_ALT"] + self.doctrine.egress_altitude ) egress_point.name = "EGRESS" egress_point.pretty_name = "EGRESS from " + location.name @@ -531,7 +531,7 @@ class FlightPlanBuilder: FlightWaypointType.ASCEND_POINT, pos_ascend.x, pos_ascend.y, - self.doctrine["PATTERN_ALTITUDE"] + self.doctrine.pattern_altitude ) ascend.name = "ASCEND" ascend.alt_type = "RADIO" @@ -553,7 +553,7 @@ class FlightPlanBuilder: FlightWaypointType.DESCENT_POINT, descend.x, descend.y, - self.doctrine["PATTERN_ALTITUDE"] + self.doctrine.pattern_altitude ) descend.name = "DESCEND" descend.alt_type = "RADIO" From cc7c2cc707ac4d743576aa3b4f4b1739ea005418 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 29 Sep 2020 00:58:19 -0700 Subject: [PATCH 04/10] Refactor flight plan generation. --- game/game.py | 2 + game/operation/operation.py | 24 +- gen/aircraft.py | 5 +- gen/briefinggen.py | 2 +- gen/flights/ai_flight_planner.py | 36 +-- gen/flights/closestairfields.py | 51 ++++ gen/flights/flightplan.py | 402 +++++++------------------------ gen/flights/waypointbuilder.py | 270 +++++++++++++++++++++ 8 files changed, 433 insertions(+), 359 deletions(-) create mode 100644 gen/flights/closestairfields.py create mode 100644 gen/flights/waypointbuilder.py diff --git a/game/game.py b/game/game.py index 0226de89..b9aead14 100644 --- a/game/game.py +++ b/game/game.py @@ -5,6 +5,7 @@ from game.inventory import GlobalAircraftInventory from game.models.game_stats import GameStats from gen.ato import AirTaskingOrder 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 @@ -204,6 +205,7 @@ class Game: return event and event.name and event.name == self.player_name def pass_turn(self, no_action=False, ignored_cps: typing.Collection[ControlPoint] = None): + ObjectiveDistanceCache.set_theater(self.theater) logging.info("Pass turn") self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0)) diff --git a/game/operation/operation.py b/game/operation/operation.py index c86efacb..86a63870 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -185,20 +185,16 @@ class Operation: self.airsupportgen.generate(self.is_awacs_enabled) # Generate Activity on the map - for cp in self.game.theater.controlpoints: - side = cp.captured - if side: - country = self.current_mission.country(self.game.player_country) - ato = self.game.blue_ato - else: - country = self.current_mission.country(self.game.enemy_country) - ato = self.game.red_ato - self.airgen.generate_flights( - cp, - country, - ato, - self.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 04301de7..f4581989 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -757,7 +757,7 @@ class AircraftConflictGenerator: for parking_slot in cp.airport.parking_slots: parking_slot.unit_id = None - def generate_flights(self, cp, country, ato: AirTaskingOrder, + def generate_flights(self, country, ato: AirTaskingOrder, dynamic_runways: Dict[str, RunwayData]) -> None: self.clear_parking_slots() @@ -768,7 +768,8 @@ class AircraftConflictGenerator: logging.info("Flight not generated: culled") continue logging.info(f"Generating flight: {flight.unit_type}") - group = self.generate_planned_flight(cp, country, flight) + 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) 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 6e97e91d..e65dc74a 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -19,6 +19,10 @@ from gen.flights.ai_flight_planner_db import ( SEAD_CAPABLE, STRIKE_CAPABLE, ) +from gen.flights.closestairfields import ( + ClosestAirfields, + ObjectiveDistanceCache, +) from gen.flights.flight import ( Flight, FlightType, @@ -37,29 +41,6 @@ if TYPE_CHECKING: from game.inventory import GlobalAircraftInventory -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 - - @dataclass(frozen=True) class ProposedFlight: """A flight outline proposed by the mission planner. @@ -208,11 +189,6 @@ class ObjectiveFinder: def __init__(self, game: Game, is_player: bool) -> None: self.game = game self.is_player = is_player - # TODO: Cache globally at startup to avoid generating twice per turn? - self.closest_airfields: Dict[str, ClosestAirfields] = { - t.name: ClosestAirfields(t, self.game.theater.controlpoints) - for t in self.all_possible_targets() - } def enemy_sams(self) -> Iterator[TheaterGroundObject]: """Iterates over all enemy SAM sites.""" @@ -303,7 +279,7 @@ class ObjectiveFinder: CP. """ for cp in self.friendly_control_points(): - airfields_in_proximity = self.closest_airfields[cp.name] + airfields_in_proximity = self.closest_airfields_to(cp) airfields_in_threat_range = airfields_in_proximity.airfields_within( self.AIRFIELD_THREAT_RANGE ) @@ -336,7 +312,7 @@ class ObjectiveFinder: def closest_airfields_to(self, location: MissionTarget) -> ClosestAirfields: """Returns the closest airfields to the given location.""" - return self.closest_airfields[location.name] + return ObjectiveDistanceCache.get_closest_airfields(location) class CoalitionMissionPlanner: 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/flightplan.py b/gen/flights/flightplan.py index 0c631b31..a6e787a4 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -11,13 +11,15 @@ import logging import random from typing import List, Optional, TYPE_CHECKING -from game.data.doctrine import Doctrine, MODERN_DOCTRINE -from .flight import Flight, FlightType, FlightWaypointType, FlightWaypoint -from ..conflictgen import Conflict -from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject -from game.utils import nm_to_meter from dcs.unit import Unit +from game.data.doctrine import Doctrine, MODERN_DOCTRINE +from game.utils import nm_to_meter +from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject +from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType +from .waypointbuilder import WaypointBuilder +from ..conflictgen import Conflict + if TYPE_CHECKING: from game import Game @@ -103,108 +105,54 @@ class FlightPlanBuilder: # TODO: Stop clobbering flight type. 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_altitude - ) - ingress_point.pretty_name = "INGRESS on " + location.name - ingress_point.description = "INGRESS on " + location.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 = ( - f"STRIKE [{location.name}] : {u.type} #{j}" - ) - point.pretty_name = ( - f"STRIKE [{location.name}] : {u.type} #{j}" - ) - point.name = f"{location.name} #{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 - ) - for building in buildings: - if building.is_dead: - continue - - point = FlightWaypoint( - FlightWaypointType.TARGET_POINT, - building.position.x, - building.position.y, - 0 - ) - point.description = ( - f"STRIKE on {building.obj_name} {building.category} " - f"[{building.dcs_identifier}]" - ) - point.pretty_name = ( - f"STRIKE on {building.obj_name} {building.category} " - f"[{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.name - point.pretty_name = "STRIKE on " + location.name - point.name = location.name - point.only_for_player = True - ingress_point.targets.append(location) - flight.points.append(point) + egress_heading = heading - 180 - 25 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_altitude - ) - egress_point.name = "EGRESS" - egress_point.pretty_name = "EGRESS from " + location.name - egress_point.description = "EGRESS from " + location.name - flight.points.append(egress_point) - descend = self.generate_descend_point(flight.from_cp) - flight.points.append(descend) + builder = WaypointBuilder(self.doctrine) + builder.ascent(flight.from_cp) + builder.ingress_strike(ingress_pos, location) - rtb = self.generate_rtb_waypoint(flight.from_cp) - flight.points.append(rtb) + 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(egress_pos, location) + builder.rtb(flight.from_cp) + + flight.points = builder.build() def generate_barcap(self, flight: Flight, location: MissionTarget) -> None: """Generate a BARCAP flight at a given location. @@ -239,39 +187,11 @@ class FlightPlanBuilder: 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(location) - - descend = self.generate_descend_point(flight.from_cp) - flight.points.append(descend) - - rtb = self.generate_rtb_waypoint(flight.from_cp) - flight.points.append(rtb) + 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_frontline_cap(self, flight: Flight, location: MissionTarget) -> None: @@ -309,40 +229,11 @@ class FlightPlanBuilder: 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 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) + 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, location: MissionTarget, custom_targets: Optional[List[Unit]] = None) -> None: @@ -359,33 +250,30 @@ class FlightPlanBuilder: if custom_targets is None: custom_targets = [] - 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_altitude - ) - ingress_point.name = "INGRESS" - ingress_point.pretty_name = "INGRESS on " + location.name - ingress_point.description = "INGRESS on " + location.name - flight.points.append(ingress_point) - if len(custom_targets) > 0: + egress_heading = heading - 180 - 25 + egress_pos = location.position.point_from_heading( + egress_heading, self.doctrine.ingress_egress_distance + ) + + builder = WaypointBuilder(self.doctrine) + builder.ascent(flight.from_cp) + builder.ingress_sead(ingress_pos, 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, @@ -395,55 +283,19 @@ class FlightPlanBuilder: ) point.alt_type = "RADIO" if flight.flight_type == FlightType.DEAD: - point.description = "DEAD on " + target.type - point.pretty_name = "DEAD on " + location.name - point.only_for_player = True + builder.dead_point(target, location.name, location) else: - point.description = "SEAD on " + location.name - point.pretty_name = "SEAD on " + location.name - point.only_for_player = True - flight.points.append(point) - ingress_point.targets.append(location) - ingress_point.targetGroup = location + builder.sead_point(target, location.name, location) else: - point = FlightWaypoint( - FlightWaypointType.TARGET_GROUP_LOC, - location.position.x, - location.position.y, - 0 - ) - point.alt_type = "RADIO" if flight.flight_type == FlightType.DEAD: - point.description = "DEAD on " + location.name - point.pretty_name = "DEAD on " + location.name - point.only_for_player = True + builder.dead_area(location) else: - point.description = "SEAD on " + location.name - point.pretty_name = "SEAD on " + location.name - point.only_for_player = True - ingress_point.targets.append(location) - ingress_point.targetGroup = location - flight.points.append(point) + builder.sead_area(location) - 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_altitude - ) - egress_point.name = "EGRESS" - egress_point.pretty_name = "EGRESS from " + location.name - egress_point.description = "EGRESS from " + location.name - flight.points.append(egress_point) + builder.egress(egress_pos, location) + builder.rtb(flight.from_cp) - descend = self.generate_descend_point(flight.from_cp) - flight.points.append(descend) - - rtb = self.generate_rtb_waypoint(flight.from_cp) - flight.points.append(rtb) + flight.points = builder.build() def generate_cas(self, flight: Flight, location: MissionTarget) -> None: """Generate a CAS flight plan for the given target. @@ -455,89 +307,36 @@ class FlightPlanBuilder: if not isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) - from_cp, location = location.control_points is_helo = getattr(flight.unit_type, "helicopter", False) - cap_alt = 1000 - flight.points = [] + cap_alt = 500 if is_helo else 1000 flight.flight_type = FlightType.CAS ingress, heading, distance = Conflict.frontline_vector( - from_cp, location, self.game.theater + 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) - ascend = self.generate_ascend_point(flight.from_cp) - if is_helo: - cap_alt = 500 - ascend.alt = 500 - flight.points.append(ascend) + builder = WaypointBuilder(self.doctrine) + builder.ascent(flight.from_cp, is_helo) + builder.ingress_cas(ingress, location) + builder.cas(center, cap_alt) + builder.egress(egress, location) + builder.rtb(flight.from_cp, is_helo) - 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) - - 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) + 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. """ - ascend_heading = departure.heading - pos_ascend = departure.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 + builder = WaypointBuilder(self.doctrine) + builder.ascent(departure) + return builder.build()[0] def generate_descend_point(self, arrival: ControlPoint) -> FlightWaypoint: """Generate approach/descend point. @@ -545,21 +344,9 @@ class FlightPlanBuilder: Args: arrival: Arrival airfield or carrier. """ - ascend_heading = arrival.heading - descend = arrival.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 + builder = WaypointBuilder(self.doctrine) + builder.descent(arrival) + return builder.build()[0] @staticmethod def generate_rtb_waypoint(arrival: ControlPoint) -> FlightWaypoint: @@ -568,15 +355,6 @@ class FlightPlanBuilder: Args: arrival: Arrival airfield or carrier. """ - rtb = arrival.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 + builder = WaypointBuilder(self.doctrine) + builder.land(arrival) + return builder.build()[0] diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py new file mode 100644 index 00000000..7ef4a30e --- /dev/null +++ b/gen/flights/waypointbuilder.py @@ -0,0 +1,270 @@ +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 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) From 582c43fb6cd0d4cbea53786f5eed65b1b924732c Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 29 Sep 2020 00:51:27 -0700 Subject: [PATCH 05/10] Generate CAP missions in useful locations. CAP missions should be between the protected location and the nearest threat. Find the closest enemy airfield and ensure that the CAP race track is between it and the protected location. --- gen/flights/flightplan.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index a6e787a4..45bf2a13 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -16,6 +16,7 @@ from dcs.unit import Unit from game.data.doctrine import Doctrine, MODERN_DOCTRINE from game.utils import nm_to_meter 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 @@ -37,6 +38,7 @@ class FlightPlanBuilder: def __init__(self, game: Game, is_player: bool) -> None: self.game = game + self.is_player = is_player if is_player: faction = self.game.player_faction else: @@ -174,18 +176,30 @@ class FlightPlanBuilder: 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 + ) + loc = location.position.point_from_heading( - random.randint(0, 360), + heading, random.randint(self.doctrine.cap_min_distance_from_cp, self.doctrine.cap_max_distance_from_cp) ) - hdg = location.position.heading_between_point(loc) radius = random.randint( self.doctrine.cap_min_track_length, self.doctrine.cap_max_track_length ) - orbit0p = loc.point_from_heading(hdg - 90, radius) - orbit1p = loc.point_from_heading(hdg + 90, radius) + orbit0p = loc.point_from_heading(heading - 90, radius) + orbit1p = loc.point_from_heading(heading + 90, radius) builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) From 2aecea88b06fa750dc65c8a148c6ac2dc05df81d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 29 Sep 2020 00:44:09 -0700 Subject: [PATCH 06/10] Orient CAP tracks toward the enemy. Pointing the race track 90 degrees away from where the enemy is expected means the radar can't see much. CAP flights normally fly *toward* the expected direction of contact and alternate approaching and retreating legs with their wingman. --- gen/flights/flightplan.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 45bf2a13..ab22c13d 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -189,21 +189,20 @@ class FlightPlanBuilder: closest_airfield.position ) - loc = location.position.point_from_heading( + end = location.position.point_from_heading( heading, random.randint(self.doctrine.cap_min_distance_from_cp, self.doctrine.cap_max_distance_from_cp) ) - radius = random.randint( + diameter = random.randint( self.doctrine.cap_min_track_length, self.doctrine.cap_max_track_length ) - orbit0p = loc.point_from_heading(heading - 90, radius) - orbit1p = loc.point_from_heading(heading + 90, radius) + start = end.point_from_heading(heading - 180, diameter) builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) - builder.race_track(orbit0p, orbit1p, patrol_alt) + builder.race_track(start, end, patrol_alt) builder.rtb(flight.from_cp) flight.points = builder.build() From 07cbaa3e7069f4c369b0dbbcff1e0cdca7433b26 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 29 Sep 2020 01:16:50 -0700 Subject: [PATCH 07/10] Plan escort flights. TODO: UI --- gen/aircraft.py | 8 ++++++++ gen/flights/ai_flight_planner.py | 6 ++++-- gen/flights/flightplan.py | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/gen/aircraft.py b/gen/aircraft.py index f4581989..9eeb1836 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -929,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/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index e65dc74a..98ef2bd2 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -116,6 +116,8 @@ class AircraftAllocator: types = SEAD_CAPABLE elif flight.task == FlightType.STRIKE: types = STRIKE_CAPABLE + elif flight.task == FlightType.ESCORT: + types = CAP_CAPABLE else: logging.error(f"Unplannable flight type: {flight.task}") return None @@ -373,7 +375,7 @@ class CoalitionMissionPlanner: yield ProposedMission(sam, [ ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE), # TODO: Max escort range. - ProposedFlight(FlightType.CAP, 2, self.MAX_SEAD_RANGE), + ProposedFlight(FlightType.ESCORT, 2, self.MAX_SEAD_RANGE), ]) # Plan strike missions. @@ -382,7 +384,7 @@ class CoalitionMissionPlanner: ProposedFlight(FlightType.STRIKE, 2, self.MAX_STRIKE_RANGE), # TODO: Max escort range. ProposedFlight(FlightType.SEAD, 2, self.MAX_STRIKE_RANGE), - ProposedFlight(FlightType.CAP, 2, self.MAX_STRIKE_RANGE), + ProposedFlight(FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE), ]) def plan_missions(self) -> None: diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index ab22c13d..6a213f7a 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -67,6 +67,8 @@ class FlightPlanBuilder: self.generate_sead(flight, objective_location) elif task == FlightType.ELINT: logging.error("ELINT flight plan generation not implemented") + elif task == FlightType.ESCORT: + self.generate_escort(flight, objective_location) elif task == FlightType.EVAC: logging.error("Evac flight plan generation not implemented") elif task == FlightType.EWAR: @@ -310,6 +312,39 @@ class FlightPlanBuilder: flight.points = builder.build() + def generate_escort(self, flight: Flight, location: MissionTarget) -> None: + flight.flight_type = FlightType.ESCORT + + # 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. + heading = flight.from_cp.position.heading_between_point( + location.position + ) + ingress_heading = heading - 180 + 25 + + ingress_pos = location.position.point_from_heading( + ingress_heading, self.doctrine.ingress_egress_distance + ) + + egress_heading = heading - 180 - 25 + egress_pos = location.position.point_from_heading( + egress_heading, self.doctrine.ingress_egress_distance + ) + + patrol_alt = random.randint( + self.doctrine.min_patrol_altitude, + self.doctrine.max_patrol_altitude + ) + + builder = WaypointBuilder(self.doctrine) + builder.ascent(flight.from_cp) + builder.race_track(ingress_pos, egress_pos, patrol_alt) + builder.rtb(flight.from_cp) + + flight.points = builder.build() + def generate_cas(self, flight: Flight, location: MissionTarget) -> None: """Generate a CAS flight plan for the given target. From 56a58646004d42b47429eaa1618e94eca8a63ea2 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 29 Sep 2020 01:51:00 -0700 Subject: [PATCH 08/10] Generate common ingress/egress points. This still isn't very good because it doesn't work well for anything but the automatically planned package. Instead, should be a part of the Package itself, generated the first time it is needed, and resettable by the user. --- gen/ato.py | 19 +++++-- gen/flights/ai_flight_planner.py | 2 +- gen/flights/flightplan.py | 96 ++++++++++++++++---------------- 3 files changed, 61 insertions(+), 56 deletions(-) diff --git a/gen/ato.py b/gen/ato.py index e82930fe..2ad8b6ec 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -11,7 +11,7 @@ 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 .flights.flight import Flight, FlightType from theater.missiontarget import MissionTarget @@ -48,10 +48,9 @@ class Package: self.flights.remove(flight) @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 +83,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/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 98ef2bd2..d2abd7fd 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -414,8 +414,8 @@ class CoalitionMissionPlanner: return package = builder.build() + builder = FlightPlanBuilder(self.game, self.is_player, package) for flight in package.flights: - builder = FlightPlanBuilder(self.game, self.is_player) builder.populate_flight_plan(flight, package.target) self.ato.add_package(package) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 6a213f7a..5e3dea03 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -11,10 +11,12 @@ 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 @@ -36,8 +38,10 @@ class InvalidObjectiveLocation(RuntimeError): class FlightPlanBuilder: """Generates flight plans for flights.""" - def __init__(self, game: Game, is_player: bool) -> None: + def __init__(self, game: Game, is_player: bool, + package: Optional[Package] = None) -> None: self.game = game + self.package = package self.is_player = is_player if is_player: faction = self.game.player_faction @@ -110,23 +114,9 @@ class FlightPlanBuilder: # TODO: Stop clobbering flight type. flight.flight_type = FlightType.STRIKE - heading = flight.from_cp.position.heading_between_point( - location.position - ) - ingress_heading = heading - 180 + 25 - - ingress_pos = location.position.point_from_heading( - ingress_heading, self.doctrine.ingress_egress_distance - ) - - egress_heading = heading - 180 - 25 - egress_pos = location.position.point_from_heading( - egress_heading, self.doctrine.ingress_egress_distance - ) - builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) - builder.ingress_strike(ingress_pos, location) + builder.ingress_strike(self.ingress_point(flight, location), location) if len(location.groups) > 0 and location.dcs_identifier == "AA": # TODO: Replace with DEAD? @@ -153,7 +143,7 @@ class FlightPlanBuilder: location ) - builder.egress(egress_pos, location) + builder.egress(self.egress_point(flight, location), location) builder.rtb(flight.from_cp) flight.points = builder.build() @@ -267,23 +257,9 @@ class FlightPlanBuilder: flight.flight_type = random.choice([FlightType.SEAD, FlightType.DEAD]) - heading = flight.from_cp.position.heading_between_point( - location.position - ) - ingress_heading = heading - 180 + 25 - - ingress_pos = location.position.point_from_heading( - ingress_heading, self.doctrine.ingress_egress_distance - ) - - egress_heading = heading - 180 - 25 - egress_pos = location.position.point_from_heading( - egress_heading, self.doctrine.ingress_egress_distance - ) - builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) - builder.ingress_sead(ingress_pos, location) + builder.ingress_sead(self.ingress_point(flight, location), location) # TODO: Unify these. # There doesn't seem to be any reason to treat the UI fragged missions @@ -307,7 +283,7 @@ class FlightPlanBuilder: else: builder.sead_area(location) - builder.egress(egress_pos, location) + builder.egress(self.egress_point(flight, location), location) builder.rtb(flight.from_cp) flight.points = builder.build() @@ -319,19 +295,6 @@ class FlightPlanBuilder: # Packages should determine some common points like push, ingress, # egress, and split points ahead of time so they can be shared by all # flights. - heading = flight.from_cp.position.heading_between_point( - location.position - ) - ingress_heading = heading - 180 + 25 - - ingress_pos = location.position.point_from_heading( - ingress_heading, self.doctrine.ingress_egress_distance - ) - - egress_heading = heading - 180 - 25 - egress_pos = location.position.point_from_heading( - egress_heading, self.doctrine.ingress_egress_distance - ) patrol_alt = random.randint( self.doctrine.min_patrol_altitude, @@ -340,7 +303,8 @@ class FlightPlanBuilder: builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) - builder.race_track(ingress_pos, egress_pos, patrol_alt) + builder.race_track(self.ingress_point(flight, location), + self.egress_point(flight, location), patrol_alt) builder.rtb(flight.from_cp) flight.points = builder.build() @@ -396,8 +360,7 @@ class FlightPlanBuilder: builder.descent(arrival) return builder.build()[0] - @staticmethod - def generate_rtb_waypoint(arrival: ControlPoint) -> FlightWaypoint: + def generate_rtb_waypoint(self, arrival: ControlPoint) -> FlightWaypoint: """Generate RTB landing point. Args: @@ -406,3 +369,38 @@ class FlightPlanBuilder: builder = WaypointBuilder(self.doctrine) builder.land(arrival) return builder.build()[0] + + def ingress_point(self, flight: Flight, target: MissionTarget) -> Point: + heading = self._heading_to_package_airfield(flight, target) + return target.position.point_from_heading( + heading - 180 + 25, self.doctrine.ingress_egress_distance + ) + + def egress_point(self, flight: Flight, target: MissionTarget) -> Point: + heading = self._heading_to_package_airfield(flight, target) + return target.position.point_from_heading( + heading - 180 - 25, self.doctrine.ingress_egress_distance + ) + + def _heading_to_package_airfield(self, flight: Flight, + target: MissionTarget) -> int: + airfield = self.package_airfield(flight, target) + return airfield.position.heading_between_point(target.position) + + # TODO: Set ingress/egress/join/split points in the Package. + def package_airfield(self, flight: Flight, + target: MissionTarget) -> ControlPoint: + # 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. + if self.package is None: + return flight.from_cp + + cache = ObjectiveDistanceCache.get_closest_airfields(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" + ) From 6ce82be46b655a044723bfc3d95d73dd107f19c3 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 29 Sep 2020 18:22:20 -0700 Subject: [PATCH 09/10] Set up split/join points. --- game/data/doctrine.py | 13 ++ game/game.py | 4 +- gen/ato.py | 9 + gen/flights/ai_flight_planner.py | 4 +- gen/flights/flight.py | 2 + gen/flights/flightplan.py | 156 +++++++++++------- gen/flights/waypointbuilder.py | 24 +++ qt_ui/dialogs.py | 9 +- qt_ui/widgets/QTopPanel.py | 4 +- qt_ui/widgets/ato.py | 2 +- qt_ui/widgets/map/QLiberationMap.py | 5 +- qt_ui/windows/GameUpdateSignal.py | 4 +- qt_ui/windows/QLiberationWindow.py | 2 + qt_ui/windows/mission/QEditFlightDialog.py | 5 +- qt_ui/windows/mission/QPackageDialog.py | 9 +- .../windows/mission/flight/QFlightCreator.py | 12 +- .../windows/mission/flight/QFlightPlanner.py | 5 +- .../generator/QAbstractMissionGenerator.py | 7 +- .../flight/generator/QCAPMissionGenerator.py | 37 +++-- .../flight/generator/QCASMissionGenerator.py | 18 +- .../flight/generator/QSEADMissionGenerator.py | 18 +- .../generator/QSTRIKEMissionGenerator.py | 18 +- .../flight/waypoints/QFlightWaypointTab.py | 36 +++- 23 files changed, 282 insertions(+), 121 deletions(-) diff --git a/game/data/doctrine.py b/game/data/doctrine.py index e7333096..d81c5484 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -14,9 +14,13 @@ class Doctrine: strike_max_range: int sead_max_range: int + rendezvous_altitude: int + join_distance: int + split_distance: int ingress_egress_distance: int ingress_altitude: int egress_altitude: int + min_patrol_altitude: int max_patrol_altitude: int pattern_altitude: int @@ -35,6 +39,9 @@ MODERN_DOCTRINE = Doctrine( 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), @@ -55,6 +62,9 @@ COLDWAR_DOCTRINE = Doctrine( 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), @@ -75,6 +85,9 @@ WWII_DOCTRINE = Doctrine( 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), diff --git a/game/game.py b/game/game.py index b9aead14..fc3e2305 100644 --- a/game/game.py +++ b/game/game.py @@ -89,6 +89,7 @@ class Game: ) self.sanitize_sides() + self.on_load() def sanitize_sides(self): @@ -204,9 +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 diff --git a/gen/ato.py b/gen/ato.py index 2ad8b6ec..a2e4a8a1 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -13,6 +13,7 @@ from dataclasses import dataclass, field import logging 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,6 +52,9 @@ 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 primary_task(self) -> Optional[FlightType]: diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index d2abd7fd..6b584e68 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -414,9 +414,9 @@ class CoalitionMissionPlanner: return package = builder.build() - builder = FlightPlanBuilder(self.game, self.is_player, package) + builder = FlightPlanBuilder(self.game, package, self.is_player) for flight in package.flights: - builder.populate_flight_plan(flight, package.target) + builder.populate_flight_plan(flight) self.ato.add_package(package) def message(self, title, text) -> None: 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 index 5e3dea03..2e595b31 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -38,8 +38,7 @@ class InvalidObjectiveLocation(RuntimeError): class FlightPlanBuilder: """Generates flight plans for flights.""" - def __init__(self, game: Game, is_player: bool, - package: Optional[Package] = None) -> None: + def __init__(self, game: Game, package: Package, is_player: bool) -> None: self.game = game self.package = package self.is_player = is_player @@ -49,9 +48,15 @@ class FlightPlanBuilder: faction = self.game.enemy_faction self.doctrine: Doctrine = faction.get("doctrine", MODERN_DOCTRINE) - def populate_flight_plan(self, flight: Flight, - objective_location: MissionTarget) -> None: + 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 @@ -62,17 +67,17 @@ class FlightPlanBuilder: elif task == FlightType.BAI: logging.error("BAI flight plan generation not implemented") elif task == FlightType.BARCAP: - self.generate_barcap(flight, objective_location) + self.generate_barcap(flight) elif task == FlightType.CAP: - self.generate_barcap(flight, objective_location) + self.generate_barcap(flight) elif task == FlightType.CAS: - self.generate_cas(flight, objective_location) + self.generate_cas(flight) elif task == FlightType.DEAD: - self.generate_sead(flight, objective_location) + 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, objective_location) + self.generate_escort(flight) elif task == FlightType.EVAC: logging.error("Evac flight plan generation not implemented") elif task == FlightType.EWAR: @@ -88,11 +93,11 @@ class FlightPlanBuilder: elif task == FlightType.RECON: logging.error("Recon flight plan generation not implemented") elif task == FlightType.SEAD: - self.generate_sead(flight, objective_location) + self.generate_sead(flight, custom_targets) elif task == FlightType.STRIKE: - self.generate_strike(flight, objective_location) + self.generate_strike(flight) elif task == FlightType.TARCAP: - self.generate_frontline_cap(flight, objective_location) + self.generate_frontline_cap(flight) elif task == FlightType.TROOP_TRANSPORT: logging.error( "Troop transport flight plan generation not implemented" @@ -100,23 +105,32 @@ class FlightPlanBuilder: except InvalidObjectiveLocation as ex: logging.error(f"Could not create flight plan: {ex}") - def generate_strike(self, flight: Flight, location: MissionTarget) -> None: + 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: The strike target location. """ + location = self.package.target + # TODO: Support airfield strikes. if not isinstance(location, TheaterGroundObject): raise InvalidObjectiveLocation(flight.flight_type, location) - # TODO: Stop clobbering flight type. - flight.flight_type = FlightType.STRIKE - builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) - builder.ingress_strike(self.ingress_point(flight, location), location) + 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? @@ -143,26 +157,23 @@ class FlightPlanBuilder: location ) - builder.egress(self.egress_point(flight, location), 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, location: MissionTarget) -> None: + 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: The control point to protect. """ + location = self.package.target + if isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) - if isinstance(location, ControlPoint) and location.is_carrier: - flight.flight_type = FlightType.BARCAP - else: - flight.flight_type = FlightType.CAP - patrol_alt = random.randint( self.doctrine.min_patrol_altitude, self.doctrine.max_patrol_altitude @@ -198,19 +209,18 @@ class FlightPlanBuilder: builder.rtb(flight.from_cp) flight.points = builder.build() - def generate_frontline_cap(self, flight: Flight, - location: MissionTarget) -> None: + 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: Front line to protect. """ + location = self.package.target + if not isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) ally_cp, enemy_cp = location.control_points - flight.flight_type = FlightType.CAP patrol_alt = random.randint(self.doctrine.min_patrol_altitude, self.doctrine.max_patrol_altitude) @@ -240,26 +250,26 @@ class FlightPlanBuilder: builder.rtb(flight.from_cp) flight.points = builder.build() - def generate_sead(self, flight: Flight, location: MissionTarget, - custom_targets: Optional[List[Unit]] = None) -> None: + 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. - location: Location of the SAM site. 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 = [] - flight.flight_type = random.choice([FlightType.SEAD, FlightType.DEAD]) - builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) - builder.ingress_sead(self.ingress_point(flight, location), location) + 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 @@ -283,14 +293,13 @@ class FlightPlanBuilder: else: builder.sead_area(location) - builder.egress(self.egress_point(flight, location), 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, location: MissionTarget) -> None: - flight.flight_type = FlightType.ESCORT - + 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 @@ -303,25 +312,30 @@ class FlightPlanBuilder: builder = WaypointBuilder(self.doctrine) builder.ascent(flight.from_cp) - builder.race_track(self.ingress_point(flight, location), - self.egress_point(flight, location), patrol_alt) + 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, location: MissionTarget) -> None: + 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: Front line with CAS targets. """ + 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 - flight.flight_type = FlightType.CAS ingress, heading, distance = Conflict.frontline_vector( location.control_points[0], location.control_points[1], @@ -332,9 +346,11 @@ class FlightPlanBuilder: 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() @@ -370,33 +386,51 @@ class FlightPlanBuilder: builder.land(arrival) return builder.build()[0] - def ingress_point(self, flight: Flight, target: MissionTarget) -> Point: - heading = self._heading_to_package_airfield(flight, target) - return target.position.point_from_heading( + 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, flight: Flight, target: MissionTarget) -> Point: - heading = self._heading_to_package_airfield(flight, target) - return target.position.point_from_heading( + 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 _heading_to_package_airfield(self, flight: Flight, - target: MissionTarget) -> int: - airfield = self.package_airfield(flight, target) - return airfield.position.heading_between_point(target.position) + 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, flight: Flight, - target: MissionTarget) -> ControlPoint: + 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. - if self.package is None: - return flight.from_cp - - cache = ObjectiveDistanceCache.get_closest_airfields(target) + 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: diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 7ef4a30e..8481b6ba 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -86,6 +86,30 @@ class WaypointBuilder: 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) 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 ae9a5db3..e4c178c4 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -123,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: diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 3f5b026a..8654a364 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple from PySide2.QtCore import Qt from PySide2.QtGui import QBrush, QColor, QPen, QPixmap, QWheelEvent @@ -43,6 +43,7 @@ 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] = [] @@ -71,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: diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py index 3e855149..8a52d555 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Optional + from PySide2.QtCore import QObject, Signal from game import Game @@ -31,7 +33,7 @@ class GameUpdateSignal(QObject): # noinspection PyUnresolvedReferences self.flight_paths_changed.emit() - def updateGame(self, game: Game): + def updateGame(self, game: Optional[Game]): # noinspection PyUnresolvedReferences self.gameupdated.emit(game) 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/QEditFlightDialog.py b/qt_ui/windows/mission/QEditFlightDialog.py index 24fdfae2..9f795b79 100644 --- a/qt_ui/windows/mission/QEditFlightDialog.py +++ b/qt_ui/windows/mission/QEditFlightDialog.py @@ -5,6 +5,7 @@ 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 @@ -14,7 +15,7 @@ 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 @@ -24,7 +25,7 @@ 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) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 37b73d04..21a44aa3 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -14,6 +14,7 @@ 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 @@ -100,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 24fb684f..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.flightplan import FlightPlanBuilder -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,9 +27,6 @@ class QFlightCreator(QDialog): super().__init__() self.game = game - self.package = package - - self.planner = FlightPlanBuilder(self.game, is_player=True) self.setWindowTitle("Create flight") self.setWindowIcon(EVENT_ICONS["strike"]) @@ -39,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)) @@ -95,7 +90,6 @@ class QFlightCreator(QDialog): size = self.flight_size_spinner.value() flight = Flight(aircraft, size, origin, task) - self.planner.populate_flight_plan(flight, self.package.target) # noinspection PyUnresolvedReferences self.created.emit(flight) 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 index c18729c6..4811da1d 100644 --- a/qt_ui/windows/mission/flight/generator/QAbstractMissionGenerator.py +++ b/qt_ui/windows/mission/flight/generator/QAbstractMissionGenerator.py @@ -2,6 +2,7 @@ from PySide2.QtCore import Qt from PySide2.QtWidgets import QDialog, QPushButton from game import Game +from gen.ato import Package from gen.flights.flight import Flight from gen.flights.flightplan import FlightPlanBuilder from qt_ui.uiconstants import EVENT_ICONS @@ -10,9 +11,11 @@ from qt_ui.windows.mission.flight.waypoints.QFlightWaypointInfoBox import QFligh class QAbstractMissionGenerator(QDialog): - def __init__(self, game: Game, flight: Flight, flight_waypoint_list, title): + def __init__(self, game: Game, package: Package, flight: Flight, + flight_waypoint_list, title) -> None: super(QAbstractMissionGenerator, self).__init__() self.game = game + self.package = package self.flight = flight self.setWindowFlags(Qt.WindowStaysOnTopHint) self.setMinimumSize(400, 250) @@ -20,7 +23,7 @@ class QAbstractMissionGenerator(QDialog): self.setWindowTitle(title) self.setWindowIcon(EVENT_ICONS["strike"]) self.flight_waypoint_list = flight_waypoint_list - self.planner = FlightPlanBuilder(self.game, is_player=True) + self.planner = FlightPlanBuilder(self.game, package, is_player=True) self.selected_waypoints = [] self.wpt_info = QFlightWaypointInfoBox() diff --git a/qt_ui/windows/mission/flight/generator/QCAPMissionGenerator.py b/qt_ui/windows/mission/flight/generator/QCAPMissionGenerator.py index c1f5591e..b376851d 100644 --- a/qt_ui/windows/mission/flight/generator/QCAPMissionGenerator.py +++ b/qt_ui/windows/mission/flight/generator/QCAPMissionGenerator.py @@ -1,15 +1,26 @@ +import logging + from PySide2.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout from game import Game -from gen.flights.flight import Flight, PredefinedWaypointCategory +from gen.ato import Package +from gen.flights.flight import Flight, FlightType from qt_ui.widgets.combos.QPredefinedWaypointSelectionComboBox import QPredefinedWaypointSelectionComboBox from qt_ui.windows.mission.flight.generator.QAbstractMissionGenerator import QAbstractMissionGenerator +from theater import ControlPoint, FrontLine class QCAPMissionGenerator(QAbstractMissionGenerator): - def __init__(self, game: Game, flight: Flight, flight_waypoint_list): - super(QCAPMissionGenerator, self).__init__(game, flight, flight_waypoint_list, "CAP Generator") + def __init__(self, game: Game, package: Package, flight: Flight, + flight_waypoint_list) -> None: + super(QCAPMissionGenerator, self).__init__( + game, + package, + 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) @@ -34,16 +45,22 @@ class QCAPMissionGenerator(QAbstractMissionGenerator): 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) + location = self.package.target + if isinstance(location, FrontLine): + self.flight.flight_type = FlightType.TARCAP + self.planner.populate_flight_plan(self.flight) + elif isinstance(location, ControlPoint): + if location.is_fleet: + self.flight.flight_type = FlightType.BARCAP + else: + self.flight.flight_type = FlightType.CAP else: + name = location.__class__.__name__ + logging.error(f"Unexpected objective type for CAP: {name}") return + self.planner.generate_barcap(self.flight) + 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 index cfae4e52..76177772 100644 --- a/qt_ui/windows/mission/flight/generator/QCASMissionGenerator.py +++ b/qt_ui/windows/mission/flight/generator/QCASMissionGenerator.py @@ -4,15 +4,23 @@ from dcs import Point from game import Game from game.utils import meter_to_nm -from gen.flights.flight import Flight +from gen.ato import Package +from gen.flights.flight import Flight, FlightType 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") + def __init__(self, game: Game, package: Package, flight: Flight, + flight_waypoint_list) -> None: + super(QCASMissionGenerator, self).__init__( + game, + package, + 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) @@ -55,8 +63,8 @@ class QCASMissionGenerator(QAbstractMissionGenerator): 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.flight_type = FlightType.CAS + self.planner.populate_flight_plan(self.flight) 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 index 7221844c..dd3a31f8 100644 --- a/qt_ui/windows/mission/flight/generator/QSEADMissionGenerator.py +++ b/qt_ui/windows/mission/flight/generator/QSEADMissionGenerator.py @@ -3,7 +3,8 @@ 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 gen.ato import Package +from gen.flights.flight import Flight, FlightType 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 @@ -11,8 +12,15 @@ from qt_ui.windows.mission.flight.generator.QAbstractMissionGenerator import QAb 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") + def __init__(self, game: Game, package: Package, flight: Flight, + flight_waypoint_list) -> None: + super(QSEADMissionGenerator, self).__init__( + game, + package, + flight, + flight_waypoint_list, + "SEAD/DEAD Generator" + ) self.tgt_selection_box = QSEADTargetSelectionComboBox(self.game) self.tgt_selection_box.setMinimumWidth(200) @@ -73,9 +81,9 @@ class QSEADMissionGenerator(QAbstractMissionGenerator): 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.flight_type = FlightType.SEAD + self.planner.populate_flight_plan(self.flight, 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 index 6da88e0b..c210208c 100644 --- a/qt_ui/windows/mission/flight/generator/QSTRIKEMissionGenerator.py +++ b/qt_ui/windows/mission/flight/generator/QSTRIKEMissionGenerator.py @@ -3,7 +3,8 @@ 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 gen.ato import Package +from gen.flights.flight import Flight, FlightType 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 @@ -11,8 +12,15 @@ from qt_ui.windows.mission.flight.generator.QAbstractMissionGenerator import QAb class QSTRIKEMissionGenerator(QAbstractMissionGenerator): - def __init__(self, game: Game, flight: Flight, flight_waypoint_list): - super(QSTRIKEMissionGenerator, self).__init__(game, flight, flight_waypoint_list, "Strike Generator") + def __init__(self, game: Game, package: Package, flight: Flight, + flight_waypoint_list) -> None: + super(QSTRIKEMissionGenerator, self).__init__( + game, + package, + flight, + flight_waypoint_list, + "Strike Generator" + ) self.tgt_selection_box = QStrikeTargetSelectionComboBox(self.game) self.tgt_selection_box.setMinimumWidth(200) @@ -53,9 +61,9 @@ class QSTRIKEMissionGenerator(QAbstractMissionGenerator): 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.flight_type = FlightType.STRIKE + self.planner.populate_flight_plan(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 9db48a09..7561d639 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -2,6 +2,7 @@ from PySide2.QtCore import Signal from PySide2.QtWidgets import QFrame, QGridLayout, QLabel, QPushButton, QVBoxLayout from game import Game +from gen.ato import Package from gen.flights.flight import Flight from gen.flights.flightplan import FlightPlanBuilder from qt_ui.windows.mission.flight.generator.QCAPMissionGenerator import QCAPMissionGenerator @@ -16,11 +17,12 @@ 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 = FlightPlanBuilder(self.game, is_player=True) + self.package = package + self.flight = flight + self.planner = FlightPlanBuilder(self.game, package, is_player=True) self.init_ui() def init_ui(self): @@ -104,22 +106,42 @@ class QFlightWaypointTab(QFrame): self.on_change() def on_cas_generator(self): - self.subwindow = QCASMissionGenerator(self.game, self.flight, self.flight_waypoint_list) + self.subwindow = QCASMissionGenerator( + self.game, + self.package, + 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 = QCAPMissionGenerator( + self.game, + self.package, + 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 = QSEADMissionGenerator( + self.game, + self.package, + 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 = QSTRIKEMissionGenerator( + self.game, + self.package, + self.flight, + self.flight_waypoint_list + ) self.subwindow.finished.connect(self.on_change) self.subwindow.show() From b13711ddef5c166eb6cbad523389cf13a8ceecce Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 29 Sep 2020 23:29:30 -0700 Subject: [PATCH 10/10] Update the waypoint builder UI. Changing targets doesn't make sense now that flights belong to a package. Change all the "generate" dialogs to simply confirm dialogs to make sure the user is okay with us clobbering the flight plan. --- .../generator/QAbstractMissionGenerator.py | 47 ----- .../flight/generator/QCAPMissionGenerator.py | 69 -------- .../flight/generator/QCASMissionGenerator.py | 73 -------- .../flight/generator/QSEADMissionGenerator.py | 92 ---------- .../generator/QSTRIKEMissionGenerator.py | 72 -------- .../flight/waypoints/QFlightWaypointTab.py | 162 +++++++++--------- 6 files changed, 82 insertions(+), 433 deletions(-) delete mode 100644 qt_ui/windows/mission/flight/generator/QAbstractMissionGenerator.py delete mode 100644 qt_ui/windows/mission/flight/generator/QCAPMissionGenerator.py delete mode 100644 qt_ui/windows/mission/flight/generator/QCASMissionGenerator.py delete mode 100644 qt_ui/windows/mission/flight/generator/QSEADMissionGenerator.py delete mode 100644 qt_ui/windows/mission/flight/generator/QSTRIKEMissionGenerator.py 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 4811da1d..00000000 --- a/qt_ui/windows/mission/flight/generator/QAbstractMissionGenerator.py +++ /dev/null @@ -1,47 +0,0 @@ -from PySide2.QtCore import Qt -from PySide2.QtWidgets import QDialog, QPushButton - -from game import Game -from gen.ato import Package -from gen.flights.flight import Flight -from gen.flights.flightplan import FlightPlanBuilder -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, package: Package, flight: Flight, - flight_waypoint_list, title) -> None: - super(QAbstractMissionGenerator, self).__init__() - self.game = game - self.package = package - 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 = FlightPlanBuilder(self.game, package, is_player=True) - - 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 b376851d..00000000 --- a/qt_ui/windows/mission/flight/generator/QCAPMissionGenerator.py +++ /dev/null @@ -1,69 +0,0 @@ -import logging - -from PySide2.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout - -from game import Game -from gen.ato import Package -from gen.flights.flight import Flight, FlightType -from qt_ui.widgets.combos.QPredefinedWaypointSelectionComboBox import QPredefinedWaypointSelectionComboBox -from qt_ui.windows.mission.flight.generator.QAbstractMissionGenerator import QAbstractMissionGenerator -from theater import ControlPoint, FrontLine - - -class QCAPMissionGenerator(QAbstractMissionGenerator): - - def __init__(self, game: Game, package: Package, flight: Flight, - flight_waypoint_list) -> None: - super(QCAPMissionGenerator, self).__init__( - game, - package, - 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): - location = self.package.target - if isinstance(location, FrontLine): - self.flight.flight_type = FlightType.TARCAP - self.planner.populate_flight_plan(self.flight) - elif isinstance(location, ControlPoint): - if location.is_fleet: - self.flight.flight_type = FlightType.BARCAP - else: - self.flight.flight_type = FlightType.CAP - else: - name = location.__class__.__name__ - logging.error(f"Unexpected objective type for CAP: {name}") - return - - self.planner.generate_barcap(self.flight) - - 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 76177772..00000000 --- a/qt_ui/windows/mission/flight/generator/QCASMissionGenerator.py +++ /dev/null @@ -1,73 +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.ato import Package -from gen.flights.flight import Flight, FlightType -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, package: Package, flight: Flight, - flight_waypoint_list) -> None: - super(QCASMissionGenerator, self).__init__( - game, - package, - 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.flight_type = FlightType.CAS - self.planner.populate_flight_plan(self.flight) - 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 dd3a31f8..00000000 --- a/qt_ui/windows/mission/flight/generator/QSEADMissionGenerator.py +++ /dev/null @@ -1,92 +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.ato import Package -from gen.flights.flight import Flight, FlightType -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, package: Package, flight: Flight, - flight_waypoint_list) -> None: - super(QSEADMissionGenerator, self).__init__( - game, - package, - 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): - target = self.tgt_selection_box.get_selected_target() - self.flight.flight_type = FlightType.SEAD - self.planner.populate_flight_plan(self.flight, 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 c210208c..00000000 --- a/qt_ui/windows/mission/flight/generator/QSTRIKEMissionGenerator.py +++ /dev/null @@ -1,72 +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.ato import Package -from gen.flights.flight import Flight, FlightType -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, package: Package, flight: Flight, - flight_waypoint_list) -> None: - super(QSTRIKEMissionGenerator, self).__init__( - game, - package, - 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): - target = self.tgt_selection_box.get_selected_target() - self.flight.flight_type = FlightType.STRIKE - self.planner.populate_flight_plan(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 7561d639..2879c356 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -1,16 +1,24 @@ +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.ato import Package -from gen.flights.flight import Flight +from gen.flights.flight import Flight, FlightType from gen.flights.flightplan import FlightPlanBuilder -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 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): @@ -23,56 +31,68 @@ class QFlightWaypointTab(QFrame): 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): @@ -105,45 +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.package, - self.flight, - self.flight_waypoint_list + 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 ) - self.subwindow.finished.connect(self.on_change) - self.subwindow.show() - - def on_cap_generator(self): - self.subwindow = QCAPMissionGenerator( - self.game, - self.package, - 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.package, - 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.package, - self.flight, - self.flight_waypoint_list - ) - self.subwindow.finished.connect(self.on_change) - self.subwindow.show() + 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()