mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
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:
parent
7fe73ad2eb
commit
4993353184
@ -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 <></>;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user