diff --git a/client/src/components/flightplan/FlightPlan.tsx b/client/src/components/flightplan/FlightPlan.tsx index 950bd31b..24d8fefc 100644 --- a/client/src/components/flightplan/FlightPlan.tsx +++ b/client/src/components/flightplan/FlightPlan.tsx @@ -69,9 +69,18 @@ interface CommitBoundaryProps { } function CommitBoundary(props: CommitBoundaryProps) { - const { data, error, isLoading } = useGetCommitBoundaryForFlightQuery({ - flightId: props.flightId, - }); + const { data, error, isLoading } = useGetCommitBoundaryForFlightQuery( + { + 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) { return <>; } diff --git a/game/ato/flight.py b/game/ato/flight.py index e7d1934e..025a0fd0 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -9,7 +9,7 @@ from dcs.planes import C_101CC, C_101EB, Su_33 from game.savecompat import has_save_compat_for from .flightroster import FlightRoster -from .flightstate import FlightState, Uninitialized +from .flightstate import FlightState, InFlight, Navigating, Uninitialized from .flightstate.killed import Killed from .loadouts import Loadout from ..sidc import ( @@ -50,6 +50,7 @@ class Flight(SidcDescribable): self.id = uuid.uuid4() self.package = package self.country = country + self.coalition = squadron.coalition self.squadron = squadron self.squadron.claim_inventory(count) if roster is None: @@ -105,6 +106,8 @@ class Flight(SidcDescribable): state["props"] = {} if "id" not in state: state["id"] = uuid.uuid4() + if "coalition" not in state: + state["coalition"] = state["squadron"].coalition self.__dict__.update(state) @property @@ -191,6 +194,27 @@ class Flight(SidcDescribable): return f"{self.custom_name} {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: self.state = state diff --git a/game/ato/flightplan.py b/game/ato/flightplan.py index 8d53ca6f..d2639616 100644 --- a/game/ato/flightplan.py +++ b/game/ato/flightplan.py @@ -1021,6 +1021,76 @@ class FerryFlightPlan(FlightPlan): 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) class CustomFlightPlan(FlightPlan): custom_waypoints: List[FlightWaypoint] diff --git a/game/ato/flightstate/__init__.py b/game/ato/flightstate/__init__.py index 71dbea96..3afe84f3 100644 --- a/game/ato/flightstate/__init__.py +++ b/game/ato/flightstate/__init__.py @@ -2,6 +2,7 @@ from .completed import Completed from .flightstate import FlightState from .incombat import InCombat from .inflight import InFlight +from .killed import Killed from .navigating import Navigating from .startup import StartUp from .takeoff import Takeoff diff --git a/game/ato/flightstate/atdeparture.py b/game/ato/flightstate/atdeparture.py index 3a724e42..c51bfaaa 100644 --- a/game/ato/flightstate/atdeparture.py +++ b/game/ato/flightstate/atdeparture.py @@ -6,5 +6,9 @@ from game.ato.flightstate import FlightState class AtDeparture(FlightState, ABC): + @property + def cancelable(self) -> bool: + return True + def estimate_position(self) -> Point: return self.flight.departure.position diff --git a/game/ato/flightstate/completed.py b/game/ato/flightstate/completed.py index fffb5f67..f09e2034 100644 --- a/game/ato/flightstate/completed.py +++ b/game/ato/flightstate/completed.py @@ -13,6 +13,10 @@ if TYPE_CHECKING: class Completed(FlightState): + @property + def cancelable(self) -> bool: + return False + def on_game_tick( self, events: GameUpdateEvents, time: datetime, duration: timedelta ) -> None: diff --git a/game/ato/flightstate/flightstate.py b/game/ato/flightstate/flightstate.py index f0546a03..262e56b4 100644 --- a/game/ato/flightstate/flightstate.py +++ b/game/ato/flightstate/flightstate.py @@ -25,6 +25,11 @@ class FlightState(ABC): def alive(self) -> bool: return True + @property + @abstractmethod + def cancelable(self) -> bool: + ... + @abstractmethod def on_game_tick( self, events: GameUpdateEvents, time: datetime, duration: timedelta diff --git a/game/ato/flightstate/inflight.py b/game/ato/flightstate/inflight.py index 01b1a279..24ebc919 100644 --- a/game/ato/flightstate/inflight.py +++ b/game/ato/flightstate/inflight.py @@ -20,10 +20,17 @@ if TYPE_CHECKING: 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) waypoints = self.flight.flight_plan.waypoints self.waypoint_index = waypoint_index + self.has_aborted = has_aborted self.current_waypoint = waypoints[self.waypoint_index] # TODO: Error checking for flight plans without landing waypoints. self.next_waypoint = waypoints[self.waypoint_index + 1] @@ -31,6 +38,10 @@ class InFlight(FlightState, ABC): self.elapsed_time = timedelta() self.current_waypoint_elapsed = False + @property + def cancelable(self) -> bool: + return False + @property def in_flight(self) -> bool: return True @@ -151,4 +162,8 @@ class InFlight(FlightState, ABC): @property 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}" diff --git a/game/ato/flightstate/killed.py b/game/ato/flightstate/killed.py index 563a7b97..86046a40 100644 --- a/game/ato/flightstate/killed.py +++ b/game/ato/flightstate/killed.py @@ -21,6 +21,10 @@ class Killed(FlightState): super().__init__(flight, settings) self.last_position = last_position + @property + def cancelable(self) -> bool: + return False + @property def alive(self) -> bool: return False diff --git a/game/ato/flightstate/navigating.py b/game/ato/flightstate/navigating.py index 11c9c750..e8f4cde2 100644 --- a/game/ato/flightstate/navigating.py +++ b/game/ato/flightstate/navigating.py @@ -75,7 +75,3 @@ class Navigating(InFlight): @property def spawn_type(self) -> StartType: return StartType.IN_FLIGHT - - @property - def description(self) -> str: - return f"Flying to {self.next_waypoint.name}" diff --git a/game/ato/flightstate/uninitialized.py b/game/ato/flightstate/uninitialized.py index 0453e4a2..32a12a80 100644 --- a/game/ato/flightstate/uninitialized.py +++ b/game/ato/flightstate/uninitialized.py @@ -14,6 +14,10 @@ if TYPE_CHECKING: class Uninitialized(FlightState): + @property + def cancelable(self) -> bool: + return True + def on_game_tick( self, events: GameUpdateEvents, time: datetime, duration: timedelta ) -> None: diff --git a/game/server/eventstream/eventstream.py b/game/server/eventstream/eventstream.py index 38c71635..8db2000a 100644 --- a/game/server/eventstream/eventstream.py +++ b/game/server/eventstream/eventstream.py @@ -1,4 +1,8 @@ +from __future__ import annotations + from asyncio import Queue +from collections.abc import Iterator +from contextlib import contextmanager from game.sim import GameUpdateEvents @@ -27,3 +31,10 @@ class EventStream: events = await cls._queue.get() cls._queue.task_done() return events + + @staticmethod + @contextmanager + def event_context() -> Iterator[GameUpdateEvents]: + events = GameUpdateEvents() + yield events + EventStream.put_nowait(events) diff --git a/qt_ui/models.py b/qt_ui/models.py index 5e7eb6ec..1fea07ee 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -161,9 +161,17 @@ class PackageModel(QAbstractListModel): # flight plan yet. Will be called manually by the caller. 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.""" - 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: """Removes the given flight from the package.""" @@ -257,13 +265,28 @@ class AtoModel(QAbstractListModel): self.client_slots_changed.emit() 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.""" - 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.""" - EventStream.put_nowait(GameUpdateEvents().delete_flights_in_package(package)) self.package_models.release(package) index = self.ato.packages.index(package) self.beginRemoveRows(QModelIndex(), index, index) diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index bd3e5608..638cf758 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -128,11 +128,8 @@ class QFlightList(QListView): parent=self.window(), ) - def delete_flight(self, index: QModelIndex) -> None: - EventStream.put_nowait( - GameUpdateEvents().delete_flight(self.package_model.flight_at_index(index)) - ) - self.package_model.delete_flight_at_index(index) + def cancel_or_abort_flight(self, index: QModelIndex) -> None: + self.package_model.cancel_or_abort_flight_at_index(index) def contextMenuEvent(self, event: QContextMenuEvent) -> None: index = self.indexAt(event.pos()) @@ -144,7 +141,7 @@ class QFlightList(QListView): menu.addAction(edit_action) 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.exec_(event.globalPos()) @@ -183,10 +180,10 @@ class QFlightPanel(QGroupBox): self.edit_button.clicked.connect(self.on_edit) self.button_row.addWidget(self.edit_button) - self.delete_button = QPushButton("Delete") + self.delete_button = QPushButton("Cancel") # noinspection PyTypeChecker 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.selection_changed.connect(self.on_selection_changed) @@ -211,6 +208,11 @@ class QFlightPanel(QGroupBox): self.edit_button.setEnabled(enabled) self.delete_button.setEnabled(enabled) 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: events = GameUpdateEvents() @@ -228,13 +230,13 @@ class QFlightPanel(QGroupBox): return self.flight_list.edit_flight(index) - def on_delete(self) -> None: + def on_cancel_flight(self) -> None: """Removes the selected flight from the package.""" index = self.flight_list.currentIndex() if not index.isValid(): logging.error(f"Cannot delete flight when no flight is selected.") return - self.flight_list.delete_flight(index) + self.flight_list.cancel_or_abort_flight(index) class PackageDelegate(TwoColumnRowDelegate): @@ -305,7 +307,7 @@ class QPackageList(QListView): Dialog.open_edit_package_dialog(self.ato_model.get_package_model(index)) 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: # 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.button_row.addWidget(self.edit_button) - self.delete_button = QPushButton("Delete") + self.delete_button = QPushButton("Cancel/abort") # noinspection PyTypeChecker self.delete_button.setProperty("style", "btn-danger") self.delete_button.clicked.connect(self.on_delete) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 8f1f4139..4cd36a32 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -202,7 +202,7 @@ class QPackageDialog(QDialog): if flight is None: logging.error(f"Cannot delete flight when no flight is selected.") return - self.package_model.delete_flight(flight) + self.package_model.cancel_or_abort_flight(flight) # noinspection PyUnresolvedReferences self.package_changed.emit() @@ -255,7 +255,7 @@ class QNewPackageDialog(QPackageDialog): def on_cancel(self) -> None: super().on_cancel() 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): @@ -283,5 +283,5 @@ class QEditPackageDialog(QPackageDialog): def on_delete(self) -> None: """Removes the viewed package from the ATO.""" # 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()