Split purchase budget based on investment ratio.

The AI purchaser will aim to have a 50/50 ground/air investment mix.
This allows it to overspend on one category if significant losses were
taken the previous turn.

The total purchase amount is still limited, so if the bases are full
when only 10% of the investment is in ground units, the full budget for
the turn will still go to air.
This commit is contained in:
Florian 2021-05-31 19:04:31 +02:00 committed by Dan Albert
parent b74f60fe0e
commit 3a592aee8b
5 changed files with 90 additions and 41 deletions

View File

@ -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 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]** 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]** 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]** 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]** 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. * **[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.

View File

@ -438,8 +438,8 @@ class Game:
# gets much more of the budget that turn. Otherwise budget (after # gets much more of the budget that turn. Otherwise budget (after
# repairs) is split evenly between air and ground. For the default # repairs) is split evenly between air and ground. For the default
# starting budget of 2000 this gives 600 to ground forces and 1400 to # starting budget of 2000 this gives 600 to ground forces and 1400 to
# aircraft. # aircraft. After that the budget will be spend proportionally based on how much is already invested
ground_portion = 0.3 if self.turn == 0 else 0.5
self.budget = ProcurementAi( self.budget = ProcurementAi(
self, self,
for_player=True, for_player=True,
@ -447,7 +447,6 @@ class Game:
manage_runways=self.settings.automate_runway_repair, manage_runways=self.settings.automate_runway_repair,
manage_front_line=self.settings.automate_front_line_reinforcements, manage_front_line=self.settings.automate_front_line_reinforcements,
manage_aircraft=self.settings.automate_aircraft_reinforcements, manage_aircraft=self.settings.automate_aircraft_reinforcements,
front_line_budget_share=ground_portion,
).spend_budget(self.budget) ).spend_budget(self.budget)
self.enemy_budget = ProcurementAi( self.enemy_budget = ProcurementAi(
@ -457,7 +456,6 @@ class Game:
manage_runways=True, manage_runways=True,
manage_front_line=True, manage_front_line=True,
manage_aircraft=True, manage_aircraft=True,
front_line_budget_share=ground_portion,
).spend_budget(self.enemy_budget) ).spend_budget(self.enemy_budget)
def message(self, text: str) -> None: def message(self, text: str) -> None:

View File

@ -8,14 +8,13 @@ from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple, Type
from dcs.unittype import FlyingType, VehicleType from dcs.unittype import FlyingType, VehicleType
from game import db from game import db
from game.data.groundunitclass import GroundUnitClass
from game.factions.faction import Faction from game.factions.faction import Faction
from game.theater import ControlPoint, MissionTarget from game.theater import ControlPoint, MissionTarget
from game.utils import Distance from game.utils import Distance
from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
from game.data.groundunitclass import GroundUnitClass
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
@ -46,10 +45,7 @@ class ProcurementAi:
manage_runways: bool, manage_runways: bool,
manage_front_line: bool, manage_front_line: bool,
manage_aircraft: bool, manage_aircraft: bool,
front_line_budget_share: float,
) -> None: ) -> None:
if front_line_budget_share > 1.0:
raise ValueError
self.game = game self.game = game
self.is_player = for_player self.is_player = for_player
@ -58,14 +54,34 @@ class ProcurementAi:
self.manage_runways = manage_runways self.manage_runways = manage_runways
self.manage_front_line = manage_front_line self.manage_front_line = manage_front_line
self.manage_aircraft = manage_aircraft 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) 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: def spend_budget(self, budget: float) -> float:
if self.manage_runways: if self.manage_runways:
budget = self.repair_runways(budget) budget = self.repair_runways(budget)
if self.manage_front_line: 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 -= armor_budget
budget += self.reinforce_front_line(armor_budget) budget += self.reinforce_front_line(armor_budget)

View File

@ -124,14 +124,38 @@ class PresetLocations:
@dataclass(frozen=True) @dataclass(frozen=True)
class PendingOccupancy: class AircraftAllocations:
present: int present: dict[Type[FlyingType], int]
ordered: int ordered: dict[Type[FlyingType], int]
transferring: 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 @property
def total(self) -> int: 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) @dataclass(frozen=True)
@ -149,6 +173,18 @@ class GroundUnitAllocations:
combined[unit_type] += count combined[unit_type] += count
return dict(combined) 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 @cached_property
def total(self) -> int: def total(self) -> int:
return self.total_present + self.total_ordered + self.total_transferring 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 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: if self.captured:
ato = game.blue_ato ato = game.blue_ato
else: else:
ato = game.red_ato ato = game.red_ato
total = 0 transferring: defaultdict[Type[FlyingType], int] = defaultdict(int)
for package in ato.packages: for package in ato.packages:
for flight in package.flights: for flight in package.flights:
if flight.departure == flight.arrival: if flight.departure == flight.arrival:
continue continue
if flight.departure == self: if flight.departure == self:
total -= flight.count transferring[flight.unit_type] -= flight.count
elif flight.arrival == self: elif flight.arrival == self:
total += flight.count transferring[flight.unit_type] += flight.count
return total return transferring
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)
)
def unclaimed_parking(self, game: Game) -> int: def unclaimed_parking(self, game: Game) -> int:
return ( return self.total_aircraft_parking - self.allocated_aircraft(game).total
self.total_aircraft_parking - self.expected_aircraft_next_turn(game).total
)
@abstractmethod @abstractmethod
def active_runway( def active_runway(
@ -665,6 +689,16 @@ class ControlPoint(MissionTarget, ABC):
u.position.x = u.position.x + delta.x u.position.x = u.position.x + delta.x
u.position.y = u.position.y + delta.y 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( def allocated_ground_units(
self, transfers: PendingTransfers self, transfers: PendingTransfers
) -> GroundUnitAllocations: ) -> GroundUnitAllocations:

View File

@ -164,16 +164,16 @@ class QHangarStatus(QHBoxLayout):
self.setAlignment(Qt.AlignLeft) self.setAlignment(Qt.AlignLeft)
def update_label(self) -> None: 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 max_amount = self.control_point.total_aircraft_parking
components = [f"{next_turn.present} present"] components = [f"{next_turn.present} present"]
if next_turn.ordered > 0: if next_turn.total_ordered > 0:
components.append(f"{next_turn.ordered} purchased") components.append(f"{next_turn.total_ordered} purchased")
elif next_turn.ordered < 0: elif next_turn.total_ordered < 0:
components.append(f"{-next_turn.ordered} sold") components.append(f"{-next_turn.total_ordered} sold")
transferring = next_turn.transferring transferring = next_turn.total_transferring
if transferring > 0: if transferring > 0:
components.append(f"{transferring} transferring in") components.append(f"{transferring} transferring in")
if transferring < 0: if transferring < 0: