diff --git a/game/event/event.py b/game/event/event.py index d3bc1adb..7498a5cf 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -144,7 +144,7 @@ class Event: def _commit_pilot_experience(ato: AirTaskingOrder) -> None: for package in ato.packages: for flight in package.flights: - for idx, pilot in enumerate(flight.pilots): + for idx, pilot in enumerate(flight.roster.pilots): if pilot is None: logging.error( f"Cannot award experience to pilot #{idx} of {flight} " diff --git a/game/squadrons.py b/game/squadrons.py index c73e5bed..aa74704c 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -13,7 +13,6 @@ from typing import ( List, TYPE_CHECKING, Optional, - Iterable, Iterator, Sequence, ) @@ -142,8 +141,12 @@ class Squadron: def return_pilot(self, pilot: Pilot) -> None: self.available_pilots.append(pilot) - def return_pilots(self, pilots: Iterable[Pilot]) -> None: - self.available_pilots.extend(pilots) + def return_pilots(self, pilots: Sequence[Pilot]) -> None: + # Return in reverse so that returning two pilots and then getting two more + # results in the same ordering. This happens commonly when resetting rosters in + # the UI, when we clear the roster because the UI is updating, then end up + # repopulating the same size flight from the same squadron. + self.available_pilots.extend(reversed(pilots)) def enlist_new_pilots(self, count: int) -> None: new_pilots = [Pilot(self.faker.name()) for _ in range(count)] diff --git a/game/unitmap.py b/game/unitmap.py index 296d5f06..bcf457ca 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -62,7 +62,7 @@ class UnitMap: self.airlifts: Dict[str, AirliftUnit] = {} def add_aircraft(self, group: FlyingGroup, flight: Flight) -> None: - for pilot, unit in zip(flight.pilots, group.units): + for pilot, unit in zip(flight.roster.pilots, group.units): # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. name = str(unit.name) diff --git a/gen/aircraft.py b/gen/aircraft.py index 995d6ded..d40faa4e 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -803,7 +803,7 @@ class AircraftConflictGenerator: self._setup_payload(flight, group) self._setup_livery(flight, group) - for unit, pilot in zip(group.units, flight.pilots): + for unit, pilot in zip(group.units, flight.roster.pilots): player = pilot is not None and pilot.player self.set_skill(unit, pilot, blue=flight.departure.captured) # Do not generate player group with late activation. diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 711e1657..fa826a30 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from enum import Enum from typing import List, Optional, TYPE_CHECKING, Type, Union @@ -200,6 +201,49 @@ class FlightWaypoint: return waypoint +class FlightRoster: + def __init__(self, squadron: Squadron, initial_size: int = 0) -> None: + self.squadron = squadron + self.pilots: list[Optional[Pilot]] = [] + self.resize(initial_size) + + @property + def max_size(self) -> int: + return len(self.pilots) + + @property + def player_count(self) -> int: + return len([p for p in self.pilots if p is not None and p.player]) + + @property + def missing_pilots(self) -> int: + return len([p for p in self.pilots if p is None]) + + def resize(self, new_size: int) -> None: + if self.max_size > new_size: + self.squadron.return_pilots( + [p for p in self.pilots[new_size:] if p is not None] + ) + self.pilots = self.pilots[:new_size] + return + self.pilots.extend( + [ + self.squadron.claim_available_pilot() + for _ in range(new_size - self.max_size) + ] + ) + + def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: + if pilot is not None: + self.squadron.claim_pilot(pilot) + if (current_pilot := self.pilots[index]) is not None: + self.squadron.return_pilot(current_pilot) + self.pilots[index] = pilot + + def clear(self) -> None: + self.squadron.return_pilots([p for p in self.pilots if p is not None]) + + class Flight: def __init__( self, @@ -214,11 +258,15 @@ class Flight: divert: Optional[ControlPoint], custom_name: Optional[str] = None, cargo: Optional[TransferOrder] = None, + roster: Optional[FlightRoster] = None, ) -> None: self.package = package self.country = country self.squadron = squadron - self.pilots = [squadron.claim_available_pilot() for _ in range(count)] + if roster is None: + self.roster = FlightRoster(self.squadron, initial_size=count) + else: + self.roster = roster self.departure = departure self.arrival = arrival self.divert = divert @@ -244,11 +292,11 @@ class Flight: @property def count(self) -> int: - return len(self.pilots) + return self.roster.max_size @property def client_count(self) -> int: - return len([p for p in self.pilots if p is not None and p.player]) + return self.roster.player_count @property def unit_type(self) -> Type[FlyingType]: @@ -263,32 +311,17 @@ class Flight: return self.flight_plan.waypoints[1:] def resize(self, new_size: int) -> None: - if self.count > new_size: - self.squadron.return_pilots( - p for p in self.pilots[new_size:] if p is not None - ) - self.pilots = self.pilots[:new_size] - return - self.pilots.extend( - [ - self.squadron.claim_available_pilot() - for _ in range(new_size - self.count) - ] - ) + self.roster.resize(new_size) def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: - if pilot is not None: - self.squadron.claim_pilot(pilot) - if (current_pilot := self.pilots[index]) is not None: - self.squadron.return_pilot(current_pilot) - self.pilots[index] = pilot + self.roster.set_pilot(index, pilot) @property def missing_pilots(self) -> int: - return len([p for p in self.pilots if p is None]) + return self.roster.missing_pilots def clear_roster(self) -> None: - self.squadron.return_pilots([p for p in self.pilots if p is not None]) + self.roster.clear() def __repr__(self): name = db.unit_type_name(self.unit_type) diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index 536aac22..05bdea5f 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -115,7 +115,7 @@ class AircraftInventoryData: flight_type = flight.flight_type.value target = flight.package.target.name for idx in range(0, num_units): - pilot = flight.pilots[idx] + pilot = flight.roster.pilots[idx] if pilot is None: pilot_name = "Unassigned" player = "" diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index acb991cd..0e8293a1 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -10,6 +10,7 @@ from PySide2.QtWidgets import ( QPushButton, QVBoxLayout, QLineEdit, + QHBoxLayout, ) from dcs.unittype import FlyingType @@ -17,7 +18,7 @@ from game import Game from game.squadrons import Squadron from game.theater import ControlPoint, OffMapSpawn from gen.ato import Package -from gen.flights.flight import Flight +from gen.flights.flight import Flight, FlightRoster from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.QFlightSizeSpinner import QFlightSizeSpinner from qt_ui.widgets.QLabeledWidget import QLabeledWidget @@ -26,6 +27,7 @@ from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelect from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector from qt_ui.windows.mission.flight.SquadronSelector import SquadronSelector +from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import FlightRosterEditor class QFlightCreator(QDialog): @@ -46,7 +48,7 @@ class QFlightCreator(QDialog): self.task_selector = QFlightTypeComboBox(self.game.theater, package.target) self.task_selector.setCurrentIndex(0) - self.task_selector.currentTextChanged.connect(self.on_task_changed) + self.task_selector.currentIndexChanged.connect(self.on_task_changed) layout.addLayout(QLabeledWidget("Task:", self.task_selector)) self.aircraft_selector = QAircraftTypeSelector( @@ -93,13 +95,20 @@ class QFlightCreator(QDialog): self.update_max_size(self.departure.available) layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner)) - self.client_slots_spinner = QFlightSizeSpinner( - min_size=0, max_size=self.flight_size_spinner.value(), default_size=0 - ) - self.flight_size_spinner.valueChanged.connect( - lambda v: self.client_slots_spinner.setMaximum(v) - ) - layout.addLayout(QLabeledWidget("Client Slots:", self.client_slots_spinner)) + squadron = self.squadron_selector.currentData() + if squadron is None: + roster = None + else: + roster = FlightRoster( + squadron, initial_size=self.flight_size_spinner.value() + ) + self.roster_editor = FlightRosterEditor(roster) + self.flight_size_spinner.valueChanged.connect(self.resize_roster) + self.squadron_selector.currentIndexChanged.connect(self.on_squadron_changed) + roster_layout = QHBoxLayout() + layout.addLayout(roster_layout) + roster_layout.addWidget(QLabel("Assigned pilots:")) + roster_layout.addLayout(self.roster_editor) # When an off-map spawn overrides the start type to in-flight, we save # the selected type into this value. If a non-off-map spawn is selected @@ -142,6 +151,10 @@ class QFlightCreator(QDialog): def set_custom_name_text(self, text: str): self.custom_name_text = text + def resize_roster(self, new_size: int) -> None: + self.roster_editor.roster.resize(new_size) + self.roster_editor.resize(new_size) + def verify_form(self) -> Optional[str]: aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData() squadron: Optional[Squadron] = self.squadron_selector.currentData() @@ -181,7 +194,7 @@ class QFlightCreator(QDialog): origin = self.departure.currentData() arrival = self.arrival.currentData() divert = self.divert.currentData() - size = self.flight_size_spinner.value() + roster = self.roster_editor.roster if arrival is None: arrival = origin @@ -190,22 +203,17 @@ class QFlightCreator(QDialog): self.package, self.country, squadron, - size, + # A bit of a hack to work around the old API. Not actually relevant because + # the roster is passed explicitly. Needs a refactor. + roster.max_size, task, self.start_type.currentText(), origin, arrival, divert, custom_name=self.custom_name_text, + roster=roster, ) - for pilot, idx in zip(flight.pilots, range(self.client_slots_spinner.value())): - if pilot is None: - logging.error( - f"Cannot create client slot because {flight} has no pilot for " - f"aircraft {idx}" - ) - continue - pilot.player = True # noinspection PyUnresolvedReferences self.created.emit(flight) @@ -234,14 +242,22 @@ class QFlightCreator(QDialog): self.start_type.setCurrentText(self.restore_start_type) self.restore_start_type = None - def on_task_changed(self) -> None: + def on_task_changed(self, index: int) -> None: + task = self.task_selector.itemData(index) self.aircraft_selector.update_items( - self.task_selector.currentData(), - self.game.aircraft_inventory.available_types_for_player, - ) - self.squadron_selector.update_items( - self.task_selector.currentData(), self.aircraft_selector.currentData() + task, self.game.aircraft_inventory.available_types_for_player ) + self.squadron_selector.update_items(task, self.aircraft_selector.currentData()) + + def on_squadron_changed(self, index: int) -> None: + squadron = self.squadron_selector.itemData(index) + # Clear the roster first so we return the pilots to the pool. This way if we end + # up repopulating from the same squadron we'll get the same pilots back. + self.roster_editor.replace(None) + if squadron is not None: + self.roster_editor.replace( + FlightRoster(squadron, self.flight_size_spinner.value()) + ) def update_max_size(self, available: int) -> None: self.flight_size_spinner.setMaximum(min(available, 4)) diff --git a/qt_ui/windows/mission/flight/SquadronSelector.py b/qt_ui/windows/mission/flight/SquadronSelector.py index b8cbd750..e9b7ae7f 100644 --- a/qt_ui/windows/mission/flight/SquadronSelector.py +++ b/qt_ui/windows/mission/flight/SquadronSelector.py @@ -28,7 +28,11 @@ class SquadronSelector(QComboBox): self, task: Optional[FlightType], aircraft: Optional[Type[FlyingType]] ) -> None: current_squadron = self.currentData() - self.clear() + self.blockSignals(True) + try: + self.clear() + finally: + self.blockSignals(False) if task is None: self.addItem("No task selected", None) return diff --git a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py index 25668aba..ec4530c2 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py +++ b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py @@ -15,16 +15,16 @@ from PySide2.QtWidgets import ( from game import Game from game.squadrons import Pilot -from gen.flights.flight import Flight +from gen.flights.flight import Flight, FlightRoster from qt_ui.models import PackageModel class PilotSelector(QComboBox): available_pilots_changed = Signal() - def __init__(self, flight: Flight, idx: int) -> None: + def __init__(self, roster: Optional[FlightRoster], idx: int) -> None: super().__init__() - self.flight = flight + self.roster = roster self.pilot_index = idx self.rebuild() @@ -34,15 +34,15 @@ class PilotSelector(QComboBox): def _do_rebuild(self) -> None: self.clear() - if self.pilot_index >= self.flight.count: + if self.roster is None or self.pilot_index >= self.roster.max_size: self.addItem("No aircraft", None) self.setDisabled(True) return self.setEnabled(True) self.addItem("Unassigned", None) - choices = list(self.flight.squadron.available_pilots) - current_pilot = self.flight.pilots[self.pilot_index] + choices = list(self.roster.squadron.available_pilots) + current_pilot = self.roster.pilots[self.pilot_index] if current_pilot is not None: choices.append(current_pilot) # Put players first, otherwise alphabetically. @@ -70,19 +70,23 @@ class PilotSelector(QComboBox): # The roster resize is handled separately, so we have no pilots to remove. return pilot = self.itemData(index) - if pilot == self.flight.pilots[self.pilot_index]: + if pilot == self.roster.pilots[self.pilot_index]: return - self.flight.set_pilot(self.pilot_index, pilot) + self.roster.set_pilot(self.pilot_index, pilot) self.available_pilots_changed.emit() + def replace(self, new_roster: Optional[FlightRoster]) -> None: + self.roster = new_roster + self.rebuild() + class PilotControls(QHBoxLayout): - def __init__(self, flight: Flight, idx: int) -> None: + def __init__(self, roster: Optional[FlightRoster], idx: int) -> None: super().__init__() - self.flight = flight + self.roster = roster self.pilot_index = idx - self.selector = PilotSelector(flight, idx) + self.selector = PilotSelector(roster, idx) self.selector.currentIndexChanged.connect(self.on_pilot_changed) self.addWidget(self.selector) @@ -95,9 +99,9 @@ class PilotControls(QHBoxLayout): @property def pilot(self) -> Optional[Pilot]: - if self.pilot_index >= self.flight.count: + if self.roster is None or self.pilot_index >= self.roster.max_size: return None - return self.flight.pilots[self.pilot_index] + return self.roster.pilots[self.pilot_index] def on_player_toggled(self, checked: bool) -> None: pilot = self.pilot @@ -130,12 +134,21 @@ class PilotControls(QHBoxLayout): finally: self.player_checkbox.blockSignals(False) + def replace(self, new_roster: Optional[FlightRoster]) -> None: + self.roster = new_roster + if self.roster is None or self.pilot_index >= self.roster.max_size: + self.disable_and_clear() + else: + self.enable_and_reset() + self.selector.replace(new_roster) + class FlightRosterEditor(QVBoxLayout): MAX_PILOTS = 4 - def __init__(self, flight: Flight) -> None: + def __init__(self, roster: Optional[FlightRoster]) -> None: super().__init__() + self.roster = roster self.pilot_controls = [] for pilot_idx in range(self.MAX_PILOTS): @@ -146,7 +159,7 @@ class FlightRosterEditor(QVBoxLayout): return callback - controls = PilotControls(flight, pilot_idx) + controls = PilotControls(roster, pilot_idx) controls.selector.available_pilots_changed.connect( make_reset_callback(pilot_idx) ) @@ -167,6 +180,13 @@ class FlightRosterEditor(QVBoxLayout): for controls in self.pilot_controls[new_size:]: controls.disable_and_clear() + def replace(self, new_roster: Optional[FlightRoster]) -> None: + if self.roster is not None: + self.roster.clear() + self.roster = new_roster + for controls in self.pilot_controls: + controls.replace(new_roster) + class QFlightSlotEditor(QGroupBox): def __init__(self, package_model: PackageModel, flight: Flight, game: Game): @@ -196,7 +216,7 @@ class QFlightSlotEditor(QGroupBox): layout.addWidget(QLabel(str(self.flight.squadron)), 1, 1) layout.addWidget(QLabel("Assigned pilots:"), 2, 0) - self.roster_editor = FlightRosterEditor(flight) + self.roster_editor = FlightRosterEditor(flight.roster) layout.addLayout(self.roster_editor, 2, 1) self.setLayout(layout)