diff --git a/game/factions/faction.py b/game/factions/faction.py index 9722c11b..d3b14134 100644 --- a/game/factions/faction.py +++ b/game/factions/faction.py @@ -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) diff --git a/game/game.py b/game/game.py index f5040dd0..61b9b2b2 100644 --- a/game/game.py +++ b/game/game.py @@ -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: diff --git a/game/procurement.py b/game/procurement.py new file mode 100644 index 00000000..a4548d75 --- /dev/null +++ b/game/procurement.py @@ -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 diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index e2083aa8..3885df11 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -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