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

@ -4,7 +4,7 @@ Saves from 2.5 are not compatible with 2.6.
## Features/Improvements
* **[Campaign]** Ground units can now be transferred by road. See https://github.com/Khopa/dcs_liberation/wiki/Unit-Transfers for more information.
* **[Campaign]** Ground units can now be transferred by road and airlift. See https://github.com/Khopa/dcs_liberation/wiki/Unit-Transfers for more information.
* **[Campaign]** Ground units can no longer be sold. To move units to a new location, transfer them.
* **[Campaign]** Ground units must now be recruited at a base with a factory and transferred to their destination. When buying units in the UI, the purchase will automatically be fulfilled at the closest factory and a transfer will be created on the next turn. This feature is off by default.
* **[UI]** Campaigns generated for an older or newer version of the game will now be marked as incompatible. They can still be played, but bugs may be present.

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,22 +86,16 @@ 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}"
)
@ -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)
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 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(
ground_unit_source,
self.destination,
self.destination.captured,
units_needing_transfer,
source, self.destination, self.destination.captured, units
)
)
def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]:
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

View File

@ -103,11 +103,15 @@ class QFrontLine(QGraphicsLineItem):
def cheat_forward(self) -> None:
self.mission_target.control_point_a.base.affect_strength(0.1)
self.mission_target.control_point_b.base.affect_strength(-0.1)
# Clear the ATO to replan missions affected by the front line.
self.game_model.game.reset_ato()
self.game_model.game.initialize_turn()
GameUpdateSignal.get_instance().updateGame(self.game_model.game)
def cheat_backward(self) -> None:
self.mission_target.control_point_a.base.affect_strength(-0.1)
self.mission_target.control_point_b.base.affect_strength(0.1)
# Clear the ATO to replan missions affected by the front line.
self.game_model.game.reset_ato()
self.game_model.game.initialize_turn()
GameUpdateSignal.get_instance().updateGame(self.game_model.game)

View File

@ -103,7 +103,9 @@ class QMapControlPoint(QMapObject):
def cheat_capture(self) -> None:
self.control_point.capture(self.game_model.game, for_player=True)
# Reinitialized ground planners and the like.
# Reinitialized ground planners and the like. The ATO needs to be reset because
# missions planned against the flipped base are no longer valid.
self.game_model.game.reset_ato()
self.game_model.game.initialize_turn()
GameUpdateSignal.get_instance().updateGame(self.game_model.game)

View File

@ -24,25 +24,27 @@ from PySide2.QtWidgets import (
QWidget,
)
from dcs.task import PinpointStrike
from dcs.unittype import FlyingType, UnitType, VehicleType
from dcs.unittype import UnitType
from game import Game, db
from game.inventory import ControlPointAircraftInventory
from game.theater import ControlPoint, SupplyRoute
from game.transfers import AirliftOrder, RoadTransferOrder
from gen.ato import Package
from gen.flights.flight import Flight, FlightType
from gen.flights.flightplan import FlightPlanBuilder, PlanningError
from qt_ui.models import GameModel, PackageModel
from game.transfers import AirliftPlanner, RoadTransferOrder
from qt_ui.models import GameModel
from qt_ui.widgets.QLabeledWidget import QLabeledWidget
class TransferDestinationComboBox(QComboBox):
def __init__(self, origin: ControlPoint) -> None:
def __init__(self, game: Game, origin: ControlPoint) -> None:
super().__init__()
self.game = game
self.origin = origin
for cp in SupplyRoute.for_control_point(origin):
if cp != origin and cp.captured:
for cp in self.game.theater.controlpoints:
if (
cp != self.origin
and cp.is_friendly(to_player=True)
and cp.can_deploy_ground_units
):
self.addItem(cp.name, cp)
self.model().sort(0)
self.setCurrentIndex(0)
@ -109,14 +111,21 @@ class AirliftCapacity:
class TransferOptionsPanel(QVBoxLayout):
def __init__(self, origin: ControlPoint, airlift_capacity: AirliftCapacity) -> None:
def __init__(
self,
game: Game,
origin: ControlPoint,
airlift_capacity: AirliftCapacity,
airlift_required: bool,
) -> None:
super().__init__()
self.source_combo_box = TransferDestinationComboBox(origin)
self.source_combo_box = TransferDestinationComboBox(game, origin)
self.addLayout(QLabeledWidget("Destination:", self.source_combo_box))
self.airlift = QCheckBox()
self.airlift.toggled.connect(self.set_airlift)
self.addLayout(QLabeledWidget("Airlift (WIP):", self.airlift))
self.airlift.setChecked(airlift_required)
self.airlift.setDisabled(airlift_required)
self.addLayout(QLabeledWidget("Airlift:", self.airlift))
self.addWidget(
QLabel(
f"{airlift_capacity.total} airlift capacity "
@ -133,9 +142,6 @@ class TransferOptionsPanel(QVBoxLayout):
def current(self) -> ControlPoint:
return self.source_combo_box.currentData()
def set_airlift(self, value: bool) -> None:
pass
class TransferControls(QGroupBox):
def __init__(
@ -324,13 +330,16 @@ class NewUnitTransferDialog(QDialog):
self.setLayout(layout)
self.airlift_capacity = AirliftCapacity.to_control_point(game_model.game)
self.dest_panel = TransferOptionsPanel(origin, self.airlift_capacity)
airlift_required = len(SupplyRoute.for_control_point(origin)) == 1
self.dest_panel = TransferOptionsPanel(
game_model.game, origin, self.airlift_capacity, airlift_required
)
self.dest_panel.changed.connect(self.rebuild_transfers)
layout.addLayout(self.dest_panel)
self.transfer_panel = ScrollingUnitTransferGrid(
origin,
airlift=False,
airlift=airlift_required,
airlift_capacity=self.airlift_capacity,
game_model=game_model,
)
@ -357,123 +366,41 @@ class NewUnitTransferDialog(QDialog):
self.layout().addWidget(self.submit_button)
def on_submit(self) -> None:
destination = self.dest_panel.current
supply_route = SupplyRoute.for_control_point(self.origin)
if not self.dest_panel.airlift.isChecked() and destination not in supply_route:
QMessageBox.critical(
self,
"Could not create transfer",
f"Transfers from {self.origin} to {destination} require airlift.",
QMessageBox.Ok,
)
return
transfers = {}
for unit_type, count in self.transfer_panel.transfers.items():
if not count:
continue
logging.info(
f"Transferring {count} {unit_type.id} from "
f"{self.transfer_panel.cp.name} to {self.dest_panel.current.name}"
f"Transferring {count} {unit_type.id} from {self.origin} to "
f"{destination}"
)
transfers[unit_type] = count
if self.dest_panel.airlift.isChecked():
self.create_package_for_airlift(
self.transfer_panel.cp,
self.dest_panel.current,
planner = AirliftPlanner(
self.game_model.game,
self.origin,
destination,
transfers,
)
planner.create_package_for_airlift()
else:
transfer = RoadTransferOrder(
player=True,
origin=self.transfer_panel.cp,
destination=self.dest_panel.current,
origin=self.origin,
destination=destination,
units=transfers,
)
self.game_model.transfer_model.new_transfer(transfer)
self.close()
@staticmethod
def take_units(
units: Dict[Type[VehicleType], int], count: int
) -> Dict[Type[VehicleType], int]:
taken = {}
for unit_type, remaining in units.items():
take = min(remaining, count)
count -= take
units[unit_type] -= take
taken[unit_type] = take
if not count:
break
return taken
def create_airlift_flight(
self,
game: Game,
package_model: PackageModel,
unit_type: Type[FlyingType],
inventory: ControlPointAircraftInventory,
needed_capacity: int,
pickup: ControlPoint,
drop_off: ControlPoint,
units: Dict[Type[VehicleType], int],
) -> int:
available = inventory.available(unit_type)
# 4 is the max flight size in DCS.
flight_size = min(needed_capacity, available, 4)
flight = Flight(
package_model.package,
game.player_country,
unit_type,
flight_size,
FlightType.TRANSPORT,
game.settings.default_start_type,
departure=inventory.control_point,
arrival=inventory.control_point,
divert=None,
)
transfer = AirliftOrder(
player=True,
origin=pickup,
destination=drop_off,
units=self.take_units(units, flight_size),
flight=flight,
)
flight.cargo = transfer
package_model.add_flight(flight)
planner = FlightPlanBuilder(game, package_model.package, is_player=True)
try:
planner.populate_flight_plan(flight)
except PlanningError as ex:
package_model.delete_flight(flight)
logging.exception("Could not create flight")
QMessageBox.critical(
self, "Could not create flight", str(ex), QMessageBox.Ok
)
game.aircraft_inventory.claim_for_flight(flight)
self.game_model.transfer_model.new_transfer(transfer)
return flight_size
def create_package_for_airlift(
self,
pickup: ControlPoint,
drop_off: ControlPoint,
units: Dict[Type[VehicleType], int],
) -> None:
package = Package(target=drop_off, auto_asap=True)
package_model = PackageModel(package, self.game_model)
needed_capacity = sum(c for c in units.values())
game = self.game_model.game
for cp in game.theater.player_points():
inventory = game.aircraft_inventory.for_control_point(cp)
for unit_type, available in inventory.all_aircraft:
if unit_type.helicopter:
while available and needed_capacity:
flight_size = self.create_airlift_flight(
self.game_model.game,
package_model,
unit_type,
inventory,
needed_capacity,
pickup,
drop_off,
units,
)
available -= flight_size
needed_capacity -= flight_size
package_model.update_tot()
self.game_model.ato_model.add_package(package)

View File

@ -105,7 +105,10 @@ class QBaseMenu2(QDialog):
@property
def has_transfer_destinations(self) -> bool:
return len(SupplyRoute.for_control_point(self.cp)) > 1
return (
self.cp.runway_is_operational()
or len(SupplyRoute.for_control_point(self.cp)) > 1
)
@property
def can_repair_runway(self) -> bool: