Cap squadron size, limit replenishment rate.

This caps squadrons to 12 pilots and limits their replenishment rate to
1 pilot per turn. Should probably make those values configurable, but
they aren't currently.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1136
This commit is contained in:
Dan Albert 2021-06-13 14:37:57 -07:00
parent 54aa161da0
commit ace42019fb
9 changed files with 128 additions and 64 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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 ""

View File

@ -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"
)