From 5db1b94ac49353ac87da14104d023507db16abfb Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 22 Oct 2021 11:45:06 -0700 Subject: [PATCH] Add option to fast forward to first contact. This is the first step in a larger project to add play/pause buttons to the Liberation UI so the mission can be generated at any point. docs/design/turnless.md describes the plan. This adds an option to fast forward the turn to first contact before generating the mission. None of that is reflected in the UI (for now), but the miz will be generated with many flights in the air. For now "first contact" means as soon as any flight reaches its IP. I'll follow up to add threat checking so that air-to-air combat also triggers this, as will entering a SAM's threat zone. This also includes an option to halt fast-forward whenever a player flight reaches a certain mission-prep phase. This can be used to avoid fast forwarding past the player's startup time, taxi time, or takeoff time. By default this option is disabled so player aircraft may start in the air (possibly even at their IP if they're the first mission to reach IP). Fuel states do not currently account for distance traveled during fast forward. That will come later. https://github.com/dcs-liberation/dcs_liberation/issues/1681 --- doc/design/turnless.md | 180 ++++++++++++++++++ game/ato/flight.py | 24 +++ game/ato/flightstate/__init__.py | 8 + game/ato/flightstate/completed.py | 18 ++ game/ato/flightstate/flightstate.py | 34 ++++ game/ato/flightstate/inflight.py | 134 +++++++++++++ game/ato/flightstate/startup.py | 44 +++++ game/ato/flightstate/takeoff.py | 45 +++++ game/ato/flightstate/taxi.py | 44 +++++ game/ato/flightstate/uninitialized.py | 17 ++ game/ato/flightstate/waitingforstart.py | 56 ++++++ game/ato/flightwaypoint.py | 31 --- .../aircraft/aircraftbehavior.py | 1 + .../aircraft/aircraftgenerator.py | 17 +- .../aircraft/flightgroupconfigurator.py | 11 +- .../aircraft/flightgroupspawner.py | 71 +++++-- .../aircraft/waypoints/baiingress.py | 7 +- .../aircraft/waypoints/casingress.py | 4 +- .../aircraft/waypoints/deadingress.py | 6 +- .../aircraft/waypoints/holdpoint.py | 10 +- .../aircraft/waypoints/joinpoint.py | 4 +- .../aircraft/waypoints/ocaaircraftingress.py | 7 +- .../aircraft/waypoints/ocarunwayingress.py | 7 +- .../waypoints/pydcswaypointbuilder.py | 8 +- .../aircraft/waypoints/racetrack.py | 11 +- .../aircraft/waypoints/seadingress.py | 6 +- .../aircraft/waypoints/strikeingress.py | 17 +- .../aircraft/waypoints/sweepingress.py | 8 +- .../aircraft/waypoints/waypointgenerator.py | 62 +++--- game/missiongenerator/environmentgenerator.py | 8 +- game/missiongenerator/missiongenerator.py | 7 +- game/settings/settings.py | 28 +++ game/sim/aircraftsimulation.py | 94 +++++++++ game/sim/missionsimulation.py | 9 +- gen/flights/flightplan.py | 2 +- gen/flights/traveltime.py | 4 +- gen/flights/waypointbuilder.py | 10 +- qt_ui/widgets/QTopPanel.py | 1 + 38 files changed, 910 insertions(+), 145 deletions(-) create mode 100644 doc/design/turnless.md create mode 100644 game/ato/flightstate/__init__.py create mode 100644 game/ato/flightstate/completed.py create mode 100644 game/ato/flightstate/flightstate.py create mode 100644 game/ato/flightstate/inflight.py create mode 100644 game/ato/flightstate/startup.py create mode 100644 game/ato/flightstate/takeoff.py create mode 100644 game/ato/flightstate/taxi.py create mode 100644 game/ato/flightstate/uninitialized.py create mode 100644 game/ato/flightstate/waitingforstart.py create mode 100644 game/sim/aircraftsimulation.py 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)