Add AI planning for airlifts.

Downside to the current implementation is that whether or not transports
that were purchased last turn will be available for airlift this turn is
arbitrary. This is because transfers are created at the same time as
units are delivered, and units are delivered in an arbitrary order per
CP. If the helicopters are delivered before the ground units they'll
have access to the transports, otherwise they'll be refunded. This will
be fixed later when I rework the transfer requests to not require
immediate fulfillment.

https://github.com/Khopa/dcs_liberation/issues/825
This commit is contained in:
Dan Albert
2021-04-23 01:00:33 -07:00
parent 26cd2d3fef
commit c258409a8d
9 changed files with 244 additions and 164 deletions

View File

@@ -154,6 +154,11 @@ class Game:
# Regenerate any state that was not persisted.
self.on_load()
def ato_for(self, player: bool) -> AirTaskingOrder:
if player:
return self.blue_ato
return self.red_ato
def generate_conditions(self) -> Conditions:
return Conditions.generate(
self.theater, self.current_day, self.current_turn_time_of_day, self.settings
@@ -257,6 +262,10 @@ class Game:
self.compute_conflicts_position()
self.compute_threat_zones()
def reset_ato(self) -> None:
self.blue_ato.clear()
self.red_ato.clear()
def pass_turn(self, no_action: bool = False) -> None:
logging.info("Pass turn")
self.informations.append(
@@ -268,11 +277,13 @@ class Game:
# one hop ahead. ControlPoint.process_turn handles unit deliveries.
self.transfers.perform_transfers()
# Needs to happen *before* planning transfers so we don't cancel the
self.reset_ato()
for control_point in self.theater.controlpoints:
control_point.process_turn(self)
self.process_enemy_income()
self.process_player_income()
if not no_action and self.turn > 1:
@@ -325,8 +336,6 @@ class Game:
self.compute_conflicts_position()
self.compute_threat_zones()
self.ground_planners = {}
self.blue_ato.clear()
self.red_ato.clear()
blue_planner = CoalitionMissionPlanner(self, is_player=True)
blue_planner.plan_missions()

View File

@@ -344,10 +344,8 @@ class ControlPoint(MissionTarget, ABC):
if not game.settings.enable_new_ground_unit_recruitment:
return True
from game.theater.supplyroutes import SupplyRoute
for cp in SupplyRoute.for_control_point(self):
if cp.can_recruit_ground_units(game):
for cp in game.theater.controlpoints:
if cp.is_friendly(self.captured) and cp.can_recruit_ground_units(game):
return True
return False

View File

@@ -6,15 +6,19 @@ from dataclasses import dataclass, field
from functools import singledispatchmethod
from typing import Dict, Iterator, List, Optional, TYPE_CHECKING, Type
from dcs.unittype import VehicleType
from dcs.unittype import FlyingType, VehicleType
if TYPE_CHECKING:
from game import Game
from gen.ato import Package
from gen.flights.flightplan import FlightPlanBuilder
from game.theater import ControlPoint, MissionTarget
from game.theater.supplyroutes import SupplyRoute
from gen.naming import namegen
from gen.flights.flight import Flight, FlightType
if TYPE_CHECKING:
from game import Game
from game.inventory import ControlPointAircraftInventory
# TODO: Remove base classes.
# Eventually we'll want multi-mode transfers (convoy from factory to port, onto a ship,
@@ -95,6 +99,82 @@ class AirliftOrder(TransferOrder):
raise KeyError
class AirliftPlanner:
def __init__(
self,
game: Game,
pickup: ControlPoint,
drop_off: ControlPoint,
units: Dict[Type[VehicleType], int],
) -> None:
self.game = game
self.pickup = pickup
self.drop_off = drop_off
self.units = units
self.for_player = drop_off.captured
self.package = Package(target=drop_off, auto_asap=True)
def create_package_for_airlift(self) -> Dict[Type[VehicleType], int]:
for cp in self.game.theater.player_points():
inventory = self.game.aircraft_inventory.for_control_point(cp)
for unit_type, available in inventory.all_aircraft:
if unit_type.helicopter:
while available and self.needed_capacity:
flight_size = self.create_airlift_flight(unit_type, inventory)
available -= flight_size
self.game.ato_for(self.for_player).add_package(self.package)
return self.units
def take_units(self, count: int) -> Dict[Type[VehicleType], int]:
taken = {}
for unit_type, remaining in self.units.items():
take = min(remaining, count)
count -= take
self.units[unit_type] -= take
taken[unit_type] = take
if not count:
break
return taken
@property
def needed_capacity(self) -> int:
return sum(c for c in self.units.values())
def create_airlift_flight(
self, unit_type: Type[FlyingType], inventory: ControlPointAircraftInventory
) -> int:
available = inventory.available(unit_type)
# 4 is the max flight size in DCS.
flight_size = min(self.needed_capacity, available, 4)
flight = Flight(
self.package,
self.game.player_country,
unit_type,
flight_size,
FlightType.TRANSPORT,
self.game.settings.default_start_type,
departure=inventory.control_point,
arrival=inventory.control_point,
divert=None,
)
transfer = AirliftOrder(
player=True,
origin=self.pickup,
destination=self.drop_off,
units=self.take_units(flight_size),
flight=flight,
)
flight.cargo = transfer
self.package.add_flight(flight)
planner = FlightPlanBuilder(self.game, self.package, self.for_player)
planner.populate_flight_plan(flight)
self.game.aircraft_inventory.claim_for_flight(flight)
self.game.transfers.new_transfer(transfer)
return flight_size
class Convoy(MissionTarget):
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
super().__init__(namegen.next_convoy_name(), origin.position)

View File

@@ -2,18 +2,28 @@ from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, Optional, TYPE_CHECKING, Type
from dcs.unittype import UnitType, VehicleType
from game.theater import ControlPoint, SupplyRoute
from gen.ato import Package
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import Flight
from .db import PRICES
from .transfers import RoadTransferOrder
from .transfers import AirliftOrder, AirliftPlanner, RoadTransferOrder
if TYPE_CHECKING:
from .game import Game
@dataclass(frozen=True)
class GroundUnitSource:
control_point: ControlPoint
requires_airlift: bool
class PendingUnitDeliveries:
def __init__(self, destination: ControlPoint) -> None:
self.destination = destination
@@ -59,6 +69,14 @@ class PendingUnitDeliveries:
def process(self, game: Game) -> None:
ground_unit_source = self.find_ground_unit_source(game)
if ground_unit_source is None:
game.message(
f"{self.destination.name} lost its source for ground unit "
"reinforcements. Refunding purchase price."
)
self.refund_all(game)
return
bought_units: Dict[Type[UnitType], int] = {}
units_needing_transfer: Dict[Type[VehicleType], int] = {}
sold_units: Dict[Type[UnitType], int] = {}
@@ -68,25 +86,19 @@ class PendingUnitDeliveries:
if (
issubclass(unit_type, VehicleType)
and self.destination != ground_unit_source
and self.destination != ground_unit_source.control_point
):
source = ground_unit_source
source = ground_unit_source.control_point
d = units_needing_transfer
ground = True
else:
source = self.destination
d = bought_units
ground = False
if count >= 0:
# 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}"
)
game.message(
f"{coalition} reinforcements: {name} x {count} at {source}"
)
else:
sold_units[unit_type] = -count
game.message(f"{coalition} sold: {name} x {-count} at {source}")
@@ -95,36 +107,70 @@ class PendingUnitDeliveries:
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,
)
ground_unit_source.control_point.base.commision_units(
units_needing_transfer
)
if ground_unit_source.requires_airlift:
self.create_air_transfer(
game, ground_unit_source.control_point, units_needing_transfer
)
else:
self.create_road_transfer(
game, ground_unit_source.control_point, units_needing_transfer
)
def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]:
def create_air_transfer(
self, game: Game, source: ControlPoint, units: Dict[Type[VehicleType], int]
) -> None:
planner = AirliftPlanner(game, source, self.destination, units)
leftovers = planner.create_package_for_airlift()
if leftovers:
game.message(
f"No airlift capacity remaining for {self.destination}. "
"Remaining unit orders were refunded."
)
self.refund(game, leftovers)
source.base.commit_losses(leftovers)
def find_transport_for(
self,
origin: ControlPoint,
destination: ControlPoint,
units: Dict[Type[VehicleType], int],
) -> Optional[Flight]:
pass
def create_road_transfer(
self, game: Game, source: ControlPoint, units: Dict[Type[VehicleType], int]
) -> None:
game.transfers.new_transfer(
RoadTransferOrder(
source, self.destination, self.destination.captured, units
)
)
def find_ground_unit_source(self, game: Game) -> Optional[GroundUnitSource]:
# This is running *after* the turn counter has been incremented, so this is the
# reaction to turn 0. On turn zero we allow units to be recruited anywhere for
# delivery on turn 1 so that turn 1 always starts with units on the front line.
if game.turn == 1:
return self.destination
return GroundUnitSource(self.destination, requires_airlift=False)
# Fast path if the destination is a valid source.
if self.destination.can_recruit_ground_units(game):
return self.destination
return GroundUnitSource(self.destination, requires_airlift=False)
by_road = self.find_ground_unit_source_by_road(game)
if by_road is not None:
return GroundUnitSource(by_road, requires_airlift=False)
by_air = self.find_ground_unit_source_by_air(game)
if by_air is not None:
return GroundUnitSource(by_air, requires_airlift=True)
return None
def find_ground_unit_source_by_road(self, game: Game) -> Optional[ControlPoint]:
supply_route = SupplyRoute.for_control_point(self.destination)
sources = []
@@ -149,3 +195,14 @@ class PendingUnitDeliveries:
closest = source
distance = new_distance
return closest
def find_ground_unit_source_by_air(self, game: Game) -> Optional[ControlPoint]:
closest_airfields = ObjectiveDistanceCache.get_closest_airfields(
self.destination
)
for airfield in closest_airfields.operational_airfields:
if airfield.is_friendly(
self.destination.captured
) and airfield.can_recruit_ground_units(game):
return airfield
return None