From bd9cbf5e3bbb243a928394504e439b59108cb586 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Apr 2021 19:03:33 -0700 Subject: [PATCH] Move transfers one CP per turn. https://github.com/Khopa/dcs_liberation/issues/824 --- game/game.py | 2 +- game/theater/__init__.py | 1 + game/theater/supplyroutes.py | 75 ++++++++++++++++++++- game/transfers.py | 89 ++++++++++++++++++++----- qt_ui/windows/PendingTransfersDialog.py | 8 ++- 5 files changed, 154 insertions(+), 21 deletions(-) diff --git a/game/game.py b/game/game.py index 9dcf5006..5e388d38 100644 --- a/game/game.py +++ b/game/game.py @@ -275,7 +275,7 @@ class Game: for control_point in self.theater.controlpoints: control_point.process_turn(self) - self.transfers.complete_transfers() + self.transfers.perform_transfers() self.process_enemy_income() diff --git a/game/theater/__init__.py b/game/theater/__init__.py index c5b83a16..f4491283 100644 --- a/game/theater/__init__.py +++ b/game/theater/__init__.py @@ -2,4 +2,5 @@ from .base import * from .conflicttheater import * from .controlpoint import * from .missiontarget import MissionTarget +from .supplyroutes import SupplyRoute from .theatergroundobject import SamGroundObject diff --git a/game/theater/supplyroutes.py b/game/theater/supplyroutes.py index 0bbaaec1..72cf9602 100644 --- a/game/theater/supplyroutes.py +++ b/game/theater/supplyroutes.py @@ -1,10 +1,37 @@ from __future__ import annotations -from typing import Iterator, List, Optional +import heapq +import math +from collections import defaultdict +from dataclasses import dataclass, field +from typing import Dict, Iterator, List, Optional from game.theater.controlpoint import ControlPoint +@dataclass(frozen=True, order=True) +class FrontierNode: + cost: float + point: ControlPoint = field(compare=False) + + +class Frontier: + def __init__(self) -> None: + self.nodes: List[FrontierNode] = [] + + def push(self, poly: ControlPoint, cost: float) -> None: + heapq.heappush(self.nodes, FrontierNode(cost, poly)) + + def pop(self) -> Optional[FrontierNode]: + try: + return heapq.heappop(self.nodes) + except IndexError: + return None + + def __bool__(self) -> bool: + return bool(self.nodes) + + class SupplyRoute: def __init__(self, control_points: List[ControlPoint]) -> None: self.control_points = control_points @@ -21,3 +48,49 @@ class SupplyRoute: if not connected_friendly_points: return None return SupplyRoute([control_point] + connected_friendly_points) + + 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") + if destination not in self: + raise ValueError(f"{destination.name} is not in this supply route") + + frontier = Frontier() + frontier.push(origin, 0) + + came_from: Dict[ControlPoint, Optional[ControlPoint]] = {origin: None} + + best_known: Dict[ControlPoint, float] = defaultdict(lambda: math.inf) + best_known[origin] = 0.0 + + while (node := frontier.pop()) is not None: + cost = node.cost + current = node.point + if cost > best_known[current]: + continue + + for neighbor in current.connected_points: + if current.captured != neighbor.captured: + continue + + new_cost = cost + 1 + if new_cost < best_known[neighbor]: + best_known[neighbor] = new_cost + frontier.push(neighbor, new_cost) + came_from[neighbor] = current + + # Reconstruct and reverse the path. + current = destination + path: List[ControlPoint] = [] + while current != origin: + path.append(current) + previous = came_from[current] + if previous is None: + raise RuntimeError( + f"Could not reconstruct path to {destination} from {origin}" + ) + current = previous + path.reverse() + return path diff --git a/game/transfers.py b/game/transfers.py index cc2e7665..3d6fa6fe 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -1,9 +1,10 @@ import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Dict, List, Type from dcs.unittype import VehicleType from game.theater import ControlPoint +from game.theater.supplyroutes import SupplyRoute @dataclass @@ -30,6 +31,19 @@ class RoadTransferOrder(TransferOrder): #: The units being transferred. units: Dict[Type[VehicleType], int] + #: The current position of the group being transferred. Groups move one control + #: point a turn through the supply line. + position: ControlPoint = field(init=False) + + def __post_init__(self) -> None: + self.position = self.origin + + def path(self) -> List[ControlPoint]: + supply_route = SupplyRoute.for_control_point(self.position) + if supply_route is None: + raise RuntimeError(f"Supply route from {self.position.name} interrupted") + return supply_route.shortest_path_between(self.position, self.destination) + class PendingTransfers: def __init__(self) -> None: @@ -50,27 +64,66 @@ class PendingTransfers: self.pending_transfers.remove(transfer) transfer.origin.base.commision_units(transfer.units) - def complete_transfers(self) -> None: + def perform_transfers(self) -> None: + incomplete = [] for transfer in self.pending_transfers: - self.complete_transfer(transfer) - self.pending_transfers.clear() + if not self.perform_transfer(transfer): + incomplete.append(transfer) + self.pending_transfers = incomplete - @staticmethod - def complete_transfer(transfer: RoadTransferOrder) -> None: - if transfer.player == transfer.destination.captured: + def perform_transfer(self, transfer: RoadTransferOrder) -> bool: + 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 supply_route is None or 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) - elif transfer.player == transfer.origin.captured: - logging.info( - f"{transfer.destination.name} was captured. Transferring units are " - f"returning to {transfer.origin.name}" - ) - transfer.origin.base.commision_units(transfer.units) - else: - logging.info( - f"Both {transfer.origin.name} and {transfer.destination.name} were " - "captured. Units were surrounded and captured during transfer." - ) + 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." + ) diff --git a/qt_ui/windows/PendingTransfersDialog.py b/qt_ui/windows/PendingTransfersDialog.py index 6892debf..447f6437 100644 --- a/qt_ui/windows/PendingTransfersDialog.py +++ b/qt_ui/windows/PendingTransfersDialog.py @@ -22,6 +22,7 @@ from PySide2.QtWidgets import ( QVBoxLayout, ) +from game.theater.supplyroutes import SupplyRoute from game.transfers import RoadTransferOrder from qt_ui.delegate_helpers import painter_context from qt_ui.models import GameModel, TransferModel @@ -50,7 +51,12 @@ class TransferDelegate(QStyledItemDelegate): def second_row_text(self, index: QModelIndex) -> str: transfer = self.transfer(index) - return f"Currently at {transfer.origin}. Arrives at destination in 1 turn." + path = transfer.path() + if len(path) == 1: + turns = "1 turn" + else: + turns = f"{len(path)} turns" + return f"Currently at {transfer.position}. Arrives at destination in {turns}." def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex