diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py index ada95795..85fc19fc 100644 --- a/game/squadrons/squadron.py +++ b/game/squadrons/squadron.py @@ -53,6 +53,9 @@ class Squadron: settings: Settings = field(hash=False, compare=False) location: ControlPoint + destination: Optional[ControlPoint] = field( + init=False, hash=False, compare=False, default=None + ) owned_aircraft: int = field(init=False, hash=False, compare=False, default=0) untasked_aircraft: int = field(init=False, hash=False, compare=False, default=0) @@ -168,6 +171,8 @@ class Squadron: self._recruit_pilots(self.settings.squadron_pilot_limit) def end_turn(self) -> None: + if self.destination is not None: + self.relocate_to(self.destination) self.replenish_lost_pilots() self.deliver_orders() @@ -280,6 +285,8 @@ class Squadron: def relocate_to(self, destination: ControlPoint) -> None: self.location = destination + if self.location == self.destination: + self.destination = None def cancel_overflow_orders(self) -> None: if self.pending_deliveries <= 0: @@ -297,6 +304,42 @@ class Squadron: def max_fulfillable_aircraft(self) -> int: return max(self.number_of_available_pilots, self.untasked_aircraft) + @property + def expected_size_next_turn(self) -> int: + return self.owned_aircraft + self.pending_deliveries + + def plan_relocation(self, destination: ControlPoint) -> None: + if destination == self.location: + logging.warning( + f"Attempted to plan relocation of {self} to current location " + f"{destination}. Ignoring." + ) + return + if destination == self.destination: + logging.warning( + f"Attempted to plan relocation of {self} to current destination " + f"{destination}. Ignoring." + ) + return + + if self.expected_size_next_turn >= destination.unclaimed_parking(): + raise RuntimeError(f"Not enough parking for {self} at {destination}.") + if not destination.can_operate(self.aircraft): + raise RuntimeError(f"{self} cannot operate at {destination}.") + self.destination = destination + + def cancel_relocation(self) -> None: + if self.destination is None: + logging.warning( + f"Attempted to cancel relocation of squadron with no transfer order. " + "Ignoring." + ) + return + + if self.expected_size_next_turn >= self.location.unclaimed_parking(): + raise RuntimeError(f"Not enough parking for {self} at {self.location}.") + self.destination = None + @classmethod def create_from( cls, diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 205a677f..7ea8a842 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -728,13 +728,19 @@ class ControlPoint(MissionTarget, ABC): def allocated_aircraft(self) -> AircraftAllocations: present: dict[AircraftType, int] = defaultdict(int) on_order: dict[AircraftType, int] = defaultdict(int) + transferring: dict[AircraftType, int] = defaultdict(int) for squadron in self.squadrons: present[squadron.aircraft] += squadron.owned_aircraft - # TODO: Only if this is the squadron destination, not location. - on_order[squadron.aircraft] += squadron.pending_deliveries + if squadron.destination is None: + on_order[squadron.aircraft] += squadron.pending_deliveries + else: + transferring[squadron.aircraft] -= squadron.owned_aircraft + for squadron in self.coalition.air_wing.iter_squadrons(): + if squadron.destination == self: + on_order[squadron.aircraft] += squadron.pending_deliveries + transferring[squadron.aircraft] += squadron.owned_aircraft - # TODO: Implement squadron transfers. - return AircraftAllocations(present, on_order, transferring={}) + return AircraftAllocations(present, on_order, transferring) def allocated_ground_units( self, transfers: PendingTransfers diff --git a/qt_ui/errorreporter.py b/qt_ui/errorreporter.py new file mode 100644 index 00000000..3e514c10 --- /dev/null +++ b/qt_ui/errorreporter.py @@ -0,0 +1,17 @@ +import logging +from collections import Iterator +from contextlib import contextmanager +from typing import Type + +from PySide2.QtWidgets import QDialog, QMessageBox + + +@contextmanager +def report_errors( + title: str, parent: QDialog, error_type: Type[Exception] = Exception +) -> Iterator[None]: + try: + yield + except error_type as ex: + logging.exception(title) + QMessageBox().critical(parent, title, str(ex), QMessageBox.Ok) diff --git a/qt_ui/main.py b/qt_ui/main.py index ff3c6e5a..14eda27f 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -13,6 +13,7 @@ from PySide2.QtWidgets import QApplication, QSplashScreen from dcs.payloads import PayloadDirectories from game import Game, VERSION, persistency +from game.campaignloader.campaign import Campaign from game.data.weapons import WeaponGroup, Pylon, Weapon from game.db import FACTIONS from game.dcs.aircrafttype import AircraftType @@ -27,7 +28,6 @@ from qt_ui import ( ) from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.QLiberationWindow import QLiberationWindow -from game.campaignloader.campaign import Campaign from qt_ui.windows.newgame.QNewGameWizard import DEFAULT_BUDGET from qt_ui.windows.preferences.QLiberationFirstStartWindow import ( QLiberationFirstStartWindow, diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py index 745cac5c..4cdc41cc 100644 --- a/qt_ui/windows/AirWingConfigurationDialog.py +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -10,7 +10,6 @@ from PySide2.QtCore import ( ) from PySide2.QtGui import QStandardItemModel, QStandardItem, QIcon from PySide2.QtWidgets import ( - QAbstractItemView, QDialog, QListView, QVBoxLayout, @@ -32,38 +31,7 @@ from game.dcs.aircrafttype import AircraftType from game.squadrons import AirWing, Pilot, Squadron from game.theater import ControlPoint, ConflictTheater from gen.flights.flight import FlightType -from qt_ui.models import AirWingModel, SquadronModel from qt_ui.uiconstants import AIRCRAFT_ICONS -from qt_ui.windows.AirWingDialog import SquadronDelegate -from qt_ui.windows.SquadronDialog import SquadronDialog - - -class SquadronList(QListView): - """List view for displaying the air wing's squadrons.""" - - def __init__(self, air_wing_model: AirWingModel) -> None: - super().__init__() - self.air_wing_model = air_wing_model - self.dialog: Optional[SquadronDialog] = None - - self.setIconSize(QSize(91, 24)) - self.setItemDelegate(SquadronDelegate(self.air_wing_model)) - self.setModel(self.air_wing_model) - self.selectionModel().setCurrentIndex( - self.air_wing_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select - ) - - # self.setIconSize(QSize(91, 24)) - self.setSelectionBehavior(QAbstractItemView.SelectItems) - self.doubleClicked.connect(self.on_double_click) - - def on_double_click(self, index: QModelIndex) -> None: - if not index.isValid(): - return - self.dialog = SquadronDialog( - SquadronModel(self.air_wing_model.squadron_at_index(index)), self - ) - self.dialog.show() class AllowedMissionTypeControls(QVBoxLayout): diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index a8f8ca3f..9fcd1c7b 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -17,6 +17,7 @@ from PySide2.QtWidgets import ( ) from game.squadrons import Squadron +from game.theater import ConflictTheater from gen.flights.flight import Flight from qt_ui.delegates import TwoColumnRowDelegate from qt_ui.models import GameModel, AirWingModel, SquadronModel @@ -56,9 +57,10 @@ class SquadronDelegate(TwoColumnRowDelegate): class SquadronList(QListView): """List view for displaying the air wing's squadrons.""" - def __init__(self, air_wing_model: AirWingModel) -> None: + def __init__(self, air_wing_model: AirWingModel, theater: ConflictTheater) -> None: super().__init__() self.air_wing_model = air_wing_model + self.theater = theater self.dialog: Optional[SquadronDialog] = None self.setIconSize(QSize(91, 24)) @@ -76,7 +78,9 @@ class SquadronList(QListView): if not index.isValid(): return self.dialog = SquadronDialog( - SquadronModel(self.air_wing_model.squadron_at_index(index)), self + SquadronModel(self.air_wing_model.squadron_at_index(index)), + self.theater, + self, ) self.dialog.show() @@ -194,7 +198,10 @@ class AirWingTabs(QTabWidget): def __init__(self, game_model: GameModel) -> None: super().__init__() - self.addTab(SquadronList(game_model.blue_air_wing_model), "Squadrons") + self.addTab( + SquadronList(game_model.blue_air_wing_model, game_model.game.theater), + "Squadrons", + ) self.addTab(AirInventoryView(game_model), "Inventory") diff --git a/qt_ui/windows/SquadronDialog.py b/qt_ui/windows/SquadronDialog.py index c17e5312..24b004cc 100644 --- a/qt_ui/windows/SquadronDialog.py +++ b/qt_ui/windows/SquadronDialog.py @@ -1,5 +1,5 @@ import logging -from typing import Callable +from typing import Callable, Iterator, Optional from PySide2.QtCore import ( QItemSelectionModel, @@ -16,11 +16,14 @@ from PySide2.QtWidgets import ( QHBoxLayout, QLabel, QCheckBox, + QComboBox, ) -from game.squadrons import Pilot +from game.squadrons import Pilot, Squadron +from game.theater import ControlPoint, ConflictTheater from gen.flights.flight import FlightType from qt_ui.delegates import TwoColumnRowDelegate +from qt_ui.errorreporter import report_errors from qt_ui.models import SquadronModel @@ -90,10 +93,50 @@ class AutoAssignedTaskControls(QVBoxLayout): self.squadron_model.set_auto_assignable(task, checked) +class SquadronDestinationComboBox(QComboBox): + def __init__(self, squadron: Squadron, theater: ConflictTheater) -> None: + super().__init__() + self.squadron = squadron + self.theater = theater + + room = squadron.location.unclaimed_parking() + self.addItem( + f"Remain at {squadron.location} (room for {room} more aircraft)", None + ) + selected_index: Optional[int] = None + for idx, destination in enumerate(sorted(self.iter_destinations(), key=str), 1): + if destination == squadron.destination: + selected_index = idx + room = destination.unclaimed_parking() + self.addItem( + f"Transfer to {destination} (room for {room} more aircraft)", + destination, + ) + + if squadron.destination is None: + selected_index = 0 + + if selected_index is not None: + self.setCurrentIndex(selected_index) + + def iter_destinations(self) -> Iterator[ControlPoint]: + size = self.squadron.expected_size_next_turn + for control_point in self.theater.control_points_for(self.squadron.player): + if control_point == self: + continue + if not control_point.can_operate(self.squadron.aircraft): + continue + if control_point.unclaimed_parking() < size: + continue + yield control_point + + class SquadronDialog(QDialog): """Dialog window showing a squadron.""" - def __init__(self, squadron_model: SquadronModel, parent) -> None: + def __init__( + self, squadron_model: SquadronModel, theater: ConflictTheater, parent + ) -> None: super().__init__(parent) self.squadron_model = squadron_model @@ -117,6 +160,15 @@ class SquadronDialog(QDialog): columns.addWidget(self.pilot_list) button_panel = QHBoxLayout() + + self.transfer_destination = SquadronDestinationComboBox( + squadron_model.squadron, theater + ) + self.transfer_destination.currentIndexChanged.connect( + self.on_destination_changed + ) + button_panel.addWidget(self.transfer_destination) + button_panel.addStretch() layout.addLayout(button_panel) @@ -132,6 +184,18 @@ class SquadronDialog(QDialog): self.toggle_leave_button.clicked.connect(self.toggle_leave) button_panel.addWidget(self.toggle_leave_button, alignment=Qt.AlignRight) + @property + def squadron(self) -> Squadron: + return self.squadron_model.squadron + + def on_destination_changed(self, index: int) -> None: + with report_errors("Could not change squadron destination", self): + destination = self.transfer_destination.itemData(index) + if destination is None: + self.squadron.cancel_relocation() + else: + self.squadron.plan_relocation(destination) + def check_disabled_button_states( self, button: QPushButton, index: QModelIndex ) -> bool: