mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
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:
parent
627f18c42b
commit
56fc2986e9
@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
from collections import defaultdict
|
||||||
from typing import Dict, Iterator, List, TYPE_CHECKING, Tuple, Type
|
from typing import Dict, List, Optional, TYPE_CHECKING, Type
|
||||||
|
|
||||||
from dcs.mapping import Point
|
from dcs.mapping import Point
|
||||||
from dcs.task import Task
|
from dcs.task import Task
|
||||||
@ -12,10 +12,11 @@ from game import persistency
|
|||||||
from game.debriefing import AirLosses, Debriefing
|
from game.debriefing import AirLosses, Debriefing
|
||||||
from game.infos.information import Information
|
from game.infos.information import Information
|
||||||
from game.operation.operation import Operation
|
from game.operation.operation import Operation
|
||||||
from game.theater import ControlPoint
|
from game.theater import ControlPoint, SupplyRoute
|
||||||
from gen import AirTaskingOrder
|
from gen import AirTaskingOrder
|
||||||
from gen.ground_forces.combat_stance import CombatStance
|
from gen.ground_forces.combat_stance import CombatStance
|
||||||
from ..db import PRICES
|
from ..db import PRICES
|
||||||
|
from ..transfers import RoadTransferOrder
|
||||||
from ..unitmap import UnitMap
|
from ..unitmap import UnitMap
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -439,35 +440,37 @@ class Event:
|
|||||||
|
|
||||||
|
|
||||||
class UnitsDeliveryEvent:
|
class UnitsDeliveryEvent:
|
||||||
def __init__(self, control_point: ControlPoint) -> None:
|
def __init__(self, destination: ControlPoint) -> None:
|
||||||
self.to_cp = control_point
|
self.destination = destination
|
||||||
self.units: Dict[Type[UnitType], int] = {}
|
|
||||||
|
# Maps unit type to order quantity.
|
||||||
|
self.units: Dict[Type[UnitType], int] = defaultdict(int)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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:
|
def order(self, units: Dict[Type[UnitType], int]) -> None:
|
||||||
for k, v in units.items():
|
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:
|
def sell(self, units: Dict[Type[UnitType], int]) -> None:
|
||||||
for k, v in units.items():
|
for k, v in units.items():
|
||||||
self.units[k] = self.units.get(k, 0) - v
|
self.units[k] -= v
|
||||||
|
|
||||||
def consume_each_order(self) -> Iterator[Tuple[Type[UnitType], int]]:
|
|
||||||
while self.units:
|
|
||||||
yield self.units.popitem()
|
|
||||||
|
|
||||||
def refund_all(self, game: Game) -> None:
|
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:
|
try:
|
||||||
price = PRICES[unit_type]
|
price = PRICES[unit_type]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
logging.error(f"Could not refund {unit_type.id}, price unknown")
|
logging.error(f"Could not refund {unit_type.id}, price unknown")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logging.info(f"Refunding {count} {unit_type.id} at {self.to_cp.name}")
|
logging.info(f"Refunding {count} {unit_type.id} at {self.destination.name}")
|
||||||
game.adjust_budget(price * count, player=self.to_cp.captured)
|
game.adjust_budget(price * count, player=self.destination.captured)
|
||||||
|
|
||||||
def pending_orders(self, unit_type: Type[UnitType]) -> int:
|
def pending_orders(self, unit_type: Type[UnitType]) -> int:
|
||||||
pending_units = self.units.get(unit_type)
|
pending_units = self.units.get(unit_type)
|
||||||
@ -476,26 +479,94 @@ class UnitsDeliveryEvent:
|
|||||||
return pending_units
|
return pending_units
|
||||||
|
|
||||||
def available_next_turn(self, unit_type: Type[UnitType]) -> int:
|
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
|
return self.pending_orders(unit_type) + current_units
|
||||||
|
|
||||||
def process(self, game: Game) -> None:
|
def process(self, game: Game) -> None:
|
||||||
|
ground_unit_source = self.find_ground_unit_source(game)
|
||||||
bought_units: Dict[Type[UnitType], int] = {}
|
bought_units: Dict[Type[UnitType], int] = {}
|
||||||
|
units_needing_transfer: Dict[Type[VehicleType], int] = {}
|
||||||
sold_units: Dict[Type[UnitType], int] = {}
|
sold_units: Dict[Type[UnitType], int] = {}
|
||||||
for unit_type, count in self.units.items():
|
for unit_type, count in self.units.items():
|
||||||
coalition = "Ally" if self.to_cp.captured else "Enemy"
|
coalition = "Ally" if self.destination.captured else "Enemy"
|
||||||
aircraft = unit_type.id
|
name = unit_type.id
|
||||||
name = self.to_cp.name
|
|
||||||
|
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:
|
if count >= 0:
|
||||||
bought_units[unit_type] = count
|
# The destination dict will be set appropriately even if we have no
|
||||||
game.message(
|
# source, and we'll refund later, buto nly emit the message when we're
|
||||||
f"{coalition} reinforcements: {aircraft} x {count} at {name}"
|
# 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:
|
else:
|
||||||
sold_units[unit_type] = -count
|
sold_units[unit_type] = -count
|
||||||
game.message(f"{coalition} sold: {aircraft} x {-count} at {name}")
|
game.message(f"{coalition} sold: {name} x {-count} at {source}")
|
||||||
self.to_cp.base.commision_units(bought_units)
|
|
||||||
self.to_cp.base.commit_losses(sold_units)
|
self.units = defaultdict(int)
|
||||||
self.units = {}
|
self.destination.base.commision_units(bought_units)
|
||||||
bought_units = {}
|
self.destination.base.commit_losses(sold_units)
|
||||||
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
|
||||||
|
|||||||
@ -34,7 +34,7 @@ from .settings import Settings
|
|||||||
from .theater import ConflictTheater, ControlPoint, TheaterGroundObject
|
from .theater import ConflictTheater, ControlPoint, TheaterGroundObject
|
||||||
from game.theater.theatergroundobject import MissileSiteGroundObject
|
from game.theater.theatergroundobject import MissileSiteGroundObject
|
||||||
from .threatzones import ThreatZones
|
from .threatzones import ThreatZones
|
||||||
from .transfers import PendingTransfers
|
from .transfers import PendingTransfers, RoadTransferOrder
|
||||||
from .unitmap import UnitMap
|
from .unitmap import UnitMap
|
||||||
from .weather import Conditions, TimeOfDay
|
from .weather import Conditions, TimeOfDay
|
||||||
|
|
||||||
@ -272,11 +272,13 @@ class Game:
|
|||||||
)
|
)
|
||||||
self.turn += 1
|
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:
|
for control_point in self.theater.controlpoints:
|
||||||
control_point.process_turn(self)
|
control_point.process_turn(self)
|
||||||
|
|
||||||
self.transfers.perform_transfers()
|
|
||||||
|
|
||||||
self.process_enemy_income()
|
self.process_enemy_income()
|
||||||
|
|
||||||
self.process_player_income()
|
self.process_player_income()
|
||||||
|
|||||||
@ -145,6 +145,8 @@ class ProcurementAi:
|
|||||||
if not self.faction.frontline_units and not self.faction.artillery_units:
|
if not self.faction.frontline_units and not self.faction.artillery_units:
|
||||||
return budget
|
return budget
|
||||||
|
|
||||||
|
# TODO: Attempt to transfer from reserves.
|
||||||
|
|
||||||
while budget > 0:
|
while budget > 0:
|
||||||
candidates = self.front_line_candidates()
|
candidates = self.front_line_candidates()
|
||||||
if not candidates:
|
if not candidates:
|
||||||
@ -239,7 +241,16 @@ class ProcurementAi:
|
|||||||
# Prefer to buy front line units at active front lines that are not
|
# Prefer to buy front line units at active front lines that are not
|
||||||
# already overloaded.
|
# already overloaded.
|
||||||
for cp in self.owned_points:
|
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.
|
# Control point is already sufficiently defended.
|
||||||
continue
|
continue
|
||||||
for connected in cp.connected_points:
|
for connected in cp.connected_points:
|
||||||
@ -258,12 +269,19 @@ class ProcurementAi:
|
|||||||
# Also, do not bother buying units at bases that will never connect
|
# Also, do not bother buying units at bases that will never connect
|
||||||
# to a front line.
|
# to a front line.
|
||||||
for cp in self.owned_points:
|
for cp in self.owned_points:
|
||||||
if not cp.can_deploy_ground_units:
|
if not cp.can_recruit_ground_units(self.game):
|
||||||
continue
|
continue
|
||||||
if cp.expected_ground_units_next_turn.total >= 10:
|
if self.total_ground_units_allocated_to(cp) >= 10:
|
||||||
continue
|
continue
|
||||||
if cp.is_global:
|
if cp.is_global:
|
||||||
continue
|
continue
|
||||||
candidates.append(cp)
|
candidates.append(cp)
|
||||||
|
|
||||||
return candidates
|
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
|
||||||
|
|||||||
@ -313,13 +313,39 @@ class ControlPoint(MissionTarget, ABC):
|
|||||||
connected.extend(cp.transitive_connected_friendly_points(seen))
|
connected.extend(cp.transitive_connected_friendly_points(seen))
|
||||||
return connected
|
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:
|
def can_recruit_ground_units(self, game: Game) -> bool:
|
||||||
"""Returns True if this control point is capable of recruiting ground units."""
|
"""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:
|
if not game.settings.enable_new_ground_unit_recruitment:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
for tgo in self.connected_objectives:
|
return self.has_factory
|
||||||
if isinstance(tgo, FactoryGroundObject) and not tgo.is_dead:
|
|
||||||
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user