diff --git a/changelog.md b/changelog.md index 92fa8ed8..d85dce66 100644 --- a/changelog.md +++ b/changelog.md @@ -15,7 +15,8 @@ Saves from 2.5 are not compatible with 3.0. * **[Campaign AI]** AI planned AEW&C missions will now be scheduled ASAP. * **[Campaign AI]** AI now considers the range to the SAM's threat zone rather than the range to the SAM itself when determining target priorities. * **[Campaign AI]** Auto purchase of ground units will now maintain unit composition instead of buying randomly. The unit composition is predefined. -* **[Campaign AI]** AI will aim to purchase enough ground units to support the frontline, plus 30% reserve units. +* **[Campaign AI]** Auto purchase will aim to purchase enough ground units to support the frontline, plus 30% reserve units. +* **[Campaign AI]** Auto purchase will now adjust its air/ground balance to favor whichever is under-funded. * **[Flight Planner]** Desired mission length is now configurable (defaults to 60 minutes). A BARCAP will be planned every 30 minutes. Other packages will simply have their takeoffs spread out or compressed such that the last flight will take off around the mission end time. * **[Flight Planner]** Flight plans now include bullseye waypoints. * **[Flight Planner]** Differentiated SEAD and SEAD escort. SEAD is tasked with suppressing the package target, SEAD escort is tasked with protecting the package from all SAMs along its route. diff --git a/game/game.py b/game/game.py index c477a53d..a6d3c97b 100644 --- a/game/game.py +++ b/game/game.py @@ -438,8 +438,8 @@ class Game: # gets much more of the budget that turn. Otherwise budget (after # repairs) is split evenly between air and ground. For the default # starting budget of 2000 this gives 600 to ground forces and 1400 to - # aircraft. - ground_portion = 0.3 if self.turn == 0 else 0.5 + # aircraft. After that the budget will be spend proportionally based on how much is already invested + self.budget = ProcurementAi( self, for_player=True, @@ -447,7 +447,6 @@ class Game: manage_runways=self.settings.automate_runway_repair, manage_front_line=self.settings.automate_front_line_reinforcements, manage_aircraft=self.settings.automate_aircraft_reinforcements, - front_line_budget_share=ground_portion, ).spend_budget(self.budget) self.enemy_budget = ProcurementAi( @@ -457,7 +456,6 @@ class Game: manage_runways=True, manage_front_line=True, manage_aircraft=True, - front_line_budget_share=ground_portion, ).spend_budget(self.enemy_budget) def message(self, text: str) -> None: diff --git a/game/procurement.py b/game/procurement.py index 02a7967c..141c38c3 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -8,14 +8,13 @@ from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple, Type from dcs.unittype import FlyingType, VehicleType from game import db +from game.data.groundunitclass import GroundUnitClass from game.factions.faction import Faction from game.theater import ControlPoint, MissionTarget from game.utils import Distance from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType -from game.data.groundunitclass import GroundUnitClass - if TYPE_CHECKING: from game import Game @@ -46,10 +45,7 @@ class ProcurementAi: manage_runways: bool, manage_front_line: bool, manage_aircraft: bool, - front_line_budget_share: float, ) -> None: - if front_line_budget_share > 1.0: - raise ValueError self.game = game self.is_player = for_player @@ -58,14 +54,34 @@ class ProcurementAi: self.manage_runways = manage_runways self.manage_front_line = manage_front_line self.manage_aircraft = manage_aircraft - self.front_line_budget_share = front_line_budget_share self.threat_zones = self.game.threat_zone_for(not self.is_player) + def calculate_ground_unit_budget_share(self) -> float: + armor_investment = 0 + aircraft_investment = 0 + for cp in self.owned_points: + cp_ground_units = cp.allocated_ground_units(self.game.transfers) + armor_investment += cp_ground_units.total_value + cp_aircraft = cp.allocated_aircraft(self.game) + aircraft_investment += cp_aircraft.total_value + + total_investment = aircraft_investment + armor_investment + if total_investment == 0: + # Turn 0 or all units were destroyed. Either way, split 50/50. + return 0.5 + + # the more planes we have, the more ground units we want and vice versa + ground_unit_share = aircraft_investment / total_investment + if ground_unit_share > 1.0: + raise ValueError + + return ground_unit_share + def spend_budget(self, budget: float) -> float: if self.manage_runways: budget = self.repair_runways(budget) if self.manage_front_line: - armor_budget = math.ceil(budget * self.front_line_budget_share) + armor_budget = budget * self.calculate_ground_unit_budget_share() budget -= armor_budget budget += self.reinforce_front_line(armor_budget) diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index ba4be18a..85afd44f 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -124,14 +124,38 @@ class PresetLocations: @dataclass(frozen=True) -class PendingOccupancy: - present: int - ordered: int - transferring: int +class AircraftAllocations: + present: dict[Type[FlyingType], int] + ordered: dict[Type[FlyingType], int] + transferring: dict[Type[FlyingType], int] + + @property + def total_value(self) -> int: + total: int = 0 + for unit_type, count in self.present.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.ordered.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.transferring.items(): + total += PRICES[unit_type] * count + + return total @property def total(self) -> int: - return self.present + self.ordered + self.transferring + return self.total_present + self.total_ordered + self.total_transferring + + @property + def total_present(self) -> int: + return sum(self.present.values()) + + @property + def total_ordered(self) -> int: + return sum(self.ordered.values()) + + @property + def total_transferring(self) -> int: + return sum(self.transferring.values()) @dataclass(frozen=True) @@ -149,6 +173,18 @@ class GroundUnitAllocations: combined[unit_type] += count return dict(combined) + @property + def total_value(self) -> int: + total: int = 0 + for unit_type, count in self.present.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.ordered.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.transferring.items(): + total += PRICES[unit_type] * count + + return total + @cached_property def total(self) -> int: return self.total_present + self.total_ordered + self.total_transferring @@ -585,37 +621,25 @@ class ControlPoint(MissionTarget, ABC): def can_operate(self, aircraft: Type[FlyingType]) -> bool: ... - def aircraft_transferring(self, game: Game) -> int: + def aircraft_transferring(self, game: Game) -> dict[Type[FlyingType], int]: if self.captured: ato = game.blue_ato else: ato = game.red_ato - total = 0 + transferring: defaultdict[Type[FlyingType], int] = defaultdict(int) for package in ato.packages: for flight in package.flights: if flight.departure == flight.arrival: continue if flight.departure == self: - total -= flight.count + transferring[flight.unit_type] -= flight.count elif flight.arrival == self: - total += flight.count - return total - - def expected_aircraft_next_turn(self, game: Game) -> PendingOccupancy: - on_order = 0 - for unit_bought in self.pending_unit_deliveries.units: - if issubclass(unit_bought, FlyingType): - on_order += self.pending_unit_deliveries.units[unit_bought] - - return PendingOccupancy( - self.base.total_aircraft, on_order, self.aircraft_transferring(game) - ) + transferring[flight.unit_type] += flight.count + return transferring def unclaimed_parking(self, game: Game) -> int: - return ( - self.total_aircraft_parking - self.expected_aircraft_next_turn(game).total - ) + return self.total_aircraft_parking - self.allocated_aircraft(game).total @abstractmethod def active_runway( @@ -665,6 +689,16 @@ class ControlPoint(MissionTarget, ABC): u.position.x = u.position.x + delta.x u.position.y = u.position.y + delta.y + def allocated_aircraft(self, game: Game) -> AircraftAllocations: + on_order = {} + for unit_bought, count in self.pending_unit_deliveries.units.items(): + if issubclass(unit_bought, FlyingType): + on_order[unit_bought] = count + + return AircraftAllocations( + self.base.aircraft, on_order, self.aircraft_transferring(game) + ) + def allocated_ground_units( self, transfers: PendingTransfers ) -> GroundUnitAllocations: diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index 3dd2bb0e..ce0b9e2a 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -164,16 +164,16 @@ class QHangarStatus(QHBoxLayout): self.setAlignment(Qt.AlignLeft) def update_label(self) -> None: - next_turn = self.control_point.expected_aircraft_next_turn(self.game_model.game) + next_turn = self.control_point.allocated_aircraft(self.game_model.game) max_amount = self.control_point.total_aircraft_parking components = [f"{next_turn.present} present"] - if next_turn.ordered > 0: - components.append(f"{next_turn.ordered} purchased") - elif next_turn.ordered < 0: - components.append(f"{-next_turn.ordered} sold") + if next_turn.total_ordered > 0: + components.append(f"{next_turn.total_ordered} purchased") + elif next_turn.total_ordered < 0: + components.append(f"{-next_turn.total_ordered} sold") - transferring = next_turn.transferring + transferring = next_turn.total_transferring if transferring > 0: components.append(f"{transferring} transferring in") if transferring < 0: