Automate transfers from factories.

The purchase system will seek out a source for its units when the
purchase is completed. If no source is available the order will be
refunded. Orders with no source available are prevented, so this only
happens when the source was cut off from the destination during the
turn.

There's still some funkiness going on with the first turn (but possibly
only when the first turn includes a cheat to capture) where the AI buys
a ton of units somewhere other than the front line. First turn behavior
should probably be different anyway, with the first turn allowing
purchases anywhere to avoid empty front lines while troops reinforce if
the front line isn't a factory.

https://github.com/Khopa/dcs_liberation/issues/986
This commit is contained in:
Dan Albert 2021-04-18 23:11:26 -07:00
parent 627f18c42b
commit 56fc2986e9
4 changed files with 155 additions and 38 deletions

View File

@ -1,8 +1,8 @@
from __future__ import annotations
import logging
import math
from typing import Dict, Iterator, List, TYPE_CHECKING, Tuple, Type
from collections import defaultdict
from typing import Dict, List, Optional, TYPE_CHECKING, Type
from dcs.mapping import Point
from dcs.task import Task
@ -12,10 +12,11 @@ from game import persistency
from game.debriefing import AirLosses, Debriefing
from game.infos.information import Information
from game.operation.operation import Operation
from game.theater import ControlPoint
from game.theater import ControlPoint, SupplyRoute
from gen import AirTaskingOrder
from gen.ground_forces.combat_stance import CombatStance
from ..db import PRICES
from ..transfers import RoadTransferOrder
from ..unitmap import UnitMap
if TYPE_CHECKING:
@ -439,35 +440,37 @@ class Event:
class UnitsDeliveryEvent:
def __init__(self, control_point: ControlPoint) -> None:
self.to_cp = control_point
self.units: Dict[Type[UnitType], int] = {}
def __init__(self, destination: ControlPoint) -> None:
self.destination = destination
# Maps unit type to order quantity.
self.units: Dict[Type[UnitType], int] = defaultdict(int)
def __str__(self) -> str:
return "Pending delivery to {}".format(self.to_cp)
return f"Pending delivery to {self.destination}"
def order(self, units: Dict[Type[UnitType], int]) -> None:
for k, v in units.items():
self.units[k] = self.units.get(k, 0) + v
self.units[k] += v
def sell(self, units: Dict[Type[UnitType], int]) -> None:
for k, v in units.items():
self.units[k] = self.units.get(k, 0) - v
def consume_each_order(self) -> Iterator[Tuple[Type[UnitType], int]]:
while self.units:
yield self.units.popitem()
self.units[k] -= v
def refund_all(self, game: Game) -> None:
for unit_type, count in self.consume_each_order():
self.refund(game, self.units)
self.units = defaultdict(int)
def refund(self, game: Game, units: Dict[Type[UnitType], int]) -> None:
for unit_type, count in units.items():
try:
price = PRICES[unit_type]
except KeyError:
logging.error(f"Could not refund {unit_type.id}, price unknown")
continue
logging.info(f"Refunding {count} {unit_type.id} at {self.to_cp.name}")
game.adjust_budget(price * count, player=self.to_cp.captured)
logging.info(f"Refunding {count} {unit_type.id} at {self.destination.name}")
game.adjust_budget(price * count, player=self.destination.captured)
def pending_orders(self, unit_type: Type[UnitType]) -> int:
pending_units = self.units.get(unit_type)
@ -476,26 +479,94 @@ class UnitsDeliveryEvent:
return pending_units
def available_next_turn(self, unit_type: Type[UnitType]) -> int:
current_units = self.to_cp.base.total_units_of_type(unit_type)
current_units = self.destination.base.total_units_of_type(unit_type)
return self.pending_orders(unit_type) + current_units
def process(self, game: Game) -> None:
ground_unit_source = self.find_ground_unit_source(game)
bought_units: Dict[Type[UnitType], int] = {}
units_needing_transfer: Dict[Type[VehicleType], int] = {}
sold_units: Dict[Type[UnitType], int] = {}
for unit_type, count in self.units.items():
coalition = "Ally" if self.to_cp.captured else "Enemy"
aircraft = unit_type.id
name = self.to_cp.name
coalition = "Ally" if self.destination.captured else "Enemy"
name = unit_type.id
if (
issubclass(unit_type, VehicleType)
and self.destination != ground_unit_source
):
source = ground_unit_source
d = units_needing_transfer
ground = True
else:
source = self.destination
d = bought_units
ground = False
if count >= 0:
bought_units[unit_type] = count
game.message(
f"{coalition} reinforcements: {aircraft} x {count} at {name}"
)
# The destination dict will be set appropriately even if we have no
# source, and we'll refund later, buto nly emit the message when we're
# actually completing the purchase.
d[unit_type] = count
if ground or ground_unit_source is not None:
game.message(
f"{coalition} reinforcements: {name} x {count} at {source}"
)
else:
sold_units[unit_type] = -count
game.message(f"{coalition} sold: {aircraft} x {-count} at {name}")
self.to_cp.base.commision_units(bought_units)
self.to_cp.base.commit_losses(sold_units)
self.units = {}
bought_units = {}
sold_units = {}
game.message(f"{coalition} sold: {name} x {-count} at {source}")
self.units = defaultdict(int)
self.destination.base.commision_units(bought_units)
self.destination.base.commit_losses(sold_units)
if ground_unit_source is None:
game.message(
f"{self.destination.name} lost its source for ground unit "
"reinforcements. Refunding purchase price."
)
self.refund(game, units_needing_transfer)
return
if units_needing_transfer:
ground_unit_source.base.commision_units(units_needing_transfer)
game.transfers.new_transfer(
RoadTransferOrder(
ground_unit_source,
self.destination,
self.destination.captured,
units_needing_transfer,
)
)
def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]:
# Fast path if the destination is a valid source.
if self.destination.can_recruit_ground_units(game):
return self.destination
supply_route = SupplyRoute.for_control_point(self.destination)
if supply_route is None:
return None
sources = []
for control_point in supply_route:
if control_point.can_recruit_ground_units(game):
sources.append(control_point)
if not sources:
return None
# Fast path to skip the distance calculation if we have only one option.
if len(sources) == 1:
return sources[0]
closest = sources[0]
distance = len(supply_route.shortest_path_between(self.destination, closest))
for source in sources:
new_distance = len(
supply_route.shortest_path_between(self.destination, source)
)
if new_distance < distance:
closest = source
distance = new_distance
return closest

View File

@ -34,7 +34,7 @@ from .settings import Settings
from .theater import ConflictTheater, ControlPoint, TheaterGroundObject
from game.theater.theatergroundobject import MissileSiteGroundObject
from .threatzones import ThreatZones
from .transfers import PendingTransfers
from .transfers import PendingTransfers, RoadTransferOrder
from .unitmap import UnitMap
from .weather import Conditions, TimeOfDay
@ -272,11 +272,13 @@ class Game:
)
self.turn += 1
# Must happen *before* unit deliveries are handled, or else new units will spawn
# one hop ahead. ControlPoint.process_turn handles unit deliveries.
self.transfers.perform_transfers()
for control_point in self.theater.controlpoints:
control_point.process_turn(self)
self.transfers.perform_transfers()
self.process_enemy_income()
self.process_player_income()

View File

@ -145,6 +145,8 @@ class ProcurementAi:
if not self.faction.frontline_units and not self.faction.artillery_units:
return budget
# TODO: Attempt to transfer from reserves.
while budget > 0:
candidates = self.front_line_candidates()
if not candidates:
@ -239,7 +241,16 @@ class ProcurementAi:
# Prefer to buy front line units at active front lines that are not
# already overloaded.
for cp in self.owned_points:
if cp.expected_ground_units_next_turn.total >= 30:
if not cp.has_ground_unit_source(self.game):
continue
# Buy to a higher limit when using the new recruitment mechanic since it
# will take longer to reinforce losses.
if self.game.settings.enable_new_ground_unit_recruitment:
limit = 50
else:
limit = 30
if self.total_ground_units_allocated_to(cp) >= limit:
# Control point is already sufficiently defended.
continue
for connected in cp.connected_points:
@ -258,12 +269,19 @@ class ProcurementAi:
# Also, do not bother buying units at bases that will never connect
# to a front line.
for cp in self.owned_points:
if not cp.can_deploy_ground_units:
if not cp.can_recruit_ground_units(self.game):
continue
if cp.expected_ground_units_next_turn.total >= 10:
if self.total_ground_units_allocated_to(cp) >= 10:
continue
if cp.is_global:
continue
candidates.append(cp)
return candidates
def total_ground_units_allocated_to(self, control_point: ControlPoint) -> int:
total = control_point.expected_ground_units_next_turn.total
for transfer in self.game.transfers:
if transfer.destination == control_point:
total += sum(transfer.units.values())
return total

View File

@ -313,13 +313,39 @@ class ControlPoint(MissionTarget, ABC):
connected.extend(cp.transitive_connected_friendly_points(seen))
return connected
@property
def has_factory(self) -> bool:
for tgo in self.connected_objectives:
if isinstance(tgo, FactoryGroundObject) and not tgo.is_dead:
return True
return False
def can_recruit_ground_units(self, game: Game) -> bool:
"""Returns True if this control point is capable of recruiting ground units."""
if not self.can_deploy_ground_units:
return False
if not game.settings.enable_new_ground_unit_recruitment:
return True
for tgo in self.connected_objectives:
if isinstance(tgo, FactoryGroundObject) and not tgo.is_dead:
return self.has_factory
def has_ground_unit_source(self, game: Game) -> bool:
"""Returns True if this control point has access to ground reinforcements."""
if not self.can_deploy_ground_units:
return False
if not game.settings.enable_new_ground_unit_recruitment:
return True
from game.theater.supplyroutes import SupplyRoute
supply_route = SupplyRoute.for_control_point(self)
if supply_route is None:
return False
for cp in supply_route:
if cp.can_recruit_ground_units(game):
return True
return False