diff --git a/gen/aircraft.py b/gen/aircraft.py index cbb028fe..49c6e8cd 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -57,14 +57,14 @@ from dcs.task import ( ) from dcs.terrain.terrain import Airport, NoParkingSlotError from dcs.translation import String -from dcs.triggers import Event, TriggerOnce +from dcs.triggers import Event, TriggerOnce, TriggerRule from dcs.unitgroup import FlyingGroup, Group, ShipGroup, StaticGroup from dcs.unittype import FlyingType, UnitType from game import db from game.data.cap_capabilities_db import GUNFIGHTERS from game.settings import Settings -from game.utils import meter_to_nm, nm_to_meter +from game.utils import nm_to_meter from gen.airfields import RunwayData from gen.airsupportgen import AirSupport from gen.ato import AirTaskingOrder, Package @@ -76,9 +76,10 @@ from gen.flights.flight import ( FlightWaypointType, ) from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio -from theater import MissionTarget, TheaterGroundObject +from theater import TheaterGroundObject from theater.controlpoint import ControlPoint, ControlPointType from .conflictgen import Conflict +from .flights.traveltime import PackageWaypointTiming, TotEstimator from .naming import namegen WARM_START_HELI_AIRSPEED = 120 @@ -86,8 +87,6 @@ WARM_START_HELI_ALT = 500 WARM_START_ALTITUDE = 3000 WARM_START_AIRSPEED = 550 -CAP_DURATION = 30 # minutes - RTB_ALTITUDE = 800 RTB_DISTANCE = 5000 HELI_ALT = 500 @@ -217,7 +216,7 @@ class FlightData: #: True if this flight belongs to the player's coalition. friendly: bool - #: Number of minutes after mission start the flight is set to depart. + #: Number of seconds after mission start the flight is set to depart. departure_delay: int #: Arrival airport. @@ -533,148 +532,6 @@ AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"] AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"] -@dataclass(frozen=True) -class PackageWaypointTiming: - #: The package being scheduled. - package: Package - - #: The package join time. - join: int - - #: The ingress waypoint TOT. - ingress: int - - #: The egress waypoint TOT. - egress: int - - #: The package split time. - split: int - - @property - def target(self) -> int: - """The package time over target.""" - assert self.package.time_over_target is not None - return self.package.time_over_target - - @property - def race_track_start(self) -> Optional[int]: - cap_types = (FlightType.BARCAP, FlightType.CAP) - if self.package.primary_task in cap_types: - # CAP flights don't have hold points, and we don't calculate takeoff - # times yet or adjust the TOT based on when the flight can arrive, - # so if we set a TOT that gives the flight a lot of extra time it - # will just fly to the start point slowly, possibly slowly enough to - # stall and crash. Just don't set a TOT for these points and let the - # CAP get on station ASAP. - return None - else: - return self.ingress - - @property - def race_track_end(self) -> int: - cap_types = (FlightType.BARCAP, FlightType.CAP) - if self.package.primary_task in cap_types: - return self.target + CAP_DURATION * 60 - else: - return self.egress - - def push_time(self, flight: Flight, hold_point: Point) -> int: - assert self.package.waypoints is not None - return self.join - self.travel_time( - hold_point, - self.package.waypoints.join, - self.flight_ground_speed(flight) - ) - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]: - target_types = ( - FlightWaypointType.TARGET_GROUP_LOC, - FlightWaypointType.TARGET_POINT, - FlightWaypointType.TARGET_SHIP, - ) - - ingress_types = ( - FlightWaypointType.INGRESS_CAS, - FlightWaypointType.INGRESS_SEAD, - FlightWaypointType.INGRESS_STRIKE, - ) - - if waypoint.waypoint_type == FlightWaypointType.JOIN: - return self.join - elif waypoint.waypoint_type in ingress_types: - return self.ingress - elif waypoint.waypoint_type in target_types: - return self.target - elif waypoint.waypoint_type == FlightWaypointType.EGRESS: - return self.egress - elif waypoint.waypoint_type == FlightWaypointType.SPLIT: - return self.split - elif waypoint.waypoint_type == FlightWaypointType.PATROL_TRACK: - return self.race_track_start - return None - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint, - flight: Flight) -> Optional[int]: - if waypoint.waypoint_type == FlightWaypointType.LOITER: - return self.push_time(flight, Point(waypoint.x, waypoint.y)) - elif waypoint.waypoint_type == FlightWaypointType.PATROL: - return self.race_track_end - return None - - @classmethod - def for_package(cls, package: Package) -> PackageWaypointTiming: - assert package.time_over_target is not None - assert package.waypoints is not None - - group_ground_speed = cls.package_ground_speed(package) - - ingress = package.time_over_target - cls.travel_time( - package.waypoints.ingress, - package.target.position, - group_ground_speed - ) - - join = ingress - cls.travel_time( - package.waypoints.join, - package.waypoints.ingress, - group_ground_speed - ) - - egress = package.time_over_target + cls.travel_time( - package.target.position, - package.waypoints.egress, - group_ground_speed - ) - - split = egress + cls.travel_time( - package.waypoints.egress, - package.waypoints.split, - group_ground_speed - ) - - return cls(package, join, ingress, egress, split) - - @classmethod - def package_ground_speed(cls, package: Package) -> int: - speeds = [] - for flight in package.flights: - speeds.append(cls.flight_ground_speed(flight)) - return min(speeds) # knots - - @staticmethod - def flight_ground_speed(_flight: Flight) -> int: - # TODO: Gather data so this is useful. - return 400 # knots - - @staticmethod - def travel_time(a: Point, b: Point, speed: float) -> int: - error_factor = 1.1 - distance = meter_to_nm(a.distance_to_point(b)) - hours = distance / speed - seconds = hours * 3600 - return int(seconds * error_factor) - - class AircraftConflictGenerator: def __init__(self, mission: Mission, conflict: Conflict, settings: Settings, game, radio_registry: RadioRegistry): @@ -702,8 +559,13 @@ class AircraftConflictGenerator: except KeyError: return get_fallback_channel(airframe) - def _start_type(self) -> StartType: - return self.settings.cold_start and StartType.Cold or StartType.Warm + @staticmethod + def _start_type(start_type: str) -> StartType: + if start_type == "Runway": + return StartType.Runway + elif start_type == "Cold": + return StartType.Cold + return StartType.Warm def _setup_group(self, group: FlyingGroup, for_task: Type[Task], flight: Flight, dynamic_runways: Dict[str, RunwayData]): @@ -808,15 +670,10 @@ class AircraftConflictGenerator: return runways[0] def _generate_at_airport(self, name: str, side: Country, - unit_type: FlyingType, count: int, - client_count: int, - airport: Optional[Airport] = None, - start_type=None) -> FlyingGroup: + unit_type: FlyingType, count: int, start_type: str, + airport: Optional[Airport] = None) -> FlyingGroup: assert count > 0 - if start_type is None: - start_type = self._start_type() - logging.info("airgen: {} for {} at {}".format(unit_type, side.id, airport)) return self.m.flight_group_from_airport( country=side, @@ -824,11 +681,11 @@ class AircraftConflictGenerator: aircraft_type=unit_type, airport=airport, maintask=None, - start_type=start_type, + start_type=self._start_type(start_type), group_size=count, parking_slots=None) - def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, at: Point) -> FlyingGroup: + def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, at: Point) -> FlyingGroup: assert count > 0 if unit_type in helicopters.helicopter_map.values(): @@ -850,21 +707,16 @@ class AircraftConflictGenerator: altitude=alt, speed=speed, maintask=None, - start_type=self._start_type(), group_size=count) group.points[0].alt_type = "RADIO" return group def _generate_at_group(self, name: str, side: Country, - unit_type: FlyingType, count: int, client_count: int, - at: Union[ShipGroup, StaticGroup], - start_type=None) -> FlyingGroup: + unit_type: FlyingType, count: int, start_type: str, + at: Union[ShipGroup, StaticGroup]) -> FlyingGroup: assert count > 0 - if start_type is None: - start_type = self._start_type() - logging.info("airgen: {} for {} at unit {}".format(unit_type, side.id, at)) return self.m.flight_group_from_unit( country=side, @@ -872,34 +724,9 @@ class AircraftConflictGenerator: aircraft_type=unit_type, pad_group=at, maintask=None, - start_type=start_type, + start_type=self._start_type(start_type), group_size=count) - def _generate_group(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, at: db.StartingPosition): - if isinstance(at, Point): - return self._generate_inflight(name, side, unit_type, count, client_count, at) - elif isinstance(at, Group): - takeoff_ban = unit_type in db.CARRIER_TAKEOFF_BAN - ai_ban = client_count == 0 and self.settings.only_player_takeoff - - if not takeoff_ban and not ai_ban: - return self._generate_at_group(name, side, unit_type, count, client_count, at) - else: - return self._generate_inflight(name, side, unit_type, count, client_count, at.position) - elif isinstance(at, Airport): - takeoff_ban = unit_type in db.TAKEOFF_BAN - ai_ban = client_count == 0 and self.settings.only_player_takeoff - - if not takeoff_ban and not ai_ban: - try: - return self._generate_at_airport(name, side, unit_type, count, client_count, at) - except NoParkingSlotError: - logging.info("No parking slot found at " + at.name + ", switching to air start.") - pass - return self._generate_inflight(name, side, unit_type, count, client_count, at.position) - else: - assert False - def _add_radio_waypoint(self, group: FlyingGroup, position, altitude: int, airspeed: int = 600): point = group.add_waypoint(position, altitude, airspeed) point.alt_type = "RADIO" @@ -970,110 +797,91 @@ class AircraftConflictGenerator: logging.info(f"Generating flight: {flight.unit_type}") group = self.generate_planned_flight(flight.from_cp, country, flight) - self.setup_flight_group(group, flight, timing, dynamic_runways) - self.setup_group_activation_trigger(flight, group) + self.setup_flight_group(group, package, flight, timing, + dynamic_runways) - def setup_group_activation_trigger(self, flight, group): - if flight.scheduled_in > 0 and flight.client_count == 0: + def set_activation_time(self, flight: Flight, group: FlyingGroup, + delay: int) -> None: + # Note: Late activation causes the waypoint TOTs to look *weird* in the + # mission editor. Waypoint times will be relative to the group + # activation time rather than in absolute local time. A flight delayed + # until 09:10 when the overall mission start time is 09:00, with a join + # time of 09:30 will show the join time as 00:30, not 09:30. + group.late_activation = True - if flight.start_type != "In Flight" and flight.from_cp.cptype not in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP]: - group.late_activation = False - group.uncontrolled = True + activation_trigger = TriggerOnce( + Event.NoEvent, f"FlightLateActivationTrigger{group.id}") + activation_trigger.add_condition(TimeAfter(seconds=delay)) - activation_trigger = TriggerOnce(Event.NoEvent, "FlightStartTrigger" + str(group.id)) - activation_trigger.add_condition(TimeAfter(seconds=flight.scheduled_in * 60)) - if (flight.from_cp.cptype == ControlPointType.AIRBASE): - if flight.from_cp.captured: - activation_trigger.add_condition( - CoalitionHasAirdrome(self.game.get_player_coalition_id(), flight.from_cp.id)) - else: - activation_trigger.add_condition( - CoalitionHasAirdrome(self.game.get_enemy_coalition_id(), flight.from_cp.id)) + self.prevent_spawn_at_hostile_airbase(flight, activation_trigger) + activation_trigger.add_action(ActivateGroup(group.id)) + self.m.triggerrules.triggers.append(activation_trigger) - if flight.flight_type == FlightType.INTERCEPTION: - self.setup_interceptor_triggers(group, flight, activation_trigger) + def set_startup_time(self, flight: Flight, group: FlyingGroup, + delay: int) -> None: + # Uncontrolled causes the AI unit to spawn, but not begin startup. + group.uncontrolled = True - group.add_trigger_action(StartCommand()) - activation_trigger.add_action(AITaskPush(group.id, len(group.tasks))) + activation_trigger = TriggerOnce(Event.NoEvent, + f"FlightStartTrigger{group.id}") + activation_trigger.add_condition(TimeAfter(seconds=delay)) - self.m.triggerrules.triggers.append(activation_trigger) - else: - group.late_activation = True - activation_trigger = TriggerOnce(Event.NoEvent, "FlightLateActivationTrigger" + str(group.id)) - activation_trigger.add_condition(TimeAfter(seconds=flight.scheduled_in*60)) + self.prevent_spawn_at_hostile_airbase(flight, activation_trigger) + group.add_trigger_action(StartCommand()) + activation_trigger.add_action(AITaskPush(group.id, len(group.tasks))) + self.m.triggerrules.triggers.append(activation_trigger) - if(flight.from_cp.cptype == ControlPointType.AIRBASE): - if flight.from_cp.captured: - activation_trigger.add_condition(CoalitionHasAirdrome(self.game.get_player_coalition_id(), flight.from_cp.id)) - else: - activation_trigger.add_condition(CoalitionHasAirdrome(self.game.get_enemy_coalition_id(), flight.from_cp.id)) + def prevent_spawn_at_hostile_airbase(self, flight: Flight, + trigger: TriggerRule) -> None: + # Prevent delayed flights from spawning at airbases if they were + # captured before they've spawned. + if flight.from_cp.cptype != ControlPointType.AIRBASE: + return - if flight.flight_type == FlightType.INTERCEPTION: - self.setup_interceptor_triggers(group, flight, activation_trigger) - - activation_trigger.add_action(ActivateGroup(group.id)) - self.m.triggerrules.triggers.append(activation_trigger) - - def setup_interceptor_triggers(self, group, flight, activation_trigger): - - detection_zone = self.m.triggers.add_triggerzone(flight.from_cp.position, radius=25000, hidden=False, name="ITZ") if flight.from_cp.captured: - activation_trigger.add_condition(PartOfCoalitionInZone(self.game.get_enemy_color(), detection_zone.id)) # TODO : support unit type in part of coalition - activation_trigger.add_action(MessageToAll(String("WARNING : Enemy aircraft have been detected in the vicinity of " + flight.from_cp.name + ". Interceptors are taking off."), 20)) + coalition = self.game.get_player_coalition_id() else: - activation_trigger.add_condition(PartOfCoalitionInZone(self.game.get_player_color(), detection_zone.id)) - activation_trigger.add_action(MessageToAll(String("WARNING : We have detected that enemy aircraft are scrambling for an interception on " + flight.from_cp.name + " airbase."), 20)) + coalition = self.game.get_enemy_coalition_id() + + trigger.add_condition( + CoalitionHasAirdrome(coalition, flight.from_cp.id)) def generate_planned_flight(self, cp, country, flight:Flight): try: - if flight.client_count == 0 and self.game.settings.perf_ai_parking_start: - flight.start_type = "Cold" - if flight.start_type == "In Flight": - group = self._generate_group( + group = self._generate_inflight( name=namegen.next_unit_name(country, cp.id, flight.unit_type), side=country, unit_type=flight.unit_type, count=flight.count, - client_count=0, at=cp.position) + elif cp.is_fleet: + group_name = cp.get_carrier_group_name() + group = self._generate_at_group( + name=namegen.next_unit_name(country, cp.id, flight.unit_type), + side=country, + unit_type=flight.unit_type, + count=flight.count, + start_type=flight.start_type, + at=self.m.find_group(group_name)) else: - st = StartType.Runway - if flight.start_type == "Cold": - st = StartType.Cold - elif flight.start_type == "Warm": - st = StartType.Warm - - if cp.cptype in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP]: - group_name = cp.get_carrier_group_name() - group = self._generate_at_group( - name=namegen.next_unit_name(country, cp.id, flight.unit_type), - side=country, - unit_type=flight.unit_type, - count=flight.count, - client_count=0, - at=self.m.find_group(group_name), - start_type=st) - else: - group = self._generate_at_airport( - name=namegen.next_unit_name(country, cp.id, flight.unit_type), - side=country, - unit_type=flight.unit_type, - count=flight.count, - client_count=0, - airport=cp.airport, - start_type=st) + group = self._generate_at_airport( + name=namegen.next_unit_name(country, cp.id, flight.unit_type), + side=country, + unit_type=flight.unit_type, + count=flight.count, + start_type=flight.start_type, + airport=cp.airport) except Exception as e: # Generated when there is no place on Runway or on Parking Slots logging.error(e) logging.warning("No room on runway or parking slots. Starting from the air.") flight.start_type = "In Flight" - group = self._generate_group( + group = self._generate_inflight( name=namegen.next_unit_name(country, cp.id, flight.unit_type), side=country, unit_type=flight.unit_type, count=flight.count, - client_count=0, at=cp.position) group.points[0].alt = 1500 @@ -1179,8 +987,8 @@ class AircraftConflictGenerator: logging.error(f"Unhandled flight type: {flight.flight_type.name}") self.configure_behavior(group) - def setup_flight_group(self, group: FlyingGroup, flight: Flight, - timing: PackageWaypointTiming, + def setup_flight_group(self, group: FlyingGroup, package: Package, + flight: Flight, timing: PackageWaypointTiming, dynamic_runways: Dict[str, RunwayData]) -> None: flight_type = flight.flight_type if flight_type in [FlightType.CAP, FlightType.BARCAP, FlightType.TARCAP, @@ -1204,6 +1012,9 @@ class AircraftConflictGenerator: for waypoint in flight.points: waypoint.tot = None + takeoff_point = FlightWaypoint.from_pydcs(group.points[0], + flight.from_cp) + self.set_takeoff_time(takeoff_point, package, flight, group) for point in flight.points: if point.only_for_player and not flight.client_count: continue @@ -1214,9 +1025,53 @@ class AircraftConflictGenerator: # Set here rather than when the FlightData is created so they waypoints # have their TOTs set. - self.flights[-1].waypoints = flight.points + self.flights[-1].waypoints = [takeoff_point] + flight.points self._setup_custom_payload(flight, group) + def set_takeoff_time(self, waypoint: FlightWaypoint, package: Package, + flight: Flight, group: FlyingGroup) -> None: + estimator = TotEstimator(package) + start_time = estimator.mission_start_time(flight) + + if start_time > 0: + if self.should_activate_late(flight): + # Late activation causes the aircraft to not be spawned until + # triggered. + self.set_activation_time(flight, group, start_time) + elif flight.start_type == "Cold": + # Setting the start time causes the AI to wait until the + # specified time to begin their startup sequence. + self.set_startup_time(flight, group, start_time) + + # And setting *our* waypoint TOT causes the takeoff time to show up in + # the player's kneeboard. + waypoint.tot = estimator.takeoff_time_for_flight(flight) + + @staticmethod + def should_activate_late(flight: Flight) -> bool: + if flight.client_count: + # Never delay players. Note that cold start player flights with + # AI members will still be marked as uncontrolled until the start + # trigger fires to postpone engine start. + # + # Player flights that start on the runway or in the air will start + # immediately, and AI flight members will not be delayed. + return False + + if flight.start_type != "Cold": + # Avoid spawning aircraft in the air or on the runway until it's + # time for their mission. Also avoid burning through gas spawning + # hot aircraft hours before their takeoff time. + return True + + if flight.from_cp.is_fleet: + # Carrier spawns will crowd the carrier deck, especially without + # super carrier. + # TODO: Is there enough parking on the supercarrier? + return True + + return False + class PydcsWaypointBuilder: def __init__(self, waypoint: FlightWaypoint, group: FlyingGroup, @@ -1243,10 +1098,8 @@ class PydcsWaypointBuilder: waypoint.speed_locked = False @classmethod - def for_waypoint(cls, waypoint: FlightWaypoint, - group: FlyingGroup, - flight: Flight, - timing: PackageWaypointTiming, + def for_waypoint(cls, waypoint: FlightWaypoint, group: FlyingGroup, + flight: Flight, timing: PackageWaypointTiming, mission: Mission) -> PydcsWaypointBuilder: builders = { FlightWaypointType.EGRESS: EgressPointBuilder, @@ -1391,9 +1244,7 @@ class RaceTrackBuilder(PydcsWaypointBuilder): pattern=OrbitAction.OrbitPattern.RaceTrack )) - start = self.timing.race_track_start - if start is not None: - self.set_waypoint_tot(waypoint, start) + self.set_waypoint_tot(waypoint, self.timing.race_track_start) racetrack.stop_after_time(self.timing.race_track_end) waypoint.add_task(racetrack) return waypoint diff --git a/gen/ato.py b/gen/ato.py index e9c5393c..f4877125 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -52,7 +52,7 @@ class Package: delay: int = field(default=0) #: Desired TOT measured in seconds from mission start. - time_over_target: Optional[int] = field(default=None) + time_over_target: int = field(default=0) waypoints: Optional[PackageWaypoints] = field(default=None) diff --git a/gen/briefinggen.py b/gen/briefinggen.py index 82744a8a..d52f25cc 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -1,3 +1,4 @@ +import datetime import os from collections import defaultdict from dataclasses import dataclass @@ -116,9 +117,10 @@ class BriefingGenerator(MissionInfoGenerator): assert not flight.client_units aircraft = flight.aircraft_type flight_unit_name = db.unit_type_name(aircraft) + delay = datetime.timedelta(seconds=flight.departure_delay) self.description += ( f"{flight.flight_type.name} {flight_unit_name} x {flight.size}, " - f"departing in {flight.departure_delay} minutes\n" + f"departing in {delay}\n" ) def generate(self): diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 09be3773..01f5d1b4 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -33,6 +33,7 @@ from gen.flights.flight import ( FlightType, ) from gen.flights.flightplan import FlightPlanBuilder +from gen.flights.traveltime import TotEstimator from theater import ( ControlPoint, FrontLine, @@ -185,11 +186,13 @@ class PackageBuilder: def __init__(self, location: MissionTarget, closest_airfields: ClosestAirfields, global_inventory: GlobalAircraftInventory, - is_player: bool) -> None: + is_player: bool, + start_type: str) -> None: self.package = Package(location) self.allocator = AircraftAllocator(closest_airfields, global_inventory, is_player) self.global_inventory = global_inventory + self.start_type = start_type def plan_flight(self, plan: ProposedFlight) -> bool: """Allocates aircraft for the given flight and adds them to the package. @@ -203,7 +206,8 @@ class PackageBuilder: if assignment is None: return False airfield, aircraft = assignment - flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task) + flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task, + self.start_type) self.package.add_flight(flight) return True @@ -444,11 +448,18 @@ class CoalitionMissionPlanner: def plan_mission(self, mission: ProposedMission) -> None: """Allocates aircraft for a proposed mission and adds it to the ATO.""" + + if self.game.settings.perf_ai_parking_start: + start_type = "Cold" + else: + start_type = "Warm" + builder = PackageBuilder( mission.location, self.objective_finder.closest_airfields_to(mission.location), self.game.aircraft_inventory, - self.is_player + self.is_player, + start_type ) missing_types: Set[FlightType] = set() @@ -497,16 +508,18 @@ class CoalitionMissionPlanner: margin=5 ) for package in self.ato.packages: + tot = TotEstimator(package).earliest_tot() if package.primary_task in dca_types: - # All CAP missions should be on station in 15-25 minutes. - package.time_over_target = (20 + random.randint(-5, 5)) * 60 + # All CAP missions should be on station ASAP. + package.time_over_target = tot else: - # But other packages should be spread out a bit. - package.delay = next(start_time) - # TODO: Compute TOT based on package. - package.time_over_target = (package.delay + 40) * 60 - for flight in package.flights: - flight.scheduled_in = package.delay + # But other packages should be spread out a bit. Note that take + # times are delayed, but all aircraft will become active at + # mission start. This makes it more worthwhile to attack enemy + # airfields to hit grounded aircraft, since they're more likely + # to be present. Runway and air started aircraft will be + # delayed until their takeoff time by AirConflictGenerator. + package.time_over_target = next(start_time) * 60 + tot def message(self, title, text) -> None: """Emits a planning message to the player. diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 0c972723..c9031bb4 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -97,7 +97,6 @@ class FlightWaypoint: # flight's offset in the UI. self.tot: Optional[int] = None - @classmethod def from_pydcs(cls, point: MovingPoint, from_cp: ControlPoint) -> "FlightWaypoint": @@ -130,13 +129,10 @@ class Flight: client_count: int = 0 use_custom_loadout = False preset_loadout_name = "" - start_type = "Runway" group = False # Contains DCS Mission group data after mission has been generated - # How long before this flight should take off - scheduled_in = 0 - - def __init__(self, unit_type: UnitType, count: int, from_cp: ControlPoint, flight_type: FlightType): + def __init__(self, unit_type: UnitType, count: int, from_cp: ControlPoint, + flight_type: FlightType, start_type: str) -> None: self.unit_type = unit_type self.count = count self.from_cp = from_cp @@ -144,11 +140,16 @@ class Flight: self.points: List[FlightWaypoint] = [] self.targets: List[MissionTarget] = [] self.loadout: Dict[str, str] = {} - self.start_type = "Runway" + self.start_type = start_type + # Late activation delay in seconds from mission start. This is not + # the same as the flight's takeoff time. Takeoff time depends on the + # mission's TOT and the other flights in the package. Takeoff time is + # determined by AirConflictGenerator. + self.scheduled_in = 0 def __repr__(self): return self.flight_type.name + " | " + str(self.count) + "x" + db.unit_type_name(self.unit_type) \ - + " in " + str(self.scheduled_in) + " minutes (" + str(len(self.points)) + " wpt)" + + " (" + str(len(self.points)) + " wpt)" # Test @@ -157,6 +158,6 @@ if __name__ == '__main__': from theater import ControlPoint, Point, List from_cp = ControlPoint(0, "AA", Point(0, 0), Point(0, 0), [], 0, 0) - f = Flight(A_10C(), 4, from_cp, FlightType.CAS) + f = Flight(A_10C(), 4, from_cp, FlightType.CAS, "Cold") f.scheduled_in = 50 print(f) diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py new file mode 100644 index 00000000..87d2817d --- /dev/null +++ b/gen/flights/traveltime.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterable, Optional + +from dcs.mapping import Point + +from game.utils import meter_to_nm +from gen.ato import Package +from gen.flights.flight import ( + Flight, + FlightType, + FlightWaypoint, + FlightWaypointType, +) + + +CAP_DURATION = 30 # Minutes +CAP_TYPES = (FlightType.BARCAP, FlightType.CAP) + + +class GroundSpeed: + @classmethod + def for_package(cls, package: Package) -> int: + speeds = [] + for flight in package.flights: + speeds.append(cls.for_flight(flight)) + return min(speeds) # knots + + @staticmethod + def for_flight(_flight: Flight) -> int: + # TODO: Gather data so this is useful. + # TODO: Expose both a cruise speed and target speed. + # The cruise speed can be used for ascent, hold, join, and RTB to save + # on fuel, but mission speed will be fast enough to keep the flight + # safer. + return 400 # knots + + +class TravelTime: + @staticmethod + def between_points(a: Point, b: Point, speed: float) -> int: + error_factor = 1.1 + distance = meter_to_nm(a.distance_to_point(b)) + hours = distance / speed + seconds = hours * 3600 + return int(seconds * error_factor) + + +class TotEstimator: + # An extra five minutes given as wiggle room. Expected to be spent at the + # hold point performing any last minute configuration. + HOLD_TIME = 5 * 60 + + def __init__(self, package: Package) -> None: + self.package = package + self.timing = PackageWaypointTiming.for_package(package) + + def mission_start_time(self, flight: Flight) -> int: + takeoff_time = self.takeoff_time_for_flight(flight) + startup_time = self.estimate_startup(flight) + ground_ops_time = self.estimate_ground_ops(flight) + return takeoff_time - startup_time - ground_ops_time + + def takeoff_time_for_flight(self, flight: Flight) -> int: + stop_types = {FlightWaypointType.JOIN, FlightWaypointType.PATROL_TRACK} + travel_time = self.estimate_waypoints_to_target(flight, stop_types) + if travel_time is None: + logging.warning("Found no join point or patrol point. Cannot " + f"estimate takeoff time takeoff time for {flight}") + # Takeoff immediately. + return 0 + + if self.package.primary_task in CAP_TYPES: + start_time = self.timing.race_track_start + else: + start_time = self.timing.join + return start_time - travel_time - self.HOLD_TIME + + def earliest_tot(self) -> int: + return max(( + self.earliest_tot_for_flight(f) for f in self.package.flights + )) + self.HOLD_TIME + + def earliest_tot_for_flight(self, flight: Flight) -> int: + """Estimate fastest time from mission start to the target position. + + For CAP missions, this is time to race track start. + + For other mission types this is the time to the mission target. + + Args: + flight: The flight to get the earliest TOT time for. + + Returns: + The earliest possible TOT for the given flight in seconds. Returns 0 + if an ingress point cannot be found. + """ + stop_types = { + FlightWaypointType.PATROL_TRACK, + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + } + time_to_ingress = self.estimate_waypoints_to_target(flight, stop_types) + if time_to_ingress is None: + logging.warning( + f"Found no ingress types. Cannot estimate TOT for {flight}") + # Return 0 so this flight's travel time does not affect the rest of + # the package. + return 0 + + if self.package.primary_task in CAP_TYPES: + # The racetrack start *is* the target. The package target is the + # protected objective. + time_to_target = 0 + else: + assert self.package.waypoints is not None + time_to_target = TravelTime.between_points( + self.package.waypoints.ingress, self.package.target.position, + GroundSpeed.for_package(self.package)) + return sum([ + self.estimate_startup(flight), + self.estimate_ground_ops(flight), + time_to_ingress, + time_to_target, + ]) + + @staticmethod + def estimate_startup(flight: Flight) -> int: + if flight.start_type == "Cold": + return 10 * 60 + return 0 + + @staticmethod + def estimate_ground_ops(flight: Flight) -> int: + if flight.start_type in ("Runway", "In Flight"): + return 0 + if flight.from_cp.is_fleet: + return 2 * 60 + else: + return 5 * 60 + + def estimate_waypoints_to_target( + self, flight: Flight, + stop_types: Iterable[FlightWaypointType]) -> Optional[int]: + total = 0 + previous_position = flight.from_cp.position + for waypoint in flight.points: + position = Point(waypoint.x, waypoint.y) + total += TravelTime.between_points( + previous_position, position, + self.speed_to_waypoint(flight, waypoint) + ) + previous_position = position + if waypoint.waypoint_type in stop_types: + return total + + return None + + def speed_to_waypoint(self, flight: Flight, + waypoint: FlightWaypoint) -> int: + pre_join = (FlightWaypointType.LOITER, FlightWaypointType.JOIN) + if waypoint.waypoint_type == FlightWaypointType.ASCEND_POINT: + # Flights that start airborne already have some altitude and a good + # amount of speed. + factor = 1.0 if flight.start_type == "In Flight" else 0.5 + return int(GroundSpeed.for_flight(flight) * factor) + elif waypoint.waypoint_type in pre_join: + return GroundSpeed.for_flight(flight) + return GroundSpeed.for_package(self.package) + + +@dataclass(frozen=True) +class PackageWaypointTiming: + #: The package being scheduled. + package: Package + + #: The package join time. + join: int + + #: The ingress waypoint TOT. + ingress: int + + #: The egress waypoint TOT. + egress: int + + #: The package split time. + split: int + + @property + def target(self) -> int: + """The package time over target.""" + assert self.package.time_over_target is not None + return self.package.time_over_target + + @property + def race_track_start(self) -> int: + if self.package.primary_task in CAP_TYPES: + return self.package.time_over_target + else: + return self.ingress + + @property + def race_track_end(self) -> int: + if self.package.primary_task in CAP_TYPES: + return self.target + CAP_DURATION * 60 + else: + return self.egress + + def push_time(self, flight: Flight, hold_point: Point) -> int: + assert self.package.waypoints is not None + return self.join - TravelTime.between_points( + hold_point, + self.package.waypoints.join, + GroundSpeed.for_flight(flight) + ) + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]: + target_types = ( + FlightWaypointType.TARGET_GROUP_LOC, + FlightWaypointType.TARGET_POINT, + FlightWaypointType.TARGET_SHIP, + ) + + ingress_types = ( + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + ) + + if waypoint.waypoint_type == FlightWaypointType.JOIN: + return self.join + elif waypoint.waypoint_type in ingress_types: + return self.ingress + elif waypoint.waypoint_type in target_types: + return self.target + elif waypoint.waypoint_type == FlightWaypointType.EGRESS: + return self.egress + elif waypoint.waypoint_type == FlightWaypointType.SPLIT: + return self.split + elif waypoint.waypoint_type == FlightWaypointType.PATROL_TRACK: + return self.race_track_start + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint, + flight: Flight) -> Optional[int]: + if waypoint.waypoint_type == FlightWaypointType.LOITER: + return self.push_time(flight, Point(waypoint.x, waypoint.y)) + elif waypoint.waypoint_type == FlightWaypointType.PATROL: + return self.race_track_end + return None + + @classmethod + def for_package(cls, package: Package) -> PackageWaypointTiming: + assert package.waypoints is not None + + group_ground_speed = GroundSpeed.for_package(package) + + ingress = package.time_over_target - TravelTime.between_points( + package.waypoints.ingress, + package.target.position, + group_ground_speed + ) + + join = ingress - TravelTime.between_points( + package.waypoints.join, + package.waypoints.ingress, + group_ground_speed + ) + + egress = package.time_over_target + TravelTime.between_points( + package.target.position, + package.waypoints.egress, + group_ground_speed + ) + + split = egress + TravelTime.between_points( + package.waypoints.egress, + package.waypoints.split, + group_ground_speed + ) + + return cls(package, join, ingress, egress, split) diff --git a/qt_ui/models.py b/qt_ui/models.py index 428b4598..ba816fd1 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -1,7 +1,6 @@ """Qt data models for game objects.""" import datetime -from enum import auto, IntEnum -from typing import Any, Callable, Dict, Iterator, TypeVar, Optional +from typing import Any, Callable, Dict, Iterator, Optional, TypeVar from PySide2.QtCore import ( QAbstractListModel, @@ -15,6 +14,7 @@ from game import db from game.game import Game from gen.ato import AirTaskingOrder, Package from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator from qt_ui.uiconstants import AIRCRAFT_ICONS from theater.missiontarget import MissionTarget @@ -119,15 +119,15 @@ class PackageModel(QAbstractListModel): return flight return None - @staticmethod - def text_for_flight(flight: Flight) -> str: + def text_for_flight(self, flight: Flight) -> str: """Returns the text that should be displayed for the flight.""" task = flight.flight_type.name count = flight.count name = db.unit_type_name(flight.unit_type) - delay = flight.scheduled_in + estimator = TotEstimator(self.package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) origin = flight.from_cp.name - return f"[{task}] {count} x {name} from {origin} in {delay} minutes" + return f"[{task}] {count} x {name} from {origin} in {delay}" @staticmethod def icon_for_flight(flight: Flight) -> Optional[QIcon]: diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 74c9608f..99f0ac9f 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -147,6 +147,8 @@ class QTopPanel(QFrame): if not self.ato_has_clients() and not self.confirm_no_client_launch(): return + # TODO: Verify no negative start times. + # TODO: Refactor this nonsense. game_event = None for event in self.game.events: diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index c978c1bc..32178381 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -24,6 +24,7 @@ from PySide2.QtWidgets import ( from game import db from gen.ato import Package from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from ..models import AtoModel, GameModel, NullListModel, PackageModel @@ -33,6 +34,10 @@ class FlightDelegate(QStyledItemDelegate): HMARGIN = 4 VMARGIN = 4 + def __init__(self, package: Package) -> None: + super().__init__() + self.package = package + def get_font(self, option: QStyleOptionViewItem) -> QFont: font = QFont(option.font) font.setPointSize(self.FONT_SIZE) @@ -47,8 +52,9 @@ class FlightDelegate(QStyledItemDelegate): task = flight.flight_type.name count = flight.count name = db.unit_type_name(flight.unit_type) - delay = flight.scheduled_in - return f"[{task}] {count} x {name} in {delay} minutes" + estimator = TotEstimator(self.package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) + return f"[{task}] {count} x {name} in {delay}" def second_row_text(self, index: QModelIndex) -> str: flight = self.flight(index) @@ -128,7 +134,8 @@ class QFlightList(QListView): super().__init__() self.package_model = model self.set_package(model) - self.setItemDelegate(FlightDelegate()) + if model is not None: + self.setItemDelegate(FlightDelegate(model.package)) self.setIconSize(QSize(91, 24)) self.setSelectionBehavior(QAbstractItemView.SelectItems) @@ -138,6 +145,7 @@ class QFlightList(QListView): self.disconnect_model() else: self.package_model = model + self.setItemDelegate(FlightDelegate(model.package)) self.setModel(model) # noinspection PyUnresolvedReferences model.deleted.connect(self.disconnect_model) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 829ea45e..3c0064f9 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -21,7 +21,8 @@ from game import Game, db from game.data.aaa_db import AAA_UNITS from game.data.radar_db import UNITS_WITH_RADAR from game.utils import meter_to_feet -from gen import Conflict, Package, PackageWaypointTiming +from gen import Conflict, PackageWaypointTiming +from gen.ato import Package from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType from qt_ui.displayoptions import DisplayOptions from qt_ui.models import GameModel diff --git a/qt_ui/windows/mission/QFlightItem.py b/qt_ui/windows/mission/QFlightItem.py index 5e4c4c11..7f006dbb 100644 --- a/qt_ui/windows/mission/QFlightItem.py +++ b/qt_ui/windows/mission/QFlightItem.py @@ -1,26 +1,28 @@ +import datetime + from PySide2.QtGui import QStandardItem, QIcon from game import db +from gen.ato import Package from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator from qt_ui.uiconstants import AIRCRAFT_ICONS +# TODO: Replace with QFlightList. class QFlightItem(QStandardItem): - def __init__(self, flight:Flight): + def __init__(self, package: Package, flight: Flight): super(QFlightItem, self).__init__() + self.package = package self.flight = flight if db.unit_type_name(self.flight.unit_type).replace("/", " ") in AIRCRAFT_ICONS.keys(): icon = QIcon((AIRCRAFT_ICONS[db.unit_type_name(self.flight.unit_type)])) self.setIcon(icon) self.setEditable(False) + estimator = TotEstimator(self.package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) self.setText("["+str(self.flight.flight_type.name[:6])+"] " + str(self.flight.count) + " x " + db.unit_type_name(self.flight.unit_type) - + " in " + str(self.flight.scheduled_in) + " minutes") - - def update(self, flight): - self.flight = flight - self.setText("[" + str(self.flight.flight_type.name[:6]) + "] " - + str(self.flight.count) + " x " + db.unit_type_name(self.flight.unit_type) - + " in " + str(self.flight.scheduled_in) + " minutes") \ No newline at end of file + + " in " + str(delay)) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index d9deb613..3c64c160 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -116,11 +116,14 @@ class QPackageDialog(QDialog): self.finished.connect(self.on_close) - def on_close(self, _result) -> None: + @staticmethod + def on_close(_result) -> None: + GameUpdateSignal.get_instance().redraw_flight_paths() + + def save_tot(self) -> None: time = self.tot_spinner.time() seconds = time.hour() * 3600 + time.minute() * 60 + time.second() self.package_model.update_tot(seconds) - GameUpdateSignal.get_instance().redraw_flight_paths() def on_selection_changed(self, selected: QItemSelection, _deselected: QItemSelection) -> None: @@ -182,6 +185,7 @@ class QNewPackageDialog(QPackageDialog): Empty packages may be created. They can be modified later, and will have no effect if empty when the mission is generated. """ + self.save_tot() self.ato_model.add_package(self.package_model.package) for flight in self.package_model.package.flights: self.game.aircraft_inventory.claim_for_flight(flight) @@ -227,6 +231,7 @@ class QEditPackageDialog(QPackageDialog): def on_done(self) -> None: """Closes the window.""" + self.save_tot() self.close() def on_delete(self) -> None: diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 2c8c7dfe..e514b9d7 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -90,7 +90,11 @@ class QFlightCreator(QDialog): origin = self.airfield_selector.currentData() size = self.flight_size_spinner.value() - flight = Flight(aircraft, size, origin, task) + if self.game.settings.perf_ai_parking_start: + start_type = "Cold" + else: + start_type = "Warm" + flight = Flight(aircraft, size, origin, task, start_type) flight.scheduled_in = self.package.delay # noinspection PyUnresolvedReferences diff --git a/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py b/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py index 25e75e7c..abf429cf 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py +++ b/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py @@ -1,6 +1,7 @@ from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QSpinBox +# TODO: Remove? class QFlightDepartureEditor(QGroupBox): def __init__(self, flight): @@ -15,7 +16,7 @@ class QFlightDepartureEditor(QGroupBox): self.departure_delta = QSpinBox(self) self.departure_delta.setMinimum(0) self.departure_delta.setMaximum(120) - self.departure_delta.setValue(self.flight.scheduled_in) + self.departure_delta.setValue(self.flight.scheduled_in // 60) self.departure_delta.valueChanged.connect(self.change_scheduled) layout.addWidget(self.depart_from) @@ -27,4 +28,4 @@ class QFlightDepartureEditor(QGroupBox): self.changed = self.departure_delta.valueChanged def change_scheduled(self): - self.flight.scheduled_in = int(self.departure_delta.value()) + self.flight.scheduled_in = int(self.departure_delta.value() * 60)