diff --git a/changelog.md b/changelog.md index 448b90b6..263c1508 100644 --- a/changelog.md +++ b/changelog.md @@ -17,6 +17,7 @@ Saves from 7.0.0 are compatible with 7.1.0 * **[Mission Generation]** Added option to prevent scud and V2 sites from firing at the start of the mission. * **[Mission Generation]** Added settings for controlling number of tactical commander, observer, JTAC, and game master slots. * **[Mission Planning]** Per-flight TOT offsets can now be set in the flight details UI. This allows individual flights to be scheduled ahead of or behind the rest of the package. +* **[UI]** Parking capacity of each squadron's base is now shown during air wing configuration to avoid overcrowding bases when beginning the game with full squadrons. ## Fixes diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py index 21fed869..0454a06c 100644 --- a/game/squadrons/squadron.py +++ b/game/squadrons/squadron.py @@ -5,7 +5,8 @@ import random from collections.abc import Iterable from dataclasses import dataclass, field from datetime import datetime -from typing import Optional, Sequence, TYPE_CHECKING +from typing import Optional, Sequence, TYPE_CHECKING, Any +from uuid import uuid4, UUID from faker import Faker @@ -13,6 +14,7 @@ from game.ato import Flight, FlightType, Package from game.settings import AutoAtoBehavior, Settings from .pilot import Pilot, PilotStatus from ..db.database import Database +from ..savecompat import has_save_compat_for from ..utils import meters if TYPE_CHECKING: @@ -26,6 +28,8 @@ if TYPE_CHECKING: @dataclass class Squadron: + id: UUID = field(init=False, default_factory=uuid4) + name: str nickname: Optional[str] country: str @@ -61,21 +65,24 @@ class Squadron: untasked_aircraft: int = field(init=False, hash=False, compare=False, default=0) pending_deliveries: int = field(init=False, hash=False, compare=False, default=0) + @has_save_compat_for(7) + def __setstate__(self, state: dict[str, Any]) -> None: + if "id" not in state: + state["id"] = uuid4() + self.__dict__.update(state) + def __str__(self) -> str: if self.nickname is None: return self.name return f'{self.name} "{self.nickname}"' def __hash__(self) -> int: - return hash( - ( - self.name, - self.nickname, - self.country, - self.role, - self.aircraft, - ) - ) + return hash(self.id) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Squadron): + return False + return self.id == other.id @property def player(self) -> bool: diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py index feb16bf7..4d6f6988 100644 --- a/qt_ui/windows/AirWingConfigurationDialog.py +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -1,3 +1,4 @@ +from collections import defaultdict from typing import Iterable, Iterator, Optional from PySide6.QtCore import ( @@ -150,6 +151,41 @@ class SquadronSizeSpinner(QSpinBox): # return size +class AirWingConfigParkingTracker(QWidget): + allocation_changed = Signal() + + def __init__(self, squadrons: Iterable[Squadron]) -> None: + super().__init__() + self.by_cp: dict[ControlPoint, set[Squadron]] = defaultdict(set) + for squadron in squadrons: + self.add_squadron(squadron) + + def add_squadron(self, squadron: Squadron) -> None: + self.by_cp[squadron.location].add(squadron) + self.signal_change() + + def remove_squadron(self, squadron: Squadron) -> None: + self.by_cp[squadron.location].remove(squadron) + self.signal_change() + + def relocate_squadron( + self, + squadron: Squadron, + prior_location: ControlPoint, + new_location: ControlPoint, + ) -> None: + self.by_cp[prior_location].remove(squadron) + self.by_cp[new_location].add(squadron) + squadron.relocate_to(new_location) + self.signal_change() + + def used_parking_at(self, control_point: ControlPoint) -> int: + return sum(s.max_size for s in self.by_cp[control_point]) + + def signal_change(self) -> None: + self.allocation_changed.emit() + + class SquadronConfigurationBox(QGroupBox): remove_squadron_signal = Signal(Squadron) @@ -158,11 +194,13 @@ class SquadronConfigurationBox(QGroupBox): game: Game, coalition: Coalition, squadron: Squadron, + parking_tracker: AirWingConfigParkingTracker, ) -> None: super().__init__() self.game = game self.coalition = coalition self.squadron = squadron + self.parking_tracker = parking_tracker columns = QHBoxLayout() self.setLayout(columns) @@ -200,6 +238,7 @@ class SquadronConfigurationBox(QGroupBox): left_column.addLayout(size_column) size_column.addWidget(QLabel("Max size:")) self.max_size_selector = SquadronSizeSpinner(self.squadron.max_size, self) + self.max_size_selector.valueChanged.connect(self.update_max_size) size_column.addWidget(self.max_size_selector) task_column = QVBoxLayout() @@ -214,8 +253,14 @@ class SquadronConfigurationBox(QGroupBox): squadron.location, squadron.aircraft, ) + self.base_selector.currentIndexChanged.connect(self.relocate_squadron) left_column.addWidget(self.base_selector) + self.parking_label = QLabel() + self.update_parking_label() + self.parking_tracker.allocation_changed.connect(self.update_parking_label) + left_column.addWidget(self.parking_label) + if not squadron.player and squadron.aircraft.flyable: player_label = QLabel("Player slots not available for opfor") elif not squadron.aircraft.flyable: @@ -266,9 +311,26 @@ class SquadronConfigurationBox(QGroupBox): self.player_list.setText( "
".join(p.name for p in self.claim_players_from_squadron()) ) + self.update_parking_label() finally: self.blockSignals(old_state) + def update_parking_label(self) -> None: + self.parking_label.setText( + f"{self.parking_tracker.used_parking_at(self.squadron.location)}/" + f"{self.squadron.location.total_aircraft_parking}" + ) + + def update_max_size(self) -> None: + self.squadron.max_size = self.max_size_selector.value() + self.parking_tracker.signal_change() + + def relocate_squadron(self) -> None: + location = self.base_selector.currentData() + self.parking_tracker.relocate_squadron( + self.squadron, self.squadron.location, location + ) + def remove_from_squadron_config(self) -> None: self.remove_squadron_signal.emit(self.squadron) @@ -321,6 +383,7 @@ class SquadronConfigurationBox(QGroupBox): self.squadron = new_squadron self.bind_data() self.mission_types.replace_squadron(self.squadron) + self.parking_tracker.signal_change() def reset_title(self) -> None: self.setTitle(f"{self.name_edit.text()} - {self.squadron.aircraft}") @@ -361,11 +424,13 @@ class SquadronConfigurationLayout(QVBoxLayout): game: Game, coalition: Coalition, squadrons: list[Squadron], + parking_tracker: AirWingConfigParkingTracker, ) -> None: super().__init__() self.game = game self.coalition = coalition self.squadron_configs = [] + self.parking_tracker = parking_tracker for squadron in squadrons: self.add_squadron(squadron) @@ -376,6 +441,7 @@ class SquadronConfigurationLayout(QVBoxLayout): return keep_squadrons def remove_squadron(self, squadron: Squadron) -> None: + self.parking_tracker.remove_squadron(squadron) for squadron_config in self.squadron_configs: if squadron_config.squadron == squadron: squadron_config.deleteLater() @@ -386,23 +452,32 @@ class SquadronConfigurationLayout(QVBoxLayout): return def add_squadron(self, squadron: Squadron) -> None: - squadron_config = SquadronConfigurationBox(self.game, self.coalition, squadron) + squadron_config = SquadronConfigurationBox( + self.game, self.coalition, squadron, self.parking_tracker + ) squadron_config.remove_squadron_signal.connect(self.remove_squadron) self.squadron_configs.append(squadron_config) self.addWidget(squadron_config) + self.parking_tracker.add_squadron(squadron) class AircraftSquadronsPage(QWidget): remove_squadron_page = Signal(AircraftType) def __init__( - self, game: Game, coalition: Coalition, squadrons: list[Squadron] + self, + game: Game, + coalition: Coalition, + squadrons: list[Squadron], + parking_tracker: AirWingConfigParkingTracker, ) -> None: super().__init__() layout = QVBoxLayout() self.setLayout(layout) - self.squadrons_config = SquadronConfigurationLayout(game, coalition, squadrons) + self.squadrons_config = SquadronConfigurationLayout( + game, coalition, squadrons, parking_tracker + ) self.squadrons_config.config_changed.connect(self.on_squadron_config_changed) scrolling_widget = QWidget() @@ -430,10 +505,16 @@ class AircraftSquadronsPage(QWidget): class AircraftSquadronsPanel(QStackedLayout): page_removed = Signal(AircraftType) - def __init__(self, game: Game, coalition: Coalition) -> None: + def __init__( + self, + game: Game, + coalition: Coalition, + parking_tracker: AirWingConfigParkingTracker, + ) -> None: super().__init__() self.game = game self.coalition = coalition + self.parking_tracker = parking_tracker self.squadrons_pages: dict[AircraftType, AircraftSquadronsPage] = {} for aircraft, squadrons in self.air_wing.squadrons.items(): self.new_page_for_type(aircraft, squadrons) @@ -453,7 +534,9 @@ class AircraftSquadronsPanel(QStackedLayout): def new_page_for_type( self, aircraft_type: AircraftType, squadrons: list[Squadron] ) -> None: - page = AircraftSquadronsPage(self.game, self.coalition, squadrons) + page = AircraftSquadronsPage( + self.game, self.coalition, squadrons, self.parking_tracker + ) page.remove_squadron_page.connect(self.remove_page_for_type) self.addWidget(page) self.squadrons_pages[aircraft_type] = page @@ -547,6 +630,9 @@ class AirWingConfigurationTab(QWidget): self.setLayout(layout) self.game = game self.coalition = coalition + self.parking_tracker = AirWingConfigParkingTracker( + coalition.air_wing.iter_squadrons() + ) self.type_list = AircraftTypeList(coalition.air_wing) @@ -556,7 +642,9 @@ class AirWingConfigurationTab(QWidget): add_button.clicked.connect(lambda state: self.add_squadron()) layout.addWidget(add_button, 2, 1, 1, 1) - self.squadrons_panel = AircraftSquadronsPanel(game, coalition) + self.squadrons_panel = AircraftSquadronsPanel( + game, coalition, self.parking_tracker + ) self.squadrons_panel.page_removed.connect(self.type_list.remove_aircraft_type) layout.addLayout(self.squadrons_panel, 1, 3, 2, 1)