First pass at cargo ships.

The simple form of this works, but without the multi-mode routing it'll
only get used when the final destination is a port with a link to a port
with a factory.

These also aren't targetable or simulated yet.

https://github.com/Khopa/dcs_liberation/issues/826
This commit is contained in:
Dan Albert 2021-04-25 11:17:41 -07:00
parent 42694d2004
commit ba8fafcc95
11 changed files with 262 additions and 99 deletions

View File

@ -315,6 +315,23 @@ class ControlPoint(MissionTarget, ABC):
connected.extend(cp.transitive_connected_friendly_points(seen))
return connected
def transitive_friendly_shipping_destinations(
self, seen: Optional[Set[ControlPoint]] = None
) -> List[ControlPoint]:
if seen is None:
seen = {self}
connected = []
for cp in self.shipping_lanes:
if cp.captured != self.captured:
continue
if cp in seen:
continue
seen.add(cp)
connected.append(cp)
connected.extend(cp.transitive_friendly_shipping_destinations(seen))
return connected
@property
def has_factory(self) -> bool:
for tgo in self.connected_objectives:

View File

@ -4,7 +4,7 @@ import heapq
import math
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Dict, Iterator, List, Optional
from typing import Dict, Iterable, Iterator, List, Optional
from game.theater.controlpoint import ControlPoint
@ -32,6 +32,25 @@ class Frontier:
return bool(self.nodes)
# TODO: Build a single SupplyRoute for each coalition at the start of the turn.
# Supply routes need to cover the whole network to support multi-mode links.
#
# Traverse each friendly control point and build out a network from each. Nodes create
# connections to:
#
# 1. Bases connected by road
# 2. Bases connected by rail
# 3. Bases connected by shipping lane
# 4. Airports large enough to operate cargo planes connect to each other
# 5. Airports capable of operating helicopters connect to other airports within cargo
# helicopter range, and FOBs within half of the range (since they can't be refueled
# at the drop off).
#
# The costs of each link would be set such that the above order roughly corresponds to
# the prevalence of each type of transport. Most units should move by road, rail should
# be used a little less often than road, ships a bit less often than that, cargo planes
# infrequently, and helicopters rarely. Convoys, trains, and ships make the most
# interesting targets for players (and the easiest to generate AI flight plans for).
class SupplyRoute:
def __init__(self, control_points: List[ControlPoint]) -> None:
self.control_points = control_points
@ -45,20 +64,16 @@ class SupplyRoute:
def __len__(self) -> int:
return len(self.control_points)
@classmethod
def for_control_point(cls, control_point: ControlPoint) -> SupplyRoute:
connected_friendly_points = control_point.transitive_connected_friendly_points()
if not connected_friendly_points:
return SupplyRoute([control_point])
return SupplyRoute([control_point] + connected_friendly_points)
def connections_from(self, control_point: ControlPoint) -> Iterable:
raise NotImplementedError
def shortest_path_between(
self, origin: ControlPoint, destination: ControlPoint
) -> List[ControlPoint]:
if origin not in self:
raise ValueError(f"{origin.name} is not in this supply route")
raise ValueError(f"{origin} is not in supply route to {destination}")
if destination not in self:
raise ValueError(f"{destination.name} is not in this supply route")
raise ValueError(f"{destination} is not in supply route from {origin}")
frontier = Frontier()
frontier.push(origin, 0)
@ -74,7 +89,7 @@ class SupplyRoute:
if cost > best_known[current]:
continue
for neighbor in current.connected_points:
for neighbor in self.connections_from(current):
if current.captured != neighbor.captured:
continue
@ -97,3 +112,29 @@ class SupplyRoute:
current = previous
path.reverse()
return path
class RoadNetwork(SupplyRoute):
@classmethod
def for_control_point(cls, control_point: ControlPoint) -> RoadNetwork:
connected_friendly_points = control_point.transitive_connected_friendly_points()
if not connected_friendly_points:
return RoadNetwork([control_point])
return RoadNetwork([control_point] + connected_friendly_points)
def connections_from(self, control_point: ControlPoint) -> Iterable:
yield from control_point.connected_points
class ShippingNetwork(SupplyRoute):
@classmethod
def for_control_point(cls, control_point: ControlPoint) -> ShippingNetwork:
connected_friendly_points = (
control_point.transitive_friendly_shipping_destinations()
)
if not connected_friendly_points:
return ShippingNetwork([control_point])
return ShippingNetwork([control_point] + connected_friendly_points)
def connections_from(self, control_point: ControlPoint) -> Iterable:
yield from control_point.shipping_lanes

View File

@ -16,7 +16,7 @@ from gen.flights.ai_flight_planner_db import TRANSPORT_CAPABLE
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flightplan import FlightPlanBuilder
from game.theater import ControlPoint, MissionTarget
from game.theater.supplyroutes import SupplyRoute
from game.theater.supplyroutes import RoadNetwork, ShippingNetwork, SupplyRoute
from gen.naming import namegen
from gen.flights.flight import Flight, FlightType
@ -242,20 +242,15 @@ class AirliftPlanner:
return flight_size
class Convoy(MissionTarget, Transport):
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
super().__init__(namegen.next_convoy_name(), origin.position)
class MultiGroupTransport(MissionTarget, Transport):
def __init__(
self, name: str, origin: ControlPoint, destination: ControlPoint
) -> None:
super().__init__(name, origin.position)
self.origin = origin
self.destination = destination
self.transfers: List[TransferOrder] = []
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
if self.is_friendly(for_player):
return
yield FlightType.BAI
yield from super().mission_types(for_player)
def is_friendly(self, to_player: bool) -> bool:
return self.origin.captured
@ -298,10 +293,30 @@ class Convoy(MissionTarget, Transport):
return self.origin.captured
def find_escape_route(self) -> Optional[ControlPoint]:
return None
raise NotImplementedError
def description(self) -> str:
return f"In a convoy to {self.destination}"
raise NotImplementedError
@property
def route_start(self) -> Point:
raise NotImplementedError
@property
def route_end(self) -> Point:
raise NotImplementedError
class Convoy(MultiGroupTransport):
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
super().__init__(namegen.next_convoy_name(), origin, destination)
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
if self.is_friendly(for_player):
return
yield FlightType.BAI
yield from super().mission_types(for_player)
@property
def route_start(self) -> Point:
@ -311,44 +326,85 @@ class Convoy(MissionTarget, Transport):
def route_end(self) -> Point:
return self.destination.convoy_spawns[self.origin]
def description(self) -> str:
return f"In a convoy to {self.destination}"
class ConvoyMap:
def find_escape_route(self) -> Optional[ControlPoint]:
return None
class CargoShip(MultiGroupTransport):
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
super().__init__(namegen.next_cargo_ship_name(), origin, destination)
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
if self.is_friendly(for_player):
return
yield FlightType.ANTISHIP
yield from super().mission_types(for_player)
@property
def route_start(self) -> Point:
return self.origin.shipping_lanes[self.destination][0]
@property
def route_end(self) -> Point:
return self.destination.shipping_lanes[self.origin][-1]
def description(self) -> str:
return f"On a ship to {self.destination}"
def find_escape_route(self) -> Optional[ControlPoint]:
return None
class TransportMap:
def __init__(self) -> None:
# Dict of origin -> destination -> convoy.
self.convoys: Dict[ControlPoint, Dict[ControlPoint, Convoy]] = defaultdict(dict)
# Dict of origin -> destination -> transport.
self.transports: Dict[
ControlPoint, Dict[ControlPoint, MultiGroupTransport]
] = defaultdict(dict)
def convoy_exists(self, origin: ControlPoint, destination: ControlPoint) -> bool:
return destination in self.convoys[origin]
def find_convoy(
def create_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> Optional[Convoy]:
return self.convoys[origin].get(destination)
) -> MultiGroupTransport:
raise NotImplementedError
def find_or_create_convoy(
def transport_exists(self, origin: ControlPoint, destination: ControlPoint) -> bool:
return destination in self.transports[origin]
def find_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> Convoy:
convoy = self.find_convoy(origin, destination)
if convoy is None:
convoy = Convoy(origin, destination)
self.convoys[origin][destination] = convoy
return convoy
) -> Optional[MultiGroupTransport]:
return self.transports[origin].get(destination)
def departing_from(self, origin: ControlPoint) -> Iterator[Convoy]:
yield from self.convoys[origin].values()
def find_or_create_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> MultiGroupTransport:
transport = self.find_transport(origin, destination)
if transport is None:
transport = self.create_transport(origin, destination)
self.transports[origin][destination] = transport
return transport
def travelling_to(self, destination: ControlPoint) -> Iterator[Convoy]:
for destination_dict in self.convoys.values():
def departing_from(self, origin: ControlPoint) -> Iterator[MultiGroupTransport]:
yield from self.transports[origin].values()
def travelling_to(self, destination: ControlPoint) -> Iterator[MultiGroupTransport]:
for destination_dict in self.transports.values():
if destination in destination_dict:
yield destination_dict[destination]
def disband_convoy(self, convoy: Convoy) -> None:
self.convoys[convoy.origin][convoy.destination].disband()
del self.convoys[convoy.origin][convoy.destination]
def disband_transport(self, transport: MultiGroupTransport) -> None:
transport.disband()
del self.transports[transport.origin][transport.destination]
@staticmethod
def path_for(transfer: TransferOrder) -> List[ControlPoint]:
supply_route = SupplyRoute.for_control_point(transfer.position)
def network_for(self, control_point: ControlPoint) -> SupplyRoute:
raise NotImplementedError
def path_for(self, transfer: TransferOrder) -> List[ControlPoint]:
supply_route = self.network_for(transfer.position)
return supply_route.shortest_path_between(
transfer.position, transfer.destination
)
@ -358,26 +414,47 @@ class ConvoyMap:
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)
self.find_or_create_transport(transfer.position, next_stop).add_units(transfer)
def remove(self, convoy: Convoy, transfer: TransferOrder) -> None:
convoy.remove_units(transfer)
if not convoy.transfers:
self.disband_convoy(convoy)
def remove(self, transport: MultiGroupTransport, transfer: TransferOrder) -> None:
transport.remove_units(transfer)
if not transport.transfers:
self.disband_transport(transport)
def disband_all(self) -> None:
for convoy in list(self):
self.disband_convoy(convoy)
for transport in list(self):
self.disband_transport(transport)
def __iter__(self) -> Iterator[Convoy]:
for destination_dict in self.convoys.values():
def __iter__(self) -> Iterator[MultiGroupTransport]:
for destination_dict in self.transports.values():
yield from destination_dict.values()
class ConvoyMap(TransportMap):
def create_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> Convoy:
return Convoy(origin, destination)
def network_for(self, control_point: ControlPoint) -> RoadNetwork:
return RoadNetwork.for_control_point(control_point)
class CargoShipMap(TransportMap):
def create_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> CargoShip:
return CargoShip(origin, destination)
def network_for(self, control_point: ControlPoint) -> ShippingNetwork:
return ShippingNetwork.for_control_point(control_point)
class PendingTransfers:
def __init__(self, game: Game) -> None:
self.game = game
self.convoys = ConvoyMap()
self.cargo_ships = CargoShipMap()
self.pending_transfers: List[TransferOrder] = []
def __iter__(self) -> Iterator[TransferOrder]:
@ -394,9 +471,12 @@ class PendingTransfers:
return self.pending_transfers.index(transfer)
def arrange_transport(self, transfer: TransferOrder) -> None:
supply_route = SupplyRoute.for_control_point(transfer.position)
if transfer.destination in supply_route:
if transfer.destination in RoadNetwork.for_control_point(transfer.position):
self.convoys.add(transfer)
elif transfer.destination in ShippingNetwork.for_control_point(
transfer.position
):
self.cargo_ships.add(transfer)
else:
AirliftPlanner(self.game, transfer).create_package_for_airlift()
@ -439,6 +519,11 @@ class PendingTransfers:
) -> None:
self.convoys.remove(transport, transfer)
def _cancel_transport_cargo_ship(
self, transfer: TransferOrder, transport: CargoShip
) -> None:
self.cargo_ships.remove(transport, transfer)
def cancel_transfer(self, transfer: TransferOrder) -> None:
if transfer.transport is not None:
self.cancel_transport(transfer, transfer.transport)

View File

@ -10,6 +10,7 @@ from dcs.unittype import UnitType, VehicleType
from game.theater import ControlPoint, SupplyRoute
from gen.flights.closestairfields import ObjectiveDistanceCache
from .db import PRICES
from .theater.supplyroutes import RoadNetwork, ShippingNetwork
from .transfers import TransferOrder
if TYPE_CHECKING:
@ -19,7 +20,6 @@ if TYPE_CHECKING:
@dataclass(frozen=True)
class GroundUnitSource:
control_point: ControlPoint
requires_airlift: bool
class PendingUnitDeliveries:
@ -84,9 +84,9 @@ class PendingUnitDeliveries:
if (
issubclass(unit_type, VehicleType)
and self.destination != ground_unit_source.control_point
and self.destination != ground_unit_source
):
source = ground_unit_source.control_point
source = ground_unit_source
d = units_needing_transfer
else:
source = self.destination
@ -106,41 +106,45 @@ class PendingUnitDeliveries:
self.destination.base.commit_losses(sold_units)
if units_needing_transfer:
ground_unit_source.control_point.base.commision_units(
units_needing_transfer
)
self.create_transfer(
game, ground_unit_source.control_point, units_needing_transfer
)
ground_unit_source.base.commision_units(units_needing_transfer)
self.create_transfer(game, ground_unit_source, units_needing_transfer)
def create_transfer(
self, game: Game, source: ControlPoint, units: Dict[Type[VehicleType], int]
) -> None:
game.transfers.new_transfer(TransferOrder(source, self.destination, units))
def find_ground_unit_source(self, game: Game) -> Optional[GroundUnitSource]:
def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]:
# 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 GroundUnitSource(self.destination, requires_airlift=False)
return self.destination
# Fast path if the destination is a valid source.
if self.destination.can_recruit_ground_units(game):
return GroundUnitSource(self.destination, requires_airlift=False)
return self.destination
by_road = self.find_ground_unit_source_by_road(game)
by_road = self.find_ground_unit_source_in_supply_route(
RoadNetwork.for_control_point(self.destination), game
)
if by_road is not None:
return GroundUnitSource(by_road, requires_airlift=False)
return by_road
by_ship = self.find_ground_unit_source_in_supply_route(
ShippingNetwork.for_control_point(self.destination), game
)
if by_ship is not None:
return by_ship
by_air = self.find_ground_unit_source_by_air(game)
if by_air is not None:
return GroundUnitSource(by_air, requires_airlift=True)
return by_air
return None
def find_ground_unit_source_by_road(self, game: Game) -> Optional[ControlPoint]:
supply_route = SupplyRoute.for_control_point(self.destination)
def find_ground_unit_source_in_supply_route(
self, supply_route: SupplyRoute, game: Game
) -> Optional[ControlPoint]:
sources = []
for control_point in supply_route:
if control_point.can_recruit_ground_units(game):

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 Convoy, TransferOrder
from game.transfers import MultiGroupTransport, TransferOrder
from gen.flights.flight import Flight
@ -29,7 +29,7 @@ class GroundObjectUnit:
@dataclass(frozen=True)
class ConvoyUnit:
unit_type: Type[VehicleType]
convoy: Convoy
convoy: MultiGroupTransport
@dataclass(frozen=True)
@ -130,7 +130,7 @@ class UnitMap:
def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit]:
return self.ground_object_units.get(name, None)
def add_convoy_units(self, group: Group, convoy: Convoy) -> None:
def add_convoy_units(self, group: Group, convoy: MultiGroupTransport) -> None:
for unit in group.units:
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.

View File

@ -10,7 +10,7 @@ from dcs.unit import Vehicle
from dcs.unitgroup import VehicleGroup
from dcs.unittype import VehicleType
from game.transfers import Convoy
from game.transfers import MultiGroupTransport
from game.unitmap import UnitMap
from game.utils import kph
@ -30,7 +30,7 @@ class ConvoyGenerator:
for convoy in self.game.transfers.convoys:
self.generate_convoy(convoy)
def generate_convoy(self, convoy: Convoy) -> VehicleGroup:
def generate_convoy(self, convoy: MultiGroupTransport) -> VehicleGroup:
group = self._create_mixed_unit_group(
convoy.name,
convoy.route_start,

View File

@ -39,7 +39,7 @@ from game.theater.theatergroundobject import (
NavalGroundObject,
VehicleGroupGroundObject,
)
from game.transfers import Convoy, TransferOrder
from game.transfers import Convoy, MultiGroupTransport, TransferOrder
from game.utils import Distance, nautical_miles
from gen import Conflict
from gen.ato import Package
@ -445,7 +445,7 @@ class ObjectiveFinder:
airfields.append(control_point)
return self._targets_by_range(airfields)
def convoys(self) -> Iterator[Convoy]:
def convoys(self) -> Iterator[MultiGroupTransport]:
for front_line in self.front_lines():
if front_line.control_point_a.is_friendly(self.is_player):
enemy_cp = front_line.control_point_a

View File

@ -251,6 +251,7 @@ class NameGenerator:
infantry_number = 0
aircraft_number = 0
convoy_number = 0
cargo_ship_number = 0
ANIMALS = ANIMALS
existing_alphas: List[str] = []
@ -260,6 +261,7 @@ class NameGenerator:
cls.number = 0
cls.infantry_number = 0
cls.convoy_number = 0
cls.cargo_ship_number = 0
cls.ANIMALS = ANIMALS
cls.existing_alphas = []
@ -269,6 +271,7 @@ class NameGenerator:
cls.infantry_number = 0
cls.aircraft_number = 0
cls.convoy_number = 0
cls.cargo_ship_number = 0
@classmethod
def next_aircraft_name(cls, country: Country, parent_base_id: int, flight: Flight):
@ -335,6 +338,11 @@ class NameGenerator:
cls.convoy_number += 1
return f"Convoy {cls.convoy_number:03}"
@classmethod
def next_cargo_ship_name(cls) -> str:
cls.cargo_ship_number += 1
return f"Cargo Ship {cls.cargo_ship_number:03}"
@classmethod
def random_objective_name(cls):
if len(cls.ANIMALS) == 0:

View File

@ -4,7 +4,7 @@ import datetime
import logging
import math
from functools import singledispatchmethod
from typing import Iterable, Iterator, List, Optional, Tuple
from typing import Iterable, Iterator, List, Optional, Sequence, Tuple
from PySide2 import QtCore, QtWidgets
from PySide2.QtCore import QLineF, QPointF, QRectF, Qt
@ -87,7 +87,7 @@ def bernstein(t: float, i: int, n: int) -> float:
return binomial(i, n) * (t ** i) * ((1 - t) ** (n - i))
def bezier(t: float, points: Iterable[Tuple[float, float]]) -> Tuple[float, float]:
def bezier(t: float, points: Sequence[Tuple[float, float]]) -> Tuple[float, float]:
"""Calculate coordinate of a point in the bezier curve"""
n = len(points) - 1
x = y = 0
@ -99,7 +99,7 @@ def bezier(t: float, points: Iterable[Tuple[float, float]]) -> Tuple[float, floa
def bezier_curve_range(
n: int, points: Iterable[Tuple[float, float]]
n: int, points: Sequence[Tuple[float, float]]
) -> Iterator[Tuple[float, float]]:
"""Range of points in a curve bezier"""
for i in range(n):
@ -145,7 +145,7 @@ class QLiberationMap(QGraphicsView):
QtCore.QLineF(QPointF(0, 0), QPointF(0, 0))
)
self.movement_line.setPen(QPen(CONST.COLORS["orange"], width=10.0))
self.selected_cp: QMapControlPoint = None
self.selected_cp: Optional[QMapControlPoint] = None
GameUpdateSignal.get_instance().flight_paths_changed.connect(
lambda: self.draw_flight_plans(self.scene())
@ -767,7 +767,7 @@ class QLiberationMap(QGraphicsView):
scene: QGraphicsScene,
number: int,
waypoint: FlightWaypoint,
position: Tuple[int, int],
position: Tuple[float, float],
flight_plan: FlightPlan,
) -> None:
@ -880,19 +880,28 @@ class QLiberationMap(QGraphicsView):
self.draw_shipping_lane_between(cp, destination)
def draw_shipping_lane_between(self, a: ControlPoint, b: ControlPoint) -> None:
ship_map = self.game.transfers.cargo_ships
ships = []
ship = ship_map.find_transport(a, b)
if ship is not None:
ships.append(ship)
ship = ship_map.find_transport(b, a)
if ship is not None:
ships.append(ship)
scene = self.scene()
for pa, pb in self.bezier_points(a.shipping_lanes[b]):
scene.addItem(ShippingLaneSegment(pa[0], pa[1], pb[0], pb[1], a, b))
scene.addItem(ShippingLaneSegment(pa[0], pa[1], pb[0], pb[1], a, b, ships))
def draw_supply_route_between(self, a: ControlPoint, b: ControlPoint) -> None:
scene = self.scene()
convoy_map = self.game.transfers.convoys
convoys = []
convoy = convoy_map.find_convoy(a, b)
convoy = convoy_map.find_transport(a, b)
if convoy is not None:
convoys.append(convoy)
convoy = convoy_map.find_convoy(b, a)
convoy = convoy_map.find_transport(b, a)
if convoy is not None:
convoys.append(convoy)

View File

@ -1,4 +1,4 @@
from typing import Optional
from typing import List, Optional
from PySide2.QtCore import Qt
from PySide2.QtGui import QColor, QPen
@ -8,6 +8,7 @@ from PySide2.QtWidgets import (
)
from game.theater import ControlPoint
from game.transfers import CargoShip
from qt_ui.uiconstants import COLORS
@ -20,12 +21,13 @@ class ShippingLaneSegment(QGraphicsLineItem):
y1: float,
control_point_a: ControlPoint,
control_point_b: ControlPoint,
ships: List[CargoShip],
parent: Optional[QGraphicsItem] = None,
) -> None:
super().__init__(x0, y0, x1, y1, parent)
self.control_point_a = control_point_a
self.control_point_b = control_point_b
self.ships = []
self.ships = ships
self.setPen(self.make_pen())
self.setToolTip(self.make_tooltip())
self.setAcceptHoverEvents(True)

View File

@ -2,12 +2,10 @@ from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Callable, Dict, Type
from PySide2.QtCore import Qt
from PySide2.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QFrame,
@ -15,7 +13,6 @@ from PySide2.QtWidgets import (
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
@ -27,7 +24,7 @@ from dcs.task import PinpointStrike
from dcs.unittype import UnitType
from game import Game, db
from game.theater import ControlPoint, SupplyRoute
from game.theater import ControlPoint
from game.transfers import TransferOrder
from qt_ui.models import GameModel
from qt_ui.widgets.QLabeledWidget import QLabeledWidget