diff --git a/changelog.md b/changelog.md index e3748a4e..2ee8bfb6 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,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 5a3c25eb..d2f70bc6 100644 --- a/game/campaignloader/defaultsquadronassigner.py +++ b/game/campaignloader/defaultsquadronassigner.py @@ -45,6 +45,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 a484a113..0617d796 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -158,14 +158,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 @@ -184,7 +184,8 @@ class Coalition: with logged_duration("Transport planning"): self.transfers.plan_transports(self.game.conditions.start_time) - self.plan_missions(self.game.conditions.start_time) + if not is_turn_0 or not self.game.settings.enable_squadron_aircraft_limits: + self.plan_missions(self.game.conditions.start_time) self.plan_procurement() def refund_outstanding_orders(self) -> None: diff --git a/game/game.py b/game/game.py index 0b055834..8d2a59e0 100644 --- a/game/game.py +++ b/game/game.py @@ -296,7 +296,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 @@ -321,8 +321,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()) @@ -381,7 +382,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. @@ -433,9 +437,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) # Update cull zones with logged_duration("Computing culling positions"): diff --git a/game/procurement.py b/game/procurement.py index 5d3a0d7a..08ced19a 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -235,6 +235,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 d49c6cd1..aa6510be 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -264,6 +264,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 9334d7f6..21fed869 100644 --- a/game/squadrons/squadron.py +++ b/game/squadrons/squadron.py @@ -31,6 +31,7 @@ class Squadron: country: str role: str aircraft: AircraftType + max_size: int livery: Optional[str] primary_task: FlightType auto_assignable_mission_types: set[FlightType] @@ -161,10 +162,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: @@ -202,7 +205,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 @@ -230,7 +233,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: @@ -334,6 +337,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 @@ -424,6 +433,7 @@ class Squadron: cls, squadron_def: SquadronDef, primary_task: FlightType, + max_size: int, base: ControlPoint, coalition: Coalition, game: Game, @@ -435,6 +445,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 3b593664..81330d5d 100644 --- a/game/version.py +++ b/game/version.py @@ -172,4 +172,7 @@ VERSION = _build_version_string() #: #: Version 10.6 #: * Support in-line definitions of campaign-specific factions. -CAMPAIGN_FORMAT_VERSION = (10, 6) +#: +#: Version 10.7 +#: * Support for defining squadron sizes. +CAMPAIGN_FORMAT_VERSION = (10, 7) diff --git a/qt_ui/main.py b/qt_ui/main.py index a92a1900..8d649c95 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -221,6 +221,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." ) @@ -263,6 +272,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: @@ -297,6 +307,7 @@ def create_game( enable_frontline_cheats=cheats, enable_base_capture_cheat=cheats, restrict_weapons_by_date=restrict_weapons_by_date, + enable_squadron_aircraft_limits=use_new_squadron_rules, ), GeneratorSettings( start_date=start_date, @@ -323,7 +334,7 @@ def create_game( lua_plugin_manager, ) game = generator.generate() - game.begin_turn_0() + game.begin_turn_0(squadrons_start_full=use_new_squadron_rules) return game @@ -417,6 +428,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 f65c8eeb..b7f21466 100644 --- a/qt_ui/windows/AirWingConfigurationDialog.py +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -27,10 +27,12 @@ from PySide6.QtWidgets import ( QToolButton, QVBoxLayout, QWidget, + 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 @@ -127,6 +129,26 @@ class SquadronBaseSelector(QComboBox): self.update() +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) @@ -166,9 +188,20 @@ class SquadronConfigurationBox(QGroupBox): reroll_nickname_button.clicked.connect(self.reroll_nickname) nickname_edit_layout.addWidget(reroll_nickname_button, 1, 1, Qt.AlignTop) - left_column.addWidget(QLabel("Primary task:")) + task_and_size_row = QHBoxLayout() + left_column.addLayout(task_and_size_row) + + size_column = QVBoxLayout() + left_column.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() + left_column.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( @@ -222,6 +255,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()) @@ -246,6 +280,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, @@ -292,6 +327,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: @@ -557,7 +593,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 7e79fe5c..af3b8005 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -155,6 +155,7 @@ class NewGameWizard(QtWidgets.QWizard): self.lua_plugin_manager.save_player_settings() + use_new_squadron_rules = self.field("use_new_squadron_rules") logging.info("New campaign start date: %s", start_date.strftime("%m/%d/%Y")) settings = Settings( player_income_multiplier=self.field("player_income_multiplier") / 10, @@ -168,6 +169,7 @@ class NewGameWizard(QtWidgets.QWizard): ), automate_aircraft_reinforcements=self.field("automate_aircraft_purchases"), supercarrier=self.field("supercarrier"), + enable_squadron_aircraft_limits=use_new_squadron_rules, ) settings.save_player_settings() generator_settings = GeneratorSettings( @@ -223,7 +225,7 @@ class NewGameWizard(QtWidgets.QWizard): AirWingConfigurationDialog(self.generatedGame, self).exec_() - self.generatedGame.begin_turn_0() + self.generatedGame.begin_turn_0(squadrons_start_full=use_new_squadron_rules) super(NewGameWizard, self).accept() @@ -603,6 +605,17 @@ 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") + new_squadron_rules.setChecked(default_settings.enable_squadron_aircraft_limits) + 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