Refactor transfers to support unfulfilled orders.

This gives a clean break between the transfer request and the type of
transport allocated to make way for transports that need to switch
types (to support driving to a port, then getting on a ship, to a train,
then back on the road, etc).

https://github.com/Khopa/dcs_liberation/issues/823
This commit is contained in:
Dan Albert
2021-04-23 18:40:31 -07:00
parent d6c84e362f
commit d80f7ebf3b
9 changed files with 227 additions and 399 deletions

View File

@@ -20,11 +20,14 @@ if TYPE_CHECKING:
from game.inventory import ControlPointAircraftInventory
# TODO: Remove base classes.
# Eventually we'll want multi-mode transfers (convoy from factory to port, onto a ship,
# then airlifted to the final destination, etc). To do this we'll need to make the
# transfer *order* represent the full journey and let classes like Convoy handle the
# individual hops.
class Transport:
def find_escape_route(self) -> Optional[ControlPoint]:
raise NotImplementedError
def description(self) -> str:
raise NotImplementedError
@dataclass
class TransferOrder:
"""The base type of all transfer orders.
@@ -38,59 +41,30 @@ class TransferOrder:
#: The location the units are transferring to.
destination: ControlPoint
#: The current position of the group being transferred. Groups may make multiple
#: stops and can switch transport modes before reaching their destination.
position: ControlPoint = field(init=False)
#: True if the transfer order belongs to the player.
player: bool
player: bool = field(init=False)
#: The units being transferred.
units: Dict[Type[VehicleType], int]
@property
def description(self) -> str:
raise NotImplementedError
@dataclass
class RoadTransferOrder(TransferOrder):
"""A transfer order that moves units by road."""
#: The current position of the group being transferred. Groups move one control
#: point a turn through the supply line.
position: ControlPoint = field(init=False)
transport: Optional[Transport] = field(default=None)
def __post_init__(self) -> None:
self.position = self.origin
def path(self) -> List[ControlPoint]:
supply_route = SupplyRoute.for_control_point(self.position)
return supply_route.shortest_path_between(self.position, self.destination)
def next_stop(self) -> ControlPoint:
return self.path()[0]
self.player = self.origin.is_friendly(to_player=True)
@property
def description(self) -> str:
path = self.path()
if len(path) == 1:
turns = "1 turn"
else:
turns = f"{len(path)} turns"
return f"Currently at {self.position}. Arrives at destination in {turns}."
if self.transport is None:
return "No transports available"
return self.transport.description()
@dataclass
class AirliftOrder(TransferOrder):
"""A transfer order that moves units by cargo planes and helicopters."""
flight: Flight
@property
def description(self) -> str:
return "Airlift"
def iter_units(self) -> Iterator[Type[VehicleType]]:
for unit_type, count in self.units.items():
for _ in range(count):
yield unit_type
def kill_all(self) -> None:
self.units.clear()
def kill_unit(self, unit_type: Type[VehicleType]) -> None:
if unit_type in self.units:
@@ -98,54 +72,102 @@ class AirliftOrder(TransferOrder):
return
raise KeyError
@property
def size(self) -> int:
return sum(c for c in self.units.values())
def iter_units(self) -> Iterator[Type[VehicleType]]:
for unit_type, count in self.units.items():
for _ in range(count):
yield unit_type
@property
def completed(self) -> bool:
return self.destination == self.position or not self.units
def disband_at(self, location: ControlPoint) -> None:
logging.info(f"Units halting at {location}.")
location.base.commision_units(self.units)
self.units.clear()
def proceed(self) -> None:
if self.transport is None:
return
if not self.destination.is_friendly(self.player):
logging.info(f"Transfer destination {self.destination} was captured.")
if self.position.is_friendly(self.player):
self.disband_at(self.position)
elif (escape_route := self.transport.find_escape_route()) is not None:
self.disband_at(escape_route)
else:
logging.info(
f"No escape route available. Units were surrounded and destroyed "
"during transfer."
)
self.kill_all()
return
self.position = self.destination
self.transport = None
if self.completed:
self.disband_at(self.position)
@dataclass
class Airlift(Transport):
"""A transfer order that moves units by cargo planes and helicopters."""
transfer: TransferOrder
flight: Flight
@property
def units(self) -> Dict[Type[VehicleType], int]:
return self.transfer.units
@property
def player_owned(self) -> bool:
return self.transfer.player
def find_escape_route(self) -> Optional[ControlPoint]:
# TODO: Move units to closest base.
return None
def description(self) -> str:
return f"Being airlifted by {self.flight}"
class AirliftPlanner:
def __init__(
self,
game: Game,
pickup: ControlPoint,
drop_off: ControlPoint,
units: Dict[Type[VehicleType], int],
) -> None:
def __init__(self, game: Game, transfer: TransferOrder) -> 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)
self.transfer = transfer
self.for_player = transfer.destination.captured
self.package = Package(target=transfer.destination, auto_asap=True)
def create_package_for_airlift(self) -> Dict[Type[VehicleType], int]:
def create_package_for_airlift(self) -> None:
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:
while available and self.transfer.transport is None:
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_size = min(self.transfer.size, available, 4)
if flight_size < self.transfer.size:
transfer = self.game.transfers.split_transfer(self.transfer, flight_size)
else:
transfer = self.transfer
flight = Flight(
self.package,
self.game.player_country,
@@ -156,31 +178,25 @@ class AirliftPlanner:
departure=inventory.control_point,
arrival=inventory.control_point,
divert=None,
cargo=transfer,
)
transfer = AirliftOrder(
player=True,
origin=self.pickup,
destination=self.drop_off,
units=self.take_units(flight_size),
flight=flight,
)
flight.cargo = transfer
transport = Airlift(transfer, flight)
transfer.transport = transport
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):
class Convoy(MissionTarget, Transport):
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
super().__init__(namegen.next_convoy_name(), origin.position)
self.origin = origin
self.destination = destination
self.transfers: List[RoadTransferOrder] = []
self.transfers: List[TransferOrder] = []
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
if self.is_friendly(for_player):
@@ -192,17 +208,20 @@ class Convoy(MissionTarget):
def is_friendly(self, to_player: bool) -> bool:
return self.origin.captured
def add_units(self, transfer: RoadTransferOrder) -> None:
def add_units(self, transfer: TransferOrder) -> None:
self.transfers.append(transfer)
transfer.transport = self
def remove_units(self, transfer: RoadTransferOrder) -> None:
def remove_units(self, transfer: TransferOrder) -> None:
self.transfers.remove(transfer)
def kill_unit(self, unit_type: Type[VehicleType]) -> None:
for transfer in self.transfers:
if unit_type in transfer.units:
transfer.units[unit_type] -= 1
try:
transfer.kill_unit(unit_type)
return
except KeyError:
pass
raise KeyError
@property
@@ -221,6 +240,12 @@ class Convoy(MissionTarget):
def player_owned(self) -> bool:
return self.origin.captured
def find_escape_route(self) -> Optional[ControlPoint]:
return None
def description(self) -> str:
return f"In a convoy to {self.destination}"
class ConvoyMap:
def __init__(self) -> None:
@@ -255,18 +280,21 @@ class ConvoyMap:
def disband_convoy(self, convoy: Convoy) -> None:
del self.convoys[convoy.origin][convoy.destination]
def add(self, transfer: RoadTransferOrder) -> None:
next_stop = transfer.next_stop()
@staticmethod
def path_for(transfer: TransferOrder) -> List[ControlPoint]:
supply_route = SupplyRoute.for_control_point(transfer.position)
return supply_route.shortest_path_between(
transfer.position, transfer.destination
)
def next_stop_for(self, transfer: TransferOrder) -> ControlPoint:
return self.path_for(transfer)[0]
def add(self, transfer: TransferOrder) -> None:
next_stop = self.next_stop_for(transfer)
self.find_or_create_convoy(transfer.position, next_stop).add_units(transfer)
def remove(self, transfer: RoadTransferOrder) -> None:
next_stop = transfer.next_stop()
convoy = self.find_convoy(transfer.position, next_stop)
if convoy is None:
logging.error(
f"Attempting to remove {transfer} from convoy but it is in no convoy."
)
return
def remove(self, convoy: Convoy, transfer: TransferOrder) -> None:
convoy.remove_units(transfer)
if not convoy.transfers:
self.disband_convoy(convoy)
@@ -298,43 +326,63 @@ class PendingTransfers:
def index_of_transfer(self, transfer: TransferOrder) -> int:
return self.pending_transfers.index(transfer)
# TODO: Move airlift arrangements here?
@singledispatchmethod
def arrange_transport(self, transfer) -> None:
pass
@arrange_transport.register
def _arrange_transport_road(self, transfer: RoadTransferOrder) -> None:
self.convoys.add(transfer)
def arrange_transport(self, transfer: TransferOrder) -> None:
supply_route = SupplyRoute.for_control_point(transfer.position)
if transfer.destination in supply_route:
self.convoys.add(transfer)
else:
AirliftPlanner(self.game, transfer).create_package_for_airlift()
def new_transfer(self, transfer: TransferOrder) -> None:
transfer.origin.base.commit_losses(transfer.units)
self.pending_transfers.append(transfer)
self.arrange_transport(transfer)
def split_transfer(self, transfer: TransferOrder, size: int) -> TransferOrder:
"""Creates a smaller transfer that is a subset of the original."""
if transfer.size <= size:
raise ValueError
units = {}
for unit_type, remaining in transfer.units.items():
take = min(remaining, size)
size -= take
transfer.units[unit_type] -= take
units[unit_type] = take
if not size:
break
new_transfer = TransferOrder(transfer.origin, transfer.destination, units)
self.pending_transfers.append(new_transfer)
return new_transfer
@singledispatchmethod
def cancel_transport(self, transfer) -> None:
def cancel_transport(self, transfer: TransferOrder, transport) -> None:
pass
@cancel_transport.register
def _cancel_transport_air(self, transfer: AirliftOrder) -> None:
flight = transfer.flight
def _cancel_transport_air(
self, _transfer: TransferOrder, transport: Airlift
) -> None:
flight = transport.flight
flight.package.remove_flight(flight)
self.game.aircraft_inventory.return_from_flight(flight)
@cancel_transport.register
def _cancel_transport_road(self, transfer: RoadTransferOrder) -> None:
self.convoys.remove(transfer)
def _cancel_transport_convoy(
self, transfer: TransferOrder, transport: Convoy
) -> None:
self.convoys.remove(transport, transfer)
def cancel_transfer(self, transfer: TransferOrder) -> None:
self.cancel_transport(transfer)
if transfer.transport is not None:
self.cancel_transport(transfer, transfer.transport)
self.pending_transfers.remove(transfer)
transfer.origin.base.commision_units(transfer.units)
def perform_transfers(self) -> None:
incomplete = []
for transfer in self.pending_transfers:
if not self.perform_transfer(transfer):
transfer.proceed()
if not transfer.completed:
incomplete.append(transfer)
self.pending_transfers = incomplete
self.rebuild_convoys()
@@ -343,80 +391,3 @@ class PendingTransfers:
self.convoys.disband_all()
for transfer in self.pending_transfers:
self.arrange_transport(transfer)
@singledispatchmethod
def perform_transfer(self, transfer) -> bool:
raise NotImplementedError
@perform_transfer.register
def _perform_transfer_air(self, transfer: AirliftOrder) -> bool:
if transfer.player != transfer.destination.captured:
logging.info(
f"Transfer destination {transfer.destination} was captured. Cancelling "
"transport."
)
transfer.origin.base.commision_units(transfer.units)
return True
transfer.destination.base.commision_units(transfer.units)
return True
@perform_transfer.register
def _perform_transfer_road(self, transfer: RoadTransferOrder) -> bool:
# TODO: Can be improved to use the convoy map.
# The convoy map already has a lot of the data that we're recomputing here.
if transfer.player != transfer.destination.captured:
logging.info(
f"Transfer destination {transfer.destination.name} was captured."
)
self.handle_route_interrupted(transfer)
return True
supply_route = SupplyRoute.for_control_point(transfer.destination)
if transfer.position not in supply_route:
logging.info(
f"Route from {transfer.position.name} to {transfer.destination.name} "
"was cut off."
)
self.handle_route_interrupted(transfer)
return True
path = transfer.path()
next_hop = path[0]
if next_hop == transfer.destination:
logging.info(
f"Units transferred from {transfer.origin.name} to "
f"{transfer.destination.name}"
)
transfer.destination.base.commision_units(transfer.units)
return True
logging.info(
f"Units transferring from {transfer.origin.name} to "
f"{transfer.destination.name} arrived at {next_hop.name}. {len(path) - 1} "
"turns remaining."
)
transfer.position = next_hop
return False
@staticmethod
def handle_route_interrupted(transfer: RoadTransferOrder):
# Halt the transfer in place if safe.
if transfer.player == transfer.position.captured:
logging.info(f"Transferring units are halting at {transfer.position.name}.")
transfer.position.base.commision_units(transfer.units)
return
# If the current position was captured attempt to divert to a neighboring
# friendly CP.
for connected in transfer.position.connected_points:
if connected.captured == transfer.player:
logging.info(f"Transferring units are re-routing to {connected.name}.")
connected.base.commision_units(transfer.units)
return
# If the units are cutoff they are destroyed.
logging.info(
f"Both {transfer.position.name} and {transfer.destination.name} were "
"captured. Units were surrounded and destroyed during transfer."
)

View File

@@ -8,11 +8,9 @@ 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 AirliftOrder, AirliftPlanner, RoadTransferOrder
from .transfers import TransferOrder
if TYPE_CHECKING:
from .game import Game
@@ -111,44 +109,14 @@ class PendingUnitDeliveries:
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
)
self.create_transfer(
game, ground_unit_source.control_point, units_needing_transfer
)
def create_air_transfer(
def create_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
)
)
game.transfers.new_transfer(TransferOrder(source, self.destination, 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

View File

@@ -9,7 +9,7 @@ from dcs.unittype import VehicleType
from game import db
from game.theater import Airfield, ControlPoint, TheaterGroundObject
from game.theater.theatergroundobject import BuildingGroundObject
from game.transfers import AirliftOrder, Convoy
from game.transfers import Convoy, TransferOrder
from gen.flights.flight import Flight
@@ -35,7 +35,7 @@ class ConvoyUnit:
@dataclass(frozen=True)
class AirliftUnit:
unit_type: Type[VehicleType]
transfer: AirliftOrder
transfer: TransferOrder
@dataclass(frozen=True)
@@ -149,17 +149,14 @@ class UnitMap:
def convoy_unit(self, name: str) -> Optional[ConvoyUnit]:
return self.convoys.get(name, None)
def add_airlift_units(self, group: FlyingGroup, airlift: AirliftOrder) -> None:
for transport, cargo_type in zip(group.units, airlift.iter_units()):
def add_airlift_units(self, group: FlyingGroup, transfer: TransferOrder) -> None:
for transport, cargo_type in zip(group.units, transfer.iter_units()):
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(transport.name)
if name in self.airlifts:
raise RuntimeError(f"Duplicate airlift unit: {name}")
unit_type = db.unit_type_from_name(transport.type)
if unit_type is None:
raise RuntimeError(f"Unknown unit type: {transport.type}")
self.airlifts[name] = AirliftUnit(cargo_type, airlift)
self.airlifts[name] = AirliftUnit(cargo_type, transfer)
def airlift_unit(self, name: str) -> Optional[AirliftUnit]:
return self.airlifts.get(name, None)