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: