Merge remote-tracking branch 'khopa/develop' into helipads

# Conflicts:
#	changelog.md
This commit is contained in:
Khopa
2021-09-08 21:56:45 +02:00
132 changed files with 2856 additions and 648 deletions

17
qt_ui/errorreporter.py Normal file
View File

@@ -0,0 +1,17 @@
import logging
from collections import Iterator
from contextlib import contextmanager
from typing import Type
from PySide2.QtWidgets import QDialog, QMessageBox
@contextmanager
def report_errors(
title: str, parent: QDialog, error_type: Type[Exception] = Exception
) -> Iterator[None]:
try:
yield
except error_type as ex:
logging.exception(title)
QMessageBox().critical(parent, title, str(ex), QMessageBox.Ok)

View File

@@ -13,6 +13,7 @@ from PySide2.QtWidgets import QApplication, QSplashScreen
from dcs.payloads import PayloadDirectories
from game import Game, VERSION, persistency
from game.campaignloader.campaign import Campaign
from game.data.weapons import WeaponGroup, Pylon, Weapon
from game.db import FACTIONS
from game.dcs.aircrafttype import AircraftType
@@ -27,7 +28,6 @@ from qt_ui import (
)
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.QLiberationWindow import QLiberationWindow
from game.campaignloader.campaign import Campaign
from qt_ui.windows.newgame.QNewGameWizard import DEFAULT_BUDGET
from qt_ui.windows.preferences.QLiberationFirstStartWindow import (
QLiberationFirstStartWindow,
@@ -252,7 +252,6 @@ def create_game(
start_date=start_date,
player_budget=DEFAULT_BUDGET,
enemy_budget=DEFAULT_BUDGET,
midgame=False,
inverted=inverted,
no_carrier=False,
no_lha=False,

View File

@@ -0,0 +1,42 @@
# From https://timlehr.com/python-exception-hooks-with-qt-message-box/
import logging
import sys
import traceback
from PySide2.QtCore import Signal, QObject
from PySide2.QtWidgets import QMessageBox, QApplication
class UncaughtExceptionHandler(QObject):
_exception_caught = Signal(str, str)
def __init__(self, parent: QObject):
super().__init__(parent)
sys.excepthook = self.exception_hook
# Use a signal so that the message box always comes from the main thread.
self._exception_caught.connect(self.show_exception_box)
def exception_hook(self, exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
# Ignore keyboard interrupt to support console applications.
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.exception(
"Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)
)
self._exception_caught.emit(
str(exc_value),
"".join(traceback.format_exception(exc_type, exc_value, exc_traceback)),
)
def show_exception_box(self, message: str, exception: str) -> None:
if QApplication.instance() is not None:
QMessageBox().critical(
self.parent(),
"An unexpected error occurred",
"\n".join([message, "", exception]),
QMessageBox.Ok,
)
else:
logging.critical("No QApplication instance available.")

View File

@@ -10,7 +10,6 @@ from PySide2.QtCore import (
)
from PySide2.QtGui import QStandardItemModel, QStandardItem, QIcon
from PySide2.QtWidgets import (
QAbstractItemView,
QDialog,
QListView,
QVBoxLayout,
@@ -32,38 +31,7 @@ from game.dcs.aircrafttype import AircraftType
from game.squadrons import AirWing, Pilot, Squadron
from game.theater import ControlPoint, ConflictTheater
from gen.flights.flight import FlightType
from qt_ui.models import AirWingModel, SquadronModel
from qt_ui.uiconstants import AIRCRAFT_ICONS
from qt_ui.windows.AirWingDialog import SquadronDelegate
from qt_ui.windows.SquadronDialog import SquadronDialog
class SquadronList(QListView):
"""List view for displaying the air wing's squadrons."""
def __init__(self, air_wing_model: AirWingModel) -> None:
super().__init__()
self.air_wing_model = air_wing_model
self.dialog: Optional[SquadronDialog] = None
self.setIconSize(QSize(91, 24))
self.setItemDelegate(SquadronDelegate(self.air_wing_model))
self.setModel(self.air_wing_model)
self.selectionModel().setCurrentIndex(
self.air_wing_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select
)
# self.setIconSize(QSize(91, 24))
self.setSelectionBehavior(QAbstractItemView.SelectItems)
self.doubleClicked.connect(self.on_double_click)
def on_double_click(self, index: QModelIndex) -> None:
if not index.isValid():
return
self.dialog = SquadronDialog(
SquadronModel(self.air_wing_model.squadron_at_index(index)), self
)
self.dialog.show()
class AllowedMissionTypeControls(QVBoxLayout):

View File

@@ -14,12 +14,14 @@ from PySide2.QtWidgets import (
QTableWidget,
QTableWidgetItem,
QWidget,
QHBoxLayout,
)
from game.squadrons import Squadron
from game.theater import ConflictTheater
from gen.flights.flight import Flight
from qt_ui.delegates import TwoColumnRowDelegate
from qt_ui.models import GameModel, AirWingModel, SquadronModel
from qt_ui.models import GameModel, AirWingModel, SquadronModel, AtoModel
from qt_ui.windows.SquadronDialog import SquadronDialog
@@ -56,9 +58,16 @@ class SquadronDelegate(TwoColumnRowDelegate):
class SquadronList(QListView):
"""List view for displaying the air wing's squadrons."""
def __init__(self, air_wing_model: AirWingModel) -> None:
def __init__(
self,
ato_model: AtoModel,
air_wing_model: AirWingModel,
theater: ConflictTheater,
) -> None:
super().__init__()
self.ato_model = ato_model
self.air_wing_model = air_wing_model
self.theater = theater
self.dialog: Optional[SquadronDialog] = None
self.setIconSize(QSize(91, 24))
@@ -76,7 +85,10 @@ class SquadronList(QListView):
if not index.isValid():
return
self.dialog = SquadronDialog(
SquadronModel(self.air_wing_model.squadron_at_index(index)), self
self.ato_model,
SquadronModel(self.air_wing_model.squadron_at_index(index)),
self.theater,
self,
)
self.dialog.show()
@@ -138,30 +150,47 @@ class AircraftInventoryData:
class AirInventoryView(QWidget):
def __init__(self, game_model: GameModel) -> None:
super().__init__()
self.game_model = game_model
self.country = self.game_model.game.country_for(player=True)
self.only_unallocated = False
self.enemy_info = False
layout = QVBoxLayout()
self.setLayout(layout)
self.only_unallocated_cb = QCheckBox("Unallocated Only?")
self.only_unallocated_cb.toggled.connect(self.update_table)
checkbox_row = QHBoxLayout()
layout.addLayout(checkbox_row)
layout.addWidget(self.only_unallocated_cb)
self.only_unallocated_cb = QCheckBox("Unallocated only")
self.only_unallocated_cb.toggled.connect(self.set_only_unallocated)
checkbox_row.addWidget(self.only_unallocated_cb)
self.enemy_info_cb = QCheckBox("Show enemy info")
self.enemy_info_cb.toggled.connect(self.set_enemy_info)
checkbox_row.addWidget(self.enemy_info_cb)
checkbox_row.addStretch()
self.table = QTableWidget()
layout.addWidget(self.table)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.verticalHeader().setVisible(False)
self.update_table(False)
self.set_only_unallocated(False)
def update_table(self, only_unallocated: bool) -> None:
def set_only_unallocated(self, value: bool) -> None:
self.only_unallocated = value
self.update_table()
def set_enemy_info(self, value: bool) -> None:
self.enemy_info = value
self.update_table()
def update_table(self) -> None:
self.table.setSortingEnabled(False)
self.table.clear()
inventory_rows = list(self.get_data(only_unallocated))
inventory_rows = list(self.get_data())
self.table.setRowCount(len(inventory_rows))
headers = AircraftInventoryData.headers()
self.table.setColumnCount(len(headers))
@@ -175,18 +204,19 @@ class AirInventoryView(QWidget):
self.table.setSortingEnabled(True)
def iter_allocated_aircraft(self) -> Iterator[AircraftInventoryData]:
for package in self.game_model.game.blue.ato.packages:
coalition = self.game_model.game.coalition_for(not self.enemy_info)
for package in coalition.ato.packages:
for flight in package.flights:
yield from AircraftInventoryData.from_flight(flight)
def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]:
game = self.game_model.game
for squadron in game.blue.air_wing.iter_squadrons():
coalition = self.game_model.game.coalition_for(not self.enemy_info)
for squadron in coalition.air_wing.iter_squadrons():
yield from AircraftInventoryData.each_untasked_from_squadron(squadron)
def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]:
def get_data(self) -> Iterator[AircraftInventoryData]:
yield from self.iter_unallocated_aircraft()
if not only_unallocated:
if not self.only_unallocated:
yield from self.iter_allocated_aircraft()
@@ -194,7 +224,14 @@ class AirWingTabs(QTabWidget):
def __init__(self, game_model: GameModel) -> None:
super().__init__()
self.addTab(SquadronList(game_model.blue_air_wing_model), "Squadrons")
self.addTab(
SquadronList(
game_model.ato_model,
game_model.blue_air_wing_model,
game_model.game.theater,
),
"Squadrons",
)
self.addTab(AirInventoryView(game_model), "Inventory")

View File

@@ -24,6 +24,7 @@ from qt_ui import liberation_install
from qt_ui.dialogs import Dialog
from qt_ui.models import GameModel
from qt_ui.uiconstants import URLS
from qt_ui.uncaughtexceptionhandler import UncaughtExceptionHandler
from qt_ui.widgets.QTopPanel import QTopPanel
from qt_ui.widgets.ato import QAirTaskingOrderPanel
from qt_ui.widgets.map.QLiberationMap import QLiberationMap
@@ -42,7 +43,9 @@ from qt_ui.windows.logs.QLogsWindow import QLogsWindow
class QLiberationWindow(QMainWindow):
def __init__(self, game: Optional[Game]) -> None:
super(QLiberationWindow, self).__init__()
super().__init__()
self._uncaught_exception_handler = UncaughtExceptionHandler(self)
self.game = game
self.game_model = GameModel(game)

View File

@@ -1,5 +1,5 @@
import logging
from typing import Callable
from typing import Callable, Iterator, Optional
from PySide2.QtCore import (
QItemSelectionModel,
@@ -16,12 +16,15 @@ from PySide2.QtWidgets import (
QHBoxLayout,
QLabel,
QCheckBox,
QComboBox,
)
from game.squadrons import Pilot
from game.squadrons import Pilot, Squadron
from game.theater import ControlPoint, ConflictTheater
from gen.flights.flight import FlightType
from qt_ui.delegates import TwoColumnRowDelegate
from qt_ui.models import SquadronModel
from qt_ui.errorreporter import report_errors
from qt_ui.models import SquadronModel, AtoModel
class PilotDelegate(TwoColumnRowDelegate):
@@ -90,12 +93,58 @@ class AutoAssignedTaskControls(QVBoxLayout):
self.squadron_model.set_auto_assignable(task, checked)
class SquadronDestinationComboBox(QComboBox):
def __init__(self, squadron: Squadron, theater: ConflictTheater) -> None:
super().__init__()
self.squadron = squadron
self.theater = theater
room = squadron.location.unclaimed_parking()
self.addItem(
f"Remain at {squadron.location} (room for {room} more aircraft)", None
)
selected_index: Optional[int] = None
for idx, destination in enumerate(sorted(self.iter_destinations(), key=str), 1):
if destination == squadron.destination:
selected_index = idx
room = destination.unclaimed_parking()
self.addItem(
f"Transfer to {destination} (room for {room} more aircraft)",
destination,
)
if squadron.destination is None:
selected_index = 0
if selected_index is not None:
self.setCurrentIndex(selected_index)
def iter_destinations(self) -> Iterator[ControlPoint]:
size = self.squadron.expected_size_next_turn
for control_point in self.theater.control_points_for(self.squadron.player):
if control_point == self:
continue
if not control_point.can_operate(self.squadron.aircraft):
continue
if control_point.unclaimed_parking() < size:
continue
yield control_point
class SquadronDialog(QDialog):
"""Dialog window showing a squadron."""
def __init__(self, squadron_model: SquadronModel, parent) -> None:
def __init__(
self,
ato_model: AtoModel,
squadron_model: SquadronModel,
theater: ConflictTheater,
parent,
) -> None:
super().__init__(parent)
self.ato_model = ato_model
self.squadron_model = squadron_model
self.theater = theater
self.setMinimumSize(1000, 440)
self.setWindowTitle(str(squadron_model.squadron))
@@ -117,6 +166,15 @@ class SquadronDialog(QDialog):
columns.addWidget(self.pilot_list)
button_panel = QHBoxLayout()
self.transfer_destination = SquadronDestinationComboBox(
squadron_model.squadron, theater
)
self.transfer_destination.currentIndexChanged.connect(
self.on_destination_changed
)
button_panel.addWidget(self.transfer_destination)
button_panel.addStretch()
layout.addLayout(button_panel)
@@ -132,6 +190,19 @@ class SquadronDialog(QDialog):
self.toggle_leave_button.clicked.connect(self.toggle_leave)
button_panel.addWidget(self.toggle_leave_button, alignment=Qt.AlignRight)
@property
def squadron(self) -> Squadron:
return self.squadron_model.squadron
def on_destination_changed(self, index: int) -> None:
with report_errors("Could not change squadron destination", self):
destination = self.transfer_destination.itemData(index)
if destination is None:
self.squadron.cancel_relocation()
else:
self.squadron.plan_relocation(destination, self.theater)
self.ato_model.replace_from_game(player=True)
def check_disabled_button_states(
self, button: QPushButton, index: QModelIndex
) -> bool:

View File

@@ -190,7 +190,7 @@ class QBaseMenu2(QDialog):
self.repair_button.setDisabled(True)
def update_intel_summary(self) -> None:
aircraft = self.cp.allocated_aircraft(self.game_model.game).total_present
aircraft = self.cp.allocated_aircraft().total_present
parking = self.cp.total_aircraft_parking
ground_unit_limit = self.cp.frontline_unit_count_limit
deployable_unit_info = ""

View File

@@ -1,6 +1,6 @@
from PySide2.QtWidgets import QTabWidget
from game.theater import ControlPoint, OffMapSpawn, Fob
from game.theater import ControlPoint, Fob
from qt_ui.models import GameModel
from qt_ui.windows.basemenu.DepartingConvoysMenu import DepartingConvoysMenu
from qt_ui.windows.basemenu.airfield.QAirfieldCommand import QAirfieldCommand
@@ -13,7 +13,7 @@ class QBaseMenuTabs(QTabWidget):
super(QBaseMenuTabs, self).__init__()
if not cp.captured:
self.intel = QIntelInfo(cp, game_model.game)
self.intel = QIntelInfo(cp)
self.addTab(self.intel, "Intel")
self.departing_convoys = DepartingConvoysMenu(cp, game_model)

View File

@@ -273,6 +273,8 @@ class UnitTransactionFrame(QFrame, Generic[TransactionItemType]):
else:
return "Unit can not be sold."
def info(self, unit_type: UnitType) -> None:
self.info_window = QUnitInfoWindow(self.game_model.game, unit_type)
def info(self, item: TransactionItemType) -> None:
self.info_window = QUnitInfoWindow(
self.game_model.game, self.purchase_adapter.unit_type_of(item)
)
self.info_window.show()

View File

@@ -21,12 +21,7 @@ from game.purchaseadapter import AircraftPurchaseAdapter
class QAircraftRecruitmentMenu(UnitTransactionFrame[Squadron]):
def __init__(self, cp: ControlPoint, game_model: GameModel) -> None:
super().__init__(
game_model,
AircraftPurchaseAdapter(
cp, game_model.game.coalition_for(cp.captured), game_model.game
),
)
super().__init__(game_model, AircraftPurchaseAdapter(cp))
self.cp = cp
self.game_model = game_model
self.purchase_groups = {}
@@ -98,7 +93,7 @@ class QHangarStatus(QHBoxLayout):
self.setAlignment(Qt.AlignLeft)
def update_label(self) -> None:
next_turn = self.control_point.allocated_aircraft(self.game_model.game)
next_turn = self.control_point.allocated_aircraft()
max_amount = self.control_point.total_aircraft_parking
components = [f"{next_turn.total_present} present"]

View File

@@ -11,22 +11,20 @@ from PySide2.QtWidgets import (
QWidget,
)
from game import Game
from game.theater import ControlPoint
class QIntelInfo(QFrame):
def __init__(self, cp: ControlPoint, game: Game):
def __init__(self, cp: ControlPoint):
super(QIntelInfo, self).__init__()
self.cp = cp
self.game = game
layout = QVBoxLayout()
scroll_content = QWidget()
intel_layout = QVBoxLayout()
units_by_task: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for unit_type, count in self.cp.allocated_aircraft(game).present.items():
for unit_type, count in self.cp.allocated_aircraft().present.items():
if count:
task_type = unit_type.dcs_unit_type.task_default.name
units_by_task[task_type][unit_type.name] += count

View File

@@ -77,7 +77,7 @@ class AircraftIntelLayout(IntelTableLayout):
total = 0
for control_point in game.theater.control_points_for(player):
allocation = control_point.allocated_aircraft(game)
allocation = control_point.allocated_aircraft()
base_total = allocation.total_present
total += base_total
if not base_total:

View File

@@ -85,7 +85,7 @@ class QFlightCreator(QDialog):
squadron, initial_size=self.flight_size_spinner.value()
)
self.roster_editor = FlightRosterEditor(roster)
self.flight_size_spinner.valueChanged.connect(self.resize_roster)
self.flight_size_spinner.valueChanged.connect(self.roster_editor.resize)
self.squadron_selector.currentIndexChanged.connect(self.on_squadron_changed)
roster_layout = QHBoxLayout()
layout.addLayout(roster_layout)
@@ -136,10 +136,6 @@ class QFlightCreator(QDialog):
def set_custom_name_text(self, text: str):
self.custom_name_text = text
def resize_roster(self, new_size: int) -> None:
self.roster_editor.roster.resize(new_size)
self.roster_editor.resize(new_size)
def verify_form(self) -> Optional[str]:
aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData()
squadron: Optional[Squadron] = self.squadron_selector.currentData()
@@ -182,8 +178,6 @@ class QFlightCreator(QDialog):
roster.max_size,
task,
self.start_type.currentText(),
squadron.location,
squadron.location,
divert,
custom_name=self.custom_name_text,
roster=roster,
@@ -198,7 +192,6 @@ class QFlightCreator(QDialog):
self.squadron_selector.update_items(
self.task_selector.currentData(), new_aircraft
)
self.departure.change_aircraft(new_aircraft)
self.divert.change_aircraft(new_aircraft)
def on_departure_changed(self, departure: ControlPoint) -> None:
@@ -223,6 +216,7 @@ class QFlightCreator(QDialog):
def on_squadron_changed(self, index: int) -> None:
squadron: Optional[Squadron] = self.squadron_selector.itemData(index)
self.update_max_size(self.squadron_selector.aircraft_available)
# Clear the roster first so we return the pilots to the pool. This way if we end
# up repopulating from the same squadron we'll get the same pilots back.
self.roster_editor.replace(None)
@@ -230,7 +224,7 @@ class QFlightCreator(QDialog):
self.roster_editor.replace(
FlightRoster(squadron, self.flight_size_spinner.value())
)
self.on_departure_changed(squadron.location)
self.on_departure_changed(squadron.location)
def update_max_size(self, available: int) -> None:
aircraft = self.aircraft_selector.currentData()

View File

@@ -176,6 +176,8 @@ class FlightRosterEditor(QVBoxLayout):
def resize(self, new_size: int) -> None:
if new_size > self.MAX_PILOTS:
raise ValueError("A flight may not have more than four pilots.")
if self.roster is not None:
self.roster.resize(new_size)
for controls in self.pilot_controls[:new_size]:
controls.enable_and_reset()
for controls in self.pilot_controls[new_size:]:

View File

@@ -65,6 +65,8 @@ class NewGameWizard(QtWidgets.QWizard):
logging.info("======================")
campaign = self.field("selectedCampaign")
if campaign is None:
campaign = self.theater_page.campaignList.selected_campaign
if campaign is None:
campaign = self.campaigns[0]
@@ -94,7 +96,6 @@ class NewGameWizard(QtWidgets.QWizard):
enemy_budget=int(self.field("enemy_starting_money")),
# QSlider forces integers, so we use 1 to 50 and divide by 10 to
# give 0.1 to 5.0.
midgame=False,
inverted=self.field("invertMap"),
no_carrier=self.field("no_carrier"),
no_lha=self.field("no_lha"),
@@ -300,13 +301,13 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
text="Show incompatible campaigns"
)
show_incompatible_campaigns_checkbox.setChecked(False)
campaignList = QCampaignList(
self.campaignList = QCampaignList(
campaigns, show_incompatible_campaigns_checkbox.isChecked()
)
show_incompatible_campaigns_checkbox.toggled.connect(
lambda checked: campaignList.setup_content(show_incompatible=checked)
lambda checked: self.campaignList.setup_content(show_incompatible=checked)
)
self.registerField("selectedCampaign", campaignList)
self.registerField("selectedCampaign", self.campaignList)
# Faction description
self.campaignMapDescription = QTextEdit("")
@@ -366,7 +367,7 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
template_perf = jinja_env.get_template(
"campaign_performance_template_EN.j2"
)
campaign = campaignList.selected_campaign
campaign = self.campaignList.selected_campaign
self.setField("selectedCampaign", campaign)
if campaign is None:
self.campaignMapDescription.setText("No campaign selected")
@@ -379,11 +380,13 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
template_perf.render({"performance": campaign.performance})
)
campaignList.selectionModel().setCurrentIndex(
campaignList.indexAt(QPoint(1, 1)), QItemSelectionModel.Rows
self.campaignList.selectionModel().setCurrentIndex(
self.campaignList.indexAt(QPoint(1, 1)), QItemSelectionModel.Rows
)
campaignList.selectionModel().selectionChanged.connect(on_campaign_selected)
self.campaignList.selectionModel().selectionChanged.connect(
on_campaign_selected
)
on_campaign_selected()
docsText = QtWidgets.QLabel(
@@ -410,7 +413,7 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
layout = QtWidgets.QGridLayout()
layout.setColumnMinimumWidth(0, 20)
layout.addWidget(campaignList, 0, 0, 5, 1)
layout.addWidget(self.campaignList, 0, 0, 5, 1)
layout.addWidget(show_incompatible_campaigns_checkbox, 5, 0, 1, 1)
layout.addWidget(docsText, 6, 0, 1, 1)
layout.addWidget(self.campaignMapDescription, 0, 1, 1, 1)

View File

@@ -22,7 +22,6 @@ from dcs.forcedoptions import ForcedOptions
import qt_ui.uiconstants as CONST
from game.game import Game
from game.infos.information import Information
from game.settings import Settings, AutoAtoBehavior
from qt_ui.widgets.QLabeledWidget import QLabeledWidget
from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs
@@ -894,18 +893,6 @@ class QSettingsWindow(QDialog):
def cheatMoney(self, amount):
logging.info("CHEATING FOR AMOUNT : " + str(amount) + "M")
self.game.blue.budget += amount
if amount > 0:
self.game.informations.append(
Information(
"CHEATER",
"You are a cheater and you should feel bad",
self.game.turn,
)
)
else:
self.game.informations.append(
Information("CHEATER", "You are still a cheater !", self.game.turn)
)
GameUpdateSignal.get_instance().updateGame(self.game)
def applySettings(self):