diff --git a/changelog.md b/changelog.md index aa23a742..daaae532 100644 --- a/changelog.md +++ b/changelog.md @@ -4,9 +4,10 @@ Saves from 3.x are not compatible with 4.0. ## Features/Improvements -* **[Flight Planner]** Added ability to plan Tankers. +* **[Campaign]** Squadrons now have a maximum size and killed pilots replenish at a limited rate. * **[Campaign AI]** AI will plan Tanker flights. * **[Factions]** Added more tankers to factions. +* **[Flight Planner]** Added ability to plan Tankers. ## Fixes diff --git a/game/game.py b/game/game.py index a6d3c97b..655d292d 100644 --- a/game/game.py +++ b/game/game.py @@ -327,11 +327,14 @@ class Game: # one hop ahead. ControlPoint.process_turn handles unit deliveries. self.transfers.perform_transfers() - # Needs to happen *before* planning transfers so we don't cancel the + # Needs to happen *before* planning transfers so we don't cancel them. self.reset_ato() for control_point in self.theater.controlpoints: control_point.process_turn(self) + self.blue_air_wing.replenish() + self.red_air_wing.replenish() + if not skipped and self.turn > 1: for cp in self.theater.player_points(): cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY) diff --git a/game/settings.py b/game/settings.py index 90723a55..1bda55bf 100644 --- a/game/settings.py +++ b/game/settings.py @@ -33,6 +33,15 @@ class Settings: player_income_multiplier: float = 1.0 enemy_income_multiplier: float = 1.0 + #: The maximum number of pilots a squadron can have at one time. Changing this after + #: the campaign has started will have no immediate effect; pilots already in the + #: squadron will not be removed if the limit is lowered and pilots will not be + #: immediately created if the limit is raised. + squadron_pilot_limit: int = 12 + + #: The number of pilots a squadron can replace per turn. + squadron_replenishment_rate: int = 1 + default_start_type: str = "Cold" # Mission specific diff --git a/game/squadrons.py b/game/squadrons.py index f999a621..c6498189 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -80,8 +80,17 @@ class Squadron: aircraft: AircraftType livery: Optional[str] mission_types: tuple[FlightType, ...] - pilots: list[Pilot] - available_pilots: list[Pilot] = field(init=False, hash=False, compare=False) + + #: The pool of pilots that have not yet been assigned to the squadron. This only + #: happens when a preset squadron defines more preset pilots than the squadron limit + #: allows. This pool will be consumed before random pilots are generated. + pilot_pool: list[Pilot] + + current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False) + available_pilots: list[Pilot] = field( + default_factory=list, init=False, hash=False, compare=False + ) + auto_assignable_mission_types: set[FlightType] = field( init=False, hash=False, compare=False ) @@ -93,7 +102,9 @@ class Squadron: player: bool def __post_init__(self) -> None: - self.available_pilots = list(self.active_pilots) + 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.game.settings.squadron_pilot_limit) self.auto_assignable_mission_types = set(self.mission_types) def __str__(self) -> str: @@ -102,11 +113,8 @@ class Squadron: return f'{self.name} "{self.nickname}"' def claim_available_pilot(self) -> Optional[Pilot]: - # No pilots available, so the preference is irrelevant. Create a new pilot and - # return it. if not self.available_pilots: - self.enlist_new_pilots(1) - return self.available_pilots.pop() + return None # For opfor, so player/AI option is irrelevant. if not self.player: @@ -127,11 +135,12 @@ class Squadron: # No pilot was found that matched the user's preference. # # If they chose to *never* assign players and only players remain in the pool, - # we cannot fill the slot with the available pilots. Recruit a new one. + # we cannot fill the slot with the available pilots. # - # If they prefer players and we're out of players, just return an AI pilot. + # If they only *prefer* players and we're out of players, just return an AI + # pilot. if not prefer_players: - self.enlist_new_pilots(1) + return None return self.available_pilots.pop() def claim_pilot(self, pilot: Pilot) -> None: @@ -151,23 +160,45 @@ class Squadron: # 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)] - self.pilots.extend(new_pilots) + def _recruit_pilots(self, count: int) -> None: + new_pilots = self.pilot_pool[:count] + self.pilot_pool = self.pilot_pool[count:] + count -= len(new_pilots) + new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)]) + self.current_roster.extend(new_pilots) self.available_pilots.extend(new_pilots) + def replenish_lost_pilots(self) -> None: + replenish_count = min( + self.game.settings.squadron_replenishment_rate, + self.number_of_unfilled_pilot_slots, + ) + if replenish_count > 0: + self._recruit_pilots(replenish_count) + def return_all_pilots(self) -> None: self.available_pilots = list(self.active_pilots) + @staticmethod + def send_on_leave(pilot: Pilot) -> None: + pilot.send_on_leave() + + def return_from_leave(self, pilot: Pilot): + if not self.has_unfilled_pilot_slots: + raise RuntimeError( + f"Cannot return {pilot} from leave because {self} is full" + ) + pilot.return_from_leave() + @property def faker(self) -> Faker: return self.game.faker_for(self.player) def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]: - return [p for p in self.pilots if p.status == status] + return [p for p in self.current_roster if p.status == status] def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]: - return [p for p in self.pilots if p.status != status] + return [p for p in self.current_roster if p.status != status] @property def active_pilots(self) -> list[Pilot]: @@ -178,15 +209,30 @@ class Squadron: return self._pilots_with_status(PilotStatus.OnLeave) @property - def number_of_pilots_including_dead(self) -> int: - return len(self.pilots) + def number_of_pilots_including_inactive(self) -> int: + return len(self.current_roster) @property - def number_of_living_pilots(self) -> int: - return len(self._pilots_without_status(PilotStatus.Dead)) + def number_of_unfilled_pilot_slots(self) -> int: + return self.game.settings.squadron_pilot_limit - len(self.active_pilots) + + @property + def number_of_available_pilots(self) -> int: + return len(self.available_pilots) + + @property + def has_available_pilots(self) -> bool: + return bool(self.available_pilots) + + @property + def has_unfilled_pilot_slots(self) -> bool: + return self.number_of_unfilled_pilot_slots > 0 + + def can_auto_assign(self, task: FlightType) -> bool: + return task in self.auto_assignable_mission_types def pilot_at_index(self, index: int) -> Pilot: - return self.pilots[index] + return self.current_roster[index] @classmethod def from_yaml(cls, path: Path, game: Game, player: bool) -> Squadron: @@ -223,7 +269,7 @@ class Squadron: aircraft=unit_type, livery=data.get("livery"), mission_types=tuple(mission_types), - pilots=pilots, + pilot_pool=pilots, game=game, player=player, ) @@ -313,7 +359,7 @@ class AirWing: aircraft=aircraft, livery=None, mission_types=tuple(tasks_for_aircraft(aircraft)), - pilots=[], + pilot_pool=[], game=game, player=player, ) @@ -327,6 +373,13 @@ class AirWing: if task in squadron.mission_types: yield squadron + def auto_assignable_for_task_with_type( + self, aircraft: AircraftType, task: FlightType + ) -> Iterator[Squadron]: + for squadron in self.squadrons_for(aircraft): + if squadron.can_auto_assign(task) and squadron.has_available_pilots: + yield squadron + def squadron_for(self, aircraft: AircraftType) -> Squadron: return self.squadrons_for(aircraft)[0] @@ -336,6 +389,10 @@ class AirWing: def squadron_at_index(self, index: int) -> Squadron: return list(self.iter_squadrons())[index] + def replenish(self) -> None: + for squadron in self.iter_squadrons(): + squadron.replenish_lost_pilots() + def reset(self) -> None: for squadron in self.iter_squadrons(): squadron.return_all_pilots() diff --git a/game/transfers.py b/game/transfers.py index 0a356047..0788cf11 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -228,37 +228,41 @@ class AirliftPlanner: distance_cache = ObjectiveDistanceCache.get_closest_airfields( self.transfer.position ) + air_wing = self.game.air_wing_for(self.for_player) for cp in distance_cache.closest_airfields: if cp.captured != self.for_player: continue inventory = self.game.aircraft_inventory.for_control_point(cp) for unit_type, available in inventory.all_aircraft: - squadrons = [ - s - for s in self.game.air_wing_for(self.for_player).squadrons_for( - unit_type - ) - if FlightType.TRANSPORT in s.auto_assignable_mission_types - ] - if not squadrons: - continue - squadron = squadrons[0] - if self.compatible_with_mission(unit_type, cp): - while available and self.transfer.transport is None: - flight_size = self.create_airlift_flight(squadron, inventory) - available -= flight_size + squadrons = air_wing.auto_assignable_for_task_with_type( + unit_type, FlightType.TRANSPORT + ) + for squadron in squadrons: + if self.compatible_with_mission(unit_type, cp): + while ( + available + and squadron.has_available_pilots + and self.transfer.transport is None + ): + flight_size = self.create_airlift_flight( + squadron, inventory + ) + available -= flight_size if self.package.flights: self.game.ato_for(self.for_player).add_package(self.package) def create_airlift_flight( self, squadron: Squadron, inventory: ControlPointAircraftInventory ) -> int: - available = inventory.available(squadron.aircraft) + available_aircraft = inventory.available(squadron.aircraft) capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2 required = math.ceil(self.transfer.size / capacity_each) flight_size = min( - required, available, squadron.aircraft.dcs_unit_type.group_size_max + required, + available_aircraft, + squadron.aircraft.dcs_unit_type.group_size_max, + squadron.number_of_available_pilots, ) capacity = flight_size * capacity_each diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 7dfe061b..be42551a 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -163,8 +163,6 @@ class AircraftAllocator: flight.max_distance ) - # Prefer using squadrons with pilots first - best_understaffed: Optional[Tuple[ControlPoint, Squadron]] = None for airfield in airfields_in_range: if not airfield.is_friendly(self.is_player): continue @@ -176,24 +174,14 @@ class AircraftAllocator: continue # Valid location with enough aircraft available. Find a squadron to fit # the role. - for squadron in self.air_wing.squadrons_for(aircraft): - if task not in squadron.auto_assignable_mission_types: - continue - if len(squadron.available_pilots) >= flight.num_aircraft: + squadrons = self.air_wing.auto_assignable_for_task_with_type( + aircraft, task + ) + for squadron in squadrons: + if squadron.number_of_available_pilots >= flight.num_aircraft: inventory.remove_aircraft(aircraft, flight.num_aircraft) return airfield, squadron - - # A compatible squadron that doesn't have enough pilots. Remember it - # as a fallback in case we find no better choices. - if best_understaffed is None: - best_understaffed = airfield, squadron - - if best_understaffed is not None: - airfield, squadron = best_understaffed - self.global_inventory.for_control_point(airfield).remove_aircraft( - squadron.aircraft, flight.num_aircraft - ) - return best_understaffed + return None class PackageBuilder: diff --git a/qt_ui/models.py b/qt_ui/models.py index 4c4a52fe..dd214e84 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -424,7 +424,7 @@ class SquadronModel(QAbstractListModel): self.squadron = squadron def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - return self.squadron.number_of_pilots_including_dead + return self.squadron.number_of_pilots_including_inactive def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: if not index.isValid(): @@ -462,9 +462,9 @@ class SquadronModel(QAbstractListModel): pilot = self.pilot_at_index(index) self.beginResetModel() if pilot.on_leave: - pilot.return_from_leave() + self.squadron.return_from_leave(pilot) else: - pilot.send_on_leave() + self.squadron.send_on_leave(pilot) self.endResetModel() def is_auto_assignable(self, task: FlightType) -> bool: diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index 9a866640..ac666e0e 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -48,10 +48,10 @@ class SquadronDelegate(TwoColumnRowDelegate): return self.squadron(index).nickname or "" elif (row, column) == (1, 1): squadron = self.squadron(index) - alive = squadron.number_of_living_pilots active = len(squadron.active_pilots) available = len(squadron.available_pilots) - return f"{alive} pilots, {active} active, {available} unassigned" + on_leave = len(squadron.pilots_on_leave) + return f"{active} active, {available} unassigned, {on_leave} on leave" return "" diff --git a/qt_ui/windows/SquadronDialog.py b/qt_ui/windows/SquadronDialog.py index 31cf5587..a932caee 100644 --- a/qt_ui/windows/SquadronDialog.py +++ b/qt_ui/windows/SquadronDialog.py @@ -178,7 +178,9 @@ class SquadronDialog(QDialog): if self.check_disabled_button_states(self.toggle_leave_button, index): return pilot = self.squadron_model.pilot_at_index(index) - self.toggle_leave_button.setEnabled(True) + self.toggle_leave_button.setEnabled( + not pilot.on_leave or self.squadron_model.squadron.has_unfilled_pilot_slots + ) self.toggle_leave_button.setText( "Return from leave" if pilot.on_leave else "Send on leave" )