RTB canceled in-progress flights.

The UI won't stop you from aborting a flight that is already home, but
that should also result in it re-completing again on the next tick.

https://github.com/dcs-liberation/dcs_liberation/issues/1680
This commit is contained in:
Dan Albert 2022-03-08 00:46:50 -08:00
parent 7fe73ad2eb
commit 4993353184
15 changed files with 203 additions and 31 deletions

View File

@ -69,9 +69,18 @@ interface CommitBoundaryProps {
} }
function CommitBoundary(props: CommitBoundaryProps) { function CommitBoundary(props: CommitBoundaryProps) {
const { data, error, isLoading } = useGetCommitBoundaryForFlightQuery({ const { data, error, isLoading } = useGetCommitBoundaryForFlightQuery(
flightId: props.flightId, {
}); flightId: props.flightId,
},
// RTK Query doesn't seem to allow us to invalidate the cache from anything
// but a mutation, but this data can be invalidated by events from the
// websocket. Just disable the cache for this.
//
// This isn't perfect. It won't redraw until the component remounts. There
// doesn't appear to be a better way.
{ refetchOnMountOrArgChange: true }
);
if (isLoading) { if (isLoading) {
return <></>; return <></>;
} }

View File

@ -9,7 +9,7 @@ from dcs.planes import C_101CC, C_101EB, Su_33
from game.savecompat import has_save_compat_for from game.savecompat import has_save_compat_for
from .flightroster import FlightRoster from .flightroster import FlightRoster
from .flightstate import FlightState, Uninitialized from .flightstate import FlightState, InFlight, Navigating, Uninitialized
from .flightstate.killed import Killed from .flightstate.killed import Killed
from .loadouts import Loadout from .loadouts import Loadout
from ..sidc import ( from ..sidc import (
@ -50,6 +50,7 @@ class Flight(SidcDescribable):
self.id = uuid.uuid4() self.id = uuid.uuid4()
self.package = package self.package = package
self.country = country self.country = country
self.coalition = squadron.coalition
self.squadron = squadron self.squadron = squadron
self.squadron.claim_inventory(count) self.squadron.claim_inventory(count)
if roster is None: if roster is None:
@ -105,6 +106,8 @@ class Flight(SidcDescribable):
state["props"] = {} state["props"] = {}
if "id" not in state: if "id" not in state:
state["id"] = uuid.uuid4() state["id"] = uuid.uuid4()
if "coalition" not in state:
state["coalition"] = state["squadron"].coalition
self.__dict__.update(state) self.__dict__.update(state)
@property @property
@ -191,6 +194,27 @@ class Flight(SidcDescribable):
return f"{self.custom_name} {self.count} x {self.unit_type}" return f"{self.custom_name} {self.count} x {self.unit_type}"
return f"[{self.flight_type}] {self.count} x {self.unit_type}" return f"[{self.flight_type}] {self.count} x {self.unit_type}"
def abort(self) -> None:
from game.ato.flightplan import RtbFlightPlan
if not isinstance(self.state, InFlight):
raise RuntimeError(f"Cannot abort {self} because it is not in flight")
altitude, altitude_reference = self.state.estimate_altitude()
self.flight_plan = RtbFlightPlan.create_for_abort(
self, self.state.estimate_position(), altitude, altitude_reference
)
self.set_state(
Navigating(
self,
self.squadron.settings,
self.flight_plan.abort_index,
has_aborted=True,
)
)
def set_state(self, state: FlightState) -> None: def set_state(self, state: FlightState) -> None:
self.state = state self.state = state

View File

@ -1021,6 +1021,76 @@ class FerryFlightPlan(FlightPlan):
return self.package.time_over_target return self.package.time_over_target
@dataclass(frozen=True)
class RtbFlightPlan(FlightPlan):
takeoff: FlightWaypoint
abort_location: FlightWaypoint
nav_to_destination: list[FlightWaypoint]
land: FlightWaypoint
divert: Optional[FlightWaypoint]
bullseye: FlightWaypoint
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield self.takeoff
yield self.abort_location
yield from self.nav_to_destination
yield self.land
if self.divert is not None:
yield self.divert
yield self.bullseye
@property
def abort_index(self) -> int:
return 1
@property
def tot_waypoint(self) -> Optional[FlightWaypoint]:
return None
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]:
return None
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]:
return None
@property
def mission_departure_time(self) -> timedelta:
return timedelta()
@staticmethod
def create_for_abort(
flight: Flight,
current_position: Point,
current_altitude: Distance,
altitude_reference: str,
) -> RtbFlightPlan:
altitude_is_agl = flight.unit_type.dcs_unit_type.helicopter
altitude = (
feet(1500)
if altitude_is_agl
else flight.unit_type.preferred_patrol_altitude
)
builder = WaypointBuilder(flight, flight.coalition)
abort_point = builder.nav(
current_position, current_altitude, altitude_reference == "RADIO"
)
abort_point.name = "ABORT AND RTB"
abort_point.pretty_name = "Abort and RTB"
abort_point.description = "Abort mission and return to base"
return RtbFlightPlan(
package=flight.package,
flight=flight,
takeoff=builder.takeoff(flight.departure),
abort_location=abort_point,
nav_to_destination=builder.nav_path(
current_position, flight.arrival.position, altitude, altitude_is_agl
),
land=builder.land(flight.arrival),
divert=builder.divert(flight.divert),
bullseye=builder.bullseye(),
)
@dataclass(frozen=True) @dataclass(frozen=True)
class CustomFlightPlan(FlightPlan): class CustomFlightPlan(FlightPlan):
custom_waypoints: List[FlightWaypoint] custom_waypoints: List[FlightWaypoint]

View File

@ -2,6 +2,7 @@ from .completed import Completed
from .flightstate import FlightState from .flightstate import FlightState
from .incombat import InCombat from .incombat import InCombat
from .inflight import InFlight from .inflight import InFlight
from .killed import Killed
from .navigating import Navigating from .navigating import Navigating
from .startup import StartUp from .startup import StartUp
from .takeoff import Takeoff from .takeoff import Takeoff

View File

@ -6,5 +6,9 @@ from game.ato.flightstate import FlightState
class AtDeparture(FlightState, ABC): class AtDeparture(FlightState, ABC):
@property
def cancelable(self) -> bool:
return True
def estimate_position(self) -> Point: def estimate_position(self) -> Point:
return self.flight.departure.position return self.flight.departure.position

View File

@ -13,6 +13,10 @@ if TYPE_CHECKING:
class Completed(FlightState): class Completed(FlightState):
@property
def cancelable(self) -> bool:
return False
def on_game_tick( def on_game_tick(
self, events: GameUpdateEvents, time: datetime, duration: timedelta self, events: GameUpdateEvents, time: datetime, duration: timedelta
) -> None: ) -> None:

View File

@ -25,6 +25,11 @@ class FlightState(ABC):
def alive(self) -> bool: def alive(self) -> bool:
return True return True
@property
@abstractmethod
def cancelable(self) -> bool:
...
@abstractmethod @abstractmethod
def on_game_tick( def on_game_tick(
self, events: GameUpdateEvents, time: datetime, duration: timedelta self, events: GameUpdateEvents, time: datetime, duration: timedelta

View File

@ -20,10 +20,17 @@ if TYPE_CHECKING:
class InFlight(FlightState, ABC): class InFlight(FlightState, ABC):
def __init__(self, flight: Flight, settings: Settings, waypoint_index: int) -> None: def __init__(
self,
flight: Flight,
settings: Settings,
waypoint_index: int,
has_aborted: bool = False,
) -> None:
super().__init__(flight, settings) super().__init__(flight, settings)
waypoints = self.flight.flight_plan.waypoints waypoints = self.flight.flight_plan.waypoints
self.waypoint_index = waypoint_index self.waypoint_index = waypoint_index
self.has_aborted = has_aborted
self.current_waypoint = waypoints[self.waypoint_index] self.current_waypoint = waypoints[self.waypoint_index]
# TODO: Error checking for flight plans without landing waypoints. # TODO: Error checking for flight plans without landing waypoints.
self.next_waypoint = waypoints[self.waypoint_index + 1] self.next_waypoint = waypoints[self.waypoint_index + 1]
@ -31,6 +38,10 @@ class InFlight(FlightState, ABC):
self.elapsed_time = timedelta() self.elapsed_time = timedelta()
self.current_waypoint_elapsed = False self.current_waypoint_elapsed = False
@property
def cancelable(self) -> bool:
return False
@property @property
def in_flight(self) -> bool: def in_flight(self) -> bool:
return True return True
@ -151,4 +162,8 @@ class InFlight(FlightState, ABC):
@property @property
def description(self) -> str: def description(self) -> str:
return f"Flying to {self.next_waypoint.name}" if self.has_aborted:
abort = "(Aborted) "
else:
abort = ""
return f"{abort}Flying to {self.next_waypoint.name}"

View File

@ -21,6 +21,10 @@ class Killed(FlightState):
super().__init__(flight, settings) super().__init__(flight, settings)
self.last_position = last_position self.last_position = last_position
@property
def cancelable(self) -> bool:
return False
@property @property
def alive(self) -> bool: def alive(self) -> bool:
return False return False

View File

@ -75,7 +75,3 @@ class Navigating(InFlight):
@property @property
def spawn_type(self) -> StartType: def spawn_type(self) -> StartType:
return StartType.IN_FLIGHT return StartType.IN_FLIGHT
@property
def description(self) -> str:
return f"Flying to {self.next_waypoint.name}"

View File

@ -14,6 +14,10 @@ if TYPE_CHECKING:
class Uninitialized(FlightState): class Uninitialized(FlightState):
@property
def cancelable(self) -> bool:
return True
def on_game_tick( def on_game_tick(
self, events: GameUpdateEvents, time: datetime, duration: timedelta self, events: GameUpdateEvents, time: datetime, duration: timedelta
) -> None: ) -> None:

View File

@ -1,4 +1,8 @@
from __future__ import annotations
from asyncio import Queue from asyncio import Queue
from collections.abc import Iterator
from contextlib import contextmanager
from game.sim import GameUpdateEvents from game.sim import GameUpdateEvents
@ -27,3 +31,10 @@ class EventStream:
events = await cls._queue.get() events = await cls._queue.get()
cls._queue.task_done() cls._queue.task_done()
return events return events
@staticmethod
@contextmanager
def event_context() -> Iterator[GameUpdateEvents]:
events = GameUpdateEvents()
yield events
EventStream.put_nowait(events)

View File

@ -161,9 +161,17 @@ class PackageModel(QAbstractListModel):
# flight plan yet. Will be called manually by the caller. # flight plan yet. Will be called manually by the caller.
self.endInsertRows() self.endInsertRows()
def delete_flight_at_index(self, index: QModelIndex) -> None: def cancel_or_abort_flight_at_index(self, index: QModelIndex) -> None:
"""Removes the flight at the given index from the package.""" """Removes the flight at the given index from the package."""
self.delete_flight(self.flight_at_index(index)) self.cancel_or_abort_flight(self.flight_at_index(index))
def cancel_or_abort_flight(self, flight: Flight) -> None:
if flight.state.cancelable:
self.delete_flight(flight)
EventStream.put_nowait(GameUpdateEvents().delete_flight(flight))
else:
flight.abort()
EventStream.put_nowait(GameUpdateEvents().update_flight(flight))
def delete_flight(self, flight: Flight) -> None: def delete_flight(self, flight: Flight) -> None:
"""Removes the given flight from the package.""" """Removes the given flight from the package."""
@ -257,13 +265,28 @@ class AtoModel(QAbstractListModel):
self.client_slots_changed.emit() self.client_slots_changed.emit()
self.on_packages_changed() self.on_packages_changed()
def delete_package_at_index(self, index: QModelIndex) -> None: def cancel_or_abort_package_at_index(self, index: QModelIndex) -> None:
"""Removes the package at the given index from the ATO.""" """Removes the package at the given index from the ATO."""
self.delete_package(self.package_at_index(index)) self.cancel_or_abort_package(self.package_at_index(index))
def delete_package(self, package: Package) -> None: def cancel_or_abort_package(self, package: Package) -> None:
with EventStream.event_context() as events:
if all(f.state.cancelable for f in package.flights):
self._delete_package(package)
events.delete_flights_in_package(package)
return
package_model = self.find_matching_package_model(package)
for flight in package.flights:
if flight.state.cancelable:
package_model.delete_flight(flight)
events.delete_flight(flight)
else:
flight.abort()
events.update_flight(flight)
def _delete_package(self, package: Package) -> None:
"""Removes the given package from the ATO.""" """Removes the given package from the ATO."""
EventStream.put_nowait(GameUpdateEvents().delete_flights_in_package(package))
self.package_models.release(package) self.package_models.release(package)
index = self.ato.packages.index(package) index = self.ato.packages.index(package)
self.beginRemoveRows(QModelIndex(), index, index) self.beginRemoveRows(QModelIndex(), index, index)

View File

@ -128,11 +128,8 @@ class QFlightList(QListView):
parent=self.window(), parent=self.window(),
) )
def delete_flight(self, index: QModelIndex) -> None: def cancel_or_abort_flight(self, index: QModelIndex) -> None:
EventStream.put_nowait( self.package_model.cancel_or_abort_flight_at_index(index)
GameUpdateEvents().delete_flight(self.package_model.flight_at_index(index))
)
self.package_model.delete_flight_at_index(index)
def contextMenuEvent(self, event: QContextMenuEvent) -> None: def contextMenuEvent(self, event: QContextMenuEvent) -> None:
index = self.indexAt(event.pos()) index = self.indexAt(event.pos())
@ -144,7 +141,7 @@ class QFlightList(QListView):
menu.addAction(edit_action) menu.addAction(edit_action)
delete_action = QAction(f"Delete") delete_action = QAction(f"Delete")
delete_action.triggered.connect(lambda: self.delete_flight(index)) delete_action.triggered.connect(lambda: self.cancel_or_abort_flight(index))
menu.addAction(delete_action) menu.addAction(delete_action)
menu.exec_(event.globalPos()) menu.exec_(event.globalPos())
@ -183,10 +180,10 @@ class QFlightPanel(QGroupBox):
self.edit_button.clicked.connect(self.on_edit) self.edit_button.clicked.connect(self.on_edit)
self.button_row.addWidget(self.edit_button) self.button_row.addWidget(self.edit_button)
self.delete_button = QPushButton("Delete") self.delete_button = QPushButton("Cancel")
# noinspection PyTypeChecker # noinspection PyTypeChecker
self.delete_button.setProperty("style", "btn-danger") self.delete_button.setProperty("style", "btn-danger")
self.delete_button.clicked.connect(self.on_delete) self.delete_button.clicked.connect(self.on_cancel_flight)
self.button_row.addWidget(self.delete_button) self.button_row.addWidget(self.delete_button)
self.selection_changed.connect(self.on_selection_changed) self.selection_changed.connect(self.on_selection_changed)
@ -211,6 +208,11 @@ class QFlightPanel(QGroupBox):
self.edit_button.setEnabled(enabled) self.edit_button.setEnabled(enabled)
self.delete_button.setEnabled(enabled) self.delete_button.setEnabled(enabled)
self.change_map_flight_selection(index) self.change_map_flight_selection(index)
delete_text = "Cancel"
if (flight := self.flight_list.selected_item) is not None:
if not flight.state.cancelable:
delete_text = "Abort"
self.delete_button.setText(delete_text)
def change_map_flight_selection(self, index: QModelIndex) -> None: def change_map_flight_selection(self, index: QModelIndex) -> None:
events = GameUpdateEvents() events = GameUpdateEvents()
@ -228,13 +230,13 @@ class QFlightPanel(QGroupBox):
return return
self.flight_list.edit_flight(index) self.flight_list.edit_flight(index)
def on_delete(self) -> None: def on_cancel_flight(self) -> None:
"""Removes the selected flight from the package.""" """Removes the selected flight from the package."""
index = self.flight_list.currentIndex() index = self.flight_list.currentIndex()
if not index.isValid(): if not index.isValid():
logging.error(f"Cannot delete flight when no flight is selected.") logging.error(f"Cannot delete flight when no flight is selected.")
return return
self.flight_list.delete_flight(index) self.flight_list.cancel_or_abort_flight(index)
class PackageDelegate(TwoColumnRowDelegate): class PackageDelegate(TwoColumnRowDelegate):
@ -305,7 +307,7 @@ class QPackageList(QListView):
Dialog.open_edit_package_dialog(self.ato_model.get_package_model(index)) Dialog.open_edit_package_dialog(self.ato_model.get_package_model(index))
def delete_package(self, index: QModelIndex) -> None: def delete_package(self, index: QModelIndex) -> None:
self.ato_model.delete_package_at_index(index) self.ato_model.cancel_or_abort_package_at_index(index)
def on_new_packages(self, _parent: QModelIndex, first: int, _last: int) -> None: def on_new_packages(self, _parent: QModelIndex, first: int, _last: int) -> None:
# Select the newly created pacakges. This should only ever happen due to # Select the newly created pacakges. This should only ever happen due to
@ -368,7 +370,7 @@ class QPackagePanel(QGroupBox):
self.edit_button.clicked.connect(self.on_edit) self.edit_button.clicked.connect(self.on_edit)
self.button_row.addWidget(self.edit_button) self.button_row.addWidget(self.edit_button)
self.delete_button = QPushButton("Delete") self.delete_button = QPushButton("Cancel/abort")
# noinspection PyTypeChecker # noinspection PyTypeChecker
self.delete_button.setProperty("style", "btn-danger") self.delete_button.setProperty("style", "btn-danger")
self.delete_button.clicked.connect(self.on_delete) self.delete_button.clicked.connect(self.on_delete)

View File

@ -202,7 +202,7 @@ class QPackageDialog(QDialog):
if flight is None: if flight is None:
logging.error(f"Cannot delete flight when no flight is selected.") logging.error(f"Cannot delete flight when no flight is selected.")
return return
self.package_model.delete_flight(flight) self.package_model.cancel_or_abort_flight(flight)
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
self.package_changed.emit() self.package_changed.emit()
@ -255,7 +255,7 @@ class QNewPackageDialog(QPackageDialog):
def on_cancel(self) -> None: def on_cancel(self) -> None:
super().on_cancel() super().on_cancel()
for flight in self.package_model.package.flights: for flight in self.package_model.package.flights:
self.package_model.delete_flight(flight) self.package_model.cancel_or_abort_flight(flight)
class QEditPackageDialog(QPackageDialog): class QEditPackageDialog(QPackageDialog):
@ -283,5 +283,5 @@ class QEditPackageDialog(QPackageDialog):
def on_delete(self) -> None: def on_delete(self) -> None:
"""Removes the viewed package from the ATO.""" """Removes the viewed package from the ATO."""
# The ATO model returns inventory for us when deleting a package. # The ATO model returns inventory for us when deleting a package.
self.ato_model.delete_package(self.package_model.package) self.ato_model.cancel_or_abort_package(self.package_model.package)
self.close() self.close()