Factor AI purchases out of Game.

For now this is mostly behavior preserving. I slightly improved the
ability to buy units when multiple front lines exist by removing full
bases as candidates, but it should be a minor change at best. A larger
improvement will come later.

This is also written such that it will work for the player as well. The
procurer currently runs for the player but with all the options off, so
it does nothing. The next patch allows adds options for the player to
use auto-procurement.
This commit is contained in:
Dan Albert 2020-12-05 20:42:02 -08:00
parent bac47dad83
commit 1adee0af17
4 changed files with 262 additions and 109 deletions

View File

@ -31,25 +31,25 @@ class Faction:
description: str = field(default="")
# Available aircraft
aircrafts: List[UnitType] = field(default_factory=list)
aircrafts: List[Type[FlyingType]] = field(default_factory=list)
# Available awacs aircraft
awacs: List[UnitType] = field(default_factory=list)
awacs: List[Type[FlyingType]] = field(default_factory=list)
# Available tanker aircraft
tankers: List[UnitType] = field(default_factory=list)
tankers: List[Type[FlyingType]] = field(default_factory=list)
# Available frontline units
frontline_units: List[VehicleType] = field(default_factory=list)
frontline_units: List[Type[VehicleType]] = field(default_factory=list)
# Available artillery units
artillery_units: List[VehicleType] = field(default_factory=list)
artillery_units: List[Type[VehicleType]] = field(default_factory=list)
# Infantry units used
infantry_units: List[VehicleType] = field(default_factory=list)
infantry_units: List[Type[VehicleType]] = field(default_factory=list)
# Logistics units used
logistics_units: List[VehicleType] = field(default_factory=list)
logistics_units: List[Type[VehicleType]] = field(default_factory=list)
# Possible SAMS site generators for this faction
air_defenses: List[str] = field(default_factory=list)
@ -64,10 +64,10 @@ class Faction:
requirements: Dict[str, str] = field(default_factory=dict)
# possible aircraft carrier units
aircraft_carrier: List[UnitType] = field(default_factory=list)
aircraft_carrier: List[Type[UnitType]] = field(default_factory=list)
# possible helicopter carrier units
helicopter_carrier: List[UnitType] = field(default_factory=list)
helicopter_carrier: List[Type[UnitType]] = field(default_factory=list)
# Possible carrier names
carrier_names: List[str] = field(default_factory=list)
@ -79,10 +79,10 @@ class Faction:
navy_generators: List[str] = field(default_factory=list)
# Available destroyers
destroyers: List[str] = field(default_factory=list)
destroyers: List[Type[ShipType]] = field(default_factory=list)
# Available cruisers
cruisers: List[str] = field(default_factory=list)
cruisers: List[Type[ShipType]] = field(default_factory=list)
# How many navy group should we try to generate per CP on startup for this faction
navy_group_count: int = field(default=1)
@ -94,7 +94,7 @@ class Faction:
has_jtac: bool = field(default=False)
# Unit to use as JTAC for this faction
jtac_unit: Optional[FlyingType] = field(default=None)
jtac_unit: Optional[Type[FlyingType]] = field(default=None)
# doctrine
doctrine: Doctrine = field(default=MODERN_DOCTRINE)
@ -103,7 +103,8 @@ class Faction:
building_set: List[str] = field(default_factory=list)
# List of default livery overrides
liveries_overrides: Dict[UnitType, List[str]] = field(default_factory=dict)
liveries_overrides: Dict[Type[UnitType], List[str]] = field(
default_factory=dict)
#: Set to True if the faction should force the "Unrestricted satnav" option
#: for the mission. This option enables GPS for capable aircraft regardless
@ -210,13 +211,14 @@ class Faction:
return faction
@property
def units(self) -> List[UnitType]:
def units(self) -> List[Type[UnitType]]:
return (self.infantry_units + self.aircrafts + self.awacs +
self.artillery_units + self.frontline_units +
self.tankers + self.logistics_units)
def unit_loader(unit: str, class_repository: List[Any]) -> Optional[UnitType]:
def unit_loader(
unit: str, class_repository: List[Any]) -> Optional[Type[UnitType]]:
"""
Find unit by name
:param unit: Unit name as string
@ -239,13 +241,13 @@ def unit_loader(unit: str, class_repository: List[Any]) -> Optional[UnitType]:
return None
def load_aircraft(name: str) -> Optional[FlyingType]:
def load_aircraft(name: str) -> Optional[Type[FlyingType]]:
return cast(Optional[FlyingType], unit_loader(
name, [dcs.planes, dcs.helicopters, MODDED_AIRPLANES]
))
def load_all_aircraft(data) -> List[FlyingType]:
def load_all_aircraft(data) -> List[Type[FlyingType]]:
items = []
for name in data:
item = load_aircraft(name)
@ -254,13 +256,13 @@ def load_all_aircraft(data) -> List[FlyingType]:
return items
def load_vehicle(name: str) -> Optional[VehicleType]:
def load_vehicle(name: str) -> Optional[Type[VehicleType]]:
return cast(Optional[FlyingType], unit_loader(
name, [Infantry, Unarmed, Armor, AirDefence, Artillery, MODDED_VEHICLES]
))
def load_all_vehicles(data) -> List[VehicleType]:
def load_all_vehicles(data) -> List[Type[VehicleType]]:
items = []
for name in data:
item = load_vehicle(name)
@ -269,11 +271,11 @@ def load_all_vehicles(data) -> List[VehicleType]:
return items
def load_ship(name: str) -> Optional[ShipType]:
def load_ship(name: str) -> Optional[Type[ShipType]]:
return cast(Optional[FlyingType], unit_loader(name, [dcs.ships]))
def load_all_ships(data) -> List[ShipType]:
def load_all_ships(data) -> List[Type[ShipType]]:
items = []
for name in data:
item = load_ship(name)

View File

@ -25,8 +25,9 @@ from .event.event import Event, UnitsDeliveryEvent
from .event.frontlineattack import FrontlineAttackEvent
from .factions.faction import Faction
from .infos.information import Information
from .procurement import ProcurementAi
from .settings import Settings
from .theater import Airfield, ConflictTheater, ControlPoint, OffMapSpawn
from .theater import ConflictTheater, ControlPoint
from .unitmap import UnitMap
from .weather import Conditions, TimeOfDay
@ -90,6 +91,9 @@ class Game:
self.__destroyed_units: List[str] = []
self.savepath = ""
self.budget = PLAYER_BUDGET_INITIAL
# The enemy currently doesn't buy anything on turn zero; they get
# pre-populated airbases that are generated by the new game generator.
self.enemy_budget = 0
self.current_unit_id = 0
self.current_group_id = 0
@ -158,9 +162,21 @@ class Game:
reward += REWARDS[g.category]
return int(reward * self.settings.player_income_multiplier)
def _budget_player(self):
def process_player_income(self):
self.budget += self.budget_reward_amount
def process_enemy_income(self):
if not hasattr(self, "enemy_budget"):
self.enemy_budget = 0
production = 0.0
for enemy_point in self.theater.enemy_points():
for g in enemy_point.ground_objects:
if g.category in REWARDS.keys() and not g.is_dead:
production = production + REWARDS[g.category]
self.enemy_budget += production * self.settings.enemy_income_multiplier
def units_delivery_event(self, to_cp: ControlPoint) -> UnitsDeliveryEvent:
event = UnitsDeliveryEvent(attacker_name=self.player_name,
defender_name=self.player_name,
@ -212,8 +228,25 @@ class Game:
for control_point in self.theater.controlpoints:
control_point.process_turn()
self._enemy_reinforcement()
self._budget_player()
self.process_enemy_income()
self.enemy_budget = ProcurementAi(
self,
for_player=False,
faction=self.enemy_faction,
manage_runways=True,
manage_front_line=True,
manage_aircraft=True
).spend_budget(self.enemy_budget)
self.process_player_income()
self.budget = ProcurementAi(
self,
for_player=True,
faction=self.player_faction,
manage_runways=False,
manage_front_line=False,
manage_aircraft=False
).spend_budget(self.budget)
if not no_action and self.turn > 1:
for cp in self.theater.player_points():
@ -255,90 +288,8 @@ class Game:
gplanner.plan_groundwar()
self.ground_planners[cp.id] = gplanner
def _enemy_reinforcement(self):
"""
Compute and commision reinforcement for enemy bases
"""
MAX_ARMOR = 30 * self.settings.multiplier
MAX_AIRCRAFT = 25 * self.settings.multiplier
production = 0.0
for enemy_point in self.theater.enemy_points():
for g in enemy_point.ground_objects:
if g.category in REWARDS.keys() and not g.is_dead:
production = production + REWARDS[g.category]
budget = production * self.settings.enemy_income_multiplier
for control_point in self.theater.enemy_points():
if budget < db.RUNWAY_REPAIR_COST:
break
if control_point.runway_can_be_repaired:
control_point.begin_runway_repair()
budget -= db.RUNWAY_REPAIR_COST
self.informations.append(Information(
f"OPFOR has begun repairing the runway at {control_point}"))
budget_for_armored_units = budget / 2
budget_for_aircraft = budget / 2
potential_cp_armor = []
for cp in self.theater.enemy_points():
for cpe in cp.connected_points:
if cpe.captured and cp.base.total_armor < MAX_ARMOR:
potential_cp_armor.append(cp)
if len(potential_cp_armor) == 0:
potential_cp_armor = self.theater.enemy_points()
potential_cp_armor = [p for p in potential_cp_armor if
not isinstance(p, OffMapSpawn)]
i = 0
potential_units = db.FACTIONS[self.enemy_name].frontline_units
print("Enemy Recruiting")
print(potential_cp_armor)
print(budget_for_armored_units)
print(potential_units)
if len(potential_units) > 0 and len(potential_cp_armor) > 0:
while budget_for_armored_units > 0:
i = i + 1
if i > 50 or budget_for_armored_units <= 0:
break
target_cp = random.choice(potential_cp_armor)
if target_cp.base.total_armor >= MAX_ARMOR:
continue
unit = random.choice(potential_units)
price = db.PRICES[unit] * 2
budget_for_armored_units -= price * 2
target_cp.base.armor[unit] = target_cp.base.armor.get(unit, 0) + 2
info = Information("Enemy Reinforcement", unit.id + " x 2 at " + target_cp.name, self.turn)
print(str(info))
self.informations.append(info)
if budget_for_armored_units > 0:
budget_for_aircraft += budget_for_armored_units
potential_units = [u for u in db.FACTIONS[self.enemy_name].aircrafts
if u in db.UNIT_BY_TASK[CAS] or u in db.UNIT_BY_TASK[CAP]]
if len(potential_units) > 0 and len(potential_cp_armor) > 0:
while budget_for_aircraft > 0:
i = i + 1
if i > 50 or budget_for_aircraft <= 0:
break
target_cp = random.choice(potential_cp_armor)
if target_cp.base.total_aircraft >= MAX_AIRCRAFT:
continue
unit = random.choice(potential_units)
price = db.PRICES[unit] * 2
budget_for_aircraft -= price * 2
target_cp.base.aircraft[unit] = target_cp.base.aircraft.get(unit, 0) + 2
info = Information("Enemy Reinforcement", unit.id + " x 2 at " + target_cp.name, self.turn)
print(str(info))
self.informations.append(info)
def message(self, text: str) -> None:
self.informations.append(Information(text, turn=self.turn))
@property
def current_turn_time_of_day(self) -> TimeOfDay:

179
game/procurement.py Normal file
View File

@ -0,0 +1,179 @@
from __future__ import annotations
import logging
import math
import random
from typing import List, TYPE_CHECKING, Type
from dcs.task import CAP, CAS
from dcs.unittype import FlyingType, UnitType
from game import db
from game.factions.faction import Faction
from game.theater import ControlPoint
if TYPE_CHECKING:
from game import Game
class AircraftProcurer:
def __init__(self, faction: Faction) -> None:
self.faction = faction
class ProcurementAi:
def __init__(self, game: Game, for_player: bool, faction: Faction,
manage_runways: bool, manage_front_line: bool,
manage_aircraft: bool) -> None:
self.game = game
self.is_player = for_player
self.faction = faction
self.manage_runways = manage_runways
self.manage_front_line = manage_front_line
self.manage_aircraft = manage_aircraft
def spend_budget(self, budget: int) -> int:
if self.manage_runways:
budget = self.repair_runways(budget)
if self.manage_front_line:
armor_budget = math.ceil(budget / 2)
budget -= armor_budget
budget += self.reinforce_front_line(armor_budget)
if self.manage_aircraft:
budget = self.purchase_aircraft(budget)
return budget
def repair_runways(self, budget: int) -> int:
for control_point in self.owned_points:
if budget < db.RUNWAY_REPAIR_COST:
break
if control_point.runway_can_be_repaired:
control_point.begin_runway_repair()
budget -= db.RUNWAY_REPAIR_COST
if self.is_player:
self.game.message(
"OPFOR has begun repairing the runway at "
f"{control_point}"
)
else:
self.game.message(
"We have begun repairing the runway at "
f"{control_point}"
)
return budget
def reinforce_front_line(self, budget: int) -> int:
if not self.faction.frontline_units:
return budget
armor_limit = int(30 * self.game.settings.multiplier)
candidates = self.front_line_candidates(armor_limit)
if not candidates:
return budget
# TODO: No need to limit?
for _ in range(50):
if budget <= 0:
break
cp = random.choice(candidates)
unit = random.choice(self.faction.frontline_units)
price = db.PRICES[unit] * 2
# TODO: Don't allow negative budget.
# Build a list of only affordable units and choose from those.
budget -= price * 2
cp.base.armor[unit] = cp.base.armor.get(unit, 0) + 2
self.reinforcement_message(unit, cp)
if cp.base.total_armor >= armor_limit:
candidates.remove(cp)
if not candidates:
break
return budget
def purchase_aircraft(self, budget: int) -> int:
aircraft_limit = int(25 * self.game.settings.multiplier)
candidates = self.airbase_candidates(aircraft_limit)
if not candidates:
return budget
unit_pool = [u for u in self.faction.aircrafts
if u in db.UNIT_BY_TASK[CAS] or u in db.UNIT_BY_TASK[CAP]]
if not unit_pool:
return budget
# TODO: No need to limit?
for _ in range(50):
if budget <= 0:
break
cp = random.choice(candidates)
unit = random.choice(unit_pool)
price = db.PRICES[unit] * 2
# TODO: Don't allow negative budget.
# Build a list of only affordable units and choose from those.
budget -= price * 2
cp.base.aircraft[unit] = cp.base.aircraft.get(unit, 0) + 2
self.reinforcement_message(unit, cp)
if cp.base.total_aircraft >= aircraft_limit:
candidates.remove(cp)
if not candidates:
break
return budget
def reinforcement_message(self, unit_type: Type[UnitType],
control_point: ControlPoint) -> None:
description = f"{unit_type.id} x 2 at {control_point.name}"
if self.is_player:
self.game.message(f"Our reinforcements: {description}")
else:
self.game.message(f"OPFOR reinforcements: {description}")
@property
def owned_points(self) -> List[ControlPoint]:
if self.is_player:
return self.game.theater.player_points()
else:
return self.game.theater.enemy_points()
def airbase_candidates(self, unit_limit: int) -> List[ControlPoint]:
candidates = []
# Prefer to buy front line units at active front lines that are not
# already overloaded.
# TODO: Buy aircraft where they are needed, not at front lines.
for cp in self.owned_points:
if cp.base.total_aircraft >= unit_limit:
continue
for connected in cp.connected_points:
if not connected.is_friendly(to_player=self.is_player):
candidates.append(cp)
if not candidates:
# Otherwise buy them anywhere valid.
candidates = [p for p in self.owned_points
if p.can_deploy_ground_units]
return candidates
def front_line_candidates(self, unit_limit: int) -> List[ControlPoint]:
candidates = []
# Prefer to buy front line units at active front lines that are not
# already overloaded.
for cp in self.owned_points:
if cp.base.total_armor >= unit_limit:
continue
for connected in cp.connected_points:
if not connected.is_friendly(to_player=self.is_player):
candidates.append(cp)
if not candidates:
# Otherwise buy them anywhere valid.
candidates = [p for p in self.owned_points
if p.can_deploy_ground_units]
return candidates

View File

@ -280,6 +280,11 @@ class ControlPoint(MissionTarget, ABC):
"""
return False
@property
@abstractmethod
def can_deploy_ground_units(self) -> bool:
...
@property
@abstractmethod
def total_aircraft_parking(self):
@ -498,6 +503,10 @@ class Airfield(ControlPoint):
def parking_slots(self) -> Iterator[ParkingSlot]:
yield from self.airport.parking_slots
@property
def can_deploy_ground_units(self) -> bool:
return True
class NavalControlPoint(ControlPoint, ABC):
@ -553,6 +562,10 @@ class NavalControlPoint(ControlPoint, ABC):
def moveable(self) -> bool:
return True
@property
def can_deploy_ground_units(self) -> bool:
return False
class Carrier(NavalControlPoint):
@ -637,6 +650,10 @@ class OffMapSpawn(ControlPoint):
def runway_status(self) -> RunwayStatus:
return RunwayStatus()
@property
def can_deploy_ground_units(self) -> bool:
return False
class Fob(ControlPoint):
@ -684,3 +701,7 @@ class Fob(ControlPoint):
@property
def heading(self) -> int:
return 0
@property
def can_deploy_ground_units(self) -> bool:
return True