diff --git a/game/ato/flight.py b/game/ato/flight.py index 978079c0..462360c3 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -10,6 +10,7 @@ from .flightstate import Uninitialized, FlightState if TYPE_CHECKING: from game.dcs.aircrafttype import AircraftType + from game.sim.aircraftengagementzones import AircraftEngagementZones from game.squadrons import Squadron, Pilot from game.theater import ControlPoint, MissionTarget from game.transfers import TransferOrder @@ -137,5 +138,5 @@ class Flight: def on_game_tick(self, time: datetime, duration: timedelta) -> None: self.state.on_game_tick(time, duration) - def should_halt_sim(self) -> bool: - return self.state.should_halt_sim() + def should_halt_sim(self, enemy_aircraft_coverage: AircraftEngagementZones) -> bool: + return self.state.should_halt_sim(enemy_aircraft_coverage) diff --git a/game/ato/flightstate/flightstate.py b/game/ato/flightstate/flightstate.py index b4bf8988..a07714cf 100644 --- a/game/ato/flightstate/flightstate.py +++ b/game/ato/flightstate/flightstate.py @@ -2,13 +2,15 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import datetime, timedelta -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from game.ato.starttype import StartType if TYPE_CHECKING: from game.ato.flight import Flight from game.settings import Settings + from game.sim.aircraftengagementzones import AircraftEngagementZones + from game.threatzones import ThreatPoly class FlightState(ABC): @@ -20,7 +22,7 @@ class FlightState(ABC): def on_game_tick(self, time: datetime, duration: timedelta) -> None: ... - def should_halt_sim(self) -> bool: + def should_halt_sim(self, enemy_aircraft_coverage: AircraftEngagementZones) -> bool: return False @property @@ -32,3 +34,6 @@ class FlightState(ABC): @abstractmethod def spawn_type(self) -> StartType: ... + + def a2a_commit_region(self) -> Optional[ThreatPoly]: + return None diff --git a/game/ato/flightstate/inflight.py b/game/ato/flightstate/inflight.py index 0b543364..d8371eb9 100644 --- a/game/ato/flightstate/inflight.py +++ b/game/ato/flightstate/inflight.py @@ -8,14 +8,16 @@ from dcs import Point from game.ato.flightstate import Completed from game.ato.flightstate.flightstate import FlightState +from game.ato.flightwaypoint import FlightWaypoint from game.ato.flightwaypointtype import FlightWaypointType from game.ato.starttype import StartType from game.utils import Distance, Speed, meters -from gen.flights.flightplan import LoiterFlightPlan, PatrollingFlightPlan +from gen.flights.flightplan import LoiterFlightPlan if TYPE_CHECKING: from game.ato.flight import Flight from game.settings import Settings + from game.sim.aircraftengagementzones import AircraftEngagementZones def lerp(v0: float, v1: float, t: float) -> float: @@ -23,27 +25,32 @@ def lerp(v0: float, v1: float, t: float) -> float: class InFlight(FlightState): - def __init__(self, flight: Flight, settings: Settings) -> None: + def __init__(self, flight: Flight, settings: Settings, waypoint_index: int) -> None: super().__init__(flight, settings) - self.waypoints = flight.flight_plan.iter_waypoints() - # TODO: Error checking for stupid flight plans with fewer than two waypoints. - self.current_waypoint = next(self.waypoints) - self.next_waypoint = next(self.waypoints) - self.passed_waypoints = {self.current_waypoint} - self.total_time_to_next_waypoint = self._total_time() + waypoints = self.flight.flight_plan.waypoints + self.waypoint_index = waypoint_index + self.current_waypoint = waypoints[self.waypoint_index] + # TODO: Error checking for flight plans without landing waypoints. + self.next_waypoint = waypoints[self.waypoint_index + 1] + self.total_time_to_next_waypoint = self.travel_time_between_waypoints() self.elapsed_time = timedelta() - def _total_time(self) -> timedelta: - if isinstance(self.flight.flight_plan, PatrollingFlightPlan): - # Racetracks should remain at the first waypoint until the patrol ends so - # that the waypoint generation doesn't need to reverse the orbit direction. - if self.current_waypoint == self.flight.flight_plan.patrol_start: - return self.flight.flight_plan.patrol_duration + def has_passed_waypoint(self, waypoint: FlightWaypoint) -> bool: + index = self.flight.flight_plan.waypoints.index(waypoint) + return index <= self.waypoint_index - # Loiter time is already built into travel_time_between_waypoints. - return self.flight.flight_plan.travel_time_between_waypoints( + def travel_time_between_waypoints(self) -> timedelta: + travel_time = self.flight.flight_plan.travel_time_between_waypoints( self.current_waypoint, self.next_waypoint ) + if self.current_waypoint.waypoint_type is FlightWaypointType.LOITER: + # Loiter time is already built into travel_time_between_waypoints. If we're + # at a loiter point but still a regular InFlight (Loiter overrides this + # method) that means we're traveling from the loiter point but no longer + # loitering. + assert isinstance(self.flight.flight_plan, LoiterFlightPlan) + travel_time -= self.flight.flight_plan.hold_duration + return travel_time def progress(self) -> float: return ( @@ -52,19 +59,6 @@ class InFlight(FlightState): ) def estimate_position(self) -> Point: - # TODO: Make Loiter and RaceTrack distinct FlightStates. - if isinstance(self.flight.flight_plan, PatrollingFlightPlan): - # Prevent spawning racetracks in the middle of a leg. For simplicity we - # always start the aircraft at the beginning of the racetrack. - if self.current_waypoint == self.flight.flight_plan.patrol_start: - return self.current_waypoint.position - elif isinstance(self.flight.flight_plan, LoiterFlightPlan): - if ( - self.current_waypoint == self.flight.flight_plan.hold - and self.elapsed_time < self.flight.flight_plan.hold_duration - ): - return self.current_waypoint.position - x0 = self.current_waypoint.position.x y0 = self.current_waypoint.position.y x1 = self.next_waypoint.position.x @@ -73,7 +67,6 @@ class InFlight(FlightState): return Point(lerp(x0, x1, progress), lerp(y0, y1, progress)) def estimate_altitude(self) -> tuple[Distance, str]: - # TODO: Should count progress as 0 until departing a hold. return ( meters( lerp( @@ -86,27 +79,32 @@ class InFlight(FlightState): ) def estimate_speed(self) -> Speed: - # TODO: Patrol/loiter speeds may be different. return self.flight.flight_plan.speed_between_waypoints( self.current_waypoint, self.next_waypoint ) - def update_waypoints(self) -> None: - self.current_waypoint = self.next_waypoint - self.passed_waypoints.add(self.current_waypoint) - try: - self.next_waypoint = next(self.waypoints) - except StopIteration: - self.flight.set_state(Completed(self.flight, self.settings)) - self.total_time_to_next_waypoint = self._total_time() - self.elapsed_time = timedelta() + def next_waypoint_state(self) -> FlightState: + from game.ato.flightstate.loiter import Loiter + from game.ato.flightstate.racetrack import RaceTrack + + new_index = self.waypoint_index + 1 + if self.next_waypoint.waypoint_type is FlightWaypointType.LANDING_POINT: + return Completed(self.flight, self.settings) + if self.next_waypoint.waypoint_type is FlightWaypointType.PATROL_TRACK: + return RaceTrack(self.flight, self.settings, new_index) + if self.next_waypoint.waypoint_type is FlightWaypointType.LOITER: + return Loiter(self.flight, self.settings, new_index) + return InFlight(self.flight, self.settings, new_index) + + def advance_to_next_waypoint(self) -> None: + self.flight.set_state(self.next_waypoint_state()) def on_game_tick(self, time: datetime, duration: timedelta) -> None: self.elapsed_time += duration if self.elapsed_time > self.total_time_to_next_waypoint: - self.update_waypoints() + self.advance_to_next_waypoint() - def should_halt_sim(self) -> bool: + def should_halt_sim(self, enemy_aircraft_coverage: AircraftEngagementZones) -> bool: contact_types = { FlightWaypointType.INGRESS_BAI, FlightWaypointType.INGRESS_CAS, @@ -116,7 +114,7 @@ class InFlight(FlightState): FlightWaypointType.INGRESS_SEAD, FlightWaypointType.INGRESS_STRIKE, } - # TODO: Check against enemy threats. + if self.current_waypoint.waypoint_type in contact_types: logging.info( f"Interrupting simulation because {self.flight} has reached its " @@ -132,6 +130,13 @@ class InFlight(FlightState): ) return True + if enemy_aircraft_coverage.covers(self.estimate_position()): + logging.info( + f"Interrupting simulation because {self.flight} has encountered enemy " + "air-to-air patrol" + ) + return True + return False @property diff --git a/game/ato/flightstate/loiter.py b/game/ato/flightstate/loiter.py new file mode 100644 index 00000000..7adbd2c9 --- /dev/null +++ b/game/ato/flightstate/loiter.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from dcs import Point + +from game.ato.flightstate import FlightState, InFlight +from game.utils import Distance, Speed +from gen.flights.flightplan import LoiterFlightPlan + +if TYPE_CHECKING: + from game.ato.flight import Flight + from game.settings import Settings + + +class Loiter(InFlight): + def __init__(self, flight: Flight, settings: Settings, waypoint_index: int) -> None: + assert isinstance(flight.flight_plan, LoiterFlightPlan) + self.hold_duration = flight.flight_plan.hold_duration + super().__init__(flight, settings, waypoint_index) + + def estimate_position(self) -> Point: + return self.current_waypoint.position + + def estimate_altitude(self) -> tuple[Distance, str]: + return self.current_waypoint.alt, self.current_waypoint.alt_type + + def estimate_speed(self) -> Speed: + return self.flight.unit_type.preferred_patrol_speed(self.estimate_altitude()[0]) + + def next_waypoint_state(self) -> FlightState: + # Do not automatically advance to the next waypoint. Just proceed from the + # current one with the normal flying state. + return InFlight(self.flight, self.settings, self.waypoint_index) + + def travel_time_between_waypoints(self) -> timedelta: + return self.hold_duration diff --git a/game/ato/flightstate/racetrack.py b/game/ato/flightstate/racetrack.py new file mode 100644 index 00000000..7309a04e --- /dev/null +++ b/game/ato/flightstate/racetrack.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import Optional, TYPE_CHECKING + +from dcs import Point +from shapely.geometry import LineString, Point as ShapelyPoint + +from game.ato import FlightType +from game.ato.flightstate import InFlight +from game.threatzones import ThreatPoly +from game.utils import Distance, Speed +from gen.flights.flightplan import PatrollingFlightPlan + +if TYPE_CHECKING: + from game.ato.flight import Flight + from game.settings import Settings + + +class RaceTrack(InFlight): + def __init__(self, flight: Flight, settings: Settings, waypoint_index: int) -> None: + assert isinstance(flight.flight_plan, PatrollingFlightPlan) + self.patrol_duration = flight.flight_plan.patrol_duration + super().__init__(flight, settings, waypoint_index) + self.commit_region = LineString( + [ + ShapelyPoint(self.current_waypoint.x, self.current_waypoint.y), + ShapelyPoint(self.next_waypoint.x, self.next_waypoint.y), + ] + ).buffer(flight.flight_plan.engagement_distance.meters) + + def estimate_position(self) -> Point: + # Prevent spawning racetracks in the middle of a leg. For simplicity we + # always start the aircraft at the beginning of the racetrack. + return self.current_waypoint.position + + def estimate_altitude(self) -> tuple[Distance, str]: + return self.current_waypoint.alt, self.current_waypoint.alt_type + + def estimate_speed(self) -> Speed: + return self.flight.unit_type.preferred_patrol_speed(self.estimate_altitude()[0]) + + def travel_time_between_waypoints(self) -> timedelta: + return self.patrol_duration + + def a2a_commit_region(self) -> Optional[ThreatPoly]: + if self.flight.flight_type in {FlightType.BARCAP, FlightType.TARCAP}: + return self.commit_region + return None diff --git a/game/ato/flightstate/startup.py b/game/ato/flightstate/startup.py index 9435a734..df94aef0 100644 --- a/game/ato/flightstate/startup.py +++ b/game/ato/flightstate/startup.py @@ -11,6 +11,7 @@ from ..starttype import StartType if TYPE_CHECKING: from game.ato.flight import Flight from game.settings import Settings + from game.sim.aircraftengagementzones import AircraftEngagementZones class StartUp(FlightState): @@ -31,7 +32,7 @@ class StartUp(FlightState): def spawn_type(self) -> StartType: return StartType.COLD - def should_halt_sim(self) -> bool: + def should_halt_sim(self, enemy_aircraft_coverage: AircraftEngagementZones) -> bool: if ( self.flight.client_count > 0 and self.settings.player_mission_interrupts_sim_at is StartType.COLD diff --git a/game/ato/flightstate/takeoff.py b/game/ato/flightstate/takeoff.py index f67a58a6..fb82131c 100644 --- a/game/ato/flightstate/takeoff.py +++ b/game/ato/flightstate/takeoff.py @@ -11,6 +11,7 @@ from ..starttype import StartType if TYPE_CHECKING: from game.ato.flight import Flight from game.settings import Settings + from game.sim.aircraftengagementzones import AircraftEngagementZones class Takeoff(FlightState): @@ -22,7 +23,7 @@ class Takeoff(FlightState): def on_game_tick(self, time: datetime, duration: timedelta) -> None: if time < self.completion_time: return - self.flight.set_state(InFlight(self.flight, self.settings)) + self.flight.set_state(InFlight(self.flight, self.settings, waypoint_index=0)) @property def is_waiting_for_start(self) -> bool: @@ -32,7 +33,7 @@ class Takeoff(FlightState): def spawn_type(self) -> StartType: return StartType.RUNWAY - def should_halt_sim(self) -> bool: + def should_halt_sim(self, enemy_aircraft_coverage: AircraftEngagementZones) -> bool: if ( self.flight.client_count > 0 and self.settings.player_mission_interrupts_sim_at is StartType.RUNWAY diff --git a/game/ato/flightstate/taxi.py b/game/ato/flightstate/taxi.py index e8ccdc76..e5d29abf 100644 --- a/game/ato/flightstate/taxi.py +++ b/game/ato/flightstate/taxi.py @@ -11,6 +11,7 @@ from ..starttype import StartType if TYPE_CHECKING: from game.ato.flight import Flight from game.settings import Settings + from game.sim.aircraftengagementzones import AircraftEngagementZones class Taxi(FlightState): @@ -31,7 +32,7 @@ class Taxi(FlightState): def spawn_type(self) -> StartType: return StartType.WARM - def should_halt_sim(self) -> bool: + def should_halt_sim(self, enemy_aircraft_coverage: AircraftEngagementZones) -> bool: if ( self.flight.client_count > 0 and self.settings.player_mission_interrupts_sim_at is StartType.WARM diff --git a/game/ato/flightstate/waitingforstart.py b/game/ato/flightstate/waitingforstart.py index bc236305..b748d31e 100644 --- a/game/ato/flightstate/waitingforstart.py +++ b/game/ato/flightstate/waitingforstart.py @@ -41,7 +41,7 @@ class WaitingForStart(FlightState): elif self.start_type is StartType.RUNWAY: new_state = Takeoff(self.flight, self.settings, time) else: - new_state = InFlight(self.flight, self.settings) + new_state = InFlight(self.flight, self.settings, waypoint_index=0) self.flight.set_state(new_state) @property diff --git a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py index dea9e4b9..79c41a36 100644 --- a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py +++ b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py @@ -74,7 +74,7 @@ class WaypointGenerator: # mission aircraft starting at a waypoint with tasks behave # correctly. self.builder_for_waypoint(point).add_tasks(self.group.points[0]) - if point not in self.flight.state.passed_waypoints: + if not self.flight.state.has_passed_waypoint(point): filtered_points.append(point) else: filtered_points.append(point) diff --git a/game/settings/settings.py b/game/settings/settings.py index 7189755a..ce86513a 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -290,7 +290,7 @@ class Settings: "Fast forward mission to first contact (WIP)", page=MISSION_GENERATOR_PAGE, section=GAMEPLAY_SECTION, - default=False, + default=True, detail=( "If enabled, the mission will be generated at the point of first contact." ), diff --git a/game/sim/aircraftengagementzones.py b/game/sim/aircraftengagementzones.py new file mode 100644 index 00000000..6501cc84 --- /dev/null +++ b/game/sim/aircraftengagementzones.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +from dcs import Point +from shapely.geometry import Point as ShapelyPoint +from shapely.ops import unary_union + +from game.ato.flightstate import InFlight + +if TYPE_CHECKING: + from game.ato import Flight + from game.ato.airtaaskingorder import AirTaskingOrder + from game.threatzones import ThreatPoly + + +class AircraftEngagementZones: + def __init__(self, threat_zones: ThreatPoly) -> None: + self.threat_zones = threat_zones + + def covers(self, position: Point) -> bool: + return self.threat_zones.intersects(ShapelyPoint(position.x, position.y)) + + @classmethod + def from_ato(cls, ato: AirTaskingOrder) -> AircraftEngagementZones: + commit_regions = [] + for package in ato.packages: + for flight in package.flights: + if (region := cls.commit_region(flight)) is not None: + commit_regions.append(region) + return AircraftEngagementZones(unary_union(commit_regions)) + + @classmethod + def commit_region(cls, flight: Flight) -> Optional[ThreatPoly]: + if isinstance(flight.state, InFlight): + return flight.state.a2a_commit_region() + return None diff --git a/game/sim/aircraftsimulation.py b/game/sim/aircraftsimulation.py index e7b17b2d..3f844b99 100644 --- a/game/sim/aircraftsimulation.py +++ b/game/sim/aircraftsimulation.py @@ -17,6 +17,7 @@ from game.ato.flightstate import ( WaitingForStart, ) from game.ato.starttype import StartType +from game.sim.aircraftengagementzones import AircraftEngagementZones from gen.flights.traveltime import TotEstimator if TYPE_CHECKING: @@ -45,18 +46,17 @@ class AircraftSimulation: return def tick(self) -> bool: - interrupt_sim = False for flight in self.iter_flights(): flight.on_game_tick(self.time, TICK) - if flight.should_halt_sim(): - interrupt_sim = True - # TODO: Check for SAM or A2A contact. - # Generate an engagement poly for all active air-to-air aircraft per-coalition - # and compare those against aircraft positions. If any aircraft intersects an - # enemy air-threat region, generate the mission. Also check against enemy SAM - # zones. - return interrupt_sim + # Finish updating all flights before computing engagement zones so that the new + # positions are used. + blue_a2a = AircraftEngagementZones.from_ato(self.game.blue.ato) + red_a2a = AircraftEngagementZones.from_ato(self.game.red.ato) + for flight in self.iter_flights(): + if flight.should_halt_sim(red_a2a if flight.squadron.player else blue_a2a): + return True + return False def set_initial_flight_states(self) -> None: now = self.game.conditions.start_time @@ -78,7 +78,7 @@ class AircraftSimulation: elif flight.start_type is StartType.RUNWAY: flight.set_state(Takeoff(flight, self.game.settings, now)) elif flight.start_type is StartType.IN_FLIGHT: - flight.set_state(InFlight(flight, self.game.settings)) + flight.set_state(InFlight(flight, self.game.settings, waypoint_index=0)) else: raise ValueError(f"Unknown start type {flight.start_type} for {flight}")