diff --git a/doc/design/turnless.md b/doc/design/turnless.md new file mode 100644 index 00000000..edd832d5 --- /dev/null +++ b/doc/design/turnless.md @@ -0,0 +1,180 @@ +# Turnless Campaign + +This outlines a path for us converting Liberation from turn based into a +turnless campaign game like BMS. + +## Hard problems + +### We can't simulate combat + +We can't feasibly simulate the details of combat, nor can we generate a mission +mid-engagement. The mission editor does not support generating aircraft +mid-maneuver, or with any SA. + +To avoid that problem, instead of simulating the combat we put the group in a +"freeze" at the moment the engagement begins. Any frozen groups are spawned +wherever they were frozen so simulation can be deferred to DCS. Each freeze will +have a deadline for completion, at which point a simple strength-based +autoresolve will take place. + +After a frozen engagement is resolved (whether in sim or automatically), if the +mission objective has not been achieved a mission abort will be evaluated. + +### We don't know where the roads are + +We don't know where the in-game roads are, so we can't precisely simulate the +location of convoys as they move, nor the amount of time they will take to move +between bases. + +Time can be solved by just having the campaign designer encode the travel time +in the campaign. The ME will estimate it for them so it’s simple to do. Spawn +locations cannot be made precise, but we can have campaign designers define +several waypoints along the route and spawn the convoy at the most appropriate +one. + +### SAM engagement zones are huge + +The large threat zone of many SAMs would cause most of the map to be a frozen +engagement. + +To avoid that, SAMs will not cause frozen engagements, but will be simulated at +a basic level. Each SAM will track a group, and when in range will periodically +“shoot”, and those shots will have a chance to kill an aircraft in the targeted +group. + +## Systems that need to change + +### AI Commander + +Theater state tracking for the HTN becomes much more complicated since it needs +to always be able to replan based on pending plans. This shouldn't be too bad +since RndName's PR did similar and the plan to continue that should work here. +The commander needs to learn to abort missions that are no longer viable. + +### Transports + +With turns gone, transports can longer move one link per turn. See the entry in +"hard problems" above. To begin with we can pick an arbitrary time per link and +just always spawn the transport at the origin regardless of where they ought to +be. + +### Turn based systems + +A number of systems that act in a number of turns become time based: + +- Runway repair +- Unit delivery +- Weather changes +- Pilot replenishment +- Income + +Procurement could be made simpler if we do away with cash in favor of fixed +replenishment rates. + +### Front line advancement + +Front line combat is happening continuously so we need to simulate its movement +and losses on the front line. + +### Mission generation + +The mission generator needs to be able to generate aircraft in specific +locations, with partial fuel, and possibly with some weapons expended. + +### Mission result processing + +Last known positions need to be recorded, as do weapon inventory and fuel +states. + +### UI + +Frozen combats need to be immutable to the player. + +Missions in progress need to be made cancelable. + +The map needs to show the locations of in-progress missions, including frozen +engagements. + +There needs to be a UI to examine frozen engagements. + +## Roadmap + +This roadmap is ordered so that we can attempt to make this change in phases +that may take more than a single release. It is not focused on providing the +best gameplay experience ASAP, but on being able to approach this gradually +rather than freezing all other development or branching and a painful merge. + +### Front-line progress rework + +Add auto-resolve for the front line, adjust progress estimates based on stances +and not just kills (an enemy in retreat is less likely to be killed, but still +gives up ground). + +### Add play from first-contact option + +Add simulation of the mission from the start of the turn up to the first +engagement. Generate the mission from that point. + +### Add fuel expenditure estimation for travel to start location + +Adjust fuel quantities at the start of the mission to account for travel. + +### Add UI to display simulation to first contact + +Before removing turns, add play/pause buttons to the game that will simulate the +mission up to the first engagement. Once play is pressed no changes may be made +to the ATO. + +When the turn ends it behaves like the normal end of turn. The ATO is reset and +the new turn is planned. + +### Add mission abort option for players + +Add the option to abort flight and packages for players. The AI won’t use this +yet but it will be needed once engagement simulation is added. + +### Add simulation for frozen combats + +Allows play to proceed through engagements. The AI will abort missions that are +unlikely to succeed after each combat is resolved. + +### Estimate fuel/weapon use simulation for frozen combats + +Improve behavior for groups that have completed an auto-resolved combat by +estimating their fuel and ammo use. This could be used to check if the mission +should be aborted. + +### Add player options to create new packages after pressing play + +Allow players to create new flights as the mission simulates. This would be the +most effective way to add intercept missions. + +### Make planning real-time + +Make the AI planner periodically reevaluate missions to add to the ATO. It will +still plan as many missions as possible up front so that the DCS side of the +mission can run as long as possible, but it could make use of aircraft that had +aborted or returned. + +### Time-based systems instead of turn based + +Move the systems that are currently based on turns to be based on duration. Each +turn is assumed to be six hours since there are four turns in a day. + +### Time between turns based on mission duration + +Change the turn duration to be based on the length of the mission played. + +### Track end positions of aircraft, remove turns + +If we can track the status of missions after the simulation ends we can continue +where we left off. Do that, and remove the concept of turns. + +### Track ammo/fuel status of aircraft at the end of the mission + +Improve post-simulation realism by tracking the ammunition and fuel spent. + +### Add turnaround times for landed aircraft + +Once aircraft returning from a mission land, a timer starts to track the +turnaround time before they'll be ready to redeploy as opposed to being instant. \ No newline at end of file diff --git a/game/ato/flight.py b/game/ato/flight.py index 3f141c8f..f611f31b 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -6,6 +6,7 @@ from typing import Any, Optional, List, TYPE_CHECKING from gen.flights.loadouts import Loadout from .flightroster import FlightRoster +from .flightstate import Uninitialized, FlightState if TYPE_CHECKING: from game.dcs.aircrafttype import AircraftType @@ -52,6 +53,9 @@ class Flight: # Only used by transport missions. self.cargo = cargo + # Used for simulating the travel to first contact. + self.state: FlightState = Uninitialized(self, squadron.settings) + # Will be replaced with a more appropriate FlightPlan by # FlightPlanBuilder, but an empty flight plan the flight begins with an # empty flight plan. @@ -61,6 +65,19 @@ class Flight: package=package, flight=self, custom_waypoints=[] ) + def __getstate__(self) -> dict[str, Any]: + state = self.__dict__.copy() + # Avoid persisting the flight state since that's not (currently) used outside + # mission generation. This is a bit of a hack for the moment and in the future + # we will need to persist the flight state, but for now keep it out of save + # compat (it also contains a generator that cannot be pickled). + del state["state"] + return state + + def __setstate__(self, state: dict[str, Any]) -> None: + state["state"] = Uninitialized(self, state["squadron"].settings) + self.__dict__.update(state) + @property def departure(self) -> ControlPoint: return self.squadron.location @@ -113,3 +130,10 @@ class Flight: if self.custom_name: return f"{self.custom_name} {self.count} x {self.unit_type}" return f"[{self.flight_type}] {self.count} x {self.unit_type}" + + def set_state(self, state: FlightState) -> None: + self.state = state + + def on_game_tick(self, time: datetime, duration: timedelta) -> bool: + self.state.on_game_tick(time, duration) + return self.state.should_halt_sim() diff --git a/game/ato/flightstate/__init__.py b/game/ato/flightstate/__init__.py new file mode 100644 index 00000000..67fbde77 --- /dev/null +++ b/game/ato/flightstate/__init__.py @@ -0,0 +1,8 @@ +from .completed import Completed +from .flightstate import FlightState +from .inflight import InFlight +from .startup import StartUp +from .takeoff import Takeoff +from .taxi import Taxi +from .uninitialized import Uninitialized +from .waitingforstart import WaitingForStart diff --git a/game/ato/flightstate/completed.py b/game/ato/flightstate/completed.py new file mode 100644 index 00000000..18004e66 --- /dev/null +++ b/game/ato/flightstate/completed.py @@ -0,0 +1,18 @@ +from datetime import datetime, timedelta + +from .flightstate import FlightState +from ..starttype import StartType + + +class Completed(FlightState): + def on_game_tick(self, time: datetime, duration: timedelta) -> None: + return + + @property + def is_waiting_for_start(self) -> bool: + return False + + @property + def spawn_type(self) -> StartType: + # TODO: May want to do something different to make these uncontrolled? + return StartType.COLD diff --git a/game/ato/flightstate/flightstate.py b/game/ato/flightstate/flightstate.py new file mode 100644 index 00000000..b4bf8988 --- /dev/null +++ b/game/ato/flightstate/flightstate.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from game.ato.starttype import StartType + +if TYPE_CHECKING: + from game.ato.flight import Flight + from game.settings import Settings + + +class FlightState(ABC): + def __init__(self, flight: Flight, settings: Settings) -> None: + self.flight = flight + self.settings = settings + + @abstractmethod + def on_game_tick(self, time: datetime, duration: timedelta) -> None: + ... + + def should_halt_sim(self) -> bool: + return False + + @property + @abstractmethod + def is_waiting_for_start(self) -> bool: + ... + + @property + @abstractmethod + def spawn_type(self) -> StartType: + ... diff --git a/game/ato/flightstate/inflight.py b/game/ato/flightstate/inflight.py new file mode 100644 index 00000000..1ba08710 --- /dev/null +++ b/game/ato/flightstate/inflight.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from dcs import Point + +from game.ato.flightstate import Completed +from game.ato.flightstate.flightstate import FlightState +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 + +if TYPE_CHECKING: + from game.ato.flight import Flight + from game.settings import Settings + + +def lerp(v0: float, v1: float, t: float) -> float: + return (1 - t) * v0 + t * v1 + + +class InFlight(FlightState): + def __init__(self, flight: Flight, settings: Settings) -> 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() + 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 + + # Loiter time is already built into travel_time_between_waypoints. + return self.flight.flight_plan.travel_time_between_waypoints( + self.current_waypoint, self.next_waypoint + ) + + def progress(self) -> float: + return ( + self.elapsed_time.total_seconds() + / self.total_time_to_next_waypoint.total_seconds() + ) + + 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 + y1 = self.next_waypoint.position.y + progress = self.progress() + 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( + self.current_waypoint.alt.meters, + self.next_waypoint.alt.meters, + self.progress(), + ) + ), + self.current_waypoint.alt_type, + ) + + 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 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() + + def should_halt_sim(self) -> bool: + contact_types = { + FlightWaypointType.INGRESS_BAI, + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_DEAD, + FlightWaypointType.INGRESS_OCA_AIRCRAFT, + FlightWaypointType.INGRESS_OCA_RUNWAY, + 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 " + "ingress point" + ) + return True + return False + + @property + def is_waiting_for_start(self) -> bool: + return False + + @property + def spawn_type(self) -> StartType: + return StartType.IN_FLIGHT diff --git a/game/ato/flightstate/startup.py b/game/ato/flightstate/startup.py new file mode 100644 index 00000000..9435a734 --- /dev/null +++ b/game/ato/flightstate/startup.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from .flightstate import FlightState +from .taxi import Taxi +from ..starttype import StartType + +if TYPE_CHECKING: + from game.ato.flight import Flight + from game.settings import Settings + + +class StartUp(FlightState): + def __init__(self, flight: Flight, settings: Settings, now: datetime) -> None: + super().__init__(flight, settings) + self.completion_time = now + flight.flight_plan.estimate_startup() + + def on_game_tick(self, time: datetime, duration: timedelta) -> None: + if time < self.completion_time: + return + self.flight.set_state(Taxi(self.flight, self.settings, time)) + + @property + def is_waiting_for_start(self) -> bool: + return False + + @property + def spawn_type(self) -> StartType: + return StartType.COLD + + def should_halt_sim(self) -> bool: + if ( + self.flight.client_count > 0 + and self.settings.player_mission_interrupts_sim_at is StartType.COLD + ): + logging.info( + f"Interrupting simulation because {self.flight} has players and has " + "reached startup time" + ) + return True + return False diff --git a/game/ato/flightstate/takeoff.py b/game/ato/flightstate/takeoff.py new file mode 100644 index 00000000..f67a58a6 --- /dev/null +++ b/game/ato/flightstate/takeoff.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from .flightstate import FlightState +from .inflight import InFlight +from ..starttype import StartType + +if TYPE_CHECKING: + from game.ato.flight import Flight + from game.settings import Settings + + +class Takeoff(FlightState): + def __init__(self, flight: Flight, settings: Settings, now: datetime) -> None: + super().__init__(flight, settings) + # TODO: Not accounted for in FlightPlan, can cause discrepancy without loiter. + self.completion_time = now + timedelta(seconds=30) + + 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)) + + @property + def is_waiting_for_start(self) -> bool: + return False + + @property + def spawn_type(self) -> StartType: + return StartType.RUNWAY + + def should_halt_sim(self) -> bool: + if ( + self.flight.client_count > 0 + and self.settings.player_mission_interrupts_sim_at is StartType.RUNWAY + ): + logging.info( + f"Interrupting simulation because {self.flight} has players and has " + "reached takeoff time" + ) + return True + return False diff --git a/game/ato/flightstate/taxi.py b/game/ato/flightstate/taxi.py new file mode 100644 index 00000000..e8ccdc76 --- /dev/null +++ b/game/ato/flightstate/taxi.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from .flightstate import FlightState +from .takeoff import Takeoff +from ..starttype import StartType + +if TYPE_CHECKING: + from game.ato.flight import Flight + from game.settings import Settings + + +class Taxi(FlightState): + def __init__(self, flight: Flight, settings: Settings, now: datetime) -> None: + super().__init__(flight, settings) + self.completion_time = now + flight.flight_plan.estimate_ground_ops() + + def on_game_tick(self, time: datetime, duration: timedelta) -> None: + if time < self.completion_time: + return + self.flight.set_state(Takeoff(self.flight, self.settings, time)) + + @property + def is_waiting_for_start(self) -> bool: + return False + + @property + def spawn_type(self) -> StartType: + return StartType.WARM + + def should_halt_sim(self) -> bool: + if ( + self.flight.client_count > 0 + and self.settings.player_mission_interrupts_sim_at is StartType.WARM + ): + logging.info( + f"Interrupting simulation because {self.flight} has players and has " + "reached taxi time" + ) + return True + return False diff --git a/game/ato/flightstate/uninitialized.py b/game/ato/flightstate/uninitialized.py new file mode 100644 index 00000000..9ac6ffaf --- /dev/null +++ b/game/ato/flightstate/uninitialized.py @@ -0,0 +1,17 @@ +from datetime import datetime, timedelta + +from .flightstate import FlightState +from ..starttype import StartType + + +class Uninitialized(FlightState): + def on_game_tick(self, time: datetime, duration: timedelta) -> None: + raise RuntimeError("Attempted to simulate flight that is not fully initialized") + + @property + def is_waiting_for_start(self) -> bool: + raise RuntimeError("Attempted to simulate flight that is not fully initialized") + + @property + def spawn_type(self) -> StartType: + raise RuntimeError("Attempted to simulate flight that is not fully initialized") diff --git a/game/ato/flightstate/waitingforstart.py b/game/ato/flightstate/waitingforstart.py new file mode 100644 index 00000000..bc236305 --- /dev/null +++ b/game/ato/flightstate/waitingforstart.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from game.ato.starttype import StartType +from .flightstate import FlightState +from .inflight import InFlight +from .startup import StartUp +from .takeoff import Takeoff +from .taxi import Taxi + +if TYPE_CHECKING: + from game.ato.flight import Flight + from game.settings import Settings + + +class WaitingForStart(FlightState): + def __init__( + self, + flight: Flight, + settings: Settings, + start_time: datetime, + ) -> None: + super().__init__(flight, settings) + self.start_time = start_time + + @property + def start_type(self) -> StartType: + return self.flight.start_type + + def on_game_tick(self, time: datetime, duration: timedelta) -> None: + if time < self.start_time: + return + + new_state: FlightState + if self.start_type is StartType.COLD: + new_state = StartUp(self.flight, self.settings, time) + elif self.start_type is StartType.WARM: + new_state = Taxi(self.flight, self.settings, time) + elif self.start_type is StartType.RUNWAY: + new_state = Takeoff(self.flight, self.settings, time) + else: + new_state = InFlight(self.flight, self.settings) + self.flight.set_state(new_state) + + @property + def is_waiting_for_start(self) -> bool: + return True + + def time_remaining(self, time: datetime) -> timedelta: + return self.start_time - time + + @property + def spawn_type(self) -> StartType: + return self.flight.start_type diff --git a/game/ato/flightwaypoint.py b/game/ato/flightwaypoint.py index c7af0aa7..cf0895a3 100644 --- a/game/ato/flightwaypoint.py +++ b/game/ato/flightwaypoint.py @@ -63,34 +63,3 @@ class FlightWaypoint: @property def position(self) -> Point: return Point(self.x, self.y) - - @classmethod - def from_pydcs(cls, point: MovingPoint, from_cp: ControlPoint) -> "FlightWaypoint": - waypoint = FlightWaypoint( - FlightWaypointType.NAV, - point.position.x, - point.position.y, - meters(point.alt), - ) - waypoint.alt_type = point.alt_type - # Other actions exist... but none of them *should* be the first - # waypoint for a flight. - waypoint.waypoint_type = { - PointAction.TurningPoint: FlightWaypointType.NAV, - PointAction.FlyOverPoint: FlightWaypointType.NAV, - PointAction.FromParkingArea: FlightWaypointType.TAKEOFF, - PointAction.FromParkingAreaHot: FlightWaypointType.TAKEOFF, - PointAction.FromRunway: FlightWaypointType.TAKEOFF, - PointAction.FromGroundArea: FlightWaypointType.TAKEOFF, - PointAction.FromGroundAreaHot: FlightWaypointType.TAKEOFF, - }[point.action] - if waypoint.waypoint_type == FlightWaypointType.NAV: - waypoint.name = "NAV" - waypoint.pretty_name = "Nav" - waypoint.description = "Nav" - else: - waypoint.name = "TAKEOFF" - waypoint.pretty_name = "Takeoff" - waypoint.description = "Takeoff" - waypoint.description = f"Takeoff from {from_cp.name}" - return waypoint diff --git a/game/missiongenerator/aircraft/aircraftbehavior.py b/game/missiongenerator/aircraft/aircraftbehavior.py index 969c0b65..74108953 100644 --- a/game/missiongenerator/aircraft/aircraftbehavior.py +++ b/game/missiongenerator/aircraft/aircraftbehavior.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from typing import Any, Optional from dcs.task import ( diff --git a/game/missiongenerator/aircraft/aircraftgenerator.py b/game/missiongenerator/aircraft/aircraftgenerator.py index 1b654bdd..a5b1b19f 100644 --- a/game/missiongenerator/aircraft/aircraftgenerator.py +++ b/game/missiongenerator/aircraft/aircraftgenerator.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from datetime import datetime from functools import cached_property from typing import Any, Dict, List, TYPE_CHECKING @@ -11,6 +12,7 @@ from dcs.unitgroup import FlyingGroup, StaticGroup from game.ato.airtaaskingorder import AirTaskingOrder from game.ato.flight import Flight +from game.ato.flightstate import Completed from game.ato.flighttype import FlightType from game.ato.package import Package from game.ato.starttype import StartType @@ -42,6 +44,7 @@ class AircraftGenerator: mission: Mission, settings: Settings, game: Game, + time: datetime, radio_registry: RadioRegistry, tacan_registry: TacanRegistry, laser_code_registry: LaserCodeRegistry, @@ -49,9 +52,10 @@ class AircraftGenerator: air_support: AirSupport, helipads: dict[ControlPoint, list[StaticGroup]], ) -> None: - self.m = mission - self.game = game + self.mission = mission self.settings = settings + self.game = game + self.time = time self.radio_registry = radio_registry self.tacan_registy = tacan_registry self.laser_code_registry = laser_code_registry @@ -144,9 +148,10 @@ class AircraftGenerator: StartType.COLD, divert=None, ) + flight.state = Completed(flight, self.game.settings) group = FlightGroupSpawner( - flight, country, self.m, self.helipads + flight, country, self.mission, self.helipads ).create_idle_aircraft() AircraftPainter(flight, group).apply_livery() self.unit_map.add_aircraft(group, flight) @@ -154,15 +159,17 @@ class AircraftGenerator: def create_and_configure_flight( self, flight: Flight, country: Country, dynamic_runways: Dict[str, RunwayData] ) -> FlyingGroup[Any]: + """Creates and configures the flight group in the mission.""" group = FlightGroupSpawner( - flight, country, self.m, self.helipads + flight, country, self.mission, self.helipads ).create_flight_group() self.flights.append( FlightGroupConfigurator( flight, group, self.game, - self.m, + self.mission, + self.time, self.radio_registry, self.tacan_registy, self.laser_code_registry, diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index 001fd4b4..d0566c04 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from datetime import datetime from typing import Any, Optional, TYPE_CHECKING from dcs import Mission @@ -36,6 +37,7 @@ class FlightGroupConfigurator: group: FlyingGroup[Any], game: Game, mission: Mission, + time: datetime, radio_registry: RadioRegistry, tacan_registry: TacanRegistry, laser_code_registry: LaserCodeRegistry, @@ -47,6 +49,7 @@ class FlightGroupConfigurator: self.group = group self.game = game self.mission = mission + self.time = time self.radio_registry = radio_registry self.tacan_registry = tacan_registry self.laser_code_registry = laser_code_registry @@ -72,7 +75,13 @@ class FlightGroupConfigurator: ) mission_start_time, waypoints = WaypointGenerator( - self.flight, self.group, self.mission, self.game.settings, self.air_support + self.flight, + self.group, + self.mission, + self.game.conditions.start_time, + self.time, + self.game.settings, + self.air_support, ).create_waypoints() return FlightData( diff --git a/game/missiongenerator/aircraft/flightgroupspawner.py b/game/missiongenerator/aircraft/flightgroupspawner.py index 40bbe32a..649168af 100644 --- a/game/missiongenerator/aircraft/flightgroupspawner.py +++ b/game/missiongenerator/aircraft/flightgroupspawner.py @@ -12,6 +12,7 @@ from dcs.terrain import Airport, NoParkingSlotError from dcs.unitgroup import FlyingGroup, ShipGroup, StaticGroup from game.ato import Flight +from game.ato.flightstate import InFlight from game.ato.starttype import StartType from game.theater import Airfield, ControlPoint, NavalControlPoint, OffMapSpawn from game.utils import meters @@ -40,7 +41,31 @@ class FlightGroupSpawner: self.helipads = helipads def create_flight_group(self) -> FlyingGroup[Any]: - return self.generate_flight_at_departure() + """Creates the group for the flight and adds it to the mission. + + Each flight is spawned according to its FlightState at the time of mission + generation. Aircraft that are WaitingForStart will be set up based on their + StartType with a delay. Note that delays are actually created during waypoint + generation. + + Aircraft that are *not* WaitingForStart will be spawned in their current state. + We cannot spawn aircraft mid-taxi, so when the simulated state is near the end + of a long taxi period the aircraft will be spawned in their parking spot. This + could lead to problems but that's what loiter points are for. The other pre- + flight states have the same problem but are much shorter and more easily covered + by the loiter time. Player flights that are spawned near the end of their cold + start have the biggest problem but players are able to cut corners to make up + for lost time. + + Aircraft that are already in the air will be spawned at their estimated + location, speed, and altitude based on their flight plan. + """ + if ( + self.flight.state.is_waiting_for_start + or self.flight.state.spawn_type is not StartType.IN_FLIGHT + ): + return self.generate_flight_at_departure() + return self.generate_mid_mission() def create_idle_aircraft(self) -> FlyingGroup[Any]: assert isinstance(self.flight.squadron.location, Airfield) @@ -54,13 +79,17 @@ class FlightGroupSpawner: group.uncontrolled = True return group + @property + def start_type(self) -> StartType: + return self.flight.state.spawn_type + def generate_flight_at_departure(self) -> FlyingGroup[Any]: name = namegen.next_aircraft_name( self.country, self.flight.departure.id, self.flight ) cp = self.flight.departure try: - if self.flight.start_type is StartType.IN_FLIGHT: + if self.start_type is StartType.IN_FLIGHT: group = self._generate_over_departure(name, cp) return group elif isinstance(cp, NavalControlPoint): @@ -69,7 +98,7 @@ class FlightGroupSpawner: if not isinstance(carrier_group, ShipGroup): raise RuntimeError( f"Carrier group {carrier_group} is a " - "{carrier_group.__class__.__name__}, expected a ShipGroup" + f"{carrier_group.__class__.__name__}, expected a ShipGroup" ) return self._generate_at_group(name, carrier_group) else: @@ -88,11 +117,33 @@ class FlightGroupSpawner: logging.exception( "No room on runway or parking slots. Starting from the air." ) - self.flight.start_type = StartType.IN_FLIGHT group = self._generate_over_departure(name, cp) group.points[0].alt = 1500 return group + def generate_mid_mission(self) -> FlyingGroup[Any]: + assert isinstance(self.flight.state, InFlight) + name = namegen.next_aircraft_name( + self.country, self.flight.departure.id, self.flight + ) + speed = self.flight.state.estimate_speed() + pos = self.flight.state.estimate_position() + alt, alt_type = self.flight.state.estimate_altitude() + group = self.mission.flight_group( + country=self.country, + name=name, + aircraft_type=self.flight.unit_type.dcs_unit_type, + airport=None, + position=pos, + altitude=alt.meters, + speed=speed.kph, + maintask=None, + group_size=self.flight.count, + ) + + group.points[0].alt_type = alt_type + return group + def _generate_at_airport(self, name: str, airport: Airport) -> FlyingGroup[Any]: # TODO: Delayed runway starts should be converted to air starts for multiplayer. # Runway starts do not work with late activated aircraft in multiplayer. Instead @@ -169,7 +220,7 @@ class FlightGroupSpawner: group.points[0].action = PointAction.FromGroundArea group.points[0].type = "TakeOffGround" group.units[0].heading = helipad.units[0].heading - if self.flight.start_type != "Cold": + if self.start_type is not StartType.COLD: group.points[0].action = PointAction.FromGroundAreaHot group.points[0].type = "TakeOffGroundHot" @@ -183,15 +234,13 @@ class FlightGroupSpawner: return group def dcs_start_type(self) -> DcsStartType: - if self.flight.start_type is StartType.RUNWAY: + if self.start_type is StartType.RUNWAY: return DcsStartType.Runway - elif self.flight.start_type is StartType.COLD: + elif self.start_type is StartType.COLD: return DcsStartType.Cold - elif self.flight.start_type is StartType.WARM: + elif self.start_type is StartType.WARM: return DcsStartType.Warm - raise ValueError( - f"There is no pydcs StartType matching {self.flight.start_type}" - ) + raise ValueError(f"There is no pydcs StartType matching {self.start_type}") def _start_type_at_group( self, diff --git a/game/missiongenerator/aircraft/waypoints/baiingress.py b/game/missiongenerator/aircraft/waypoints/baiingress.py index bdaf8532..c3306cf9 100644 --- a/game/missiongenerator/aircraft/waypoints/baiingress.py +++ b/game/missiongenerator/aircraft/waypoints/baiingress.py @@ -9,9 +9,7 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class BaiIngressBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() - + def add_tasks(self, waypoint: MovingPoint) -> None: # TODO: Add common "UnitGroupTarget" base type. group_names = [] target = self.package.target @@ -25,7 +23,7 @@ class BaiIngressBuilder(PydcsWaypointBuilder): "Unexpected target type for BAI mission: %s", target.__class__.__name__, ) - return waypoint + return for group_name in group_names: group = self.mission.find_group(group_name) @@ -39,4 +37,3 @@ class BaiIngressBuilder(PydcsWaypointBuilder): task.params["altitudeEnabled"] = False task.params["groupAttack"] = True waypoint.tasks.append(task) - return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/casingress.py b/game/missiongenerator/aircraft/waypoints/casingress.py index dd068e6e..ac93d5db 100644 --- a/game/missiongenerator/aircraft/waypoints/casingress.py +++ b/game/missiongenerator/aircraft/waypoints/casingress.py @@ -9,8 +9,7 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class CasIngressBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() + def add_tasks(self, waypoint: MovingPoint) -> None: if isinstance(self.flight.flight_plan, CasFlightPlan): waypoint.add_task( EngageTargetsInZone( @@ -35,4 +34,3 @@ class CasIngressBuilder(PydcsWaypointBuilder): ], ) ) - return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/deadingress.py b/game/missiongenerator/aircraft/waypoints/deadingress.py index dd57e6f8..826afa70 100644 --- a/game/missiongenerator/aircraft/waypoints/deadingress.py +++ b/game/missiongenerator/aircraft/waypoints/deadingress.py @@ -8,8 +8,7 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class DeadIngressBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() + def add_tasks(self, waypoint: MovingPoint) -> None: self.register_special_waypoints(self.waypoint.targets) target = self.package.target @@ -18,7 +17,7 @@ class DeadIngressBuilder(PydcsWaypointBuilder): "Unexpected target type for DEAD mission: %s", target.__class__.__name__, ) - return waypoint + return for group in target.groups: miz_group = self.mission.find_group(group.name) @@ -33,4 +32,3 @@ class DeadIngressBuilder(PydcsWaypointBuilder): task.params["altitudeEnabled"] = False task.params["groupAttack"] = True waypoint.tasks.append(task) - return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/holdpoint.py b/game/missiongenerator/aircraft/waypoints/holdpoint.py index 496e7f0b..2046a78f 100644 --- a/game/missiongenerator/aircraft/waypoints/holdpoint.py +++ b/game/missiongenerator/aircraft/waypoints/holdpoint.py @@ -8,8 +8,7 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class HoldPointBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() + def add_tasks(self, waypoint: MovingPoint) -> None: loiter = ControlledTask( OrbitAction(altitude=waypoint.alt, pattern=OrbitAction.OrbitPattern.Circle) ) @@ -20,9 +19,10 @@ class HoldPointBuilder(PydcsWaypointBuilder): f"{flight_plan_type} does not define a push time. AI will push " "immediately and may flight unsuitable speeds." ) - return waypoint + return push_time = self.flight.flight_plan.push_time self.waypoint.departure_time = push_time - loiter.stop_after_time(int(push_time.total_seconds())) + loiter.stop_after_time( + int((push_time - self.elapsed_mission_time).total_seconds()) + ) waypoint.add_task(loiter) - return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/joinpoint.py b/game/missiongenerator/aircraft/waypoints/joinpoint.py index c61f5c2b..1842c823 100644 --- a/game/missiongenerator/aircraft/waypoints/joinpoint.py +++ b/game/missiongenerator/aircraft/waypoints/joinpoint.py @@ -9,8 +9,7 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class JoinPointBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() + def add_tasks(self, waypoint: MovingPoint) -> None: if self.flight.flight_type == FlightType.ESCORT: self.configure_escort_tasks( waypoint, @@ -23,7 +22,6 @@ class JoinPointBuilder(PydcsWaypointBuilder): self.configure_escort_tasks( waypoint, [Targets.All.GroundUnits.AirDefence.AAA.SAMRelated] ) - return waypoint @staticmethod def configure_escort_tasks( diff --git a/game/missiongenerator/aircraft/waypoints/ocaaircraftingress.py b/game/missiongenerator/aircraft/waypoints/ocaaircraftingress.py index 1a683cc7..cf5dc5e7 100644 --- a/game/missiongenerator/aircraft/waypoints/ocaaircraftingress.py +++ b/game/missiongenerator/aircraft/waypoints/ocaaircraftingress.py @@ -9,16 +9,14 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class OcaAircraftIngressBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() - + def add_tasks(self, waypoint: MovingPoint) -> None: target = self.package.target if not isinstance(target, Airfield): logging.error( "Unexpected target type for OCA Strike mission: %s", target.__class__.__name__, ) - return waypoint + return task = EngageTargetsInZone( position=target.position, @@ -32,4 +30,3 @@ class OcaAircraftIngressBuilder(PydcsWaypointBuilder): task.params["altitudeEnabled"] = False task.params["groupAttack"] = True waypoint.tasks.append(task) - return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/ocarunwayingress.py b/game/missiongenerator/aircraft/waypoints/ocarunwayingress.py index 4009dc8b..c7416c00 100644 --- a/game/missiongenerator/aircraft/waypoints/ocarunwayingress.py +++ b/game/missiongenerator/aircraft/waypoints/ocarunwayingress.py @@ -8,18 +8,15 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class OcaRunwayIngressBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() - + def add_tasks(self, waypoint: MovingPoint) -> None: target = self.package.target if not isinstance(target, Airfield): logging.error( "Unexpected target type for runway bombing mission: %s", target.__class__.__name__, ) - return waypoint + return waypoint.tasks.append( BombingRunway(airport_id=target.airport.id, group_attack=True) ) - return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py b/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py index acc84dfa..9d6c4861 100644 --- a/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py +++ b/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py @@ -28,6 +28,7 @@ class PydcsWaypointBuilder: group: FlyingGroup[Any], flight: Flight, mission: Mission, + elapsed_mission_time: timedelta, air_support: AirSupport, ) -> None: self.waypoint = waypoint @@ -35,6 +36,7 @@ class PydcsWaypointBuilder: self.package = flight.package self.flight = flight self.mission = mission + self.elapsed_mission_time = elapsed_mission_time self.air_support = air_support def build(self) -> MovingPoint: @@ -54,12 +56,16 @@ class PydcsWaypointBuilder: tot = self.flight.flight_plan.tot_for_waypoint(self.waypoint) if tot is not None: self.set_waypoint_tot(waypoint, tot) + self.add_tasks(waypoint) return waypoint + def add_tasks(self, waypoint: MovingPoint) -> None: + pass + def set_waypoint_tot(self, waypoint: MovingPoint, tot: timedelta) -> None: self.waypoint.tot = tot if not self._viggen_client_tot(): - waypoint.ETA = int(tot.total_seconds()) + waypoint.ETA = int((tot - self.elapsed_mission_time).total_seconds()) waypoint.ETA_locked = True waypoint.speed_locked = False diff --git a/game/missiongenerator/aircraft/waypoints/racetrack.py b/game/missiongenerator/aircraft/waypoints/racetrack.py index a29677e3..cfe0ebc7 100644 --- a/game/missiongenerator/aircraft/waypoints/racetrack.py +++ b/game/missiongenerator/aircraft/waypoints/racetrack.py @@ -16,9 +16,7 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class RaceTrackBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() - + def add_tasks(self, waypoint: MovingPoint) -> None: flight_plan = self.flight.flight_plan if not isinstance(flight_plan, PatrollingFlightPlan): @@ -27,7 +25,7 @@ class RaceTrackBuilder(PydcsWaypointBuilder): f"Cannot create race track for {self.flight} because " f"{flight_plan_type} does not define a patrol." ) - return waypoint + return # NB: It's important that the engage task comes before the orbit task. # Though they're on the same waypoint, if the orbit task comes first it @@ -58,11 +56,10 @@ class RaceTrackBuilder(PydcsWaypointBuilder): racetrack = ControlledTask(orbit) self.set_waypoint_tot(waypoint, flight_plan.patrol_start_time) - racetrack.stop_after_time(int(flight_plan.patrol_end_time.total_seconds())) + loiter_duration = flight_plan.patrol_end_time - self.elapsed_mission_time + racetrack.stop_after_time(int(loiter_duration.total_seconds())) waypoint.add_task(racetrack) - return waypoint - def configure_refueling_actions(self, waypoint: MovingPoint) -> None: waypoint.add_task(Tanker()) diff --git a/game/missiongenerator/aircraft/waypoints/seadingress.py b/game/missiongenerator/aircraft/waypoints/seadingress.py index d7fe87d7..22cd75c6 100644 --- a/game/missiongenerator/aircraft/waypoints/seadingress.py +++ b/game/missiongenerator/aircraft/waypoints/seadingress.py @@ -8,8 +8,7 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class SeadIngressBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() + def add_tasks(self, waypoint: MovingPoint) -> None: self.register_special_waypoints(self.waypoint.targets) target = self.package.target @@ -18,7 +17,7 @@ class SeadIngressBuilder(PydcsWaypointBuilder): "Unexpected target type for SEAD mission: %s", target.__class__.__name__, ) - return waypoint + return for group in target.groups: miz_group = self.mission.find_group(group.name) @@ -33,4 +32,3 @@ class SeadIngressBuilder(PydcsWaypointBuilder): task.params["altitudeEnabled"] = False task.params["groupAttack"] = True waypoint.tasks.append(task) - return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/strikeingress.py b/game/missiongenerator/aircraft/waypoints/strikeingress.py index 7e084729..fbc19ca5 100644 --- a/game/missiongenerator/aircraft/waypoints/strikeingress.py +++ b/game/missiongenerator/aircraft/waypoints/strikeingress.py @@ -7,18 +7,16 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class StrikeIngressBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: + def add_tasks(self, waypoint: MovingPoint) -> None: if self.group.units[0].unit_type in [B_17G, B_52H, Tu_22M3]: - return self.build_bombing() + self.add_bombing_tasks(waypoint) else: - return self.build_strike() - - def build_bombing(self) -> MovingPoint: - waypoint = super().build() + self.add_strike_tasks(waypoint) + def add_bombing_tasks(self, waypoint: MovingPoint) -> None: targets = self.waypoint.targets if not targets: - return waypoint + return center = Point(0, 0) for target in targets: @@ -33,10 +31,8 @@ class StrikeIngressBuilder(PydcsWaypointBuilder): bombing.params["altitudeEnabled"] = False bombing.params["groupAttack"] = True waypoint.tasks.append(bombing) - return waypoint - def build_strike(self) -> MovingPoint: - waypoint = super().build() + def add_strike_tasks(self, waypoint: MovingPoint) -> None: for target in self.waypoint.targets: bombing = Bombing(target.position, weapon_type=WeaponType.Auto) # If there is only one target, drop all ordnance in one pass. @@ -47,4 +43,3 @@ class StrikeIngressBuilder(PydcsWaypointBuilder): # Register special waypoints self.register_special_waypoints(self.waypoint.targets) - return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/sweepingress.py b/game/missiongenerator/aircraft/waypoints/sweepingress.py index 15ae6250..5b0d632e 100644 --- a/game/missiongenerator/aircraft/waypoints/sweepingress.py +++ b/game/missiongenerator/aircraft/waypoints/sweepingress.py @@ -9,16 +9,14 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class SweepIngressBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() - + def add_tasks(self, waypoint: MovingPoint) -> None: if not isinstance(self.flight.flight_plan, SweepFlightPlan): flight_plan_type = self.flight.flight_plan.__class__.__name__ logging.error( f"Cannot create sweep for {self.flight} because " f"{flight_plan_type} is not a sweep flight plan." ) - return waypoint + return waypoint.tasks.append( EngageTargets( @@ -29,5 +27,3 @@ class SweepIngressBuilder(PydcsWaypointBuilder): ], ) ) - - return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py index 3dab37f2..bfc648e2 100644 --- a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py +++ b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py @@ -1,6 +1,6 @@ import itertools import random -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from dcs import Mission @@ -12,15 +12,13 @@ from dcs.triggers import Event, TriggerOnce, TriggerRule from dcs.unitgroup import FlyingGroup from game.ato import Flight, FlightWaypoint +from game.ato.flightstate import InFlight, WaitingForStart from game.ato.flightwaypointtype import FlightWaypointType from game.ato.starttype import StartType from game.missiongenerator.airsupport import AirSupport from game.settings import Settings from game.theater import ControlPointType from game.utils import meters, pairwise -from gen.flights.traveltime import TotEstimator - -from .pydcswaypointbuilder import PydcsWaypointBuilder, TARGET_WAYPOINTS from .baiingress import BaiIngressBuilder from .cargostop import CargoStopBuilder from .casingress import CasIngressBuilder @@ -31,6 +29,7 @@ from .joinpoint import JoinPointBuilder from .landingpoint import LandingPointBuilder from .ocaaircraftingress import OcaAircraftIngressBuilder from .ocarunwayingress import OcaRunwayIngressBuilder +from .pydcswaypointbuilder import PydcsWaypointBuilder, TARGET_WAYPOINTS from .racetrack import RaceTrackBuilder from .racetrackend import RaceTrackEndBuilder from .seadingress import SeadIngressBuilder @@ -44,12 +43,16 @@ class WaypointGenerator: flight: Flight, group: FlyingGroup[Any], mission: Mission, + turn_start_time: datetime, + time: datetime, settings: Settings, air_support: AirSupport, ) -> None: self.flight = flight self.group = group self.mission = mission + self.elapsed_mission_time = time - turn_start_time + self.time = time self.settings = settings self.air_support = air_support @@ -57,17 +60,22 @@ class WaypointGenerator: for waypoint in self.flight.points: waypoint.tot = None - takeoff_point = FlightWaypoint.from_pydcs( - self.group.points[0], self.flight.from_cp - ) - mission_start_time = self.set_takeoff_time(takeoff_point) + waypoints = self.flight.flight_plan.waypoints + mission_start_time = self.set_takeoff_time(waypoints[0]) filtered_points: list[FlightWaypoint] = [] - for point in self.flight.points: if point.only_for_player and not self.flight.client_count: continue - filtered_points.append(point) + if isinstance(self.flight.state, InFlight): + if point == self.flight.state.current_waypoint: + # We don't need to build this waypoint because pydcs did that for + # us, but we do need to configure the tasks for it so that mid- + # 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: + filtered_points.append(point) # Only add 1 target waypoint for Viggens. This only affects player flights, the # Viggen can't have more than 9 waypoints which leaves us with two target point # under the current flight plans. @@ -100,7 +108,6 @@ class WaypointGenerator: # estimation ability the minimum fuel amounts will be calculated during flight # plan construction, but for now it's only used by the kneeboard so is generated # late. - waypoints = [takeoff_point] + self.flight.points self._estimate_min_fuel_for(waypoints) return mission_start_time, waypoints @@ -124,7 +131,12 @@ class WaypointGenerator: } builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder) return builder( - waypoint, self.group, self.flight, self.mission, self.air_support + waypoint, + self.group, + self.flight, + self.mission, + self.elapsed_mission_time, + self.air_support, ) def _estimate_min_fuel_for(self, waypoints: list[FlightWaypoint]) -> None: @@ -171,23 +183,25 @@ class WaypointGenerator: a.min_fuel = min_fuel def set_takeoff_time(self, waypoint: FlightWaypoint) -> timedelta: - estimator = TotEstimator(self.flight.package) - start_time = estimator.mission_start_time(self.flight) + if isinstance(self.flight.state, WaitingForStart): + delay = self.flight.state.time_remaining(self.time) + else: + delay = timedelta() - if self.should_delay_flight(start_time): + if self.should_delay_flight(): if self.should_activate_late(): # Late activation causes the aircraft to not be spawned # until triggered. - self.set_activation_time(start_time) + self.set_activation_time(delay) elif self.flight.start_type is StartType.COLD: # Setting the start time causes the AI to wait until the # specified time to begin their startup sequence. - self.set_startup_time(start_time) + self.set_startup_time(delay) # And setting *our* waypoint TOT causes the takeoff time to show up in # the player's kneeboard. waypoint.tot = self.flight.flight_plan.takeoff_time() - return start_time + return delay def set_activation_time(self, delay: timedelta) -> None: # Note: Late activation causes the waypoint TOTs to look *weird* in the @@ -232,17 +246,17 @@ class WaypointGenerator: activation_trigger.add_action(AITaskPush(self.group.id, len(self.group.tasks))) self.mission.triggerrules.triggers.append(activation_trigger) - def should_delay_flight(self, start_time: timedelta) -> bool: - if start_time.total_seconds() <= 0: + def should_delay_flight(self) -> bool: + if not isinstance(self.flight.state, WaitingForStart): return False if not self.flight.client_count: return True - if start_time < timedelta(minutes=10): - # Don't bother delaying client flights with short start delays. Much - # more than ten minutes starts to eat into fuel a bit more - # (espeicially for something fuel limited like a Harrier). + if self.flight.state.time_remaining(self.time) < timedelta(minutes=10): + # Don't bother delaying client flights with short start delays. Much more + # than ten minutes starts to eat into fuel a bit more (especially for + # something fuel limited like a Harrier). return False return not self.settings.never_delay_player_flights diff --git a/game/missiongenerator/environmentgenerator.py b/game/missiongenerator/environmentgenerator.py index 84f5bd59..dec34969 100644 --- a/game/missiongenerator/environmentgenerator.py +++ b/game/missiongenerator/environmentgenerator.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from dcs.mission import Mission @@ -6,9 +7,12 @@ from game.weather import Clouds, Fog, Conditions, WindConditions, AtmosphericCon class EnvironmentGenerator: - def __init__(self, mission: Mission, conditions: Conditions) -> None: + def __init__( + self, mission: Mission, conditions: Conditions, time: datetime + ) -> None: self.mission = mission self.conditions = conditions + self.time = time def set_atmospheric(self, atmospheric: AtmosphericConditions) -> None: self.mission.weather.qnh = atmospheric.qnh.mm_hg @@ -35,7 +39,7 @@ class EnvironmentGenerator: self.mission.weather.wind_at_8000 = wind.at_8000m def generate(self) -> None: - self.mission.start_time = self.conditions.start_time + self.mission.start_time = self.time self.set_atmospheric(self.conditions.weather.atmospheric) self.set_clouds(self.conditions.weather.clouds) self.set_fog(self.conditions.weather.fog) diff --git a/game/missiongenerator/missiongenerator.py b/game/missiongenerator/missiongenerator.py index 1ad58df0..774e989e 100644 --- a/game/missiongenerator/missiongenerator.py +++ b/game/missiongenerator/missiongenerator.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, cast @@ -46,8 +47,9 @@ COMBINED_ARMS_SLOTS = 1 class MissionGenerator: - def __init__(self, game: Game) -> None: + def __init__(self, game: Game, time: datetime) -> None: self.game = game + self.time = time self.mission = Mission(game.theater.terrain) self.unit_map = UnitMap() @@ -74,7 +76,7 @@ class MissionGenerator: self.add_airfields_to_unit_map() self.initialize_registries() - EnvironmentGenerator(self.mission, self.game.conditions).generate() + EnvironmentGenerator(self.mission, self.game.conditions, self.time).generate() tgo_generator = TgoGenerator( self.mission, @@ -232,6 +234,7 @@ class MissionGenerator: self.mission, self.game.settings, self.game, + self.time, self.radio_registry, self.tacan_registry, self.laser_code_registry, diff --git a/game/settings/settings.py b/game/settings/settings.py index 533a03ae..7189755a 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -286,6 +286,34 @@ class Settings: # Mission Generator # Gameplay + fast_forward_to_first_contact: bool = boolean_option( + "Fast forward mission to first contact (WIP)", + page=MISSION_GENERATOR_PAGE, + section=GAMEPLAY_SECTION, + default=False, + detail=( + "If enabled, the mission will be generated at the point of first contact." + ), + ) + player_mission_interrupts_sim_at: Optional[StartType] = choices_option( + "Player missions interrupt fast forward", + page=MISSION_GENERATOR_PAGE, + section=GAMEPLAY_SECTION, + default=None, + choices={ + "Never": None, + "At startup time": StartType.COLD, + "At taxi time": StartType.WARM, + "At takeoff time": StartType.RUNWAY, + }, + detail=( + "Determines what player mission states will interrupt fast-forwarding to " + "first contact, if enabled. If never is selected player missions will not " + "impact simulation and player missions may be generated mid-flight. The " + "other options will cause the mission to be generated as soon as a player " + "mission reaches the set state or at first contact, whichever comes first." + ), + ) supercarrier: bool = boolean_option( "Use supercarrier module", MISSION_GENERATOR_PAGE, diff --git a/game/sim/aircraftsimulation.py b/game/sim/aircraftsimulation.py new file mode 100644 index 00000000..ae48c2c8 --- /dev/null +++ b/game/sim/aircraftsimulation.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import itertools +import logging +from collections import Iterator +from datetime import datetime, timedelta + +from typing_extensions import TYPE_CHECKING + +from game.ato import Flight +from game.ato.flightstate import ( + InFlight, + StartUp, + Takeoff, + Taxi, + Uninitialized, + WaitingForStart, +) +from game.ato.starttype import StartType +from gen.flights.traveltime import TotEstimator + +if TYPE_CHECKING: + from game import Game + + +TICK = timedelta(seconds=1) + + +class AircraftSimulation: + def __init__(self, game: Game) -> None: + self.game = game + self.time = self.game.conditions.start_time + + def run(self) -> None: + self.reset() + self.set_initial_flight_states() + if self.game.settings.fast_forward_to_first_contact: + self.simulate_until_first_contact() + logging.info(f"Mission simulation completed at {self.time}") + + def simulate_until_first_contact(self) -> None: + while True: + self.time += TICK + if self.tick(): + return + + def tick(self) -> bool: + interrupt_sim = False + for flight in self.iter_flights(): + if flight.on_game_tick(self.time, TICK): + 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 + + def set_initial_flight_states(self) -> None: + now = self.game.conditions.start_time + for flight in self.iter_flights(): + estimator = TotEstimator(flight.package) + start_time = estimator.mission_start_time(flight) + if start_time <= timedelta(): + self.set_active_flight_state(flight, now) + else: + flight.set_state( + WaitingForStart(flight, self.game.settings, now + start_time) + ) + + def set_active_flight_state(self, flight: Flight, now: datetime) -> None: + if flight.start_type is StartType.COLD: + flight.set_state(StartUp(flight, self.game.settings, now)) + elif flight.start_type is StartType.WARM: + flight.set_state(Taxi(flight, self.game.settings, now)) + 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)) + else: + raise ValueError(f"Unknown start type {flight.start_type} for {flight}") + + def reset(self) -> None: + self.time = self.game.conditions.start_time + for flight in self.iter_flights(): + flight.set_state(Uninitialized(flight, self.game.settings)) + + def iter_flights(self) -> Iterator[Flight]: + packages = itertools.chain( + self.game.blue.ato.packages, self.game.red.ato.packages + ) + for package in packages: + yield from package.flights diff --git a/game/sim/missionsimulation.py b/game/sim/missionsimulation.py index 67948a2c..0469845b 100644 --- a/game/sim/missionsimulation.py +++ b/game/sim/missionsimulation.py @@ -6,6 +6,7 @@ from typing import Optional, TYPE_CHECKING from game.debriefing import Debriefing from game.missiongenerator import MissionGenerator +from game.sim.aircraftsimulation import AircraftSimulation from game.sim.missionresultsprocessor import MissionResultsProcessor from game.unitmap import UnitMap @@ -17,9 +18,15 @@ class MissionSimulation: def __init__(self, game: Game) -> None: self.game = game self.unit_map: Optional[UnitMap] = None + self.time = game.conditions.start_time + + def run(self) -> None: + sim = AircraftSimulation(self.game) + sim.run() + self.time = sim.time def generate_miz(self, output: Path) -> None: - self.unit_map = MissionGenerator(self.game).generate_miz(output) + self.unit_map = MissionGenerator(self.game, self.time).generate_miz(output) def debrief_current_state( self, state_path: Path, force_end: bool = False diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index dc7ea3fe..663aead7 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -43,11 +43,11 @@ from .closestairfields import ObjectiveDistanceCache from game.ato.flighttype import FlightType from game.ato.flightwaypointtype import FlightWaypointType from game.ato.flightwaypoint import FlightWaypoint -from game.ato.flight import Flight from .traveltime import GroundSpeed, TravelTime from .waypointbuilder import StrikeTarget, WaypointBuilder if TYPE_CHECKING: + from game.ato.flight import Flight from game.ato.package import Package from game.coalition import Coalition from game.threatzones import ThreatZones diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index ec4f54f4..795b240b 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -6,19 +6,17 @@ from datetime import timedelta from typing import TYPE_CHECKING from dcs.mapping import Point -from dcs.unittype import FlyingType from game.utils import ( Distance, SPEED_OF_SOUND_AT_SEA_LEVEL, Speed, - kph, mach, meters, ) -from game.ato.flight import Flight if TYPE_CHECKING: + from game.ato.flight import Flight from game.ato.package import Package diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 10254b14..04869730 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -18,10 +18,6 @@ from dcs.mapping import Point from dcs.unit import Unit from dcs.unitgroup import VehicleGroup, ShipGroup -if TYPE_CHECKING: - from game.coalition import Coalition - from game.transfers import MultiGroupTransport - from game.theater import ( ControlPoint, MissionTarget, @@ -31,7 +27,11 @@ from game.theater import ( from game.utils import Distance, meters, nautical_miles from game.ato.flightwaypointtype import FlightWaypointType from game.ato.flightwaypoint import FlightWaypoint -from game.ato.flight import Flight + +if TYPE_CHECKING: + from game.ato.flight import Flight + from game.coalition import Coalition + from game.transfers import MultiGroupTransport @dataclass(frozen=True) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 58f3ca66..a1e0c730 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -278,6 +278,7 @@ class QTopPanel(QFrame): return sim = MissionSimulation(self.game) + sim.run() sim.generate_miz(persistency.mission_path_for("liberation_nextturn.miz")) waiting = QWaitingForMissionResultWindow(self.game, sim, self)