From 5c07a2556e4c65dccf433d698203b9155f336770 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 5 May 2023 18:02:23 -0700 Subject: [PATCH] Add option to limit squadron sizes and begin full. Adding temporarily as an option to make sure it's not a terrible idea, but the old mode will probably go away. Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1583. Fixes https://github.com/dcs-liberation/dcs_liberation/issues/2808. --- changelog.md | 1 + game/campaignloader/campaignairwingconfig.py | 5 ++ .../campaignloader/defaultsquadronassigner.py | 1 + game/coalition.py | 9 ++-- game/game.py | 16 ++++--- game/procurement.py | 2 + game/purchaseadapter.py | 6 ++- game/settings/settings.py | 9 ++++ game/squadrons/airwing.py | 4 +- game/squadrons/squadron.py | 17 +++++-- game/version.py | 3 +- qt_ui/main.py | 14 +++++- qt_ui/windows/AirWingConfigurationDialog.py | 46 +++++++++++++++++-- qt_ui/windows/newgame/QNewGameWizard.py | 16 ++++++- resources/campaigns/black_sea.yaml | 13 +++++- 15 files changed, 138 insertions(+), 24 deletions(-) diff --git a/changelog.md b/changelog.md index acb714e7..0c4d5f56 100644 --- a/changelog.md +++ b/changelog.md @@ -129,6 +129,7 @@ Saves from 6.x are not compatible with 7.0. * **[Engine]** Support for DCS 2.8.3.37556. * **[Engine]** Saved games are now a zip file of save assets for easier bug reporting. The new extension is .liberation.zip. Drag and drop that file into bug reports. +* **[Campaign]** Added options to limit squadron sizes and to begin all squadrons at maximum strength. Maximum squadron size is defined during air wing configuration with default values provided by the campaign. * **[Campaign AI]** Added an option to instruct the campaign AI to prefer fulfilling missions with squadrons which have a matching primary task. Previously distance from target held a stronger influence than task preference. Primary tasks for squadrons are set by campaign designers but are user-configurable. * **[Flight Planning]** Package TOT and composition can be modified after advancing time in Liberation. * **[Mission Generation]** Units on the front line are now hidden on MFDs. diff --git a/game/campaignloader/campaignairwingconfig.py b/game/campaignloader/campaignairwingconfig.py index 8065db90..6146624e 100644 --- a/game/campaignloader/campaignairwingconfig.py +++ b/game/campaignloader/campaignairwingconfig.py @@ -11,11 +11,15 @@ if TYPE_CHECKING: from game.theater import ConflictTheater +DEFAULT_SQUADRON_SIZE = 12 + + @dataclass(frozen=True) class SquadronConfig: primary: FlightType secondary: list[FlightType] aircraft: list[str] + max_size: int name: Optional[str] nickname: Optional[str] @@ -39,6 +43,7 @@ class SquadronConfig: FlightType(data["primary"]), secondary, data.get("aircraft", []), + data.get("size", DEFAULT_SQUADRON_SIZE), data.get("name", None), data.get("nickname", None), data.get("female_pilot_percentage", None), diff --git a/game/campaignloader/defaultsquadronassigner.py b/game/campaignloader/defaultsquadronassigner.py index 7ed62fb8..711f2b19 100644 --- a/game/campaignloader/defaultsquadronassigner.py +++ b/game/campaignloader/defaultsquadronassigner.py @@ -44,6 +44,7 @@ class DefaultSquadronAssigner: squadron = Squadron.create_from( squadron_def, squadron_config.primary, + squadron_config.max_size, control_point, self.coalition, self.game, diff --git a/game/coalition.py b/game/coalition.py index 0ad296f7..040feeee 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -161,14 +161,14 @@ class Coalition: # is handled correctly. self.transfers.perform_transfers() - def preinit_turn_0(self) -> None: + def preinit_turn_0(self, squadrons_start_full: bool) -> None: """Runs final Coalition initialization. Final initialization occurs before Game.initialize_turn runs for turn 0. """ - self.air_wing.populate_for_turn_0() + self.air_wing.populate_for_turn_0(squadrons_start_full) - def initialize_turn(self) -> None: + def initialize_turn(self, is_turn_0: bool) -> None: """Processes coalition-specific turn initialization. For more information on turn initialization in general, see the documentation @@ -187,7 +187,8 @@ class Coalition: with logged_duration("Transport planning"): self.transfers.plan_transports() - self.plan_missions() + if not is_turn_0 or not self.game.settings.enable_squadron_aircraft_limits: + self.plan_missions() self.plan_procurement() def refund_outstanding_orders(self) -> None: diff --git a/game/game.py b/game/game.py index 1f7ffef5..bc618656 100644 --- a/game/game.py +++ b/game/game.py @@ -294,7 +294,7 @@ class Game: if self.turn > 1: self.conditions = self.generate_conditions() - def begin_turn_0(self) -> None: + def begin_turn_0(self, squadrons_start_full: bool) -> None: """Initialization for the first turn of the game.""" from .sim import GameUpdateEvents @@ -319,8 +319,9 @@ class Game: # Rotate the whole TGO with the new heading tgo.rotate(heading or tgo.heading) - self.blue.preinit_turn_0() - self.red.preinit_turn_0() + self.blue.preinit_turn_0(squadrons_start_full) + self.red.preinit_turn_0(squadrons_start_full) + # TODO: Check for overfull bases. # We don't need to actually stream events for turn zero because we haven't given # *any* state to the UI yet, so it will need to do a full draw once we do. self.initialize_turn(GameUpdateEvents()) @@ -365,7 +366,10 @@ class Game: self.red.bullseye = Bullseye(player_cp.position) def initialize_turn( - self, events: GameUpdateEvents, for_red: bool = True, for_blue: bool = True + self, + events: GameUpdateEvents, + for_red: bool = True, + for_blue: bool = True, ) -> None: """Performs turn initialization for the specified players. @@ -418,9 +422,9 @@ class Game: # Plan Coalition specific turn if for_blue: - self.blue.initialize_turn() + self.blue.initialize_turn(self.turn == 0) if for_red: - self.red.initialize_turn() + self.red.initialize_turn(self.turn == 0) # Plan GroundWar self.ground_planners = {} diff --git a/game/procurement.py b/game/procurement.py index a4a7c6a8..1c6a9c5e 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -234,6 +234,8 @@ class ProcurementAi: ): if not squadron.can_provide_pilots(request.number): continue + if not squadron.has_aircraft_capacity_for(request.number): + continue if squadron.location.unclaimed_parking() < request.number: continue if self.threat_zones.threatened(squadron.location.position): diff --git a/game/purchaseadapter.py b/game/purchaseadapter.py index 03987f46..a4a71123 100644 --- a/game/purchaseadapter.py +++ b/game/purchaseadapter.py @@ -109,7 +109,11 @@ class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]): return item.owned_aircraft def can_buy(self, item: Squadron) -> bool: - return super().can_buy(item) and self.control_point.unclaimed_parking() > 0 + return ( + super().can_buy(item) + and self.control_point.unclaimed_parking() > 0 + and item.has_aircraft_capacity_for(1) + ) def can_sell(self, item: Squadron) -> bool: return item.untasked_aircraft > 0 diff --git a/game/settings/settings.py b/game/settings/settings.py index d3763a26..3ef8e24f 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -245,6 +245,15 @@ class Settings: "this many pilots each turn up to the limit." ), ) + # Feature flag for squadron limits. + enable_squadron_aircraft_limits: bool = boolean_option( + "Enable per-squadron aircraft limits", + CAMPAIGN_MANAGEMENT_PAGE, + PILOTS_AND_SQUADRONS_SECTION, + default=False, + remember_player_choice=True, + detail="If set, squadrons will be limited to a maximum number of aircraft.", + ) # HQ Automation automate_runway_repair: bool = boolean_option( diff --git a/game/squadrons/airwing.py b/game/squadrons/airwing.py index ac1f8430..b5975f11 100644 --- a/game/squadrons/airwing.py +++ b/game/squadrons/airwing.py @@ -125,9 +125,9 @@ class AirWing: def squadron_at_index(self, index: int) -> Squadron: return list(self.iter_squadrons())[index] - def populate_for_turn_0(self) -> None: + def populate_for_turn_0(self, squadrons_start_full: bool) -> None: for squadron in self.iter_squadrons(): - squadron.populate_for_turn_0() + squadron.populate_for_turn_0(squadrons_start_full) def end_turn(self) -> None: for squadron in self.iter_squadrons(): diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py index 8daedf15..51705e2a 100644 --- a/game/squadrons/squadron.py +++ b/game/squadrons/squadron.py @@ -30,6 +30,7 @@ class Squadron: country: str role: str aircraft: AircraftType + max_size: int livery: Optional[str] primary_task: FlightType auto_assignable_mission_types: set[FlightType] @@ -160,10 +161,12 @@ class Squadron: self.current_roster.extend(new_pilots) self.available_pilots.extend(new_pilots) - def populate_for_turn_0(self) -> None: + def populate_for_turn_0(self, squadrons_start_full: bool) -> None: if any(p.status is not PilotStatus.Active for p in self.pilot_pool): raise ValueError("Squadrons can only be created with active pilots.") self._recruit_pilots(self.settings.squadron_pilot_limit) + if squadrons_start_full: + self.owned_aircraft = self.max_size def end_turn(self) -> None: if self.destination is not None: @@ -201,7 +204,7 @@ class Squadron: return [p for p in self.current_roster if p.status != status] @property - def max_size(self) -> int: + def pilot_limit(self) -> int: return self.settings.squadron_pilot_limit @property @@ -229,7 +232,7 @@ class Squadron: @property def _number_of_unfilled_pilot_slots(self) -> int: - return self.max_size - len(self.active_pilots) + return self.pilot_limit - len(self.active_pilots) @property def number_of_available_pilots(self) -> int: @@ -328,6 +331,12 @@ class Squadron: def expected_size_next_turn(self) -> int: return self.owned_aircraft + self.pending_deliveries + def has_aircraft_capacity_for(self, n: int) -> bool: + if not self.settings.enable_squadron_aircraft_limits: + return True + remaining = self.max_size - self.owned_aircraft - self.pending_deliveries + return remaining >= n + @property def arrival(self) -> ControlPoint: return self.location if self.destination is None else self.destination @@ -418,6 +427,7 @@ class Squadron: cls, squadron_def: SquadronDef, primary_task: FlightType, + max_size: int, base: ControlPoint, coalition: Coalition, game: Game, @@ -429,6 +439,7 @@ class Squadron: squadron_def.country, squadron_def.role, squadron_def.aircraft, + max_size, squadron_def.livery, primary_task, squadron_def.auto_assignable_mission_types, diff --git a/game/version.py b/game/version.py index 7bbf768c..8feefbb5 100644 --- a/game/version.py +++ b/game/version.py @@ -177,7 +177,6 @@ VERSION = _build_version_string() #: #: Version 10.6 #: * Designated CTLD zones for ControlPoints (Airbases & FOBs/FARPs) +#: * Support for defining squadron sizes. #: * 'ground_forces' in yaml file to specify preset groups for TGOs, #: given the group is available for the faction and the task matches - -CAMPAIGN_FORMAT_VERSION = (10, 6) diff --git a/qt_ui/main.py b/qt_ui/main.py index a73889b9..bfad7fb8 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -238,6 +238,15 @@ def parse_args() -> argparse.Namespace: "--auto-procurement", action="store_true", help="Automate bluefor procurement." ) + new_game.add_argument( + "--use-new-squadron-rules", + action="store_true", + help=( + "Limit the number of aircraft per squadron and begin the campaign with " + "them at full strength." + ), + ) + new_game.add_argument( "--inverted", action="store_true", help="Invert the campaign." ) @@ -280,6 +289,7 @@ def create_game( start_date: datetime, restrict_weapons_by_date: bool, advanced_iads: bool, + use_new_squadron_rules: bool, ) -> Game: first_start = liberation_install.init() if first_start: @@ -312,6 +322,7 @@ def create_game( enable_base_capture_cheat=cheats, enable_transfer_cheat=cheats, restrict_weapons_by_date=restrict_weapons_by_date, + enable_squadron_aircraft_limits=use_new_squadron_rules, ), GeneratorSettings( start_date=start_date, @@ -346,7 +357,7 @@ def create_game( ), ) game = generator.generate() - game.begin_turn_0() + game.begin_turn_0(squadrons_start_full=use_new_squadron_rules) return game @@ -438,6 +449,7 @@ def main(): args.date, args.restrict_weapons_by_date, args.advanced_iads, + args.use_new_squadron_rules, ) if args.subcommand == "lint-weapons": lint_weapon_data_for_aircraft(AircraftType.named(args.aircraft)) diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py index ccd074a2..e3c43d95 100644 --- a/qt_ui/windows/AirWingConfigurationDialog.py +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -28,10 +28,12 @@ from PySide2.QtWidgets import ( QGridLayout, QToolButton, QMessageBox, + QSpinBox, ) from game import Game from game.ato.flighttype import FlightType +from game.campaignloader.campaignairwingconfig import DEFAULT_SQUADRON_SIZE from game.coalition import Coalition from game.dcs.aircrafttype import AircraftType from game.squadrons import AirWing, Pilot, Squadron @@ -167,6 +169,25 @@ class SquadronLiverySelector(QComboBox): self.addItem("No available liveries (using DCS default)") self.setEnabled(False) +class SquadronSizeSpinner(QSpinBox): + def __init__(self, starting_size: int, parent: QWidget | None) -> None: + super().__init__(parent) + + # Disable text editing, which wouldn't work in the first place, but also + # obnoxiously selects the text on change (highlighting it) and leaves a flashing + # cursor in the middle of the element when clicked. + self.lineEdit().setEnabled(False) + + self.setMinimum(1) + self.setValue(starting_size) + + # def sizeHint(self) -> QSize: + # # The default size hinting fails to deal with label width, and will truncate + # # "Paused". + # size = super().sizeHint() + # size.setWidth(86) + # return size + class SquadronConfigurationBox(QGroupBox): remove_squadron_signal = Signal(Squadron) @@ -211,9 +232,20 @@ class SquadronConfigurationBox(QGroupBox): self.livery_selector = SquadronLiverySelector(squadron) left_column.addWidget(self.livery_selector) - left_column.addWidget(QLabel("Primary task:")) + task_and_size_row = QHBoxLayout() + left_column.addLayout(task_and_size_row) + + size_column = QVBoxLayout() + task_and_size_row.addLayout(size_column) + size_column.addWidget(QLabel("Max size:")) + self.max_size_selector = SquadronSizeSpinner(self.squadron.max_size, self) + size_column.addWidget(self.max_size_selector) + + task_column = QVBoxLayout() + task_and_size_row.addLayout(task_column) + task_column.addWidget(QLabel("Primary task:")) self.primary_task_selector = PrimaryTaskSelector.for_squadron(self.squadron) - left_column.addWidget(self.primary_task_selector) + task_column.addWidget(self.primary_task_selector) left_column.addWidget(QLabel("Base:")) self.base_selector = SquadronBaseSelector( @@ -268,6 +300,7 @@ class SquadronConfigurationBox(QGroupBox): self.name_edit.setText(self.squadron.name) self.nickname_edit.setText(self.squadron.nickname) self.primary_task_selector.setCurrentText(self.squadron.primary_task.value) + self.max_size_selector.setValue(self.squadron.max_size) self.base_selector.setCurrentText(self.squadron.location.name) self.player_list.setText( "
".join(p.name for p in self.claim_players_from_squadron()) @@ -292,6 +325,7 @@ class SquadronConfigurationBox(QGroupBox): squadron = Squadron.create_from( selected_def, self.squadron.primary_task, + self.squadron.max_size, self.squadron.location, self.coalition, self.game, @@ -338,6 +372,7 @@ class SquadronConfigurationBox(QGroupBox): def apply(self) -> Squadron: self.squadron.name = self.name_edit.text() self.squadron.nickname = self.nickname_edit.text() + self.squadron.max_size = self.max_size_selector.value() if (primary_task := self.primary_task_selector.selected_task) is not None: self.squadron.primary_task = primary_task else: @@ -606,7 +641,12 @@ class AirWingConfigurationTab(QWidget): ) squadron = Squadron.create_from( - squadron_def, selected_task, selected_base, self.coalition, self.game + squadron_def, + selected_task, + DEFAULT_SQUADRON_SIZE, + selected_base, + self.coalition, + self.game, ) # Add Squadron diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 546d5aa0..608eb752 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -94,6 +94,7 @@ FRONTLINE = f"{Settings.automate_front_line_reinforcements=}".split("=")[0].spli AIRCRAFT = f"{Settings.automate_aircraft_reinforcements=}".split("=")[0].split(".")[1] MISSION_LENGTH = f"{Settings.desired_player_mission_duration=}".split("=")[0].split(".")[1] SUPER_CARRIER = f"{Settings.supercarrier=}".split("=")[0].split(".")[1] +SQN_AC_LIMITS = f"{Settings.enable_squadron_aircraft_limits=}".split("=")[0].split(".")[1] # fmt: on @@ -159,6 +160,7 @@ class NewGameWizard(QtWidgets.QWizard): settings.desired_player_mission_duration = timedelta( minutes=self.field(MISSION_LENGTH) ) + settings.enable_squadron_aircraft_limits = self.field("use_new_squadron_rules") settings.automate_aircraft_reinforcements = self.field(AIRCRAFT) settings.supercarrier = self.field(SUPER_CARRIER) settings.perf_culling = ( @@ -233,7 +235,9 @@ class NewGameWizard(QtWidgets.QWizard): if herc in g.blue.air_wing.squadrons or herc in g.red.air_wing.squadrons: g.settings.set_plugin_option("herculescargo", True) - self.generatedGame.begin_turn_0() + self.generatedGame.begin_turn_0( + squadrons_start_full=settings.enable_squadron_aircraft_limits + ) super(NewGameWizard, self).accept() @@ -735,6 +739,16 @@ class DifficultyAndAutomationOptions(QtWidgets.QWizardPage): self.registerField("enemy_starting_money", self.enemy_budget.starting_money) economy_layout.addLayout(self.enemy_budget) + new_squadron_rules = QtWidgets.QCheckBox("Enable new squadron rules") + self.registerField("use_new_squadron_rules", new_squadron_rules) + economy_layout.addWidget(new_squadron_rules) + economy_layout.addWidget( + QLabel( + "With new squadron rules enabled, squadrons will not be able to exceed a maximum number of aircraft " + "(configurable), and the campaign will begin with all squadrons at full strength." + ) + ) + assist_group = QtWidgets.QGroupBox("Player assists") layout.addWidget(assist_group) assist_layout = QtWidgets.QGridLayout() diff --git a/resources/campaigns/black_sea.yaml b/resources/campaigns/black_sea.yaml index edaeb9f9..2d7cf97a 100644 --- a/resources/campaigns/black_sea.yaml +++ b/resources/campaigns/black_sea.yaml @@ -9,7 +9,7 @@ recommended_enemy_faction: Russia 2010 recommended_start_date: 2004-01-07 miz: black_sea.miz performance: 2 -version: "10.2" +version: "10.7" squadrons: # Anapa-Vityazevo 12: @@ -20,16 +20,20 @@ squadrons: - primary: AEW&C aircraft: - A-50 + size: 2 - primary: Refueling aircraft: - IL-78M + size: 2 - primary: Transport aircraft: - IL-78MD + size: 4 - primary: Strike secondary: air-to-ground aircraft: - Tu-160 Blackjack + size: 4 # Krasnodar-Center 13: - primary: BARCAP @@ -77,6 +81,7 @@ squadrons: - primary: Transport aircraft: - UH-60A + size: 8 # Kobuleti 24: - primary: BARCAP @@ -99,16 +104,20 @@ squadrons: - primary: AEW&C aircraft: - E-3A + size: 2 - primary: Refueling aircraft: - KC-135 Stratotanker + size: 2 - primary: Transport aircraft: - C-17A + size: 4 - primary: Strike secondary: air-to-ground aircraft: - B-1B Lancer + size: 4 Blue CV: - primary: BARCAP secondary: air-to-air @@ -129,6 +138,7 @@ squadrons: - primary: Refueling aircraft: - S-3B Tanker + size: 2 Blue LHA: - primary: BAI secondary: air-to-ground @@ -148,6 +158,7 @@ squadrons: - primary: BAI secondary: any - primary: Refueling + size: 2 Red LHA: - primary: BAI secondary: air-to-ground