From b65d178cf1a416c63cc7992a29b3fb47f3dae2d4 Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sun, 18 Apr 2021 15:57:18 -0700
Subject: [PATCH 001/438] Move develop to 2.6.
---
changelog.md | 8 ++++++++
game/version.py | 2 +-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/changelog.md b/changelog.md
index 2f5e9d14..11770bcb 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,11 @@
+# 2.6.0
+
+Saves from 2.5 are not compatible with 2.6.
+
+## Features/Improvements
+
+## Fixes
+
# 2.5.0
Saves from 2.4 are not compatible with 2.5.
diff --git a/game/version.py b/game/version.py
index 4d54004e..37a7dcfb 100644
--- a/game/version.py
+++ b/game/version.py
@@ -2,7 +2,7 @@ from pathlib import Path
def _build_version_string() -> str:
- components = ["2.5"]
+ components = ["2.6"]
build_number_path = Path("resources/buildnumber")
if build_number_path.exists():
with build_number_path.open("r") as build_number_file:
From e9ff554f397a3ad8fe61423cdfdff8e0eafc9a5e Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Mon, 15 Feb 2021 12:49:36 -0800
Subject: [PATCH 002/438] Basic implementation of road based transfers.
This adds the models and UIs for creating ground unit transfer orders.
Most of the feature is still missing:
* The AI doesn't do them.
* Transfers can move across the whole map in one turn.
* Transfers between disconnected bases are allowed.
* Transfers are not modeled in the simulation, so they can't be
interdicted.
https://github.com/Khopa/dcs_liberation/issues/824
---
game/db.py | 4 +-
game/game.py | 13 +
game/transfers.py | 76 +++++
qt_ui/delegate_helpers.py | 13 +
qt_ui/models.py | 63 +++-
qt_ui/widgets/QTopPanel.py | 23 +-
qt_ui/widgets/ato.py | 14 +-
qt_ui/windows/PendingTransfersDialog.py | 189 +++++++++++
.../windows/basemenu/NewUnitTransferDialog.py | 310 ++++++++++++++++++
qt_ui/windows/basemenu/QBaseMenu2.py | 9 +
qt_ui/windows/basemenu/QRecruitBehaviour.py | 17 +-
.../airfield/QAircraftRecruitmentMenu.py | 1 -
12 files changed, 702 insertions(+), 30 deletions(-)
create mode 100644 game/transfers.py
create mode 100644 qt_ui/delegate_helpers.py
create mode 100644 qt_ui/windows/PendingTransfersDialog.py
create mode 100644 qt_ui/windows/basemenu/NewUnitTransferDialog.py
diff --git a/game/db.py b/game/db.py
index 809a43b1..c091a399 100644
--- a/game/db.py
+++ b/game/db.py
@@ -1442,11 +1442,11 @@ def unit_task(unit: UnitType) -> Optional[Task]:
return None
-def find_unittype(for_task: Task, country_name: str) -> List[Type[UnitType]]:
+def find_unittype(for_task: Type[MainTask], country_name: str) -> List[Type[UnitType]]:
return [x for x in UNIT_BY_TASK[for_task] if x in FACTIONS[country_name].units]
-MANPADS: List[VehicleType] = [
+MANPADS: List[Type[VehicleType]] = [
AirDefence.MANPADS_SA_18_Igla_Grouse,
AirDefence.MANPADS_SA_18_Igla_S_Grouse,
AirDefence.MANPADS_Stinger,
diff --git a/game/game.py b/game/game.py
index 22598504..9dcf5006 100644
--- a/game/game.py
+++ b/game/game.py
@@ -34,6 +34,7 @@ from .settings import Settings
from .theater import ConflictTheater, ControlPoint, TheaterGroundObject
from game.theater.theatergroundobject import MissileSiteGroundObject
from .threatzones import ThreatZones
+from .transfers import PendingTransfers
from .unitmap import UnitMap
from .weather import Conditions, TimeOfDay
@@ -121,6 +122,8 @@ class Game:
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
+ self._transfers = PendingTransfers()
+
self.sanitize_sides()
self.on_load()
@@ -151,6 +154,14 @@ class Game:
# Regenerate any state that was not persisted.
self.on_load()
+ @property
+ def transfers(self) -> PendingTransfers:
+ try:
+ return self._transfers
+ except AttributeError:
+ self._transfers = PendingTransfers()
+ return self._transfers
+
def generate_conditions(self) -> Conditions:
return Conditions.generate(
self.theater, self.current_day, self.current_turn_time_of_day, self.settings
@@ -264,6 +275,8 @@ class Game:
for control_point in self.theater.controlpoints:
control_point.process_turn(self)
+ self.transfers.complete_transfers()
+
self.process_enemy_income()
self.process_player_income()
diff --git a/game/transfers.py b/game/transfers.py
new file mode 100644
index 00000000..cc2e7665
--- /dev/null
+++ b/game/transfers.py
@@ -0,0 +1,76 @@
+import logging
+from dataclasses import dataclass
+from typing import Dict, List, Type
+
+from dcs.unittype import VehicleType
+from game.theater import ControlPoint
+
+
+@dataclass
+class TransferOrder:
+ """The base type of all transfer orders.
+
+ A transfer order can transfer multiple units of multiple types.
+ """
+
+ #: The location the units are transferring from.
+ origin: ControlPoint
+
+ #: The location the units are transferring to.
+ destination: ControlPoint
+
+ #: True if the transfer order belongs to the player.
+ player: bool
+
+
+@dataclass
+class RoadTransferOrder(TransferOrder):
+ """A transfer order that moves units by road."""
+
+ #: The units being transferred.
+ units: Dict[Type[VehicleType], int]
+
+
+class PendingTransfers:
+ def __init__(self) -> None:
+ self.pending_transfers: List[RoadTransferOrder] = []
+
+ @property
+ def pending_transfer_count(self) -> int:
+ return len(self.pending_transfers)
+
+ def transfer_at_index(self, index: int) -> RoadTransferOrder:
+ return self.pending_transfers[index]
+
+ def new_transfer(self, transfer: RoadTransferOrder) -> None:
+ transfer.origin.base.commit_losses(transfer.units)
+ self.pending_transfers.append(transfer)
+
+ def cancel_transfer(self, transfer: RoadTransferOrder) -> None:
+ self.pending_transfers.remove(transfer)
+ transfer.origin.base.commision_units(transfer.units)
+
+ def complete_transfers(self) -> None:
+ for transfer in self.pending_transfers:
+ self.complete_transfer(transfer)
+ self.pending_transfers.clear()
+
+ @staticmethod
+ def complete_transfer(transfer: RoadTransferOrder) -> None:
+ if transfer.player == transfer.destination.captured:
+ 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."
+ )
diff --git a/qt_ui/delegate_helpers.py b/qt_ui/delegate_helpers.py
new file mode 100644
index 00000000..0c437310
--- /dev/null
+++ b/qt_ui/delegate_helpers.py
@@ -0,0 +1,13 @@
+from contextlib import contextmanager
+from typing import ContextManager
+
+from PySide2.QtGui import QPainter
+
+
+@contextmanager
+def painter_context(painter: QPainter) -> ContextManager[None]:
+ try:
+ painter.save()
+ yield
+ finally:
+ painter.restore()
diff --git a/qt_ui/models.py b/qt_ui/models.py
index cdc594d6..bd7d2bc8 100644
--- a/qt_ui/models.py
+++ b/qt_ui/models.py
@@ -1,4 +1,6 @@
"""Qt data models for game objects."""
+from __future__ import annotations
+
import datetime
from typing import Any, Callable, Dict, Iterator, Optional, TypeVar
@@ -12,11 +14,12 @@ from PySide2.QtGui import QIcon
from game import db
from game.game import Game
+from game.theater.missiontarget import MissionTarget
+from game.transfers import RoadTransferOrder
from gen.ato import AirTaskingOrder, Package
from gen.flights.flight import Flight
from gen.flights.traveltime import TotEstimator
from qt_ui.uiconstants import AIRCRAFT_ICONS
-from game.theater.missiontarget import MissionTarget
class DeletableChildModelManager:
@@ -285,6 +288,63 @@ class AtoModel(QAbstractListModel):
yield self.package_models.acquire(package)
+class TransferModel(QAbstractListModel):
+ """The model for a ground unit transfer."""
+
+ TransferRole = Qt.UserRole
+
+ def __init__(self, game_model: GameModel) -> None:
+ super().__init__()
+ self.game_model = game_model
+
+ def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
+ return self.game_model.game.transfers.pending_transfer_count
+
+ def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
+ if not index.isValid():
+ return None
+ transfer = self.transfer_at_index(index)
+ if role == Qt.DisplayRole:
+ return self.text_for_transfer(transfer)
+ if role == Qt.DecorationRole:
+ return self.icon_for_transfer(transfer)
+ elif role == TransferModel.TransferRole:
+ return transfer
+ return None
+
+ @staticmethod
+ def text_for_transfer(transfer: RoadTransferOrder) -> str:
+ """Returns the text that should be displayed for the transfer."""
+ count = sum(transfer.units.values())
+ origin = transfer.origin.name
+ destination = transfer.destination.name
+ return f"Transfer of {count} units from {origin} to {destination}"
+
+ @staticmethod
+ def icon_for_transfer(_transfer: RoadTransferOrder) -> Optional[QIcon]:
+ """Returns the icon that should be displayed for the transfer."""
+ return None
+
+ def new_transfer(self, transfer: RoadTransferOrder) -> None:
+ """Updates the game with the new unit transfer."""
+ self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
+ # TODO: Needs to regenerate base inventory tab.
+ self.game_model.game.transfers.new_transfer(transfer)
+ self.endInsertRows()
+
+ def cancel_transfer_at_index(self, index: QModelIndex) -> None:
+ """Cancels the planned unit transfer at the given index."""
+ transfer = self.transfer_at_index(index)
+ self.beginRemoveRows(QModelIndex(), index.row(), index.row())
+ # TODO: Needs to regenerate base inventory tab.
+ self.game_model.game.transfers.cancel_transfer(transfer)
+ self.endRemoveRows()
+
+ def transfer_at_index(self, index: QModelIndex) -> RoadTransferOrder:
+ """Returns the transfer located at the given index."""
+ return self.game_model.game.transfers.transfer_at_index(index.row())
+
+
class GameModel:
"""A model for the Game object.
@@ -294,6 +354,7 @@ class GameModel:
def __init__(self, game: Optional[Game]) -> None:
self.game: Optional[Game] = game
+ self.transfer_model = TransferModel(self)
if self.game is None:
self.ato_model = AtoModel(self.game, AirTaskingOrder())
self.red_ato_model = AtoModel(self.game, AirTaskingOrder())
diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py
index 68834d28..f43657f7 100644
--- a/qt_ui/widgets/QTopPanel.py
+++ b/qt_ui/widgets/QTopPanel.py
@@ -4,6 +4,7 @@ from datetime import timedelta
from typing import List, Optional
from PySide2.QtWidgets import (
+ QDialog,
QFrame,
QGroupBox,
QHBoxLayout,
@@ -22,6 +23,7 @@ from qt_ui.widgets.QFactionsInfos import QFactionsInfos
from qt_ui.widgets.QIntelBox import QIntelBox
from qt_ui.widgets.clientslots import MaxPlayerCount
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
+from qt_ui.windows.PendingTransfersDialog import PendingTransfersDialog
from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow
from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow
from qt_ui.windows.stats.QStatsWindow import QStatsWindow
@@ -32,6 +34,8 @@ class QTopPanel(QFrame):
def __init__(self, game_model: GameModel):
super(QTopPanel, self).__init__()
self.game_model = game_model
+ self.dialog: Optional[QDialog] = None
+
self.setMaximumHeight(70)
self.init_ui()
GameUpdateSignal.get_instance().gameupdated.connect(self.setGame)
@@ -61,6 +65,11 @@ class QTopPanel(QFrame):
self.factionsInfos = QFactionsInfos(self.game)
+ self.transfers = QPushButton("Transfers")
+ self.transfers.setDisabled(True)
+ self.transfers.setProperty("style", "btn-primary")
+ self.transfers.clicked.connect(self.open_transfers)
+
self.settings = QPushButton("Settings")
self.settings.setDisabled(True)
self.settings.setIcon(CONST.ICONS["Settings"])
@@ -77,6 +86,7 @@ class QTopPanel(QFrame):
self.buttonBox = QGroupBox("Misc")
self.buttonBoxLayout = QHBoxLayout()
+ self.buttonBoxLayout.addWidget(self.transfers)
self.buttonBoxLayout.addWidget(self.settings)
self.buttonBoxLayout.addWidget(self.statistics)
self.buttonBox.setLayout(self.buttonBoxLayout)
@@ -106,6 +116,7 @@ class QTopPanel(QFrame):
if game is None:
return
+ self.transfers.setEnabled(True)
self.settings.setEnabled(True)
self.statistics.setEnabled(True)
@@ -121,13 +132,17 @@ class QTopPanel(QFrame):
else:
self.proceedButton.setEnabled(True)
+ def open_transfers(self):
+ self.dialog = PendingTransfersDialog(self.game_model)
+ self.dialog.show()
+
def openSettings(self):
- self.subwindow = QSettingsWindow(self.game)
- self.subwindow.show()
+ self.dialog = QSettingsWindow(self.game)
+ self.dialog.show()
def openStatisticsWindow(self):
- self.subwindow = QStatsWindow(self.game)
- self.subwindow.show()
+ self.dialog = QStatsWindow(self.game)
+ self.dialog.show()
def passTurn(self):
start = timeit.default_timer()
diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py
index fa5e7072..93b170bb 100644
--- a/qt_ui/widgets/ato.py
+++ b/qt_ui/widgets/ato.py
@@ -1,7 +1,6 @@
"""Widgets for displaying air tasking orders."""
import logging
-from contextlib import contextmanager
-from typing import ContextManager, Optional
+from typing import Optional
from PySide2.QtCore import (
QItemSelectionModel,
@@ -32,11 +31,11 @@ from PySide2.QtWidgets import (
QVBoxLayout,
)
-from game import db
from gen.ato import Package
from gen.flights.flight import Flight
from gen.flights.traveltime import TotEstimator
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
+from ..delegate_helpers import painter_context
from ..models import AtoModel, GameModel, NullListModel, PackageModel
@@ -312,15 +311,6 @@ class QFlightPanel(QGroupBox):
self.flight_list.delete_flight(index)
-@contextmanager
-def painter_context(painter: QPainter) -> ContextManager[None]:
- try:
- painter.save()
- yield
- finally:
- painter.restore()
-
-
class PackageDelegate(QStyledItemDelegate):
FONT_SIZE = 12
HMARGIN = 4
diff --git a/qt_ui/windows/PendingTransfersDialog.py b/qt_ui/windows/PendingTransfersDialog.py
new file mode 100644
index 00000000..6892debf
--- /dev/null
+++ b/qt_ui/windows/PendingTransfersDialog.py
@@ -0,0 +1,189 @@
+from typing import Optional
+
+from PySide2.QtCore import (
+ QItemSelection,
+ QItemSelectionModel,
+ QModelIndex,
+ QSize,
+ Qt,
+)
+from PySide2.QtGui import QContextMenuEvent, QFont, QFontMetrics, QIcon, QPainter
+from PySide2.QtWidgets import (
+ QAbstractItemView,
+ QAction,
+ QDialog,
+ QHBoxLayout,
+ QListView,
+ QMenu,
+ QPushButton,
+ QStyle,
+ QStyleOptionViewItem,
+ QStyledItemDelegate,
+ QVBoxLayout,
+)
+
+from game.transfers import RoadTransferOrder
+from qt_ui.delegate_helpers import painter_context
+from qt_ui.models import GameModel, TransferModel
+
+
+class TransferDelegate(QStyledItemDelegate):
+ FONT_SIZE = 10
+ HMARGIN = 4
+ VMARGIN = 4
+
+ def __init__(self, transfer_model: TransferModel) -> None:
+ super().__init__()
+ self.transfer_model = transfer_model
+
+ def get_font(self, option: QStyleOptionViewItem) -> QFont:
+ font = QFont(option.font)
+ font.setPointSize(self.FONT_SIZE)
+ return font
+
+ @staticmethod
+ def transfer(index: QModelIndex) -> RoadTransferOrder:
+ return index.data(TransferModel.TransferRole)
+
+ def first_row_text(self, index: QModelIndex) -> str:
+ return self.transfer_model.data(index, Qt.DisplayRole)
+
+ def second_row_text(self, index: QModelIndex) -> str:
+ transfer = self.transfer(index)
+ return f"Currently at {transfer.origin}. Arrives at destination in 1 turn."
+
+ def paint(
+ self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
+ ) -> None:
+ # Draw the list item with all the default selection styling, but with an
+ # invalid index so text formatting is left to us.
+ super().paint(painter, option, QModelIndex())
+
+ rect = option.rect.adjusted(
+ self.HMARGIN, self.VMARGIN, -self.HMARGIN, -self.VMARGIN
+ )
+
+ with painter_context(painter):
+ painter.setFont(self.get_font(option))
+
+ icon: Optional[QIcon] = index.data(Qt.DecorationRole)
+ if icon is not None:
+ icon.paint(
+ painter,
+ rect,
+ Qt.AlignLeft | Qt.AlignVCenter,
+ self.icon_mode(option),
+ self.icon_state(option),
+ )
+
+ rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, 0, 0, 0)
+ painter.drawText(rect, Qt.AlignLeft, self.first_row_text(index))
+ line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2)
+ painter.drawText(line2, Qt.AlignLeft, self.second_row_text(index))
+
+ @staticmethod
+ def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode:
+ if not (option.state & QStyle.State_Enabled):
+ return QIcon.Disabled
+ elif option.state & QStyle.State_Selected:
+ return QIcon.Selected
+ elif option.state & QStyle.State_Active:
+ return QIcon.Active
+ return QIcon.Normal
+
+ @staticmethod
+ def icon_state(option: QStyleOptionViewItem) -> QIcon.State:
+ return QIcon.On if option.state & QStyle.State_Open else QIcon.Off
+
+ @staticmethod
+ def icon_size(option: QStyleOptionViewItem) -> QSize:
+ icon_size: Optional[QSize] = option.decorationSize
+ if icon_size is None:
+ return QSize(0, 0)
+ else:
+ return icon_size
+
+ def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
+ left = self.icon_size(option).width() + self.HMARGIN
+ metrics = QFontMetrics(self.get_font(option))
+ first = metrics.size(0, self.first_row_text(index))
+ second = metrics.size(0, self.second_row_text(index))
+ text_width = max(first.width(), second.width())
+ return QSize(
+ left + text_width + 2 * self.HMARGIN,
+ first.height() + second.height() + 2 * self.VMARGIN,
+ )
+
+
+class PendingTransfersList(QListView):
+ """List view for displaying the pending unit transfers."""
+
+ def __init__(self, transfer_model: TransferModel) -> None:
+ super().__init__()
+ self.transfer_model = transfer_model
+
+ self.setItemDelegate(TransferDelegate(self.transfer_model))
+ self.setModel(self.transfer_model)
+ self.selectionModel().setCurrentIndex(
+ self.transfer_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select
+ )
+
+ # self.setIconSize(QSize(91, 24))
+ self.setSelectionBehavior(QAbstractItemView.SelectItems)
+
+ def contextMenuEvent(self, event: QContextMenuEvent) -> None:
+ index = self.indexAt(event.pos())
+
+ menu = QMenu("Menu")
+
+ delete_action = QAction("Cancel")
+ delete_action.triggered.connect(lambda: self.cancel_transfer(index))
+ menu.addAction(delete_action)
+
+ menu.exec_(event.globalPos())
+
+ def cancel_transfer(self, index: QModelIndex) -> None:
+ """Cancels the given transfer order."""
+ self.transfer_model.cancel_transfer_at_index(index)
+
+
+class PendingTransfersDialog(QDialog):
+ """Dialog window showing all scheduled transfers for the player."""
+
+ def __init__(self, game_model: GameModel, parent=None) -> None:
+ super().__init__(parent)
+ self.transfer_model = game_model.transfer_model
+
+ self.setMinimumSize(1000, 440)
+ self.setWindowTitle(f"Pending Transfers")
+ # TODO: self.setWindowIcon()
+
+ layout = QVBoxLayout()
+ self.setLayout(layout)
+
+ self.transfer_list = PendingTransfersList(self.transfer_model)
+ self.transfer_list.selectionModel().selectionChanged.connect(
+ self.on_selection_changed
+ )
+ layout.addWidget(self.transfer_list)
+
+ button_layout = QHBoxLayout()
+ layout.addLayout(button_layout)
+
+ button_layout.addStretch()
+
+ self.cancel_button = QPushButton("Cancel Transfer")
+ self.cancel_button.setProperty("style", "btn-danger")
+ self.cancel_button.clicked.connect(self.on_cancel_transfer)
+ self.cancel_button.setEnabled(self.transfer_model.rowCount() > 0)
+ button_layout.addWidget(self.cancel_button)
+
+ def on_cancel_transfer(self) -> None:
+ """Cancels the selected transfer order."""
+ self.transfer_model.cancel_transfer_at_index(self.transfer_list.currentIndex())
+
+ def on_selection_changed(
+ self, selected: QItemSelection, _deselected: QItemSelection
+ ) -> None:
+ """Updates the state of the delete button."""
+ self.cancel_button.setEnabled(not selected.empty())
diff --git a/qt_ui/windows/basemenu/NewUnitTransferDialog.py b/qt_ui/windows/basemenu/NewUnitTransferDialog.py
new file mode 100644
index 00000000..4df54d31
--- /dev/null
+++ b/qt_ui/windows/basemenu/NewUnitTransferDialog.py
@@ -0,0 +1,310 @@
+from __future__ import annotations
+
+import logging
+from collections import defaultdict
+from typing import Callable, Dict, Type
+
+from PySide2.QtCore import Qt
+from PySide2.QtWidgets import (
+ QComboBox,
+ QDialog,
+ QFrame,
+ QGridLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QSpacerItem,
+ QVBoxLayout,
+ QWidget,
+)
+from dcs.task import PinpointStrike
+from dcs.unittype import UnitType
+
+from game import db
+from game.theater import ControlPoint
+from game.transfers import RoadTransferOrder
+from qt_ui.models import GameModel
+from qt_ui.widgets.QLabeledWidget import QLabeledWidget
+
+
+class TransferDestinationComboBox(QComboBox):
+ def __init__(self, game_model: GameModel, origin: ControlPoint) -> None:
+ super().__init__()
+
+ for cp in game_model.game.theater.controlpoints:
+ if cp != origin and cp.captured and not cp.is_global:
+ self.addItem(cp.name, cp)
+ self.model().sort(0)
+ self.setCurrentIndex(0)
+
+
+class UnitTransferList(QFrame):
+ def __init__(self, cp: ControlPoint, game_model: GameModel):
+ super().__init__(self)
+ self.cp = cp
+ self.game_model = game_model
+
+ self.bought_amount_labels = {}
+ self.existing_units_labels = {}
+
+ main_layout = QVBoxLayout()
+ self.setLayout(main_layout)
+
+ scroll_content = QWidget()
+ task_box_layout = QGridLayout()
+ scroll_content.setLayout(task_box_layout)
+
+ units_column = sorted(
+ cp.base.armor,
+ key=lambda u: db.unit_get_expanded_info(
+ self.game_model.game.player_country, u, "name"
+ ),
+ )
+
+ count = 0
+ for count, unit_type in enumerate(units_column):
+ self.add_purchase_row(unit_type, task_box_layout, count)
+ stretch = QVBoxLayout()
+ stretch.addStretch()
+ task_box_layout.addLayout(stretch, count, 0)
+
+ scroll_content.setLayout(task_box_layout)
+ scroll = QScrollArea()
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(scroll_content)
+ main_layout.addWidget(scroll)
+
+
+class TransferDestinationPanel(QVBoxLayout):
+ def __init__(self, label: str, origin: ControlPoint, game_model: GameModel) -> None:
+ super().__init__()
+
+ self.source_combo_box = TransferDestinationComboBox(game_model, origin)
+ self.addLayout(QLabeledWidget(label, self.source_combo_box))
+
+ @property
+ def changed(self):
+ return self.source_combo_box.currentIndexChanged
+
+ @property
+ def current(self) -> ControlPoint:
+ return self.source_combo_box.currentData()
+
+
+class TransferControls(QGroupBox):
+ def __init__(
+ self,
+ increase_text: str,
+ on_increase: Callable[[TransferControls], None],
+ decrease_text: str,
+ on_decrease: Callable[[TransferControls], None],
+ initial_amount: int = 0,
+ disabled: bool = False,
+ ) -> None:
+ super().__init__()
+
+ self.quantity = initial_amount
+
+ self.setProperty("style", "buy-box")
+ self.setMaximumHeight(36)
+ self.setMinimumHeight(36)
+ layout = QHBoxLayout()
+ self.setLayout(layout)
+
+ decrease = QPushButton(decrease_text)
+ decrease.setProperty("style", "btn-sell")
+ decrease.setDisabled(disabled)
+ decrease.setMinimumSize(16, 16)
+ decrease.setMaximumSize(16, 16)
+ decrease.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
+ decrease.clicked.connect(lambda: on_decrease(self))
+ layout.addWidget(decrease)
+
+ self.count_label = QLabel()
+ self.count_label.setSizePolicy(
+ QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ )
+ self.set_quantity(initial_amount)
+ layout.addWidget(self.count_label)
+
+ increase = QPushButton(increase_text)
+ increase.setProperty("style", "btn-buy")
+ increase.setDisabled(disabled)
+ increase.setMinimumSize(16, 16)
+ increase.setMaximumSize(16, 16)
+ increase.clicked.connect(lambda: on_increase(self))
+ increase.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
+ layout.addWidget(increase)
+
+ def set_quantity(self, quantity: int) -> None:
+ self.quantity = quantity
+ self.count_label.setText(f"{self.quantity}")
+
+
+class ScrollingUnitTransferGrid(QFrame):
+ def __init__(self, cp: ControlPoint, game_model: GameModel) -> None:
+ super().__init__()
+ self.cp = cp
+ self.game_model = game_model
+ self.transfers: Dict[Type[UnitType, int]] = defaultdict(int)
+
+ main_layout = QVBoxLayout()
+
+ scroll_content = QWidget()
+ task_box_layout = QGridLayout()
+
+ unit_types = set(
+ db.find_unittype(PinpointStrike, self.game_model.game.player_name)
+ )
+ sorted_units = sorted(
+ {u for u in unit_types if self.cp.base.total_units_of_type(u)},
+ key=lambda u: db.unit_get_expanded_info(
+ self.game_model.game.player_country, u, "name"
+ ),
+ )
+ for row, unit_type in enumerate(sorted_units):
+ self.add_unit_row(unit_type, task_box_layout, row)
+ stretch = QVBoxLayout()
+ stretch.addStretch()
+ task_box_layout.addLayout(stretch, task_box_layout.count(), 0)
+
+ scroll_content.setLayout(task_box_layout)
+ scroll = QScrollArea()
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(scroll_content)
+ main_layout.addWidget(scroll)
+ self.setLayout(main_layout)
+
+ def add_unit_row(
+ self,
+ unit_type: Type[UnitType],
+ layout: QGridLayout,
+ row: int,
+ ) -> None:
+ exist = QGroupBox()
+ exist.setProperty("style", "buy-box")
+ exist.setMaximumHeight(36)
+ exist.setMinimumHeight(36)
+ origin_inventory_layout = QHBoxLayout()
+ exist.setLayout(origin_inventory_layout)
+
+ origin_inventory = self.cp.base.total_units_of_type(unit_type)
+
+ unit_name = QLabel(
+ ""
+ + db.unit_get_expanded_info(
+ self.game_model.game.player_country, unit_type, "name"
+ )
+ + ""
+ )
+ unit_name.setSizePolicy(
+ QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ )
+
+ origin_inventory_label = QLabel(str(origin_inventory))
+ origin_inventory_label.setSizePolicy(
+ QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ )
+
+ def increase(controls: TransferControls):
+ nonlocal origin_inventory
+ nonlocal origin_inventory_label
+ if not origin_inventory:
+ return
+
+ self.transfers[unit_type] += 1
+ origin_inventory -= 1
+ controls.set_quantity(self.transfers[unit_type])
+ origin_inventory_label.setText(str(origin_inventory))
+
+ def decrease(controls: TransferControls):
+ nonlocal origin_inventory
+ nonlocal origin_inventory_label
+ if not controls.quantity:
+ return
+
+ self.transfers[unit_type] -= 1
+ origin_inventory += 1
+ controls.set_quantity(self.transfers[unit_type])
+ origin_inventory_label.setText(str(origin_inventory))
+
+ transfer_controls = TransferControls("->", increase, "<-", decrease)
+
+ origin_inventory_layout.addWidget(unit_name)
+ origin_inventory_layout.addItem(
+ QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Minimum)
+ )
+ origin_inventory_layout.addWidget(origin_inventory_label)
+ origin_inventory_layout.addItem(
+ QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Minimum)
+ )
+
+ layout.addWidget(exist, row, 1)
+ layout.addWidget(transfer_controls, row, 2)
+
+
+class NewUnitTransferDialog(QDialog):
+ def __init__(
+ self,
+ game_model: GameModel,
+ origin: ControlPoint,
+ parent=None,
+ ) -> None:
+ super().__init__(parent)
+ self.origin = origin
+ self.setWindowTitle(f"New unit transfer from {origin.name}")
+
+ self.game_model = game_model
+
+ layout = QVBoxLayout()
+ self.setLayout(layout)
+
+ self.dest_panel = TransferDestinationPanel("Destination:", origin, game_model)
+ self.dest_panel.changed.connect(self.on_destination_changed)
+ layout.addLayout(self.dest_panel)
+
+ self.transfer_panel = ScrollingUnitTransferGrid(origin, game_model)
+ layout.addWidget(self.transfer_panel)
+
+ self.submit_button = QPushButton("Create Transfer Order", parent=self)
+ self.submit_button.clicked.connect(self.on_submit)
+ self.submit_button.setProperty("style", "start-button")
+ layout.addWidget(self.submit_button)
+
+ def on_destination_changed(self, index: int) -> None:
+ # Rebuild the transfer panel to reset everything. It's easier to recreate the
+ # panel itself than to clear the grid layout in the panel.
+ self.layout().removeWidget(self.transfer_panel)
+ self.layout().removeWidget(self.submit_button)
+ self.transfer_panel = ScrollingUnitTransferGrid(self.origin, self.game_model)
+ self.layout().addWidget(self.transfer_panel)
+ self.layout().addWidget(self.submit_button)
+
+ def on_submit(self) -> None:
+ 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}"
+ )
+ transfers[unit_type] = count
+
+ self.game_model.transfer_model.new_transfer(
+ RoadTransferOrder(
+ player=True,
+ origin=self.transfer_panel.cp,
+ destination=self.dest_panel.current,
+ units=transfers,
+ )
+ )
+ self.close()
diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py
index a4793b97..ec6f03dc 100644
--- a/qt_ui/windows/basemenu/QBaseMenu2.py
+++ b/qt_ui/windows/basemenu/QBaseMenu2.py
@@ -19,6 +19,7 @@ from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs
from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour
+from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog
class QBaseMenu2(QDialog):
@@ -88,6 +89,11 @@ class QBaseMenu2(QDialog):
runway_attack_button.setProperty("style", "btn-danger")
runway_attack_button.clicked.connect(self.new_package)
+ if self.cp.captured and not self.cp.is_global:
+ transfer_button = QPushButton("Transfer Units")
+ bottom_row.addWidget(transfer_button)
+ transfer_button.clicked.connect(self.open_transfer_dialog)
+
self.budget_display = QLabel(
QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.budget)
)
@@ -180,5 +186,8 @@ class QBaseMenu2(QDialog):
def new_package(self) -> None:
Dialog.open_new_package_dialog(self.cp, parent=self.window())
+ def open_transfer_dialog(self) -> None:
+ NewUnitTransferDialog(self.game_model, self.cp, parent=self.window()).show()
+
def update_budget(self, game: Game) -> None:
self.budget_display.setText(QRecruitBehaviour.BUDGET_FORMAT.format(game.budget))
diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py
index 97ef9b2a..1e429895 100644
--- a/qt_ui/windows/basemenu/QRecruitBehaviour.py
+++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py
@@ -1,17 +1,22 @@
import logging
-from typing import Type
+from typing import Callable, Set, Type
from PySide2.QtCore import Qt
from PySide2.QtWidgets import (
+ QFrame,
+ QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLayout,
QPushButton,
+ QScrollArea,
QSizePolicy,
QSpacerItem,
+ QVBoxLayout,
+ QWidget,
)
-from dcs.unittype import UnitType
+from dcs.unittype import FlyingType, UnitType
from game import db
from game.event import UnitsDeliveryEvent
@@ -27,13 +32,11 @@ class QRecruitBehaviour:
existing_units_labels = None
bought_amount_labels = None
maximum_units = -1
- recruitable_types = []
BUDGET_FORMAT = "Available Budget: ${:.2f}M"
def __init__(self) -> None:
self.bought_amount_labels = {}
self.existing_units_labels = {}
- self.recruitable_types = []
self.update_available_budget()
@property
@@ -195,9 +198,3 @@ class QRecruitBehaviour:
Set the maximum number of units that can be bought
"""
self.maximum_units = maximum_units
-
- def set_recruitable_types(self, recruitables_types):
- """
- Set the maximum number of units that can be bought
- """
- self.recruitables_types = recruitables_types
diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
index 53f6cf69..970e1c6c 100644
--- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
+++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py
@@ -34,7 +34,6 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
# Determine maximum number of aircrafts that can be bought
self.set_maximum_units(self.cp.total_aircraft_parking)
- self.set_recruitable_types([CAP, CAS])
self.bought_amount_labels = {}
self.existing_units_labels = {}
From 65f6a4eddd9ffe68f6b5359127bb4396001e5c80 Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sat, 17 Apr 2021 17:27:21 -0700
Subject: [PATCH 003/438] Restrict transfers to connected bases.
https://github.com/Khopa/dcs_liberation/issues/824
---
game/theater/controlpoint.py | 19 ++++++++++++++-
game/theater/supplyroutes.py | 23 +++++++++++++++++++
.../windows/basemenu/NewUnitTransferDialog.py | 14 +++++------
qt_ui/windows/basemenu/QBaseMenu2.py | 8 +++++--
4 files changed, 54 insertions(+), 10 deletions(-)
create mode 100644 game/theater/supplyroutes.py
diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py
index f2a95200..69b9db2f 100644
--- a/game/theater/controlpoint.py
+++ b/game/theater/controlpoint.py
@@ -8,7 +8,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from functools import total_ordering
-from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Type
+from typing import Any, Dict, Iterator, List, Optional, Set, TYPE_CHECKING, Type
from dcs.mapping import Point
from dcs.ships import (
@@ -292,6 +292,23 @@ class ControlPoint(MissionTarget, ABC):
def is_global(self):
return not self.connected_points
+ def transitive_connected_friendly_points(
+ self, seen: Optional[Set[ControlPoint]] = None
+ ) -> List[ControlPoint]:
+ if seen is None:
+ seen = {self}
+
+ connected = []
+ for cp in self.connected_points:
+ if cp.captured != self.captured:
+ continue
+ if cp in seen:
+ continue
+ seen.add(cp)
+ connected.append(cp)
+ connected.extend(cp.transitive_connected_friendly_points(seen))
+ return connected
+
@property
def is_carrier(self):
"""
diff --git a/game/theater/supplyroutes.py b/game/theater/supplyroutes.py
new file mode 100644
index 00000000..0bbaaec1
--- /dev/null
+++ b/game/theater/supplyroutes.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from typing import Iterator, List, Optional
+
+from game.theater.controlpoint import ControlPoint
+
+
+class SupplyRoute:
+ def __init__(self, control_points: List[ControlPoint]) -> None:
+ self.control_points = control_points
+
+ def __contains__(self, item: ControlPoint) -> bool:
+ return item in self.control_points
+
+ def __iter__(self) -> Iterator[ControlPoint]:
+ yield from self.control_points
+
+ @classmethod
+ def for_control_point(cls, control_point: ControlPoint) -> Optional[SupplyRoute]:
+ connected_friendly_points = control_point.transitive_connected_friendly_points()
+ if not connected_friendly_points:
+ return None
+ return SupplyRoute([control_point] + connected_friendly_points)
diff --git a/qt_ui/windows/basemenu/NewUnitTransferDialog.py b/qt_ui/windows/basemenu/NewUnitTransferDialog.py
index 4df54d31..a4c3f554 100644
--- a/qt_ui/windows/basemenu/NewUnitTransferDialog.py
+++ b/qt_ui/windows/basemenu/NewUnitTransferDialog.py
@@ -24,18 +24,18 @@ from dcs.task import PinpointStrike
from dcs.unittype import UnitType
from game import db
-from game.theater import ControlPoint
+from game.theater import ControlPoint, SupplyRoute
from game.transfers import RoadTransferOrder
from qt_ui.models import GameModel
from qt_ui.widgets.QLabeledWidget import QLabeledWidget
class TransferDestinationComboBox(QComboBox):
- def __init__(self, game_model: GameModel, origin: ControlPoint) -> None:
+ def __init__(self, origin: ControlPoint) -> None:
super().__init__()
- for cp in game_model.game.theater.controlpoints:
- if cp != origin and cp.captured and not cp.is_global:
+ for cp in SupplyRoute.for_control_point(origin):
+ if cp != origin and cp.captured:
self.addItem(cp.name, cp)
self.model().sort(0)
self.setCurrentIndex(0)
@@ -81,10 +81,10 @@ class UnitTransferList(QFrame):
class TransferDestinationPanel(QVBoxLayout):
- def __init__(self, label: str, origin: ControlPoint, game_model: GameModel) -> None:
+ def __init__(self, label: str, origin: ControlPoint) -> None:
super().__init__()
- self.source_combo_box = TransferDestinationComboBox(game_model, origin)
+ self.source_combo_box = TransferDestinationComboBox(origin)
self.addLayout(QLabeledWidget(label, self.source_combo_box))
@property
@@ -266,7 +266,7 @@ class NewUnitTransferDialog(QDialog):
layout = QVBoxLayout()
self.setLayout(layout)
- self.dest_panel = TransferDestinationPanel("Destination:", origin, game_model)
+ self.dest_panel = TransferDestinationPanel("Destination:", origin)
self.dest_panel.changed.connect(self.on_destination_changed)
layout.addLayout(self.dest_panel)
diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py
index ec6f03dc..8f287b6d 100644
--- a/qt_ui/windows/basemenu/QBaseMenu2.py
+++ b/qt_ui/windows/basemenu/QBaseMenu2.py
@@ -11,7 +11,7 @@ from PySide2.QtWidgets import (
)
from game import Game, db
-from game.theater import ControlPoint, ControlPointType
+from game.theater import ControlPoint, ControlPointType, SupplyRoute
from gen.flights.flight import FlightType
from qt_ui.dialogs import Dialog
from qt_ui.models import GameModel
@@ -89,7 +89,7 @@ class QBaseMenu2(QDialog):
runway_attack_button.setProperty("style", "btn-danger")
runway_attack_button.clicked.connect(self.new_package)
- if self.cp.captured and not self.cp.is_global:
+ if self.cp.captured and self.has_transfer_destinations:
transfer_button = QPushButton("Transfer Units")
bottom_row.addWidget(transfer_button)
transfer_button.clicked.connect(self.open_transfer_dialog)
@@ -103,6 +103,10 @@ class QBaseMenu2(QDialog):
GameUpdateSignal.get_instance().budgetupdated.connect(self.update_budget)
self.setLayout(main_layout)
+ @property
+ def has_transfer_destinations(self) -> bool:
+ return SupplyRoute.for_control_point(self.cp) is not None
+
@property
def can_repair_runway(self) -> bool:
return self.cp.captured and self.cp.runway_can_be_repaired
From bd9cbf5e3bbb243a928394504e439b59108cb586 Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sat, 17 Apr 2021 19:03:33 -0700
Subject: [PATCH 004/438] 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
From 5dd7ea306053b3e0b08a27e40640def5e020fe82 Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sat, 17 Apr 2021 20:04:48 -0700
Subject: [PATCH 005/438] Spawn convoys for transfers.
Destroying these units currently has no effect.
https://github.com/Khopa/dcs_liberation/issues/824
---
game/operation/operation.py | 7 +++
game/transfers.py | 5 +-
game/unitmap.py | 27 +++++++++++
gen/convoys.py | 96 +++++++++++++++++++++++++++++++++++++
4 files changed, 134 insertions(+), 1 deletion(-)
create mode 100644 gen/convoys.py
diff --git a/game/operation/operation.py b/game/operation/operation.py
index 271a99aa..5bf7c4e7 100644
--- a/game/operation/operation.py
+++ b/game/operation/operation.py
@@ -22,6 +22,7 @@ from gen.airsupportgen import AirSupport, AirSupportConflictGenerator
from gen.armor import GroundConflictGenerator, JtacInfo
from gen.beacons import load_beacons_for_terrain
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
+from gen.convoys import ConvoyGenerator
from gen.environmentgen import EnvironmentGenerator
from gen.forcedoptionsgen import ForcedOptionsGenerator
from gen.groundobjectsgen import GroundObjectsGenerator
@@ -314,6 +315,7 @@ class Operation:
cls.airgen.flights, cls.airsupportgen.air_support
)
cls._generate_ground_conflicts()
+ cls._generate_convoys()
# Triggers
triggersgen = TriggersGenerator(cls.current_mission, cls.game)
@@ -428,6 +430,11 @@ class Operation:
ground_conflict_gen.generate()
cls.jtacs.extend(ground_conflict_gen.jtacs)
+ @classmethod
+ def _generate_convoys(cls) -> None:
+ """Generates convoys for unit transfers by road."""
+ ConvoyGenerator(cls.current_mission, cls.game, cls.unit_map).generate()
+
@classmethod
def reset_naming_ids(cls):
namegen.reset_numbers()
diff --git a/game/transfers.py b/game/transfers.py
index 3d6fa6fe..9372df65 100644
--- a/game/transfers.py
+++ b/game/transfers.py
@@ -1,6 +1,6 @@
import logging
from dataclasses import dataclass, field
-from typing import Dict, List, Type
+from typing import Dict, Iterator, List, Type
from dcs.unittype import VehicleType
from game.theater import ControlPoint
@@ -49,6 +49,9 @@ class PendingTransfers:
def __init__(self) -> None:
self.pending_transfers: List[RoadTransferOrder] = []
+ def __iter__(self) -> Iterator[RoadTransferOrder]:
+ yield from self.pending_transfers
+
@property
def pending_transfer_count(self) -> int:
return len(self.pending_transfers)
diff --git a/game/unitmap.py b/game/unitmap.py
index 149cba40..119eae50 100644
--- a/game/unitmap.py
+++ b/game/unitmap.py
@@ -9,6 +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 RoadTransferOrder
from gen.flights.flight import Flight
@@ -25,6 +26,12 @@ class GroundObjectUnit:
unit: Unit
+@dataclass(frozen=True)
+class ConvoyUnit:
+ unit_type: Type[VehicleType]
+ transfer: RoadTransferOrder
+
+
@dataclass(frozen=True)
class Building:
ground_object: BuildingGroundObject
@@ -37,6 +44,7 @@ class UnitMap:
self.front_line_units: Dict[str, FrontLineUnit] = {}
self.ground_object_units: Dict[str, GroundObjectUnit] = {}
self.buildings: Dict[str, Building] = {}
+ self.convoys: Dict[str, ConvoyUnit] = {}
def add_aircraft(self, group: FlyingGroup, flight: Flight) -> None:
for unit in group.units:
@@ -113,6 +121,25 @@ 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, transfer: RoadTransferOrder) -> None:
+ for unit in group.units:
+ # The actual name is a String (the pydcs translatable string), which
+ # doesn't define __eq__.
+ name = str(unit.name)
+ if name in self.convoys:
+ raise RuntimeError(f"Duplicate convoy unit: {name}")
+ unit_type = db.unit_type_from_name(unit.type)
+ if unit_type is None:
+ raise RuntimeError(f"Unknown unit type: {unit.type}")
+ if not issubclass(unit_type, VehicleType):
+ raise RuntimeError(
+ f"{name} is a {unit_type.__name__}, expected a VehicleType"
+ )
+ self.convoys[name] = ConvoyUnit(unit_type, transfer)
+
+ def convoy_unit(self, name: str) -> Optional[ConvoyUnit]:
+ return self.convoys.get(name, None)
+
def add_building(self, ground_object: BuildingGroundObject, group: Group) -> None:
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
diff --git a/gen/convoys.py b/gen/convoys.py
new file mode 100644
index 00000000..ff9f9ccc
--- /dev/null
+++ b/gen/convoys.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import itertools
+from typing import Dict, TYPE_CHECKING, Type
+
+from dcs import Mission
+from dcs.mapping import Point
+from dcs.point import PointAction
+from dcs.unit import Vehicle
+from dcs.unitgroup import VehicleGroup
+from dcs.unittype import VehicleType
+
+from game.transfers import RoadTransferOrder
+from game.unitmap import UnitMap
+
+if TYPE_CHECKING:
+ from game import Game
+
+
+class ConvoyGenerator:
+ def __init__(self, mission: Mission, game: Game, unit_map: UnitMap) -> None:
+ self.mission = mission
+ self.game = game
+ self.unit_map = unit_map
+ self.count = itertools.count()
+
+ def generate(self) -> None:
+ # Reset the count to make generation deterministic.
+ self.count = itertools.count()
+ for transfer in self.game.transfers:
+ self.generate_convoy_for(transfer)
+
+ def generate_convoy_for(self, transfer: RoadTransferOrder) -> None:
+ # TODO: Add convoy spawn points to campaign so these can start on/near a road.
+ # Groups that start with an on-road waypoint that are not on a road will move to
+ # the road one at a time. Spawning them arbitrarily at the control point spawns
+ # them on the runway (or in a FOB structure) and they'll take forever to get to
+ # a road.
+ origin = transfer.position.position
+ next_hop = transfer.path()[0]
+ destination = next_hop.position
+
+ group = self._create_mixed_unit_group(
+ f"Convoy {next(self.count)}",
+ origin,
+ transfer.units,
+ transfer.player,
+ )
+ group.add_waypoint(destination, move_formation=PointAction.OnRoad)
+ self.make_drivable(group)
+ self.unit_map.add_convoy_units(group, transfer)
+
+ def _create_mixed_unit_group(
+ self,
+ name: str,
+ position: Point,
+ units: Dict[Type[VehicleType], int],
+ for_player: bool,
+ ) -> VehicleGroup:
+ country = self.mission.country(
+ self.game.player_country if for_player else self.game.enemy_country
+ )
+
+ unit_types = list(units.items())
+ main_unit_type, main_unit_count = unit_types[0]
+
+ group = self.mission.vehicle_group(
+ country,
+ name,
+ main_unit_type,
+ position=position,
+ group_size=main_unit_count,
+ move_formation=PointAction.OnRoad,
+ )
+
+ unit_name_counter = itertools.count(main_unit_count + 1)
+ # pydcs spreads units out by 20 in the Y axis by default. Pick up where it left
+ # off.
+ y = itertools.count(position.y + main_unit_count * 20, 20)
+ for unit_type, count in unit_types[1:]:
+ for i in range(count):
+ v = self.mission.vehicle(
+ f"{name} Unit #{next(unit_name_counter)}", unit_type
+ )
+ v.position.x = position.x
+ v.position.y = next(y)
+ v.heading = 0
+ group.add_unit(v)
+
+ return group
+
+ @staticmethod
+ def make_drivable(group: VehicleGroup) -> None:
+ for v in group.units:
+ if isinstance(v, Vehicle):
+ v.player_can_drive = True
From 65ed110ab731453d080b13a44b208e522d83fda8 Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sat, 17 Apr 2021 21:52:06 -0700
Subject: [PATCH 006/438] Track convoy kills.
https://github.com/Khopa/dcs_liberation/issues/824
---
game/debriefing.py | 28 ++++++++++++++++++-
game/event/event.py | 18 ++++++++++++
qt_ui/windows/QDebriefingWindow.py | 22 +++++++++++++++
.../windows/QWaitingForMissionResultWindow.py | 15 ++++++----
4 files changed, 76 insertions(+), 7 deletions(-)
diff --git a/game/debriefing.py b/game/debriefing.py
index c0221838..ea5b8758 100644
--- a/game/debriefing.py
+++ b/game/debriefing.py
@@ -22,7 +22,7 @@ from dcs.unittype import FlyingType, UnitType
from game import db
from game.theater import Airfield, ControlPoint
-from game.unitmap import Building, FrontLineUnit, GroundObjectUnit, UnitMap
+from game.unitmap import Building, ConvoyUnit, FrontLineUnit, GroundObjectUnit, UnitMap
from gen.flights.flight import Flight
if TYPE_CHECKING:
@@ -60,6 +60,9 @@ class GroundLosses:
player_front_line: List[FrontLineUnit] = field(default_factory=list)
enemy_front_line: List[FrontLineUnit] = field(default_factory=list)
+ player_convoy: List[ConvoyUnit] = field(default_factory=list)
+ enemy_convoy: List[ConvoyUnit] = field(default_factory=list)
+
player_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
@@ -120,6 +123,11 @@ class Debriefing:
yield from self.ground_losses.player_front_line
yield from self.ground_losses.enemy_front_line
+ @property
+ def convoy_losses(self) -> Iterator[ConvoyUnit]:
+ yield from self.ground_losses.player_convoy
+ yield from self.ground_losses.enemy_convoy
+
@property
def ground_object_losses(self) -> Iterator[GroundObjectUnit]:
yield from self.ground_losses.player_ground_objects
@@ -148,6 +156,16 @@ class Debriefing:
losses_by_type[loss.unit_type] += 1
return losses_by_type
+ def convoy_losses_by_type(self, player: bool) -> Dict[Type[UnitType], int]:
+ losses_by_type: Dict[Type[UnitType], int] = defaultdict(int)
+ if player:
+ losses = self.ground_losses.player_convoy
+ else:
+ losses = self.ground_losses.enemy_convoy
+ for loss in losses:
+ losses_by_type[loss.unit_type] += 1
+ return losses_by_type
+
def building_losses_by_type(self, player: bool) -> Dict[str, int]:
losses_by_type: Dict[str, int] = defaultdict(int)
if player:
@@ -186,6 +204,14 @@ class Debriefing:
losses.enemy_front_line.append(front_line_unit)
continue
+ convoy_unit = self.unit_map.convoy_unit(unit_name)
+ if convoy_unit is not None:
+ if convoy_unit.transfer.player:
+ losses.player_convoy.append(convoy_unit)
+ else:
+ losses.enemy_convoy.append(convoy_unit)
+ continue
+
ground_object_unit = self.unit_map.ground_object_unit(unit_name)
if ground_object_unit is not None:
if ground_object_unit.ground_object.control_point.captured:
diff --git a/game/event/event.py b/game/event/event.py
index ea7f0e17..157b7461 100644
--- a/game/event/event.py
+++ b/game/event/event.py
@@ -154,6 +154,23 @@ class Event:
logging.info(f"{unit_type} destroyed from {control_point}")
control_point.base.armor[unit_type] -= 1
+ @staticmethod
+ def commit_convoy_losses(debriefing: Debriefing) -> None:
+ for loss in debriefing.convoy_losses:
+ unit_type = loss.unit_type
+ transfer = loss.transfer
+ available = loss.transfer.units.get(unit_type, 0)
+ convoy_name = f"convoy from {transfer.position} to {transfer.destination}"
+ if available <= 0:
+ logging.error(
+ f"Found killed {unit_type} in {convoy_name} but that convoy has "
+ "none available."
+ )
+ continue
+
+ logging.info(f"{unit_type} destroyed in {convoy_name}")
+ transfer.units[unit_type] -= 1
+
@staticmethod
def commit_ground_object_losses(debriefing: Debriefing) -> None:
for loss in debriefing.ground_object_losses:
@@ -186,6 +203,7 @@ class Event:
self.commit_air_losses(debriefing)
self.commit_front_line_losses(debriefing)
+ self.commit_convoy_losses(debriefing)
self.commit_ground_object_losses(debriefing)
self.commit_building_losses(debriefing)
self.commit_damaged_runways(debriefing)
diff --git a/qt_ui/windows/QDebriefingWindow.py b/qt_ui/windows/QDebriefingWindow.py
index d4fa312b..4b62c397 100644
--- a/qt_ui/windows/QDebriefingWindow.py
+++ b/qt_ui/windows/QDebriefingWindow.py
@@ -72,6 +72,17 @@ class QDebriefingWindow(QDialog):
except AttributeError:
logging.exception(f"Issue adding {unit_type} to debriefing information")
+ convoy_losses = self.debriefing.convoy_losses_by_type(player=True)
+ for unit_type, count in convoy_losses.items():
+ try:
+ lostUnitsLayout.addWidget(
+ QLabel(f"{db.unit_type_name(unit_type)} from convoy"), row, 0
+ )
+ lostUnitsLayout.addWidget(QLabel(str(count)), row, 1)
+ row += 1
+ except AttributeError:
+ logging.exception(f"Issue adding {unit_type} to debriefing information")
+
building_losses = self.debriefing.building_losses_by_type(player=True)
for building, count in building_losses.items():
try:
@@ -113,6 +124,17 @@ class QDebriefingWindow(QDialog):
enemylostUnitsLayout.addWidget(QLabel("{}".format(count)), row, 1)
row += 1
+ convoy_losses = self.debriefing.convoy_losses_by_type(player=False)
+ for unit_type, count in convoy_losses.items():
+ try:
+ lostUnitsLayout.addWidget(
+ QLabel(f"{db.unit_type_name(unit_type)} from convoy"), row, 0
+ )
+ lostUnitsLayout.addWidget(QLabel(str(count)), row, 1)
+ row += 1
+ except AttributeError:
+ logging.exception(f"Issue adding {unit_type} to debriefing information")
+
building_losses = self.debriefing.building_losses_by_type(player=False)
for building, count in building_losses.items():
try:
diff --git a/qt_ui/windows/QWaitingForMissionResultWindow.py b/qt_ui/windows/QWaitingForMissionResultWindow.py
index 2efa730b..3b06e877 100644
--- a/qt_ui/windows/QWaitingForMissionResultWindow.py
+++ b/qt_ui/windows/QWaitingForMissionResultWindow.py
@@ -148,16 +148,19 @@ class QWaitingForMissionResultWindow(QDialog):
QLabel(str(len(list(debriefing.front_line_losses)))), 1, 1
)
- updateLayout.addWidget(QLabel("Other ground units destroyed"), 2, 0)
+ updateLayout.addWidget(QLabel("Convoy units destroyed"), 2, 0)
+ updateLayout.addWidget(QLabel(str(len(list(debriefing.convoy_losses)))), 2, 1)
+
+ updateLayout.addWidget(QLabel("Other ground units destroyed"), 3, 0)
updateLayout.addWidget(
- QLabel(str(len(list(debriefing.ground_object_losses)))), 2, 1
+ QLabel(str(len(list(debriefing.ground_object_losses)))), 3, 1
)
- updateLayout.addWidget(QLabel("Buildings destroyed"), 3, 0)
- updateLayout.addWidget(QLabel(str(len(list(debriefing.building_losses)))), 3, 1)
+ updateLayout.addWidget(QLabel("Buildings destroyed"), 4, 0)
+ updateLayout.addWidget(QLabel(str(len(list(debriefing.building_losses)))), 4, 1)
- updateLayout.addWidget(QLabel("Base Capture Events"), 4, 0)
- updateLayout.addWidget(QLabel(str(len(debriefing.base_capture_events))), 4, 1)
+ updateLayout.addWidget(QLabel("Base Capture Events"), 5, 0)
+ updateLayout.addWidget(QLabel(str(len(debriefing.base_capture_events))), 5, 1)
# Clear previous content of the window
for i in reversed(range(self.gridLayout.count())):
From 3b72c13f9d2a6f57a7657b52f79f02ea53e6d150 Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sun, 18 Apr 2021 16:18:47 -0700
Subject: [PATCH 007/438] Add ground unit transfers to the changelog.
Also documented the behavior on the wiki (link in the changelog).
This is currently fully functional for players, but since units can be
bought and sold at any base there's no real reason to use these yet.
Will follow up with making ground units only purchasable at bases with
factories (the UI will still allow the purchase directly at the base,
but it will automatically create the transfer order) so convoys end up
being used, and to make factories a more interesting strategic target.
https://github.com/Khopa/dcs_liberation/issues/824
---
changelog.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/changelog.md b/changelog.md
index 11770bcb..fb153681 100644
--- a/changelog.md
+++ b/changelog.md
@@ -4,6 +4,8 @@ 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.
+
## Fixes
# 2.5.0
From 5e054cfc774c9b325254c5c8c7ba8da48a75b1e7 Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sun, 18 Apr 2021 16:32:02 -0700
Subject: [PATCH 008/438] Disallow selling ground units.
Ground units should be transferred to a new location, not sold and
repurchased.
https://github.com/Khopa/dcs_liberation/issues/823
---
changelog.md | 1 +
game/event/event.py | 7 +++++--
.../basemenu/ground_forces/QArmorRecruitmentMenu.py | 10 ++++++----
3 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/changelog.md b/changelog.md
index fb153681..33a77730 100644
--- a/changelog.md
+++ b/changelog.md
@@ -5,6 +5,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 no longer be sold. To move units to a new location, transfer them.
## Fixes
diff --git a/game/event/event.py b/game/event/event.py
index 157b7461..116bc5d0 100644
--- a/game/event/event.py
+++ b/game/event/event.py
@@ -469,12 +469,15 @@ class UnitsDeliveryEvent:
logging.info(f"Refunding {count} {unit_type.id} at {self.to_cp.name}")
game.adjust_budget(price * count, player=self.to_cp.captured)
- def available_next_turn(self, unit_type: Type[UnitType]) -> int:
+ def pending_orders(self, unit_type: Type[UnitType]) -> int:
pending_units = self.units.get(unit_type)
if pending_units is None:
pending_units = 0
+ return pending_units
+
+ def available_next_turn(self, unit_type: Type[UnitType]) -> int:
current_units = self.to_cp.base.total_units_of_type(unit_type)
- return pending_units + current_units
+ return self.pending_orders(unit_type) + current_units
def process(self, game: Game) -> None:
bought_units: Dict[Type[UnitType], int] = {}
diff --git a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py
index 46536204..fa7d0246 100644
--- a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py
+++ b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py
@@ -1,3 +1,5 @@
+from typing import Type
+
from PySide2.QtCore import Qt
from PySide2.QtWidgets import (
QFrame,
@@ -65,13 +67,13 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour):
main_layout.addWidget(scroll)
self.setLayout(main_layout)
- def sell(self, unit_type: UnitType):
- if self.pending_deliveries.available_next_turn(unit_type) <= 0:
+ def sell(self, unit_type: Type[UnitType]) -> None:
+ if self.pending_deliveries.pending_orders(unit_type) <= 0:
QMessageBox.critical(
self,
"Could not sell ground unit",
- f"Attempted to sell one {unit_type.id} at {self.cp.name} "
- "but none are available.",
+ f"Attempted to cancel order of one {unit_type.id} at {self.cp.name} "
+ "but no orders are pending.",
QMessageBox.Ok,
)
return
From 39135f8c8017bf41de298c8d82365c2b6675e0be Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sun, 18 Apr 2021 17:30:49 -0700
Subject: [PATCH 009/438] Add version field to campaign descriptor file.
This is used to provide a UI hint to guide players towards campaigns
that have been updated to work with the current version of the game.
All the campaigns we currently have were made for an unknown version of
the game, so they're all flagged as incompatible.
The version field is not the DCS Liberation version number because the
campaign format may change multiple times during development. Instead
the version number is a monotonically increasing integer that we
increment whenever a game change requires campaign updates.
---
changelog.md | 1 +
game/theater/conflicttheater.py | 9 +++++
qt_ui/windows/newgame/QCampaignList.py | 38 +++++++++++++++++--
qt_ui/windows/newgame/QNewGameWizard.py | 2 +-
resources/ui/templates/campaigntemplate_EN.j2 | 28 +++++++++++---
5 files changed, 68 insertions(+), 10 deletions(-)
diff --git a/changelog.md b/changelog.md
index 33a77730..59ee06e0 100644
--- a/changelog.md
+++ b/changelog.md
@@ -6,6 +6,7 @@ Saves from 2.5 are not compatible with 2.6.
* **[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 no longer be sold. To move units to a new location, transfer them.
+* **[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.
## Fixes
diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py
index 80194189..07c17aa9 100644
--- a/game/theater/conflicttheater.py
+++ b/game/theater/conflicttheater.py
@@ -84,6 +84,15 @@ def pairwise(iterable):
class MizCampaignLoader:
+ #: The latest version of the campaign format. Increment this version whenever all
+ #: existing campaigns should be flagged as incompatible in the UI. We will still
+ #: attempt to load old campaigns, but this provides a warning to the user that the
+ #: campaign may not work correctly.
+ #:
+ #: There is no verification that the campaign author updated their campaign
+ #: correctly, this is just a UI hint.
+ VERSION = 1
+
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
RED_COUNTRY = CombinedJointTaskForcesRed()
diff --git a/qt_ui/windows/newgame/QCampaignList.py b/qt_ui/windows/newgame/QCampaignList.py
index b97f2ef0..44ed7175 100644
--- a/qt_ui/windows/newgame/QCampaignList.py
+++ b/qt_ui/windows/newgame/QCampaignList.py
@@ -4,7 +4,7 @@ import json
import logging
from dataclasses import dataclass
from pathlib import Path
-from typing import Any, Dict, List, Union
+from typing import Any, Dict, List, Optional, Union
from PySide2 import QtGui
from PySide2.QtCore import QItemSelectionModel
@@ -12,7 +12,7 @@ from PySide2.QtGui import QStandardItem, QStandardItemModel
from PySide2.QtWidgets import QAbstractItemView, QListView
import qt_ui.uiconstants as CONST
-from game.theater import ConflictTheater
+from game.theater import ConflictTheater, MizCampaignLoader
PERF_FRIENDLY = 0
PERF_MEDIUM = 1
@@ -26,6 +26,12 @@ class Campaign:
icon_name: str
authors: str
description: str
+
+ #: The revision of the campaign format the campaign was built for. We do not attempt
+ #: to migrate old campaigns, but this is used to show a warning in the UI when
+ #: selecting a campaign that is not up to date.
+ version: int
+
recommended_player_faction: str
recommended_enemy_faction: str
performance: Union[PERF_FRIENDLY, PERF_MEDIUM, PERF_HARD, PERF_NASA]
@@ -43,6 +49,7 @@ class Campaign:
f"Terrain_{sanitized_theater}",
data.get("authors", "???"),
data.get("description", ""),
+ data.get("version", 0),
data.get("recommended_player_faction", "USA 2005"),
data.get("recommended_enemy_faction", "Russia 1990"),
data.get("performance", 0),
@@ -53,6 +60,27 @@ class Campaign:
def load_theater(self) -> ConflictTheater:
return ConflictTheater.from_json(self.path.parent, self.data)
+ @property
+ def is_out_of_date(self) -> bool:
+ """Returns True if this campaign is not up to date with the latest format."""
+ return self.version < MizCampaignLoader.VERSION
+
+ @property
+ def is_from_future(self) -> bool:
+ """Returns True if this campaign is newer than the supported format."""
+ return self.version > MizCampaignLoader.VERSION
+
+ @property
+ def is_compatible(self) -> bool:
+ """Returns True is this campaign was built for this version of the game."""
+ if not self.version:
+ return False
+ if self.is_out_of_date:
+ return False
+ if self.is_from_future:
+ return False
+ return True
+
def load_campaigns() -> List[Campaign]:
campaign_dir = Path("resources\\campaigns")
@@ -73,7 +101,11 @@ class QCampaignItem(QStandardItem):
super(QCampaignItem, self).__init__()
self.setIcon(QtGui.QIcon(CONST.ICONS[campaign.icon_name]))
self.setEditable(False)
- self.setText(campaign.name)
+ if campaign.is_compatible:
+ name = campaign.name
+ else:
+ name = f"[INCOMPATIBLE] {campaign.name}"
+ self.setText(name)
class QCampaignList(QListView):
diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py
index 0ff28d59..c3cac473 100644
--- a/qt_ui/windows/newgame/QNewGameWizard.py
+++ b/qt_ui/windows/newgame/QNewGameWizard.py
@@ -321,7 +321,7 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
# Faction description
self.campaignMapDescription = QTextEdit("")
self.campaignMapDescription.setReadOnly(True)
- self.campaignMapDescription.setMaximumHeight(100)
+ self.campaignMapDescription.setMaximumHeight(200)
self.performanceText = QTextEdit("")
self.performanceText.setReadOnly(True)
diff --git a/resources/ui/templates/campaigntemplate_EN.j2 b/resources/ui/templates/campaigntemplate_EN.j2
index 2ec97f6e..6cd913c7 100644
--- a/resources/ui/templates/campaigntemplate_EN.j2
+++ b/resources/ui/templates/campaigntemplate_EN.j2
@@ -1,8 +1,24 @@
-Author(s): {{ campaign.authors }}
-
+Author(s): {{ campaign.authors }}
-Default factions:
- {{campaign.recommended_player_faction}} VS {{campaign.recommended_enemy_faction}}
-
+{% if not campaign.version %}
+This campaign was created for an unknown version
+of the game.
+You can still attempt to play this campaign but there may be game breaking
+{% elif campaign.is_out_of_date %}
+
This campaign was created for an older version
+of the game.
+You can still attempt to play this campaign but there may be game breaking
+bugs.
+{% elif campaign.is_from_future %}
+This campaign was created for a newer version
+of the game.
+You can still attempt to play this campaign but there may be game breaking
+bugs.
+{% else %}
+This campaign is up to date.
+{% endif %}
-{{ campaign.description|safe }}
+Default factions:
+{{campaign.recommended_player_faction}} VS {{campaign.recommended_enemy_faction}}
+
+{{ campaign.description|safe }}
\ No newline at end of file
From c92e4e06cc818c95b9cbbb8f67bf41ea63e66bab Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sun, 18 Apr 2021 17:37:55 -0700
Subject: [PATCH 010/438] Move campaign format version to a stable location.
---
game/theater/conflicttheater.py | 9 ---------
game/version.py | 9 +++++++++
qt_ui/windows/newgame/QCampaignList.py | 5 +++--
3 files changed, 12 insertions(+), 11 deletions(-)
diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py
index 07c17aa9..80194189 100644
--- a/game/theater/conflicttheater.py
+++ b/game/theater/conflicttheater.py
@@ -84,15 +84,6 @@ def pairwise(iterable):
class MizCampaignLoader:
- #: The latest version of the campaign format. Increment this version whenever all
- #: existing campaigns should be flagged as incompatible in the UI. We will still
- #: attempt to load old campaigns, but this provides a warning to the user that the
- #: campaign may not work correctly.
- #:
- #: There is no verification that the campaign author updated their campaign
- #: correctly, this is just a UI hint.
- VERSION = 1
-
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
RED_COUNTRY = CombinedJointTaskForcesRed()
diff --git a/game/version.py b/game/version.py
index 37a7dcfb..9bb8c434 100644
--- a/game/version.py
+++ b/game/version.py
@@ -16,3 +16,12 @@ def _build_version_string() -> str:
#: Current version of Liberation.
VERSION = _build_version_string()
+
+#: The latest version of the campaign format. Increment this version whenever all
+#: existing campaigns should be flagged as incompatible in the UI. We will still attempt
+#: to load old campaigns, but this provides a warning to the user that the campaign may
+#: not work correctly.
+#:
+#: There is no verification that the campaign author updated their campaign correctly
+#: this is just a UI hint.
+CAMPAIGN_FORMAT_VERSION = 1
diff --git a/qt_ui/windows/newgame/QCampaignList.py b/qt_ui/windows/newgame/QCampaignList.py
index 44ed7175..45ac295b 100644
--- a/qt_ui/windows/newgame/QCampaignList.py
+++ b/qt_ui/windows/newgame/QCampaignList.py
@@ -13,6 +13,7 @@ from PySide2.QtWidgets import QAbstractItemView, QListView
import qt_ui.uiconstants as CONST
from game.theater import ConflictTheater, MizCampaignLoader
+from game.version import CAMPAIGN_FORMAT_VERSION
PERF_FRIENDLY = 0
PERF_MEDIUM = 1
@@ -63,12 +64,12 @@ class Campaign:
@property
def is_out_of_date(self) -> bool:
"""Returns True if this campaign is not up to date with the latest format."""
- return self.version < MizCampaignLoader.VERSION
+ return self.version < CAMPAIGN_FORMAT_VERSION
@property
def is_from_future(self) -> bool:
"""Returns True if this campaign is newer than the supported format."""
- return self.version > MizCampaignLoader.VERSION
+ return self.version > CAMPAIGN_FORMAT_VERSION
@property
def is_compatible(self) -> bool:
From 9a4ec5a899f859511121f0d2cd301537f57fa014 Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sun, 18 Apr 2021 17:41:27 -0700
Subject: [PATCH 011/438] Fix campaign description template.
---
resources/ui/templates/campaigntemplate_EN.j2 | 1 +
1 file changed, 1 insertion(+)
diff --git a/resources/ui/templates/campaigntemplate_EN.j2 b/resources/ui/templates/campaigntemplate_EN.j2
index 6cd913c7..7101e095 100644
--- a/resources/ui/templates/campaigntemplate_EN.j2
+++ b/resources/ui/templates/campaigntemplate_EN.j2
@@ -4,6 +4,7 @@
This campaign was created for an unknown version
of the game.
You can still attempt to play this campaign but there may be game breaking
+bugs.
{% elif campaign.is_out_of_date %}
This campaign was created for an older version
of the game.
From 777cd310efa4c13b2b756ce1e96f1020a3abcadd Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sun, 18 Apr 2021 17:45:27 -0700
Subject: [PATCH 012/438] Clarify which game we're talking about.
---
resources/ui/templates/campaigntemplate_EN.j2 | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/resources/ui/templates/campaigntemplate_EN.j2 b/resources/ui/templates/campaigntemplate_EN.j2
index 7101e095..c9ee31e2 100644
--- a/resources/ui/templates/campaigntemplate_EN.j2
+++ b/resources/ui/templates/campaigntemplate_EN.j2
@@ -2,17 +2,17 @@
{% if not campaign.version %}
This campaign was created for an unknown version
-of the game.
+of DCS Liberation.
You can still attempt to play this campaign but there may be game breaking
bugs.
{% elif campaign.is_out_of_date %}
This campaign was created for an older version
-of the game.
+of DCS Liberation.
You can still attempt to play this campaign but there may be game breaking
bugs.
{% elif campaign.is_from_future %}
This campaign was created for a newer version
-of the game.
+of DCS Liberation.
You can still attempt to play this campaign but there may be game breaking
bugs.
{% else %}
From cb2ba2f53a47118d015eea5aab5cb9fbdf6e7f1e Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sun, 18 Apr 2021 19:14:48 -0700
Subject: [PATCH 013/438] Update pydcs, move back to upstream.
---
pydcs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pydcs b/pydcs
index 8c657333..22c14702 160000
--- a/pydcs
+++ b/pydcs
@@ -1 +1 @@
-Subproject commit 8c657333af437c715c20772c9dee452cf88dcb78
+Subproject commit 22c147026553f378f70bc06234e35a9061aca96c
From 707323ca120d7e1389ed44651aa0f2c629795e98 Mon Sep 17 00:00:00 2001
From: Dan Albert
Date: Sun, 18 Apr 2021 19:08:14 -0700
Subject: [PATCH 014/438] Update Inherent Resolve to latest camapign format.
* Moves front line endpoints to roads for convoys (not used yet).
* Adds EWR sites.
---
resources/campaigns/inherent_resolve.json | 1 +
resources/campaigns/inherent_resolve.miz | Bin 47602 -> 50819 bytes
2 files changed, 1 insertion(+)
diff --git a/resources/campaigns/inherent_resolve.json b/resources/campaigns/inherent_resolve.json
index 10d8d2fe..67acba0a 100644
--- a/resources/campaigns/inherent_resolve.json
+++ b/resources/campaigns/inherent_resolve.json
@@ -5,6 +5,7 @@
"recommended_player_faction": "USA 2005",
"recommended_enemy_faction": "Insurgents (Hard)",
"description": "In this scenario, you start from Jordan, and have to fight your way through eastern Syria.
",
+ "version": 1,
"miz": "inherent_resolve.miz",
"performance": 1
}
\ No newline at end of file
diff --git a/resources/campaigns/inherent_resolve.miz b/resources/campaigns/inherent_resolve.miz
index 64a7448561dd6a9e98b4ac05e6ed6c2c61635d8a..8652f8dfe1b55cc8c20bd312fb2222601e2d1379 100644
GIT binary patch
literal 50819
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