From c258409a8d6d4e692862c39f2f8dae05aaeba2c4 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 23 Apr 2021 01:00:33 -0700 Subject: [PATCH] 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 --- changelog.md | 2 +- game/game.py | 15 +- game/theater/controlpoint.py | 6 +- game/transfers.py | 86 ++++++++- game/unitdelivery.py | 119 +++++++++---- qt_ui/widgets/map/QFrontLine.py | 4 + qt_ui/widgets/map/QMapControlPoint.py | 4 +- .../windows/basemenu/NewUnitTransferDialog.py | 167 +++++------------- qt_ui/windows/basemenu/QBaseMenu2.py | 5 +- 9 files changed, 244 insertions(+), 164 deletions(-) diff --git a/changelog.md b/changelog.md index f09addf7..0cd53adb 100644 --- a/changelog.md +++ b/changelog.md @@ -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. diff --git a/game/game.py b/game/game.py index dae06d7c..e264842f 100644 --- a/game/game.py +++ b/game/game.py @@ -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() diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index d63b3b48..a6777663 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -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 diff --git a/game/transfers.py b/game/transfers.py index ffbc3d9e..d45f7832 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -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) diff --git a/game/unitdelivery.py b/game/unitdelivery.py index 36f30fcc..845eec69 100644 --- a/game/unitdelivery.py +++ b/game/unitdelivery.py @@ -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 diff --git a/qt_ui/widgets/map/QFrontLine.py b/qt_ui/widgets/map/QFrontLine.py index 41edcaea..2203bf57 100644 --- a/qt_ui/widgets/map/QFrontLine.py +++ b/qt_ui/widgets/map/QFrontLine.py @@ -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) diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py index 12bfce6a..b7016536 100644 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ b/qt_ui/widgets/map/QMapControlPoint.py @@ -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) diff --git a/qt_ui/windows/basemenu/NewUnitTransferDialog.py b/qt_ui/windows/basemenu/NewUnitTransferDialog.py index ca6b1bc6..9e2b5af4 100644 --- a/qt_ui/windows/basemenu/NewUnitTransferDialog.py +++ b/qt_ui/windows/basemenu/NewUnitTransferDialog.py @@ -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) diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index 1dac5f43..b5b9c885 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -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: