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) {
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 <></>;
}

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 .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

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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}"

View File

@ -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

View File

@ -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}"

View File

@ -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:

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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()