From d99f8fef0995be5034cd7f92242d96c35f7704ab Mon Sep 17 00:00:00 2001 From: Chris Seagraves <47610393+nosv1@users.noreply.github.com> Date: Tue, 29 Jun 2021 17:19:37 -0500 Subject: [PATCH 001/167] Update main.py (#1382) --- qt_ui/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/qt_ui/main.py b/qt_ui/main.py index 744f90de..d1aacdff 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Optional from PySide2 import QtWidgets +from PySide2.QtCore import Qt from PySide2.QtGui import QPixmap from PySide2.QtWidgets import QApplication, QSplashScreen from dcs.payloads import PayloadDirectories @@ -62,6 +63,8 @@ def run_ui(game: Optional[Game]) -> None: os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens app = QApplication(sys.argv) + app.setAttribute(Qt.AA_DisableWindowContextHelpButton) + # init the theme and load the stylesheet based on the theme index liberation_theme.init() with open( From 5f5b5f69e31c0abb358f64431c686356a051543a Mon Sep 17 00:00:00 2001 From: Chris Seagraves <47610393+nosv1@users.noreply.github.com> Date: Wed, 30 Jun 2021 14:04:06 -0500 Subject: [PATCH 002/167] asset reference links :sunglasses: (#1363) Adds urls to unit info pages that don't have data. --- changelog.md | 2 ++ game/dcs/aircrafttype.py | 5 ++++- game/dcs/groundunittype.py | 6 +++++- qt_ui/windows/QUnitInfoWindow.py | 3 +++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index d8f11ab9..c588033a 100644 --- a/changelog.md +++ b/changelog.md @@ -12,6 +12,8 @@ Saves from 4.0.0 are compatible with 4.0.1. ## Features/Improvements +* **[UI]** Google search link added to unit information when there is no information provided. + ## Fixes # 4.0.0 diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index 126f2064..9b5fedae 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -229,7 +229,10 @@ class AircraftType(UnitType[FlyingType]): yield AircraftType( dcs_unit_type=aircraft, name=variant, - description=data.get("description", "No data."), + description=data.get( + "description", + f"No data. Google {variant}", + ), year_introduced=introduction, country_of_origin=data.get("origin", "No data."), manufacturer=data.get("manufacturer", "No data."), diff --git a/game/dcs/groundunittype.py b/game/dcs/groundunittype.py index 7cf92fcf..c2b6f2ab 100644 --- a/game/dcs/groundunittype.py +++ b/game/dcs/groundunittype.py @@ -57,6 +57,7 @@ class GroundUnitType(UnitType[VehicleType]): def _load_all(cls) -> None: for unit_type in cls._each_unit_type(): for data in cls._each_variant_of(unit_type): + print(data.name, data.description) cls.register(data) cls._loaded = True @@ -88,7 +89,10 @@ class GroundUnitType(UnitType[VehicleType]): unit_class=unit_class, spawn_weight=data.get("spawn_weight", 0), name=variant, - description=data.get("description", "No data."), + description=data.get( + "description", + f"No data. Google {variant}", + ), year_introduced=introduction, country_of_origin=data.get("origin", "No data."), manufacturer=data.get("manufacturer", "No data."), diff --git a/qt_ui/windows/QUnitInfoWindow.py b/qt_ui/windows/QUnitInfoWindow.py index a87ce597..e5503544 100644 --- a/qt_ui/windows/QUnitInfoWindow.py +++ b/qt_ui/windows/QUnitInfoWindow.py @@ -94,6 +94,9 @@ class QUnitInfoWindow(QDialog): self.details_text = QTextBrowser() self.details_text.setProperty("style", "info-desc") self.details_text.setText(unit_type.description) + self.details_text.setOpenExternalLinks( + True + ) # in aircrafttype.py and groundunittype.py, for the descriptions, if No Data. including a google search link self.gridLayout.addWidget(self.details_text, 3, 0) self.layout.addLayout(self.gridLayout, 1, 0) From f80696b7244d2eace20111dacdcb149cb4747055 Mon Sep 17 00:00:00 2001 From: Chris Seagraves <47610393+nosv1@users.noreply.github.com> Date: Wed, 30 Jun 2021 14:22:14 -0500 Subject: [PATCH 003/167] Add Cloud Base Altitude to Weather Display (#1371) Adds tooltip with cloud base altitude to weather panel --- changelog.md | 1 + qt_ui/widgets/QTopPanel.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/changelog.md b/changelog.md index c588033a..1b5d2386 100644 --- a/changelog.md +++ b/changelog.md @@ -12,6 +12,7 @@ Saves from 4.0.0 are compatible with 4.0.1. ## Features/Improvements +* **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). * **[UI]** Google search link added to unit information when there is no information provided. ## Fixes diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index a00b6044..83959b12 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -13,6 +13,7 @@ import qt_ui.uiconstants as CONST from game import Game from game.event.airwar import AirWarEvent from game.profiling import logged_duration +from game.utils import meters from gen.ato import Package from gen.flights.traveltime import TotEstimator from qt_ui.models import GameModel @@ -112,6 +113,11 @@ class QTopPanel(QFrame): self.transfers.setEnabled(True) self.conditionsWidget.setCurrentTurn(game.turn, game.conditions) + + base_m = game.conditions.weather.clouds.base + base_ft = int(meters(base_m).feet) + self.conditionsWidget.setToolTip(f"Cloud Base: {base_m}m / {base_ft}ft") + self.intel_box.set_game(game) self.budgetBox.setGame(game) self.factionsInfos.setGame(game) From 2a5c523afd7c3329907445ebad0981053939a6e2 Mon Sep 17 00:00:00 2001 From: Fryderyk Wysocki Date: Wed, 30 Jun 2021 21:30:38 +0200 Subject: [PATCH 004/167] Update poland_2010.json (#1380) * Update poland_2010.json * Adding MiG-29G to PL faction Poland has bought some MiG-29Gs from unified Germany in the early '90s --- resources/factions/poland_2010.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/factions/poland_2010.json b/resources/factions/poland_2010.json index 7b092d9e..57585c3e 100644 --- a/resources/factions/poland_2010.json +++ b/resources/factions/poland_2010.json @@ -8,8 +8,11 @@ ], "aircrafts": [ "F-16CM Fighting Falcon (Block 50)", + "Mi-24V Hind-E", + "Mi-24P Hind-F", "Mi-8MTV2 Hip", "MiG-29A Fulcrum-A", + "MiG-29G Fulcrum-A", "Su-22M4 Fitter-K" ], "tankers": [ @@ -56,4 +59,4 @@ "MolniyaGroupGenerator", "OliverHazardPerryGroupGenerator" ] -} \ No newline at end of file +} From e94d48c265024b511190fb4d0db7c9bb5144c9c5 Mon Sep 17 00:00:00 2001 From: Chris Seagraves <47610393+nosv1@users.noreply.github.com> Date: Wed, 30 Jun 2021 17:07:53 -0500 Subject: [PATCH 005/167] Notes to kneeboard (#1375) Adds global-level kneeboard notes. Explicit save compatability with 4.0.0 --- changelog.md | 1 + game/game.py | 1 + gen/kneeboard.py | 26 +++++++++++ qt_ui/uiconstants.py | 1 + qt_ui/windows/QLiberationWindow.py | 10 ++++ qt_ui/windows/notes/QNotesWindow.py | 67 +++++++++++++++++++++++++++ resources/ui/misc/dark/notes.png | Bin 0 -> 3243 bytes resources/ui/misc/light/notes.png | Bin 0 -> 3536 bytes resources/ui/misc/notes.png | Bin 0 -> 3536 bytes resources/ui/misc/original/notes.png | Bin 0 -> 3435 bytes 10 files changed, 106 insertions(+) create mode 100644 qt_ui/windows/notes/QNotesWindow.py create mode 100644 resources/ui/misc/dark/notes.png create mode 100644 resources/ui/misc/light/notes.png create mode 100644 resources/ui/misc/notes.png create mode 100644 resources/ui/misc/original/notes.png diff --git a/changelog.md b/changelog.md index 1b5d2386..fd71f412 100644 --- a/changelog.md +++ b/changelog.md @@ -12,6 +12,7 @@ Saves from 4.0.0 are compatible with 4.0.1. ## Features/Improvements +* **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). * **[UI]** Google search link added to unit information when there is no information provided. diff --git a/game/game.py b/game/game.py index 80867ecd..3a783e40 100644 --- a/game/game.py +++ b/game/game.py @@ -110,6 +110,7 @@ class Game: self.date = date(start_date.year, start_date.month, start_date.day) self.game_stats = GameStats() self.game_stats.update(self) + self.notes = "" self.ground_planners: dict[int, GroundPlanner] = {} self.informations = [] self.informations.append(Information("Game Start", "-" * 40, 0)) diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 62fd9d25..9a074940 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -554,6 +554,28 @@ class StrikeTaskPage(KneeboardPage): ] +class NotesPage(KneeboardPage): + """A kneeboard page containing the campaign owner's notes.""" + + def __init__( + self, + game: "Game", + dark_kneeboard: bool, + ) -> None: + self.game = game + self.dark_kneeboard = dark_kneeboard + + def write(self, path: Path) -> None: + writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) + writer.title(f"Notes") + + try: + writer.text(self.game.notes) + except AttributeError: # old saves may not have .notes ;) + writer.text("") + writer.write(path) + + class KneeboardGenerator(MissionInfoGenerator): """Creates kneeboard pages for each client flight in the mission.""" @@ -621,6 +643,10 @@ class KneeboardGenerator(MissionInfoGenerator): self.mission.start_time, self.dark_kneeboard, ), + NotesPage( + self.game, + self.dark_kneeboard, + ), ] if (target_page := self.generate_task_page(flight)) is not None: diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index 30ee7adf..e36ffb7e 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -106,6 +106,7 @@ def load_icons(): ICONS["PluginsOptions"] = QPixmap( "./resources/ui/misc/" + get_theme_icons() + "/pluginsoptions.png" ) + ICONS["Notes"] = QPixmap("./resources/ui/misc/" + get_theme_icons() + "/notes.png") ICONS["TaskCAS"] = QPixmap("./resources/ui/tasks/cas.png") ICONS["TaskCAP"] = QPixmap("./resources/ui/tasks/cap.png") diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index e4d7f403..1cc946fd 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -36,6 +36,7 @@ from qt_ui.windows.preferences.QLiberationPreferencesWindow import ( ) from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow from qt_ui.windows.stats.QStatsWindow import QStatsWindow +from qt_ui.windows.notes.QNotesWindow import QNotesWindow class QLiberationWindow(QMainWindow): @@ -158,6 +159,10 @@ class QLiberationWindow(QMainWindow): self.openStatsAction.setIcon(CONST.ICONS["Statistics"]) self.openStatsAction.triggered.connect(self.showStatsDialog) + self.openNotesAction = QAction("Notes", self) + self.openNotesAction.setIcon(CONST.ICONS["Notes"]) + self.openNotesAction.triggered.connect(self.showNotesDialog) + def initToolbar(self): self.tool_bar = self.addToolBar("File") self.tool_bar.addAction(self.newGameAction) @@ -171,6 +176,7 @@ class QLiberationWindow(QMainWindow): self.actions_bar = self.addToolBar("Actions") self.actions_bar.addAction(self.openSettingsAction) self.actions_bar.addAction(self.openStatsAction) + self.actions_bar.addAction(self.openNotesAction) def initMenuBar(self): self.menu = self.menuBar() @@ -351,6 +357,10 @@ class QLiberationWindow(QMainWindow): self.dialog = QStatsWindow(self.game) self.dialog.show() + def showNotesDialog(self): + self.dialog = QNotesWindow(self.game) + self.dialog.show() + def onDebriefing(self, debrief: Debriefing): logging.info("On Debriefing") self.debriefing = QDebriefingWindow(debrief) diff --git a/qt_ui/windows/notes/QNotesWindow.py b/qt_ui/windows/notes/QNotesWindow.py new file mode 100644 index 00000000..cb1419b5 --- /dev/null +++ b/qt_ui/windows/notes/QNotesWindow.py @@ -0,0 +1,67 @@ +from PySide2.QtWidgets import ( + QDialog, + QPlainTextEdit, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QLabel, +) +from PySide2.QtGui import QTextCursor +from PySide2.QtCore import QTimer + +import qt_ui.uiconstants as CONST +from game.game import Game + +from time import sleep + + +class QNotesWindow(QDialog): + def __init__(self, game: Game): + super(QNotesWindow, self).__init__() + + self.game = game + self.setWindowTitle("Notes") + self.setWindowIcon(CONST.ICONS["Notes"]) + self.setMinimumSize(400, 100) + self.resize(600, 450) + + self.vbox = QVBoxLayout() + self.setLayout(self.vbox) + + self.vbox.addWidget( + QLabel("Saved notes are available as a page in your kneeboard.") + ) + + self.textbox = QPlainTextEdit(self) + try: + self.textbox.setPlainText(self.game.notes) + self.textbox.moveCursor(QTextCursor.End) + except AttributeError: # old save may not have game.notes + pass + self.textbox.move(10, 10) + self.textbox.resize(600, 450) + self.textbox.setStyleSheet("background: #1D2731;") + self.vbox.addWidget(self.textbox) + + self.button_row = QHBoxLayout() + self.vbox.addLayout(self.button_row) + + self.clear_button = QPushButton(self) + self.clear_button.setText("CLEAR") + self.clear_button.setProperty("style", "btn-primary") + self.clear_button.clicked.connect(self.clearNotes) + self.button_row.addWidget(self.clear_button) + + self.save_button = QPushButton(self) + self.save_button.setText("SAVE") + self.save_button.setProperty("style", "btn-success") + self.save_button.clicked.connect(self.saveNotes) + self.button_row.addWidget(self.save_button) + + def clearNotes(self) -> None: + self.textbox.setPlainText("") + + def saveNotes(self) -> None: + self.game.notes = self.textbox.toPlainText() + self.save_button.setText("SAVED") + QTimer.singleShot(5000, lambda: self.save_button.setText("SAVE")) diff --git a/resources/ui/misc/dark/notes.png b/resources/ui/misc/dark/notes.png new file mode 100644 index 0000000000000000000000000000000000000000..2bb0f1eb5ac50255d7c2db71c48bb0a2aad5c639 GIT binary patch literal 3243 zcmV;c3{>-pP)KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z0005jNkl-@25Zq)mL#xVVe&Khhq&xMzARX`tdbx9{Z>3U8;c*&^Ib;En zP;wRaz}@X>Z&^N`&*y-;F#-5Vy;-AAZQFh~P4l&vQv|=e3LF^yQ~)$Y z)a?OVHIPe4eqZ zbUSh{!3 z4~)TovHRjuTw+SL&Yb%U4=(T?(^)A#G$_1y%zM;S!XbuouXqh9_K7Z>^`2lGL|mDZ zQ&P+cFDu05w&Hf3`yIz^Tz07Uf|dh78;UnOLqUm z4_xMX{`LjA>=s$Fg6Qt|d&56&BGx>sflKCxiGO?cr9vhm{=Jq1N^jutc&v7}mJhgI duZQ>j4gkk;MXM#IRGt6;002ovPDHLkV1h4r1)~4} literal 0 HcmV?d00001 diff --git a/resources/ui/misc/light/notes.png b/resources/ui/misc/light/notes.png new file mode 100644 index 0000000000000000000000000000000000000000..9f1300fc28f618acc6ef055af7654dc17f94ff72 GIT binary patch literal 3536 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 literal 0 HcmV?d00001 diff --git a/resources/ui/misc/notes.png b/resources/ui/misc/notes.png new file mode 100644 index 0000000000000000000000000000000000000000..9f1300fc28f618acc6ef055af7654dc17f94ff72 GIT binary patch literal 3536 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 literal 0 HcmV?d00001 diff --git a/resources/ui/misc/original/notes.png b/resources/ui/misc/original/notes.png new file mode 100644 index 0000000000000000000000000000000000000000..1223307a4646c32499d70fa92fce61e03c0a6fe9 GIT binary patch literal 3435 zcmV-x4V3bUP)KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z0007(Nklc|Qx^SEt+c-nsX^*Zk`CJLh-LIrsbf zo!`HkFf(S8l0X``0h9oX`Ah;Ifxf5!5@vQAIH3907)oS-`Of?vu+Mw9{i9!I)?#MF zcvk9+muB`t_!jdTr^QErt-xzw4=@NMe0%^PRf8YR6<;R49=HW`0Z*!J zP6y^Gpc`1|@!@yCQ%B1-AP;;8o&kFWVi)kJnh#9qPTDORVP>sW)*PV-z@;z%`)gF{vj7B@+y*>%2o!r-O-TmDNstI_rw&lTN8WCRx$UQemn1Col}mtHJF3z!u=BWSuJYdEm0jcMh0o zR4Q(EtkEM`&XIMv>+205t+UL`PD!@Z%%&yl4eqHofMK0C;K)WLJMUAD=cqyTNkk*S z11H?B0~?)Edja=p Date: Sun, 27 Jun 2021 19:46:07 -0700 Subject: [PATCH 006/167] Update TGP Restriction Dates TGP dates to more accurately reflect IRL IOC dates. --- game/data/weapons.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/game/data/weapons.py b/game/data/weapons.py index 22aa53b9..6b9164a7 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -888,16 +888,16 @@ WEAPON_INTRODUCTION_YEARS = { Weapon.from_pydcs(Weapons.ALQ_184): 1989, Weapon.from_pydcs(Weapons.AN_ALQ_164_DECM_Pod): 1984, # TGP Pods - Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod): 1995, - Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod_): 1995, - Weapon.from_pydcs(Weapons.AN_ASQ_228_ATFLIR___Targeting_Pod): 1993, + Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod): 1999, + Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod_): 1999, + Weapon.from_pydcs(Weapons.AN_ASQ_228_ATFLIR___Targeting_Pod): 2003, Weapon.from_pydcs( Weapons.AN_ASQ_173_Laser_Spot_Tracker_Strike_CAMera__LST_SCAM_ ): 1993, Weapon.from_pydcs(Weapons.AWW_13_DATALINK_POD): 1967, - Weapon.from_pydcs(Weapons.LANTIRN_Targeting_Pod): 1985, - Weapon.from_pydcs(Weapons.Lantirn_F_16): 1985, - Weapon.from_pydcs(Weapons.Lantirn_Target_Pod): 1985, + Weapon.from_pydcs(Weapons.LANTIRN_Targeting_Pod): 1990, + Weapon.from_pydcs(Weapons.Lantirn_F_16): 1990, + Weapon.from_pydcs(Weapons.Lantirn_Target_Pod): 1990, Weapon.from_pydcs(Weapons.Pavetack_F_111): 1982, # BLU-107 Weapon.from_pydcs(Weapons.BLU_107___440lb_Anti_Runway_Penetrator_Bomb): 1983, From 7ba4077f9f83822468d1b3c191b470634a71dbd6 Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Wed, 30 Jun 2021 23:50:02 -0400 Subject: [PATCH 007/167] Fixes #240 by making statistics windows axis labels integers (#1370) --- changelog.md | 1 + qt_ui/windows/stats/QAircraftChart.py | 6 ++++++ qt_ui/windows/stats/QArmorChart.py | 6 ++++++ qt_ui/windows/stats/QStatsWindow.py | 2 +- 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index fd71f412..53c138c3 100644 --- a/changelog.md +++ b/changelog.md @@ -17,6 +17,7 @@ Saves from 4.0.0 are compatible with 4.0.1. * **[UI]** Google search link added to unit information when there is no information provided. ## Fixes +* **[UI]** Statistics window tick marks are now always integers. # 4.0.0 diff --git a/qt_ui/windows/stats/QAircraftChart.py b/qt_ui/windows/stats/QAircraftChart.py index 6c8d1db9..6516ec58 100644 --- a/qt_ui/windows/stats/QAircraftChart.py +++ b/qt_ui/windows/stats/QAircraftChart.py @@ -42,10 +42,16 @@ class QAircraftChart(QFrame): self.chart.setTitle("Aircraft forces over time") self.chart.createDefaultAxes() + self.chart.axisX().setTitleText("Turn") + self.chart.axisX().setLabelFormat("%i") self.chart.axisX().setRange(0, len(self.alliedAircraft)) + self.chart.axisX().applyNiceNumbers() + + self.chart.axisY().setLabelFormat("%i") self.chart.axisY().setRange( 0, max(max(self.alliedAircraft), max(self.enemyAircraft)) + 10 ) + self.chart.axisY().applyNiceNumbers() self.chartView = QtCharts.QChartView(self.chart) self.chartView.setRenderHint(QPainter.Antialiasing) diff --git a/qt_ui/windows/stats/QArmorChart.py b/qt_ui/windows/stats/QArmorChart.py index 09c272fa..e952c717 100644 --- a/qt_ui/windows/stats/QArmorChart.py +++ b/qt_ui/windows/stats/QArmorChart.py @@ -42,10 +42,16 @@ class QArmorChart(QFrame): self.chart.setTitle("Combat vehicles over time") self.chart.createDefaultAxes() + self.chart.axisX().setTitleText("Turn") + self.chart.axisX().setLabelFormat("%i") self.chart.axisX().setRange(0, len(self.alliedArmor)) + self.chart.axisX().applyNiceNumbers() + + self.chart.axisY().setLabelFormat("%i") self.chart.axisY().setRange( 0, max(max(self.alliedArmor), max(self.enemyArmor)) + 10 ) + self.chart.axisY().applyNiceNumbers() self.chartView = QtCharts.QChartView(self.chart) self.chartView.setRenderHint(QPainter.Antialiasing) diff --git a/qt_ui/windows/stats/QStatsWindow.py b/qt_ui/windows/stats/QStatsWindow.py index 7d4fda07..14817d18 100644 --- a/qt_ui/windows/stats/QStatsWindow.py +++ b/qt_ui/windows/stats/QStatsWindow.py @@ -14,7 +14,7 @@ class QStatsWindow(QDialog): self.setModal(True) self.setWindowTitle("Stats") self.setWindowIcon(CONST.ICONS["Statistics"]) - self.setMinimumSize(600, 250) + self.setMinimumSize(600, 300) self.layout = QGridLayout() self.aircraft_charts = QAircraftChart(self.game) From c8e5cefd3675040d0cec83b23e1306a56fca2e65 Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Wed, 30 Jun 2021 23:55:37 -0400 Subject: [PATCH 008/167] Increasing time JTAC radio messages stay on the UI. (#1369) - Target lost or killed: 10s -> 20s - New target : 10s -> 30s - Request JTAC Status: 25s -> 60s --- changelog.md | 1 + resources/plugins/jtacautolase/JTACAutoLase.lua | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/changelog.md b/changelog.md index 53c138c3..b75fab07 100644 --- a/changelog.md +++ b/changelog.md @@ -11,6 +11,7 @@ Saves from 3.x are not compatible with 5.0. Saves from 4.0.0 are compatible with 4.0.1. ## Features/Improvements +* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). diff --git a/resources/plugins/jtacautolase/JTACAutoLase.lua b/resources/plugins/jtacautolase/JTACAutoLase.lua index 05315574..01cd8649 100644 --- a/resources/plugins/jtacautolase/JTACAutoLase.lua +++ b/resources/plugins/jtacautolase/JTACAutoLase.lua @@ -161,9 +161,9 @@ function JTACAutoLase(jtacGroupName, laserCode,smoke,lock,colour) local tempUnit = Unit.getByName(tempUnitInfo.name) if tempUnit ~= nil and tempUnit:getLife() > 0 and tempUnit:isActive() == true then - notify(jtacGroupName .. " target " .. tempUnitInfo.unitType .. " lost. Scanning for Targets. ", 10) + notify(jtacGroupName .. " target " .. tempUnitInfo.unitType .. " lost. Scanning for Targets. ", 20) else - notify(jtacGroupName .. " target " .. tempUnitInfo.unitType .. " KIA. Good Job! Scanning for Targets. ", 10) + notify(jtacGroupName .. " target " .. tempUnitInfo.unitType .. " KIA. Good Job! Scanning for Targets. ", 20) end --remove from smoke list @@ -186,7 +186,7 @@ function JTACAutoLase(jtacGroupName, laserCode,smoke,lock,colour) -- store current target for easy lookup GLOBAL_JTAC_CURRENT_TARGETS[jtacGroupName] = { name = enemyUnit:getName(), unitType = enemyUnit:getTypeName(), unitId = enemyUnit:getID() } - notify(jtacGroupName .. " lasing new target " .. enemyUnit:getTypeName() .. '. CODE: ' .. laserCode ..getPositionString(enemyUnit) , 10) + notify(jtacGroupName .. " lasing new target " .. enemyUnit:getTypeName() .. '. CODE: ' .. laserCode ..getPositionString(enemyUnit) , 30) -- create smoke if smoke == true then @@ -554,7 +554,7 @@ function getJTACStatus() end end - notify(message, 25) + notify(message, 60) end From 9bd6f9ef47475e936d2abe052bb56fd30d0d2fbb Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Wed, 30 Jun 2021 23:58:20 -0400 Subject: [PATCH 009/167] Addresses #478 to clean up the angle summing functionality. (#1386) --- changelog.md | 2 ++ game/utils.py | 7 +------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/changelog.md b/changelog.md index b75fab07..09058d82 100644 --- a/changelog.md +++ b/changelog.md @@ -20,6 +20,8 @@ Saves from 4.0.0 are compatible with 4.0.1. ## Fixes * **[UI]** Statistics window tick marks are now always integers. +* **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles. + # 4.0.0 Saves from 3.x are not compatible with 4.0. diff --git a/game/utils.py b/game/utils.py index 0bd1f79c..68e38d31 100644 --- a/game/utils.py +++ b/game/utils.py @@ -18,12 +18,7 @@ KPH_TO_MS = 1 / MS_TO_KPH def heading_sum(h, a) -> int: h += a - if h > 360: - return h - 360 - elif h < 0: - return 360 + h - else: - return h + return h % 360 def opposite_heading(h): From 568655d503ce5d017f9be3d1d239b6d7d487de99 Mon Sep 17 00:00:00 2001 From: Schneefl0cke <60181177+Schneefl0cke@users.noreply.github.com> Date: Fri, 2 Jul 2021 02:04:03 +0200 Subject: [PATCH 010/167] Add incomes for WW2 villages and camps. --- game/db.py | 2 ++ game/income.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/game/db.py b/game/db.py index 61426d12..0a63c056 100644 --- a/game/db.py +++ b/game/db.py @@ -317,6 +317,8 @@ REWARDS = { "comms": 10, "oil": 10, "derrick": 8, + "village": 0.25, + "allycamp": 0.5, } """ diff --git a/game/income.py b/game/income.py index dd2be887..f9a74eb6 100644 --- a/game/income.py +++ b/game/income.py @@ -14,10 +14,10 @@ class BuildingIncome: name: str category: str number: int - income_per_building: int + income_per_building: float @property - def income(self) -> int: + def income(self) -> float: return self.number * self.income_per_building From 2ef2eafdd3d90b330825e206b4638c50c1b29b2c Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 1 Jul 2021 19:37:08 -0700 Subject: [PATCH 011/167] Remove debug cruft. We don't need to print the description of every unit on startup. --- game/dcs/groundunittype.py | 1 - 1 file changed, 1 deletion(-) diff --git a/game/dcs/groundunittype.py b/game/dcs/groundunittype.py index c2b6f2ab..908e0e18 100644 --- a/game/dcs/groundunittype.py +++ b/game/dcs/groundunittype.py @@ -57,7 +57,6 @@ class GroundUnitType(UnitType[VehicleType]): def _load_all(cls) -> None: for unit_type in cls._each_unit_type(): for data in cls._each_variant_of(unit_type): - print(data.name, data.description) cls.register(data) cls._loaded = True From b2dd8c68e1aa7cb9e009777cc6e6a06272496dca Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 1 Jul 2021 20:00:08 -0700 Subject: [PATCH 012/167] Revert "Add Cloud Base Altitude to Weather Display (#1371)" Reverting until https://github.com/dcs-liberation/dcs_liberation/issues/1394 is resovled. This reverts commit f80696b7244d2eace20111dacdcb149cb4747055. --- changelog.md | 2 -- qt_ui/widgets/QTopPanel.py | 6 ------ 2 files changed, 8 deletions(-) diff --git a/changelog.md b/changelog.md index 09058d82..69f5dd8d 100644 --- a/changelog.md +++ b/changelog.md @@ -12,9 +12,7 @@ Saves from 4.0.0 are compatible with 4.0.1. ## Features/Improvements * **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. - * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. -* **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). * **[UI]** Google search link added to unit information when there is no information provided. ## Fixes diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 83959b12..a00b6044 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -13,7 +13,6 @@ import qt_ui.uiconstants as CONST from game import Game from game.event.airwar import AirWarEvent from game.profiling import logged_duration -from game.utils import meters from gen.ato import Package from gen.flights.traveltime import TotEstimator from qt_ui.models import GameModel @@ -113,11 +112,6 @@ class QTopPanel(QFrame): self.transfers.setEnabled(True) self.conditionsWidget.setCurrentTurn(game.turn, game.conditions) - - base_m = game.conditions.weather.clouds.base - base_ft = int(meters(base_m).feet) - self.conditionsWidget.setToolTip(f"Cloud Base: {base_m}m / {base_ft}ft") - self.intel_box.set_game(game) self.budgetBox.setGame(game) self.factionsInfos.setGame(game) From c89416702d7f9153212e4af45f79d3c735c09805 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 1 Jul 2021 20:02:29 -0700 Subject: [PATCH 013/167] Revert "Revert "Add Cloud Base Altitude to Weather Display (#1371)"" This reverts commit b2dd8c68e1aa7cb9e009777cc6e6a06272496dca. --- changelog.md | 2 ++ qt_ui/widgets/QTopPanel.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/changelog.md b/changelog.md index 69f5dd8d..09058d82 100644 --- a/changelog.md +++ b/changelog.md @@ -12,7 +12,9 @@ Saves from 4.0.0 are compatible with 4.0.1. ## Features/Improvements * **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. + * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. +* **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). * **[UI]** Google search link added to unit information when there is no information provided. ## Fixes diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index a00b6044..83959b12 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -13,6 +13,7 @@ import qt_ui.uiconstants as CONST from game import Game from game.event.airwar import AirWarEvent from game.profiling import logged_duration +from game.utils import meters from gen.ato import Package from gen.flights.traveltime import TotEstimator from qt_ui.models import GameModel @@ -112,6 +113,11 @@ class QTopPanel(QFrame): self.transfers.setEnabled(True) self.conditionsWidget.setCurrentTurn(game.turn, game.conditions) + + base_m = game.conditions.weather.clouds.base + base_ft = int(meters(base_m).feet) + self.conditionsWidget.setToolTip(f"Cloud Base: {base_m}m / {base_ft}ft") + self.intel_box.set_game(game) self.budgetBox.setGame(game) self.factionsInfos.setGame(game) From bc2539b566a9849d3c3997729128087f3f21e557 Mon Sep 17 00:00:00 2001 From: Chris Seagraves Date: Thu, 1 Jul 2021 20:04:14 -0700 Subject: [PATCH 014/167] Fix for crash when clear weather. Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1394 --- qt_ui/widgets/QTopPanel.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 83959b12..5f295a6a 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -114,9 +114,12 @@ class QTopPanel(QFrame): self.conditionsWidget.setCurrentTurn(game.turn, game.conditions) - base_m = game.conditions.weather.clouds.base - base_ft = int(meters(base_m).feet) - self.conditionsWidget.setToolTip(f"Cloud Base: {base_m}m / {base_ft}ft") + if game.conditions.weather.clouds: + base_m = game.conditions.weather.clouds.base + base_ft = int(meters(base_m).feet) + self.conditionsWidget.setToolTip(f"Cloud Base: {base_m}m / {base_ft}ft") + else: + self.conditionsWidget.setToolTip("") self.intel_box.set_game(game) self.budgetBox.setGame(game) From 20839853b7bfac7c3907fc62ba821f05bd8efda9 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 1 Jul 2021 20:07:28 -0700 Subject: [PATCH 015/167] Minor formatting fix for the changelog. --- changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 09058d82..abfac327 100644 --- a/changelog.md +++ b/changelog.md @@ -11,8 +11,8 @@ Saves from 3.x are not compatible with 5.0. Saves from 4.0.0 are compatible with 4.0.1. ## Features/Improvements -* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. +* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). * **[UI]** Google search link added to unit information when there is no information provided. From de443fa3f0ac7ab2d450ec097cf7ac94f17f3506 Mon Sep 17 00:00:00 2001 From: RndName Date: Thu, 1 Jul 2021 16:00:04 +0200 Subject: [PATCH 016/167] reworked the skynet group name generation - added information about the role of the aa site - moved handling of ground name from tgo to the sam generator to make the tgo cleaner - adjusted the skynet-config lua to the changes --- changelog.md | 3 +- game/game.py | 2 +- game/theater/theatergroundobject.py | 18 +----- gen/sam/airdefensegroupgenerator.py | 58 ++++++++++++++++--- gen/sam/sam_hawk.py | 3 +- gen/sam/sam_hq7.py | 3 +- gen/sam/sam_patriot.py | 3 +- gen/sam/sam_rapier.py | 5 ++ gen/sam/sam_roland.py | 5 ++ gen/sam/sam_sa10.py | 9 +-- .../plugins/skynetiads/skynetiads-config.lua | 23 +++++++- 11 files changed, 97 insertions(+), 35 deletions(-) diff --git a/changelog.md b/changelog.md index abfac327..bd9a6626 100644 --- a/changelog.md +++ b/changelog.md @@ -13,13 +13,14 @@ Saves from 4.0.0 are compatible with 4.0.1. ## Features/Improvements * **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. +* **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). * **[UI]** Google search link added to unit information when there is no information provided. ## Fixes * **[UI]** Statistics window tick marks are now always integers. - +* **[Mission Generation]** The lua data for other plugins is now generated correctly * **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles. # 4.0.0 diff --git a/game/game.py b/game/game.py index 3a783e40..6d2aa329 100644 --- a/game/game.py +++ b/game/game.py @@ -483,7 +483,7 @@ class Game: self.current_unit_id += 1 return self.current_unit_id - def next_group_id(self): + def next_group_id(self) -> int: """ Next unit id for pre-generated units """ diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index df637cbc..49fb8fd9 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -460,9 +460,9 @@ class CoastalSiteGroundObject(TheaterGroundObject): return False -# TODO: Differentiate types. -# This type gets used both for AA sites (SAM, AAA, or SHORAD). These should each -# be split into their own types. +# The SamGroundObject represents all type of AA +# The TGO can have multiple types of units (AAA,SAM,Support...) +# Differentiation can be made during generation with the airdefensegroupgenerator class SamGroundObject(TheaterGroundObject): def __init__( self, @@ -481,18 +481,6 @@ class SamGroundObject(TheaterGroundObject): dcs_identifier="AA", sea_object=False, ) - # Set by the SAM unit generator if the generated group is compatible - # with Skynet. - self.skynet_capable = False - - @property - def group_name(self) -> str: - if self.skynet_capable: - # Prefix the group names of SAM sites with the side color so Skynet - # can find them. - return f"{self.faction_color}|SAM|{self.group_id}" - else: - return super().group_name def mission_types(self, for_player: bool) -> Iterator[FlightType]: from gen.flights.flight import FlightType diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py index a62a5f11..7d269ece 100644 --- a/gen/sam/airdefensegroupgenerator.py +++ b/gen/sam/airdefensegroupgenerator.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from enum import Enum from typing import Iterator, List @@ -9,11 +11,31 @@ from game.theater.theatergroundobject import SamGroundObject from gen.sam.group_generator import GroupGenerator +class SkynetRole(Enum): + #: A radar SAM that should be controlled by Skynet. + Sam = "Sam" + + #: A radar SAM that should be controlled and used as an EWR by Skynet. + SamAsEwr = "SamAsEwr" + + #: An air defense unit that should be used as point defense by Skynet. + PointDefense = "PD" + + #: All other types of groups that might be present in a SAM TGO. This includes + #: SHORADS, AAA, supply trucks, etc. Anything that shouldn't be controlled by Skynet + #: should use this role. + NoSkynetBehavior = "NoSkynetBehavior" + + class AirDefenseRange(Enum): - AAA = "AAA" - Short = "short" - Medium = "medium" - Long = "long" + AAA = ("AAA", SkynetRole.NoSkynetBehavior) + Short = ("short", SkynetRole.NoSkynetBehavior) + Medium = ("medium", SkynetRole.Sam) + Long = ("long", SkynetRole.SamAsEwr) + + def __init__(self, description: str, default_role: SkynetRole) -> None: + self.range_name = description + self.default_role = default_role class AirDefenseGroupGenerator(GroupGenerator, ABC): @@ -24,18 +46,32 @@ class AirDefenseGroupGenerator(GroupGenerator, ABC): price: int def __init__(self, game: Game, ground_object: SamGroundObject) -> None: - ground_object.skynet_capable = True super().__init__(game, ground_object) + self.vg.name = self.group_name_for_role(self.vg.id, self.primary_group_role()) self.auxiliary_groups: List[VehicleGroup] = [] - def add_auxiliary_group(self, name_suffix: str) -> VehicleGroup: - group = VehicleGroup( - self.game.next_group_id(), "|".join([self.go.group_name, name_suffix]) - ) + def add_auxiliary_group(self, role: SkynetRole) -> VehicleGroup: + gid = self.game.next_group_id() + group = VehicleGroup(gid, self.group_name_for_role(gid, role)) self.auxiliary_groups.append(group) return group + def group_name_for_role(self, gid: int, role: SkynetRole) -> str: + if role is SkynetRole.NoSkynetBehavior: + # No special naming needed for air defense groups that don't participate in + # Skynet. + return f"{self.go.group_name}|{gid}" + + # For those that do, we need a prefix of `$COLOR|SAM| so our Skynet config picks + # the group up at all. To support PDs we need to append the ID of the TGO so + # that the PD will know which group it's protecting. We then append the role so + # our config knows what to do with the group, and finally the GID of *this* + # group to ensure no conflicts. + return "|".join( + [self.go.faction_color, "SAM", str(self.go.group_id), role.value, str(gid)] + ) + def get_generated_group(self) -> VehicleGroup: raise RuntimeError( "Deprecated call to AirDefenseGroupGenerator.get_generated_group " @@ -52,3 +88,7 @@ class AirDefenseGroupGenerator(GroupGenerator, ABC): @abstractmethod def range(cls) -> AirDefenseRange: ... + + @classmethod + def primary_group_role(cls) -> SkynetRole: + return cls.range().default_role diff --git a/gen/sam/sam_hawk.py b/gen/sam/sam_hawk.py index 01e463e1..ea05f726 100644 --- a/gen/sam/sam_hawk.py +++ b/gen/sam/sam_hawk.py @@ -6,6 +6,7 @@ from dcs.vehicles import AirDefence from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) @@ -41,7 +42,7 @@ class HawkGenerator(AirDefenseGroupGenerator): ) # Triple A for close range defense - aa_group = self.add_auxiliary_group("AA") + aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) self.add_unit_to_group( aa_group, AirDefence.Vulcan, diff --git a/gen/sam/sam_hq7.py b/gen/sam/sam_hq7.py index d05aecd8..be5eeb6a 100644 --- a/gen/sam/sam_hq7.py +++ b/gen/sam/sam_hq7.py @@ -6,6 +6,7 @@ from dcs.vehicles import AirDefence from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) @@ -34,7 +35,7 @@ class HQ7Generator(AirDefenseGroupGenerator): ) # Triple A for close range defense - aa_group = self.add_auxiliary_group("AA") + aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) self.add_unit_to_group( aa_group, AirDefence.Ural_375_ZU_23, diff --git a/gen/sam/sam_patriot.py b/gen/sam/sam_patriot.py index 21f6cd18..aafeb79c 100644 --- a/gen/sam/sam_patriot.py +++ b/gen/sam/sam_patriot.py @@ -6,6 +6,7 @@ from dcs.vehicles import AirDefence from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) @@ -69,7 +70,7 @@ class PatriotGenerator(AirDefenseGroupGenerator): ) # Short range protection for high value site - aa_group = self.add_auxiliary_group("AA") + aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) num_launchers = random.randint(3, 4) positions = self.get_circular_position( num_launchers, launcher_distance=200, coverage=360 diff --git a/gen/sam/sam_rapier.py b/gen/sam/sam_rapier.py index 0e361459..af3965e4 100644 --- a/gen/sam/sam_rapier.py +++ b/gen/sam/sam_rapier.py @@ -5,6 +5,7 @@ from dcs.vehicles import AirDefence from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) @@ -49,3 +50,7 @@ class RapierGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: return AirDefenseRange.Short + + @classmethod + def primary_group_role(cls) -> SkynetRole: + return SkynetRole.Sam diff --git a/gen/sam/sam_roland.py b/gen/sam/sam_roland.py index 4a88cfd4..e2e704af 100644 --- a/gen/sam/sam_roland.py +++ b/gen/sam/sam_roland.py @@ -3,6 +3,7 @@ from dcs.vehicles import AirDefence, Unarmed from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) @@ -40,3 +41,7 @@ class RolandGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: return AirDefenseRange.Short + + @classmethod + def primary_group_role(cls) -> SkynetRole: + return SkynetRole.Sam diff --git a/gen/sam/sam_sa10.py b/gen/sam/sam_sa10.py index 6daf8bfb..35611e83 100644 --- a/gen/sam/sam_sa10.py +++ b/gen/sam/sam_sa10.py @@ -8,6 +8,7 @@ from game.theater import SamGroundObject from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, + SkynetRole, ) from pydcs_extensions.highdigitsams import highdigitsams @@ -76,7 +77,7 @@ class SA10Generator(AirDefenseGroupGenerator): def generate_defensive_groups(self) -> None: # AAA for defending against close targets. - aa_group = self.add_auxiliary_group("AA") + aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) num_launchers = random.randint(6, 8) positions = self.get_circular_position( num_launchers, launcher_distance=210, coverage=360 @@ -101,7 +102,7 @@ class Tier2SA10Generator(SA10Generator): super().generate_defensive_groups() # SA-15 for both shorter range targets and point defense. - pd_group = self.add_auxiliary_group("PD") + pd_group = self.add_auxiliary_group(SkynetRole.PointDefense) num_launchers = random.randint(2, 4) positions = self.get_circular_position( num_launchers, launcher_distance=140, coverage=360 @@ -123,7 +124,7 @@ class Tier3SA10Generator(SA10Generator): def generate_defensive_groups(self) -> None: # AAA for defending against close targets. - aa_group = self.add_auxiliary_group("AA") + aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) num_launchers = random.randint(6, 8) positions = self.get_circular_position( num_launchers, launcher_distance=210, coverage=360 @@ -138,7 +139,7 @@ class Tier3SA10Generator(SA10Generator): ) # SA-15 for both shorter range targets and point defense. - pd_group = self.add_auxiliary_group("PD") + pd_group = self.add_auxiliary_group(SkynetRole.PointDefense) num_launchers = random.randint(2, 4) positions = self.get_circular_position( num_launchers, launcher_distance=140, coverage=360 diff --git a/resources/plugins/skynetiads/skynetiads-config.lua b/resources/plugins/skynetiads/skynetiads-config.lua index aa0ce992..f083c6f9 100644 --- a/resources/plugins/skynetiads/skynetiads-config.lua +++ b/resources/plugins/skynetiads/skynetiads-config.lua @@ -93,9 +93,28 @@ if dcsLiberation and SkynetIADS then for i = 1, #sites do local site = sites[i] local name = site:getDCSName() + + if string.match(name, "|SamAsEwr|") then + env.info(string.format("DCSLiberation|Skynet-IADS plugin - %s now acting as EWR", name)) + site:setActAsEW(true) + end + if not string.match(name, "|PD") then - env.info(string.format("DCSLiberation|Skynet-IADS plugin - Checking %s for PD", name)) - local pds = iads:getSAMSitesByPrefix(name .. "|PD") + -- Name is prefixed with `$color|SAM|$tgoid`. For pre-4.1 generated + -- campaigns that's the full name of the primary SAM and any PD are just + -- that name suffixed with |PD. + -- + -- For 4.1+ generated campaigns the name will be + -- `$color|SAM|$tgoid|$role|$gid`, so we need to replace the content + -- beginning with the third pipe with `|PD` to find our PDs. + local first_pipe = string.find(name, "|") + local second_pipe = string.find(name, "|", first_pipe + 1) + local third_pipe = string.find(name, "|", second_pipe + 1) + local pd_prefix = name .. "|PD" + if third_pipe ~= nil then + pd_prefix = string.sub(name, 1, third_pipe) .. "PD" + end + local pds = iads:getSAMSitesByPrefix(pd_prefix) for j = 1, #pds do pd = pds[j] env.info(string.format("DCSLiberation|Skynet-IADS plugin - Adding %s as PD for %s", pd:getDCSName(), name)) From 357361de3deabc8894c46207e784c8ba6c47e482 Mon Sep 17 00:00:00 2001 From: RndName Date: Sat, 19 Jun 2021 23:29:44 +0200 Subject: [PATCH 017/167] fixed lua data generation --- game/operation/operation.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/game/operation/operation.py b/game/operation/operation.py index da5239cb..a44dd5aa 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -439,8 +439,8 @@ class Operation: "BlueAA": {}, } # type: ignore - for tanker in airsupportgen.air_support.tankers: - luaData["Tankers"][tanker.callsign] = { + for i, tanker in enumerate(airsupportgen.air_support.tankers): + luaData["Tankers"][i] = { "dcsGroupName": tanker.group_name, "callsign": tanker.callsign, "variant": tanker.variant, @@ -448,23 +448,22 @@ class Operation: "tacan": str(tanker.tacan.number) + tanker.tacan.band.name, } - if airsupportgen.air_support.awacs: - for awacs in airsupportgen.air_support.awacs: - luaData["AWACs"][awacs.callsign] = { - "dcsGroupName": awacs.group_name, - "callsign": awacs.callsign, - "radio": awacs.freq.mhz, - } + for i, awacs in enumerate(airsupportgen.air_support.awacs): + luaData["AWACs"][i] = { + "dcsGroupName": awacs.group_name, + "callsign": awacs.callsign, + "radio": awacs.freq.mhz, + } - for jtac in jtacs: - luaData["JTACs"][jtac.callsign] = { + for i, jtac in enumerate(jtacs): + luaData["JTACs"][i] = { "dcsGroupName": jtac.group_name, "callsign": jtac.callsign, "zone": jtac.region, "dcsUnit": jtac.unit_name, "laserCode": jtac.code, } - + flight_count = 0 for flight in airgen.flights: if flight.friendly and flight.flight_type in [ FlightType.ANTISHIP, @@ -485,7 +484,7 @@ class Operation: elif hasattr(flightTarget, "name"): flightTargetName = flightTarget.name flightTargetType = flightType + " TGT (Airbase)" - luaData["TargetPoints"][flightTargetName] = { + luaData["TargetPoints"][flight_count] = { "name": flightTargetName, "type": flightTargetType, "position": { @@ -493,6 +492,7 @@ class Operation: "y": flightTarget.position.y, }, } + flight_count += 1 for cp in cls.game.theater.controlpoints: for ground_object in cp.ground_objects: From 9e22d4b5dfa1a5848fc707dea831ea66930a7049 Mon Sep 17 00:00:00 2001 From: Chris Seagraves <47610393+nosv1@users.noreply.github.com> Date: Fri, 2 Jul 2021 03:26:55 -0500 Subject: [PATCH 018/167] Note TGO tooltip improvement in the changelog. --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index bd9a6626..7e2ae0a8 100644 --- a/changelog.md +++ b/changelog.md @@ -17,6 +17,7 @@ Saves from 4.0.0 are compatible with 4.0.1. * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). * **[UI]** Google search link added to unit information when there is no information provided. +* **[UI]** Control point name displayed with ground object group name on map. ## Fixes * **[UI]** Statistics window tick marks are now always integers. From 4e6659e7e8099c371251c9df8e193607f9386f7e Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 2 Jul 2021 01:27:51 -0700 Subject: [PATCH 019/167] 4.0.1 -> 4.1.0 This includes new features now. --- changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 7e2ae0a8..6ed5616c 100644 --- a/changelog.md +++ b/changelog.md @@ -6,9 +6,9 @@ Saves from 3.x are not compatible with 5.0. ## Fixes -# 4.0.1 +# 4.1.0 -Saves from 4.0.0 are compatible with 4.0.1. +Saves from 4.0.0 are compatible with 4.1.0. ## Features/Improvements From 3f65928e9d1012cff95029ce03bec08db682ea92 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 24 Jun 2021 00:42:13 +0200 Subject: [PATCH 020/167] Remove the randomness from SAM group size. --- changelog.md | 3 ++- gen/sam/aaa_bofors.py | 25 ++++++++++----------- gen/sam/aaa_flak.py | 34 +++++++++++++---------------- gen/sam/aaa_ks19.py | 24 ++++++++++---------- gen/sam/aaa_zsu57.py | 2 +- gen/sam/aaa_zu23_insurgent.py | 26 ++++++++++------------ gen/sam/sam_avenger.py | 2 +- gen/sam/sam_chaparral.py | 2 +- gen/sam/sam_gepard.py | 20 ++++++++--------- gen/sam/sam_hawk.py | 2 +- gen/sam/sam_hq7.py | 9 +------- gen/sam/sam_linebacker.py | 2 +- gen/sam/sam_patriot.py | 21 +++++++++--------- gen/sam/sam_rapier.py | 2 +- gen/sam/sam_roland.py | 18 ++++++++++----- gen/sam/sam_sa10.py | 18 +++++---------- gen/sam/sam_sa11.py | 2 +- gen/sam/sam_sa13.py | 2 +- gen/sam/sam_sa15.py | 17 ++++++++++----- gen/sam/sam_sa19.py | 2 +- gen/sam/sam_sa2.py | 2 +- gen/sam/sam_sa3.py | 2 +- gen/sam/sam_sa6.py | 2 +- gen/sam/sam_sa8.py | 18 ++++++++++----- gen/sam/sam_sa9.py | 2 +- gen/sam/sam_vulcan.py | 20 ++++++++--------- gen/sam/sam_zsu23.py | 11 ++++++++-- gen/sam/sam_zu23.py | 35 ++++++++++++++++-------------- gen/sam/sam_zu23_ural.py | 2 +- gen/sam/sam_zu23_ural_insurgent.py | 2 +- 30 files changed, 164 insertions(+), 165 deletions(-) diff --git a/changelog.md b/changelog.md index 6ed5616c..4864dfd3 100644 --- a/changelog.md +++ b/changelog.md @@ -12,8 +12,9 @@ Saves from 4.0.0 are compatible with 4.1.0. ## Features/Improvements -* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. +* **[Campaign]** Air defense sites now generate a fixed number of launchers per type. * **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR +* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). * **[UI]** Google search link added to unit information when there is no information provided. diff --git a/gen/sam/aaa_bofors.py b/gen/sam/aaa_bofors.py index 8c76f7f4..283c1d59 100644 --- a/gen/sam/aaa_bofors.py +++ b/gen/sam/aaa_bofors.py @@ -17,22 +17,19 @@ class BoforsGenerator(AirDefenseGroupGenerator): price = 75 def generate(self): - grid_x = random.randint(2, 3) - grid_y = random.randint(2, 3) - - spacing = random.randint(10, 40) index = 0 - for i in range(grid_x): - for j in range(grid_y): - index = index + 1 - self.add_unit( - AirDefence.Bofors40, - "AAA#" + str(index), - self.position.x + spacing * i, - self.position.y + spacing * j, - self.heading, - ) + for i in range(4): + spacing_x = random.randint(10, 40) + spacing_y = random.randint(10, 40) + index = index + 1 + self.add_unit( + AirDefence.Bofors40, + "AAA#" + str(index), + self.position.x + spacing_x * i, + self.position.y + spacing_y * i, + self.heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/aaa_flak.py b/gen/sam/aaa_flak.py index f918e48a..141e5b7d 100644 --- a/gen/sam/aaa_flak.py +++ b/gen/sam/aaa_flak.py @@ -26,28 +26,24 @@ class FlakGenerator(AirDefenseGroupGenerator): price = 135 def generate(self): - grid_x = random.randint(2, 3) - grid_y = random.randint(2, 3) - - spacing = random.randint(20, 35) - index = 0 mixed = random.choice([True, False]) unit_type = random.choice(GFLAK) - for i in range(grid_x): - for j in range(grid_y): - index = index + 1 - self.add_unit( - unit_type, - "AAA#" + str(index), - self.position.x + spacing * i + random.randint(1, 5), - self.position.y + spacing * j + random.randint(1, 5), - self.heading, - ) + for i in range(4): + index = index + 1 + spacing_x = random.randint(10, 40) + spacing_y = random.randint(10, 40) + self.add_unit( + unit_type, + "AAA#" + str(index), + self.position.x + spacing_x * i + random.randint(1, 5), + self.position.y + spacing_y * i + random.randint(1, 5), + self.heading, + ) - if mixed: - unit_type = random.choice(GFLAK) + if mixed: + unit_type = random.choice(GFLAK) # Search lights search_pos = self.get_circular_position(random.randint(2, 3), 80) @@ -86,8 +82,8 @@ class FlakGenerator(AirDefenseGroupGenerator): ) # Some Opel Blitz trucks - for i in range(int(max(1, grid_x / 2))): - for j in range(int(max(1, grid_x / 2))): + for i in range(int(max(1, 2))): + for j in range(int(max(1, 2))): self.add_unit( Unarmed.Blitz_36_6700A, "BLITZ#" + str(index), diff --git a/gen/sam/aaa_ks19.py b/gen/sam/aaa_ks19.py index 1e3de4ca..bb51e92a 100644 --- a/gen/sam/aaa_ks19.py +++ b/gen/sam/aaa_ks19.py @@ -16,9 +16,6 @@ class KS19Generator(AirDefenseGroupGenerator): price = 98 def generate(self): - - spacing = random.randint(10, 40) - self.add_unit( highdigitsams.AAA_SON_9_Fire_Can, "TR", @@ -28,16 +25,17 @@ class KS19Generator(AirDefenseGroupGenerator): ) index = 0 - for i in range(3): - for j in range(3): - index = index + 1 - self.add_unit( - highdigitsams.AAA_100mm_KS_19, - "AAA#" + str(index), - self.position.x + spacing * i, - self.position.y + spacing * j, - self.heading, - ) + for i in range(4): + spacing_x = random.randint(10, 40) + spacing_y = random.randint(10, 40) + index = index + 1 + self.add_unit( + highdigitsams.AAA_100mm_KS_19, + "AAA#" + str(index), + self.position.x + spacing_x * i, + self.position.y + spacing_y * i, + self.heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/aaa_zsu57.py b/gen/sam/aaa_zsu57.py index 4648e90b..422693d5 100644 --- a/gen/sam/aaa_zsu57.py +++ b/gen/sam/aaa_zsu57.py @@ -15,7 +15,7 @@ class ZSU57Generator(AirDefenseGroupGenerator): price = 60 def generate(self): - num_launchers = 5 + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=110, coverage=360 ) diff --git a/gen/sam/aaa_zu23_insurgent.py b/gen/sam/aaa_zu23_insurgent.py index 5ca97638..49113668 100644 --- a/gen/sam/aaa_zu23_insurgent.py +++ b/gen/sam/aaa_zu23_insurgent.py @@ -17,22 +17,18 @@ class ZU23InsurgentGenerator(AirDefenseGroupGenerator): price = 56 def generate(self): - grid_x = random.randint(2, 3) - grid_y = random.randint(2, 3) - - spacing = random.randint(10, 40) - index = 0 - for i in range(grid_x): - for j in range(grid_y): - index = index + 1 - self.add_unit( - AirDefence.ZU_23_Closed_Insurgent, - "AAA#" + str(index), - self.position.x + spacing * i, - self.position.y + spacing * j, - self.heading, - ) + for i in range(4): + index = index + 1 + spacing_x = random.randint(10, 40) + spacing_y = random.randint(10, 40) + self.add_unit( + AirDefence.ZU_23_Closed_Insurgent, + "AAA#" + str(index), + self.position.x + spacing_x * i, + self.position.y + spacing_y * i, + self.heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/sam_avenger.py b/gen/sam/sam_avenger.py index b778cc62..b3f63354 100644 --- a/gen/sam/sam_avenger.py +++ b/gen/sam/sam_avenger.py @@ -17,7 +17,7 @@ class AvengerGenerator(AirDefenseGroupGenerator): price = 62 def generate(self): - num_launchers = random.randint(2, 3) + num_launchers = 2 self.add_unit( Unarmed.M_818, diff --git a/gen/sam/sam_chaparral.py b/gen/sam/sam_chaparral.py index 465ba0bd..ea239746 100644 --- a/gen/sam/sam_chaparral.py +++ b/gen/sam/sam_chaparral.py @@ -17,7 +17,7 @@ class ChaparralGenerator(AirDefenseGroupGenerator): price = 66 def generate(self): - num_launchers = random.randint(2, 4) + num_launchers = 2 self.add_unit( Unarmed.M_818, diff --git a/gen/sam/sam_gepard.py b/gen/sam/sam_gepard.py index 669781df..6128efab 100644 --- a/gen/sam/sam_gepard.py +++ b/gen/sam/sam_gepard.py @@ -17,20 +17,18 @@ class GepardGenerator(AirDefenseGroupGenerator): price = 50 def generate(self): - self.add_unit( - AirDefence.Gepard, - "SPAAA", - self.position.x, - self.position.y, - self.heading, + num_launchers = 2 + + positions = self.get_circular_position( + num_launchers, launcher_distance=120, coverage=180 ) - if random.randint(0, 1) == 1: + for i, position in enumerate(positions): self.add_unit( AirDefence.Gepard, - "SPAAA2", - self.position.x, - self.position.y, - self.heading, + "SPAA#" + str(i), + position[0], + position[1], + position[2], ) self.add_unit( Unarmed.M_818, diff --git a/gen/sam/sam_hawk.py b/gen/sam/sam_hawk.py index ea05f726..efec60a2 100644 --- a/gen/sam/sam_hawk.py +++ b/gen/sam/sam_hawk.py @@ -51,7 +51,7 @@ class HawkGenerator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(3, 6) + num_launchers = 6 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=180 ) diff --git a/gen/sam/sam_hq7.py b/gen/sam/sam_hq7.py index be5eeb6a..0143fc63 100644 --- a/gen/sam/sam_hq7.py +++ b/gen/sam/sam_hq7.py @@ -26,13 +26,6 @@ class HQ7Generator(AirDefenseGroupGenerator): self.position.y, self.heading, ) - self.add_unit( - AirDefence.HQ_7_LN_SP, - "LN", - self.position.x + 20, - self.position.y, - self.heading, - ) # Triple A for close range defense aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) @@ -51,7 +44,7 @@ class HQ7Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(0, 3) + num_launchers = 2 if num_launchers > 0: positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=360 diff --git a/gen/sam/sam_linebacker.py b/gen/sam/sam_linebacker.py index b140b138..09c57117 100644 --- a/gen/sam/sam_linebacker.py +++ b/gen/sam/sam_linebacker.py @@ -17,7 +17,7 @@ class LinebackerGenerator(AirDefenseGroupGenerator): price = 75 def generate(self): - num_launchers = random.randint(2, 4) + num_launchers = 2 self.add_unit( Unarmed.M_818, diff --git a/gen/sam/sam_patriot.py b/gen/sam/sam_patriot.py index aafeb79c..fcd82417 100644 --- a/gen/sam/sam_patriot.py +++ b/gen/sam/sam_patriot.py @@ -1,5 +1,3 @@ -import random - from dcs.mapping import Point from dcs.vehicles import AirDefence @@ -56,10 +54,7 @@ class PatriotGenerator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(3, 4) - positions = self.get_circular_position( - num_launchers, launcher_distance=120, coverage=360 - ) + positions = self.get_circular_position(8, launcher_distance=120, coverage=360) for i, position in enumerate(positions): self.add_unit( AirDefence.Patriot_ln, @@ -71,10 +66,7 @@ class PatriotGenerator(AirDefenseGroupGenerator): # Short range protection for high value site aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) - num_launchers = random.randint(3, 4) - positions = self.get_circular_position( - num_launchers, launcher_distance=200, coverage=360 - ) + positions = self.get_circular_position(2, launcher_distance=200, coverage=360) for i, (x, y, heading) in enumerate(positions): self.add_unit_to_group( aa_group, @@ -83,6 +75,15 @@ class PatriotGenerator(AirDefenseGroupGenerator): Point(x, y), heading, ) + positions = self.get_circular_position(2, launcher_distance=300, coverage=360) + for i, (x, y, heading) in enumerate(positions): + self.add_unit_to_group( + aa_group, + AirDefence.M1097_Avenger, + f"Avenger#{i}", + Point(x, y), + heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/sam_rapier.py b/gen/sam/sam_rapier.py index af3965e4..538dd7c4 100644 --- a/gen/sam/sam_rapier.py +++ b/gen/sam/sam_rapier.py @@ -33,7 +33,7 @@ class RapierGenerator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(3, 6) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=80, coverage=240 ) diff --git a/gen/sam/sam_roland.py b/gen/sam/sam_roland.py index e2e704af..64ee154d 100644 --- a/gen/sam/sam_roland.py +++ b/gen/sam/sam_roland.py @@ -16,6 +16,7 @@ class RolandGenerator(AirDefenseGroupGenerator): price = 40 def generate(self): + num_launchers = 2 self.add_unit( AirDefence.Roland_Radar, "EWR", @@ -23,13 +24,18 @@ class RolandGenerator(AirDefenseGroupGenerator): self.position.y, self.heading, ) - self.add_unit( - AirDefence.Roland_ADS, - "ADS", - self.position.x, - self.position.y, - self.heading, + positions = self.get_circular_position( + num_launchers, launcher_distance=80, coverage=240 ) + + for i, position in enumerate(positions): + self.add_unit( + AirDefence.Roland_ADS, + "ADS#" + str(i), + position[0], + position[1], + position[2], + ) self.add_unit( Unarmed.M_818, "TRUCK", diff --git a/gen/sam/sam_sa10.py b/gen/sam/sam_sa10.py index 35611e83..8d4d4e2c 100644 --- a/gen/sam/sam_sa10.py +++ b/gen/sam/sam_sa10.py @@ -1,5 +1,3 @@ -import random - from dcs.mapping import Point from dcs.vehicles import AirDefence @@ -45,17 +43,13 @@ class SA10Generator(AirDefenseGroupGenerator): # Command Post self.add_unit(self.cp, "CP", self.position.x, self.position.y, self.heading) - # 2 Tracking radars + # 1 Tracking radar self.add_unit( self.tr1, "TR1", self.position.x - 40, self.position.y - 40, self.heading ) - self.add_unit( - self.tr2, "TR2", self.position.x + 40, self.position.y - 40, self.heading - ) - # 2 different launcher type (C & D) - num_launchers = random.randint(6, 8) + num_launchers = 6 positions = self.get_circular_position( num_launchers, launcher_distance=100, coverage=360 ) @@ -78,7 +72,7 @@ class SA10Generator(AirDefenseGroupGenerator): def generate_defensive_groups(self) -> None: # AAA for defending against close targets. aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) - num_launchers = random.randint(6, 8) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=210, coverage=360 ) @@ -103,7 +97,7 @@ class Tier2SA10Generator(SA10Generator): # SA-15 for both shorter range targets and point defense. pd_group = self.add_auxiliary_group(SkynetRole.PointDefense) - num_launchers = random.randint(2, 4) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=140, coverage=360 ) @@ -125,7 +119,7 @@ class Tier3SA10Generator(SA10Generator): def generate_defensive_groups(self) -> None: # AAA for defending against close targets. aa_group = self.add_auxiliary_group(SkynetRole.NoSkynetBehavior) - num_launchers = random.randint(6, 8) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=210, coverage=360 ) @@ -140,7 +134,7 @@ class Tier3SA10Generator(SA10Generator): # SA-15 for both shorter range targets and point defense. pd_group = self.add_auxiliary_group(SkynetRole.PointDefense) - num_launchers = random.randint(2, 4) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=140, coverage=360 ) diff --git a/gen/sam/sam_sa11.py b/gen/sam/sam_sa11.py index 7fec37c2..3611aff5 100644 --- a/gen/sam/sam_sa11.py +++ b/gen/sam/sam_sa11.py @@ -32,7 +32,7 @@ class SA11Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(2, 4) + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=140, coverage=180 ) diff --git a/gen/sam/sam_sa13.py b/gen/sam/sam_sa13.py index 0fbe1af0..24cd75a0 100644 --- a/gen/sam/sam_sa13.py +++ b/gen/sam/sam_sa13.py @@ -32,7 +32,7 @@ class SA13Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(2, 3) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=360 ) diff --git a/gen/sam/sam_sa15.py b/gen/sam/sam_sa15.py index 3dcb881a..d5d74b9c 100644 --- a/gen/sam/sam_sa15.py +++ b/gen/sam/sam_sa15.py @@ -15,13 +15,18 @@ class SA15Generator(AirDefenseGroupGenerator): price = 55 def generate(self): - self.add_unit( - AirDefence.Tor_9A331, - "ADS", - self.position.x, - self.position.y, - self.heading, + num_launchers = 2 + positions = self.get_circular_position( + num_launchers, launcher_distance=120, coverage=360 ) + for i, position in enumerate(positions): + self.add_unit( + AirDefence.Tor_9A331, + "ADS#" + str(i), + position[0], + position[1], + position[2], + ) self.add_unit( Unarmed.UAZ_469, "EWR", diff --git a/gen/sam/sam_sa19.py b/gen/sam/sam_sa19.py index caac1f7c..0ff23158 100644 --- a/gen/sam/sam_sa19.py +++ b/gen/sam/sam_sa19.py @@ -17,7 +17,7 @@ class SA19Generator(AirDefenseGroupGenerator): price = 90 def generate(self): - num_launchers = random.randint(1, 3) + num_launchers = 2 if num_launchers == 1: self.add_unit( diff --git a/gen/sam/sam_sa2.py b/gen/sam/sam_sa2.py index 4b7341df..3cfae81e 100644 --- a/gen/sam/sam_sa2.py +++ b/gen/sam/sam_sa2.py @@ -32,7 +32,7 @@ class SA2Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(3, 6) + num_launchers = 6 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=180 ) diff --git a/gen/sam/sam_sa3.py b/gen/sam/sam_sa3.py index 1a95de12..400bda82 100644 --- a/gen/sam/sam_sa3.py +++ b/gen/sam/sam_sa3.py @@ -32,7 +32,7 @@ class SA3Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(3, 6) + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=180 ) diff --git a/gen/sam/sam_sa6.py b/gen/sam/sam_sa6.py index fa72b24a..6706f250 100644 --- a/gen/sam/sam_sa6.py +++ b/gen/sam/sam_sa6.py @@ -25,7 +25,7 @@ class SA6Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(2, 4) + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=360 ) diff --git a/gen/sam/sam_sa8.py b/gen/sam/sam_sa8.py index 3ab28dfc..7f3f72b3 100644 --- a/gen/sam/sam_sa8.py +++ b/gen/sam/sam_sa8.py @@ -15,13 +15,19 @@ class SA8Generator(AirDefenseGroupGenerator): price = 55 def generate(self): - self.add_unit( - AirDefence.Osa_9A33_ln, - "OSA", - self.position.x, - self.position.y, - self.heading, + num_launchers = 2 + positions = self.get_circular_position( + num_launchers, launcher_distance=120, coverage=180 ) + + for i, position in enumerate(positions): + self.add_unit( + AirDefence.Osa_9A33_ln, + "OSA" + str(i), + position[0], + position[1], + position[2], + ) self.add_unit( AirDefence.SA_8_Osa_LD_9T217, "LD", diff --git a/gen/sam/sam_sa9.py b/gen/sam/sam_sa9.py index fccc7973..ed7883b5 100644 --- a/gen/sam/sam_sa9.py +++ b/gen/sam/sam_sa9.py @@ -32,7 +32,7 @@ class SA9Generator(AirDefenseGroupGenerator): self.heading, ) - num_launchers = random.randint(2, 3) + num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=360 ) diff --git a/gen/sam/sam_vulcan.py b/gen/sam/sam_vulcan.py index 2d057dc0..0c869afc 100644 --- a/gen/sam/sam_vulcan.py +++ b/gen/sam/sam_vulcan.py @@ -17,20 +17,18 @@ class VulcanGenerator(AirDefenseGroupGenerator): price = 25 def generate(self): - self.add_unit( - AirDefence.Vulcan, - "SPAAA", - self.position.x, - self.position.y, - self.heading, + num_launchers = 2 + + positions = self.get_circular_position( + num_launchers, launcher_distance=120, coverage=180 ) - if random.randint(0, 1) == 1: + for i, position in enumerate(positions): self.add_unit( AirDefence.Vulcan, - "SPAAA2", - self.position.x, - self.position.y, - self.heading, + "SPAA#" + str(i), + position[0], + position[1], + position[2], ) self.add_unit( Unarmed.M_818, diff --git a/gen/sam/sam_zsu23.py b/gen/sam/sam_zsu23.py index 708ae5c6..0e638b62 100644 --- a/gen/sam/sam_zsu23.py +++ b/gen/sam/sam_zsu23.py @@ -1,6 +1,6 @@ import random -from dcs.vehicles import AirDefence +from dcs.vehicles import AirDefence, Unarmed from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, @@ -17,7 +17,7 @@ class ZSU23Generator(AirDefenseGroupGenerator): price = 50 def generate(self): - num_launchers = random.randint(4, 5) + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=180 @@ -30,6 +30,13 @@ class ZSU23Generator(AirDefenseGroupGenerator): position[1], position[2], ) + self.add_unit( + Unarmed.M_818, + "TRUCK", + self.position.x + 80, + self.position.y, + self.heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/sam_zu23.py b/gen/sam/sam_zu23.py index 6a1b41cb..58c55ad5 100644 --- a/gen/sam/sam_zu23.py +++ b/gen/sam/sam_zu23.py @@ -1,6 +1,6 @@ import random -from dcs.vehicles import AirDefence +from dcs.vehicles import AirDefence, Unarmed from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, @@ -17,22 +17,25 @@ class ZU23Generator(AirDefenseGroupGenerator): price = 54 def generate(self): - grid_x = random.randint(2, 3) - grid_y = random.randint(2, 3) - - spacing = random.randint(10, 40) - index = 0 - for i in range(grid_x): - for j in range(grid_y): - index = index + 1 - self.add_unit( - AirDefence.ZU_23_Emplacement_Closed, - "AAA#" + str(index), - self.position.x + spacing * i, - self.position.y + spacing * j, - self.heading, - ) + for i in range(4): + index = index + 1 + spacing_x = random.randint(10, 40) + spacing_y = random.randint(10, 40) + self.add_unit( + AirDefence.ZU_23_Emplacement_Closed, + "AAA#" + str(index), + self.position.x + spacing_x * i, + self.position.y + spacing_y * i, + self.heading, + ) + self.add_unit( + Unarmed.M_818, + "TRUCK", + self.position.x + 80, + self.position.y, + self.heading, + ) @classmethod def range(cls) -> AirDefenseRange: diff --git a/gen/sam/sam_zu23_ural.py b/gen/sam/sam_zu23_ural.py index 4f97d6f3..47b7ac6a 100644 --- a/gen/sam/sam_zu23_ural.py +++ b/gen/sam/sam_zu23_ural.py @@ -17,7 +17,7 @@ class ZU23UralGenerator(AirDefenseGroupGenerator): price = 64 def generate(self): - num_launchers = random.randint(2, 8) + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=80, coverage=360 diff --git a/gen/sam/sam_zu23_ural_insurgent.py b/gen/sam/sam_zu23_ural_insurgent.py index d0ab8405..0e277599 100644 --- a/gen/sam/sam_zu23_ural_insurgent.py +++ b/gen/sam/sam_zu23_ural_insurgent.py @@ -17,7 +17,7 @@ class ZU23UralInsurgentGenerator(AirDefenseGroupGenerator): price = 64 def generate(self): - num_launchers = random.randint(2, 8) + num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=80, coverage=360 From ceb77c990b5e15c4a7d8313f2f3b041d88811b47 Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Fri, 2 Jul 2021 11:45:01 -0700 Subject: [PATCH 021/167] Corrected Silkworm launcher name --- gen/coastal/silkworm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen/coastal/silkworm.py b/gen/coastal/silkworm.py index 4198e004..1ffe6b04 100644 --- a/gen/coastal/silkworm.py +++ b/gen/coastal/silkworm.py @@ -23,7 +23,7 @@ class SilkwormGenerator(GroupGenerator): # Launchers for i, p in enumerate(positions): self.add_unit( - MissilesSS.Silkworm_SR, + MissilesSS.Hy_launcher, "Missile#" + str(i), p[0], p[1], From bab8384803eb0bcf16f1a3e599c6555a060ac304 Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Fri, 2 Jul 2021 11:45:01 -0700 Subject: [PATCH 022/167] Corrected Silkworm launcher name --- gen/coastal/silkworm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen/coastal/silkworm.py b/gen/coastal/silkworm.py index 4198e004..1ffe6b04 100644 --- a/gen/coastal/silkworm.py +++ b/gen/coastal/silkworm.py @@ -23,7 +23,7 @@ class SilkwormGenerator(GroupGenerator): # Launchers for i, p in enumerate(positions): self.add_unit( - MissilesSS.Silkworm_SR, + MissilesSS.Hy_launcher, "Missile#" + str(i), p[0], p[1], From 3f42f1281d60b941b0b269c50c3aebc0ed5338fd Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 2 Jul 2021 16:28:06 -0700 Subject: [PATCH 023/167] Note the silkworm fix in the changelog. --- changelog.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 4864dfd3..00f2ca94 100644 --- a/changelog.md +++ b/changelog.md @@ -21,9 +21,11 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[UI]** Control point name displayed with ground object group name on map. ## Fixes -* **[UI]** Statistics window tick marks are now always integers. -* **[Mission Generation]** The lua data for other plugins is now generated correctly + +* **[Campaign]** Fixed the Silkworm generator to include launchers and not all radars. * **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles. +* **[Mission Generation]** The lua data for other plugins is now generated correctly +* **[UI]** Statistics window tick marks are now always integers. # 4.0.0 From 96be6c0efe4b6b0f3af38bf5c43a9818e1eed4e6 Mon Sep 17 00:00:00 2001 From: RndName Date: Thu, 24 Jun 2021 21:50:02 +0200 Subject: [PATCH 024/167] correct prices for ewr and sams prices will now be calculated for the whole group by the generator by looking up the price using the GroundUnitType wrapper fixes #1163 --- changelog.md | 2 ++ gen/sam/airdefensegroupgenerator.py | 2 -- gen/sam/ewrs.py | 5 ---- gen/sam/group_generator.py | 14 +++++++++++ .../windows/groundobject/QGroundObjectMenu.py | 24 ++++++++++--------- 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/changelog.md b/changelog.md index 00f2ca94..9bd0a301 100644 --- a/changelog.md +++ b/changelog.md @@ -19,10 +19,12 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). * **[UI]** Google search link added to unit information when there is no information provided. * **[UI]** Control point name displayed with ground object group name on map. +* **[UI]** Buy or Replace will now show the correct price for generated ground objects like sams. ## Fixes * **[Campaign]** Fixed the Silkworm generator to include launchers and not all radars. +* **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money * **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles. * **[Mission Generation]** The lua data for other plugins is now generated correctly * **[UI]** Statistics window tick marks are now always integers. diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py index 7d269ece..bc192691 100644 --- a/gen/sam/airdefensegroupgenerator.py +++ b/gen/sam/airdefensegroupgenerator.py @@ -43,8 +43,6 @@ class AirDefenseGroupGenerator(GroupGenerator, ABC): This is the base for all SAM group generators """ - price: int - def __init__(self, game: Game, ground_object: SamGroundObject) -> None: super().__init__(game, ground_object) diff --git a/gen/sam/ewrs.py b/gen/sam/ewrs.py index df27e6ad..3678fe79 100644 --- a/gen/sam/ewrs.py +++ b/gen/sam/ewrs.py @@ -13,11 +13,6 @@ class EwrGenerator(GroupGenerator): def name(cls) -> str: return cls.unit_type.name - @staticmethod - def price() -> int: - # TODO: Differentiate sites. - return 20 - def generate(self) -> None: self.add_unit( self.unit_type, "EWR", self.position.x, self.position.y, self.heading diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index 65eb0b50..f41b9543 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import math import random from typing import TYPE_CHECKING, Type @@ -10,6 +11,7 @@ from dcs.point import PointAction from dcs.unit import Ship, Vehicle from dcs.unittype import VehicleType +from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction from game.theater.theatergroundobject import TheaterGroundObject @@ -23,11 +25,15 @@ if TYPE_CHECKING: # care about in the format we want if we just generate our own group description # types rather than pydcs groups. class GroupGenerator: + + price: int + def __init__(self, game: Game, ground_object: TheaterGroundObject) -> None: self.game = game self.go = ground_object self.position = ground_object.position self.heading = random.randint(0, 359) + self.price = 0 self.vg = unitgroup.VehicleGroup(self.game.next_group_id(), self.go.group_name) wp = self.vg.add_waypoint(self.position, PointAction.OffRoad, 0) wp.ETA_locked = True @@ -62,6 +68,14 @@ class GroupGenerator: unit.position = position unit.heading = heading group.add_unit(unit) + + # get price of unit to calculate the real price of the whole group + try: + ground_unit_type = next(GroundUnitType.for_dcs_type(unit_type)) + self.price += ground_unit_type.price + except StopIteration: + logging.error(f"Cannot get price for unit {unit_type.name}") + return unit def get_circular_position(self, num_units, launcher_distance, coverage=90): diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py index 96debe14..7f955f3d 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -305,8 +305,11 @@ class QBuyGroupForGroundObjectDialog(QDialog): possible_sams = get_faction_possible_sams_generator(faction) for sam in possible_sams: + # Pre Generate SAM to get the real price + generator = sam(self.game, self.ground_object) + generator.generate() self.samCombo.addItem( - sam.name + " [$" + str(sam.price) + "M]", userData=sam + generator.name + " [$" + str(generator.price) + "M]", userData=generator ) self.samCombo.currentIndexChanged.connect(self.samComboChanged) @@ -331,8 +334,12 @@ class QBuyGroupForGroundObjectDialog(QDialog): buy_ewr_layout.addWidget(self.ewr_selector, 0, 1, alignment=Qt.AlignRight) ewr_types = get_faction_possible_ewrs_generator(faction) for ewr_type in ewr_types: + # Pre Generate to get the real price + generator = ewr_type(self.game, self.ground_object) + generator.generate() self.ewr_selector.addItem( - f"{ewr_type.name()} [${ewr_type.price()}M]", ewr_type + generator.name() + " [$" + str(generator.price) + "M]", + userData=generator, ) self.ewr_selector.currentIndexChanged.connect(self.on_ewr_selection_changed) @@ -402,7 +409,7 @@ class QBuyGroupForGroundObjectDialog(QDialog): def on_ewr_selection_changed(self, index): ewr = self.ewr_selector.itemData(index) self.buy_ewr_button.setText( - f"Buy [${ewr.price()}M][-${self.current_group_value}M]" + f"Buy [${ewr.price}M][-${self.current_group_value}M]" ) def armorComboChanged(self, index): @@ -443,25 +450,20 @@ class QBuyGroupForGroundObjectDialog(QDialog): else: self.game.budget -= price - # Generate SAM - generator = sam_generator(self.game, self.ground_object) - generator.generate() - self.ground_object.groups = list(generator.groups) + self.ground_object.groups = list(sam_generator.groups) GameUpdateSignal.get_instance().updateGame(self.game) def buy_ewr(self): ewr_generator = self.ewr_selector.itemData(self.ewr_selector.currentIndex()) - price = ewr_generator.price() - self.current_group_value + price = ewr_generator.price - self.current_group_value if price > self.game.budget: self.error_money() return else: self.game.budget -= price - generator = ewr_generator(self.game, self.ground_object) - generator.generate() - self.ground_object.groups = [generator.vg] + self.ground_object.groups = [ewr_generator.vg] GameUpdateSignal.get_instance().updateGame(self.game) From b2db27f9aa06ddbeb6c9e87686abb04cd2f2bb16 Mon Sep 17 00:00:00 2001 From: RndName Date: Sun, 27 Jun 2021 12:35:02 +0200 Subject: [PATCH 025/167] remove prices from sam generators The prices are only estimations due to randomization. the real price will be only known when the generator was used and the final units are known --- gen/sam/aaa_bofors.py | 1 - gen/sam/aaa_flak.py | 1 - gen/sam/aaa_flak18.py | 1 - gen/sam/aaa_ks19.py | 1 - gen/sam/aaa_ww2_ally_flak.py | 1 - gen/sam/aaa_zsu57.py | 1 - gen/sam/aaa_zu23_insurgent.py | 1 - gen/sam/cold_war_flak.py | 2 -- gen/sam/freya_ewr.py | 1 - gen/sam/sam_avenger.py | 1 - gen/sam/sam_chaparral.py | 1 - gen/sam/sam_gepard.py | 1 - gen/sam/sam_hawk.py | 1 - gen/sam/sam_hq7.py | 1 - gen/sam/sam_linebacker.py | 1 - gen/sam/sam_patriot.py | 1 - gen/sam/sam_rapier.py | 1 - gen/sam/sam_roland.py | 1 - gen/sam/sam_sa10.py | 8 -------- gen/sam/sam_sa11.py | 1 - gen/sam/sam_sa13.py | 1 - gen/sam/sam_sa15.py | 1 - gen/sam/sam_sa17.py | 1 - gen/sam/sam_sa19.py | 1 - gen/sam/sam_sa2.py | 1 - gen/sam/sam_sa3.py | 1 - gen/sam/sam_sa6.py | 1 - gen/sam/sam_sa8.py | 1 - gen/sam/sam_sa9.py | 1 - gen/sam/sam_vulcan.py | 1 - gen/sam/sam_zsu23.py | 1 - gen/sam/sam_zu23.py | 1 - gen/sam/sam_zu23_ural.py | 1 - gen/sam/sam_zu23_ural_insurgent.py | 1 - 34 files changed, 42 deletions(-) diff --git a/gen/sam/aaa_bofors.py b/gen/sam/aaa_bofors.py index 283c1d59..b4c87e34 100644 --- a/gen/sam/aaa_bofors.py +++ b/gen/sam/aaa_bofors.py @@ -14,7 +14,6 @@ class BoforsGenerator(AirDefenseGroupGenerator): """ name = "Bofors AAA" - price = 75 def generate(self): diff --git a/gen/sam/aaa_flak.py b/gen/sam/aaa_flak.py index 141e5b7d..882e5ad3 100644 --- a/gen/sam/aaa_flak.py +++ b/gen/sam/aaa_flak.py @@ -23,7 +23,6 @@ class FlakGenerator(AirDefenseGroupGenerator): """ name = "Flak Site" - price = 135 def generate(self): index = 0 diff --git a/gen/sam/aaa_flak18.py b/gen/sam/aaa_flak18.py index 91f81f15..60f1d389 100644 --- a/gen/sam/aaa_flak18.py +++ b/gen/sam/aaa_flak18.py @@ -14,7 +14,6 @@ class Flak18Generator(AirDefenseGroupGenerator): """ name = "WW2 Flak Site" - price = 40 def generate(self): diff --git a/gen/sam/aaa_ks19.py b/gen/sam/aaa_ks19.py index bb51e92a..f173dab2 100644 --- a/gen/sam/aaa_ks19.py +++ b/gen/sam/aaa_ks19.py @@ -13,7 +13,6 @@ class KS19Generator(AirDefenseGroupGenerator): """ name = "KS-19 AAA Site" - price = 98 def generate(self): self.add_unit( diff --git a/gen/sam/aaa_ww2_ally_flak.py b/gen/sam/aaa_ww2_ally_flak.py index 415bdab3..7e58718f 100644 --- a/gen/sam/aaa_ww2_ally_flak.py +++ b/gen/sam/aaa_ww2_ally_flak.py @@ -14,7 +14,6 @@ class AllyWW2FlakGenerator(AirDefenseGroupGenerator): """ name = "WW2 Ally Flak Site" - price = 140 def generate(self): diff --git a/gen/sam/aaa_zsu57.py b/gen/sam/aaa_zsu57.py index 422693d5..5da161c8 100644 --- a/gen/sam/aaa_zsu57.py +++ b/gen/sam/aaa_zsu57.py @@ -12,7 +12,6 @@ class ZSU57Generator(AirDefenseGroupGenerator): """ name = "ZSU-57-2 Group" - price = 60 def generate(self): num_launchers = 4 diff --git a/gen/sam/aaa_zu23_insurgent.py b/gen/sam/aaa_zu23_insurgent.py index 49113668..a91d143e 100644 --- a/gen/sam/aaa_zu23_insurgent.py +++ b/gen/sam/aaa_zu23_insurgent.py @@ -14,7 +14,6 @@ class ZU23InsurgentGenerator(AirDefenseGroupGenerator): """ name = "Zu-23 Site" - price = 56 def generate(self): index = 0 diff --git a/gen/sam/cold_war_flak.py b/gen/sam/cold_war_flak.py index 6c0bdf40..4030321b 100644 --- a/gen/sam/cold_war_flak.py +++ b/gen/sam/cold_war_flak.py @@ -17,7 +17,6 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator): """ name = "Early Cold War Flak Site" - price = 74 def generate(self): @@ -90,7 +89,6 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator): """ name = "Cold War Flak Site" - price = 72 def generate(self): diff --git a/gen/sam/freya_ewr.py b/gen/sam/freya_ewr.py index 917767fb..fbd8dbfb 100644 --- a/gen/sam/freya_ewr.py +++ b/gen/sam/freya_ewr.py @@ -12,7 +12,6 @@ class FreyaGenerator(AirDefenseGroupGenerator): """ name = "Freya EWR Site" - price = 60 def generate(self): diff --git a/gen/sam/sam_avenger.py b/gen/sam/sam_avenger.py index b3f63354..75df690f 100644 --- a/gen/sam/sam_avenger.py +++ b/gen/sam/sam_avenger.py @@ -14,7 +14,6 @@ class AvengerGenerator(AirDefenseGroupGenerator): """ name = "Avenger Group" - price = 62 def generate(self): num_launchers = 2 diff --git a/gen/sam/sam_chaparral.py b/gen/sam/sam_chaparral.py index ea239746..3489fd8b 100644 --- a/gen/sam/sam_chaparral.py +++ b/gen/sam/sam_chaparral.py @@ -14,7 +14,6 @@ class ChaparralGenerator(AirDefenseGroupGenerator): """ name = "Chaparral Group" - price = 66 def generate(self): num_launchers = 2 diff --git a/gen/sam/sam_gepard.py b/gen/sam/sam_gepard.py index 6128efab..cb752f90 100644 --- a/gen/sam/sam_gepard.py +++ b/gen/sam/sam_gepard.py @@ -14,7 +14,6 @@ class GepardGenerator(AirDefenseGroupGenerator): """ name = "Gepard Group" - price = 50 def generate(self): num_launchers = 2 diff --git a/gen/sam/sam_hawk.py b/gen/sam/sam_hawk.py index efec60a2..007b0b99 100644 --- a/gen/sam/sam_hawk.py +++ b/gen/sam/sam_hawk.py @@ -16,7 +16,6 @@ class HawkGenerator(AirDefenseGroupGenerator): """ name = "Hawk Site" - price = 115 def generate(self): self.add_unit( diff --git a/gen/sam/sam_hq7.py b/gen/sam/sam_hq7.py index 0143fc63..8c6f5d2a 100644 --- a/gen/sam/sam_hq7.py +++ b/gen/sam/sam_hq7.py @@ -16,7 +16,6 @@ class HQ7Generator(AirDefenseGroupGenerator): """ name = "HQ-7 Site" - price = 120 def generate(self): self.add_unit( diff --git a/gen/sam/sam_linebacker.py b/gen/sam/sam_linebacker.py index 09c57117..224e09bf 100644 --- a/gen/sam/sam_linebacker.py +++ b/gen/sam/sam_linebacker.py @@ -14,7 +14,6 @@ class LinebackerGenerator(AirDefenseGroupGenerator): """ name = "Linebacker Group" - price = 75 def generate(self): num_launchers = 2 diff --git a/gen/sam/sam_patriot.py b/gen/sam/sam_patriot.py index fcd82417..3fb8a995 100644 --- a/gen/sam/sam_patriot.py +++ b/gen/sam/sam_patriot.py @@ -14,7 +14,6 @@ class PatriotGenerator(AirDefenseGroupGenerator): """ name = "Patriot Battery" - price = 240 def generate(self): # Command Post diff --git a/gen/sam/sam_rapier.py b/gen/sam/sam_rapier.py index 538dd7c4..a2c1d07b 100644 --- a/gen/sam/sam_rapier.py +++ b/gen/sam/sam_rapier.py @@ -15,7 +15,6 @@ class RapierGenerator(AirDefenseGroupGenerator): """ name = "Rapier AA Site" - price = 50 def generate(self): self.add_unit( diff --git a/gen/sam/sam_roland.py b/gen/sam/sam_roland.py index 64ee154d..30d51aa5 100644 --- a/gen/sam/sam_roland.py +++ b/gen/sam/sam_roland.py @@ -13,7 +13,6 @@ class RolandGenerator(AirDefenseGroupGenerator): """ name = "Roland Site" - price = 40 def generate(self): num_launchers = 2 diff --git a/gen/sam/sam_sa10.py b/gen/sam/sam_sa10.py index 8d4d4e2c..282515ab 100644 --- a/gen/sam/sam_sa10.py +++ b/gen/sam/sam_sa10.py @@ -17,7 +17,6 @@ class SA10Generator(AirDefenseGroupGenerator): """ name = "SA-10/S-300PS Battery - With ZSU-23" - price = 550 def __init__(self, game: Game, ground_object: SamGroundObject): super().__init__(game, ground_object) @@ -89,7 +88,6 @@ class SA10Generator(AirDefenseGroupGenerator): class Tier2SA10Generator(SA10Generator): name = "SA-10/S-300PS Battery - With SA-15 PD" - price = 650 def generate_defensive_groups(self) -> None: # Create AAA the way the main group does. @@ -114,7 +112,6 @@ class Tier2SA10Generator(SA10Generator): class Tier3SA10Generator(SA10Generator): name = "SA-10/S-300PS Battery - With SA-15 PD & SA-19 SHORAD" - price = 750 def generate_defensive_groups(self) -> None: # AAA for defending against close targets. @@ -150,7 +147,6 @@ class Tier3SA10Generator(SA10Generator): class SA10BGenerator(Tier3SA10Generator): - price = 700 name = "SA-10B/S-300PS Battery" def __init__(self, game: Game, ground_object: SamGroundObject): @@ -166,7 +162,6 @@ class SA10BGenerator(Tier3SA10Generator): class SA12Generator(Tier3SA10Generator): - price = 750 name = "SA-12/S-300V Battery" def __init__(self, game: Game, ground_object: SamGroundObject): @@ -182,7 +177,6 @@ class SA12Generator(Tier3SA10Generator): class SA20Generator(Tier3SA10Generator): - price = 800 name = "SA-20/S-300PMU-1 Battery" def __init__(self, game: Game, ground_object: SamGroundObject): @@ -198,7 +192,6 @@ class SA20Generator(Tier3SA10Generator): class SA20BGenerator(Tier3SA10Generator): - price = 850 name = "SA-20B/S-300PMU-2 Battery" def __init__(self, game: Game, ground_object: SamGroundObject): @@ -214,7 +207,6 @@ class SA20BGenerator(Tier3SA10Generator): class SA23Generator(Tier3SA10Generator): - price = 950 name = "SA-23/S-300VM Battery" def __init__(self, game: Game, ground_object: SamGroundObject): diff --git a/gen/sam/sam_sa11.py b/gen/sam/sam_sa11.py index 3611aff5..8e53e6b5 100644 --- a/gen/sam/sam_sa11.py +++ b/gen/sam/sam_sa11.py @@ -14,7 +14,6 @@ class SA11Generator(AirDefenseGroupGenerator): """ name = "SA-11 Buk Battery" - price = 180 def generate(self): self.add_unit( diff --git a/gen/sam/sam_sa13.py b/gen/sam/sam_sa13.py index 24cd75a0..d364edf3 100644 --- a/gen/sam/sam_sa13.py +++ b/gen/sam/sam_sa13.py @@ -14,7 +14,6 @@ class SA13Generator(AirDefenseGroupGenerator): """ name = "SA-13 Strela Group" - price = 50 def generate(self): self.add_unit( diff --git a/gen/sam/sam_sa15.py b/gen/sam/sam_sa15.py index d5d74b9c..ca0d4b22 100644 --- a/gen/sam/sam_sa15.py +++ b/gen/sam/sam_sa15.py @@ -12,7 +12,6 @@ class SA15Generator(AirDefenseGroupGenerator): """ name = "SA-15 Tor Group" - price = 55 def generate(self): num_launchers = 2 diff --git a/gen/sam/sam_sa17.py b/gen/sam/sam_sa17.py index 093044b8..c59eb263 100644 --- a/gen/sam/sam_sa17.py +++ b/gen/sam/sam_sa17.py @@ -13,7 +13,6 @@ class SA17Generator(AirDefenseGroupGenerator): """ name = "SA-17 Grizzly Battery" - price = 180 def generate(self): self.add_unit( diff --git a/gen/sam/sam_sa19.py b/gen/sam/sam_sa19.py index 0ff23158..fb0fabe8 100644 --- a/gen/sam/sam_sa19.py +++ b/gen/sam/sam_sa19.py @@ -14,7 +14,6 @@ class SA19Generator(AirDefenseGroupGenerator): """ name = "SA-19 Tunguska Group" - price = 90 def generate(self): num_launchers = 2 diff --git a/gen/sam/sam_sa2.py b/gen/sam/sam_sa2.py index 3cfae81e..70d57f2d 100644 --- a/gen/sam/sam_sa2.py +++ b/gen/sam/sam_sa2.py @@ -14,7 +14,6 @@ class SA2Generator(AirDefenseGroupGenerator): """ name = "SA-2/S-75 Site" - price = 74 def generate(self): self.add_unit( diff --git a/gen/sam/sam_sa3.py b/gen/sam/sam_sa3.py index 400bda82..802a28b6 100644 --- a/gen/sam/sam_sa3.py +++ b/gen/sam/sam_sa3.py @@ -14,7 +14,6 @@ class SA3Generator(AirDefenseGroupGenerator): """ name = "SA-3/S-125 Site" - price = 80 def generate(self): self.add_unit( diff --git a/gen/sam/sam_sa6.py b/gen/sam/sam_sa6.py index 6706f250..67975914 100644 --- a/gen/sam/sam_sa6.py +++ b/gen/sam/sam_sa6.py @@ -14,7 +14,6 @@ class SA6Generator(AirDefenseGroupGenerator): """ name = "SA-6 Kub Site" - price = 102 def generate(self): self.add_unit( diff --git a/gen/sam/sam_sa8.py b/gen/sam/sam_sa8.py index 7f3f72b3..dc6184c2 100644 --- a/gen/sam/sam_sa8.py +++ b/gen/sam/sam_sa8.py @@ -12,7 +12,6 @@ class SA8Generator(AirDefenseGroupGenerator): """ name = "SA-8 OSA Site" - price = 55 def generate(self): num_launchers = 2 diff --git a/gen/sam/sam_sa9.py b/gen/sam/sam_sa9.py index ed7883b5..add7358c 100644 --- a/gen/sam/sam_sa9.py +++ b/gen/sam/sam_sa9.py @@ -14,7 +14,6 @@ class SA9Generator(AirDefenseGroupGenerator): """ name = "SA-9 Group" - price = 40 def generate(self): self.add_unit( diff --git a/gen/sam/sam_vulcan.py b/gen/sam/sam_vulcan.py index 0c869afc..ea0b8834 100644 --- a/gen/sam/sam_vulcan.py +++ b/gen/sam/sam_vulcan.py @@ -14,7 +14,6 @@ class VulcanGenerator(AirDefenseGroupGenerator): """ name = "Vulcan Group" - price = 25 def generate(self): num_launchers = 2 diff --git a/gen/sam/sam_zsu23.py b/gen/sam/sam_zsu23.py index 0e638b62..8f9d0529 100644 --- a/gen/sam/sam_zsu23.py +++ b/gen/sam/sam_zsu23.py @@ -14,7 +14,6 @@ class ZSU23Generator(AirDefenseGroupGenerator): """ name = "ZSU-23 Group" - price = 50 def generate(self): num_launchers = 4 diff --git a/gen/sam/sam_zu23.py b/gen/sam/sam_zu23.py index 58c55ad5..7c73da0f 100644 --- a/gen/sam/sam_zu23.py +++ b/gen/sam/sam_zu23.py @@ -14,7 +14,6 @@ class ZU23Generator(AirDefenseGroupGenerator): """ name = "ZU-23 Group" - price = 54 def generate(self): index = 0 diff --git a/gen/sam/sam_zu23_ural.py b/gen/sam/sam_zu23_ural.py index 47b7ac6a..fe2f38fa 100644 --- a/gen/sam/sam_zu23_ural.py +++ b/gen/sam/sam_zu23_ural.py @@ -14,7 +14,6 @@ class ZU23UralGenerator(AirDefenseGroupGenerator): """ name = "ZU-23 Ural Group" - price = 64 def generate(self): num_launchers = 4 diff --git a/gen/sam/sam_zu23_ural_insurgent.py b/gen/sam/sam_zu23_ural_insurgent.py index 0e277599..aea7c92b 100644 --- a/gen/sam/sam_zu23_ural_insurgent.py +++ b/gen/sam/sam_zu23_ural_insurgent.py @@ -14,7 +14,6 @@ class ZU23UralInsurgentGenerator(AirDefenseGroupGenerator): """ name = "ZU-23 Ural Insurgent Group" - price = 64 def generate(self): num_launchers = 4 From 4add8534735c75865955ec7a00a45c43bcf78cde Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 2 Jul 2021 17:17:25 -0700 Subject: [PATCH 026/167] Fix the legacy tanker. Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1379 --- changelog.md | 1 + gen/airsupportgen.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 9bd0a301..d85f0b7e 100644 --- a/changelog.md +++ b/changelog.md @@ -27,6 +27,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money * **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles. * **[Mission Generation]** The lua data for other plugins is now generated correctly +* **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation. * **[UI]** Statistics window tick marks are now always integers. # 4.0.0 diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 875a0e58..7ed159e2 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -127,7 +127,7 @@ class AirSupportConflictGenerator: self.mission.country(self.game.player_country), tanker_unit_type ), airport=None, - plane_type=tanker_unit_type, + plane_type=tanker_unit_type.dcs_unit_type, position=tanker_position, altitude=alt, race_distance=58000, @@ -177,6 +177,8 @@ class AirSupportConflictGenerator: tanker_unit_type.name, freq, tacan, + start_time=None, + end_time=None, blue=True, ) ) From 727facfb90dac06c3eb74b90e0296a097fae79a3 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 2 Jul 2021 17:30:37 -0700 Subject: [PATCH 027/167] Fixup None loadouts for aircraft with no loadouts. Aircraft that have no loadouts at all (such as the IL-78M) will have no loadouts and thus no values in the dropdown menu. If the player toggles the custom layout box we reset the flight's loadout to the selected loadout, and with no loadouts in the combo box that is None, and `Flight.loadout` isn't supposed to be optional. Check for that case in the loadout selector and replace with an empty loadout if that happens. Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1402 --- changelog.md | 1 + gen/flights/loadouts.py | 4 ++++ .../mission/flight/payload/QFlightPayloadTab.py | 16 ++++++++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index d85f0b7e..06e98544 100644 --- a/changelog.md +++ b/changelog.md @@ -29,6 +29,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Mission Generation]** The lua data for other plugins is now generated correctly * **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation. * **[UI]** Statistics window tick marks are now always integers. +* **[UI]** Toggling custom loadout for an aircraft with no preset loadouts no longer breaks the flight. # 4.0.0 diff --git a/gen/flights/loadouts.py b/gen/flights/loadouts.py index 0a51245a..5906315e 100644 --- a/gen/flights/loadouts.py +++ b/gen/flights/loadouts.py @@ -133,4 +133,8 @@ class Loadout: ) # TODO: Try group.load_task_default_loadout(loadout_for_task) + return cls.empty_loadout() + + @classmethod + def empty_loadout(cls) -> Loadout: return Loadout("Empty", {}, date=None) diff --git a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py index 5cf5b370..b1eb809e 100644 --- a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py +++ b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py @@ -47,8 +47,20 @@ class QFlightPayloadTab(QFrame): def reload_from_flight(self) -> None: self.loadout_selector.setCurrentText(self.flight.loadout.name) + def loadout_at(self, index: int) -> Loadout: + loadout = self.loadout_selector.itemData(index) + if loadout is None: + return Loadout.empty_loadout() + return loadout + + def current_loadout(self) -> Loadout: + loadout = self.loadout_selector.currentData() + if loadout is None: + return Loadout.empty_loadout() + return loadout + def on_new_loadout(self, index: int) -> None: - self.flight.loadout = self.loadout_selector.itemData(index) + self.flight.loadout = self.loadout_at(index) self.payload_editor.reset_pylons() def on_custom_toggled(self, use_custom: bool) -> None: @@ -56,5 +68,5 @@ class QFlightPayloadTab(QFrame): if use_custom: self.flight.loadout = self.flight.loadout.derive_custom("Custom") else: - self.flight.loadout = self.loadout_selector.currentData() + self.flight.loadout = self.current_loadout() self.payload_editor.reset_pylons() From aa328d3ef715bd86180b29d8784ae9040d51aec6 Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Sat, 3 Jul 2021 14:51:26 -0400 Subject: [PATCH 028/167] Adds Marianas Islands support (#1406) * Implements #1399 * Reverting accidental change in generate_landmap.py * Changelog update * Import beacon data for Marianas. Co-authored-by: Dan Albert --- changelog.md | 1 + game/theater/conflicttheater.py | 20 +++ game/theater/marianaislands.py | 8 ++ game/version.py | 5 +- gen/airfields.py | 43 +++++++ qt_ui/uiconstants.py | 1 + requirements.txt | 2 +- resources/dcs/beacons/marianaislands.json | 135 +++++++++++++++++++++ resources/marianaislandslandmap.p | Bin 0 -> 47753 bytes resources/marianasislands.gif | Bin 0 -> 13754 bytes resources/tools/export_coordinates.py | 3 + resources/tools/generate_frontlines.py | 14 ++- resources/tools/generate_landmap.py | 2 +- resources/tools/marianaislands_terrain.miz | Bin 0 -> 147341 bytes resources/ui/terrain_marianas.gif | Bin 0 -> 1091 bytes 15 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 game/theater/marianaislands.py create mode 100644 resources/dcs/beacons/marianaislands.json create mode 100644 resources/marianaislandslandmap.p create mode 100644 resources/marianasislands.gif create mode 100644 resources/tools/marianaislands_terrain.miz create mode 100644 resources/ui/terrain_marianas.gif diff --git a/changelog.md b/changelog.md index 06e98544..5aab0539 100644 --- a/changelog.md +++ b/changelog.md @@ -13,6 +13,7 @@ Saves from 4.0.0 are compatible with 4.1.0. ## Features/Improvements * **[Campaign]** Air defense sites now generate a fixed number of launchers per type. +* **[Campaign]** Added support for Mariana Islands map. * **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR * **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 0d49b1e6..1db5cee5 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -29,6 +29,7 @@ from dcs.terrain import ( persiangulf, syria, thechannel, + marianaislands, ) from dcs.terrain.terrain import Airport, Terrain from dcs.unitgroup import ( @@ -856,3 +857,22 @@ class SyriaTheater(ConflictTheater): from .syria import PARAMETERS return PARAMETERS + + +class MarianaIslandsTheater(ConflictTheater): + terrain = marianaislands.MarianaIslands() + overview_image = "marianaislands.gif" + + landmap = load_landmap("resources\\marianaislandslandmap.p") + daytime_map = { + "dawn": (6, 8), + "day": (8, 16), + "dusk": (16, 18), + "night": (0, 5), + } + + @property + def projection_parameters(self) -> TransverseMercator: + from .marianaislands import PARAMETERS + + return PARAMETERS diff --git a/game/theater/marianaislands.py b/game/theater/marianaislands.py new file mode 100644 index 00000000..b5ddd5e1 --- /dev/null +++ b/game/theater/marianaislands.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=147, + false_easting=238417.99999989968, + false_northing=-1491840.000000048, + scale_factor=0.9996, +) diff --git a/game/version.py b/game/version.py index bcb8b8cf..c25490e0 100644 --- a/game/version.py +++ b/game/version.py @@ -96,4 +96,7 @@ VERSION = _build_version_string() #: mission using map buildings as strike targets must check and potentially recreate #: all those objectives. This definitely affects all Syria campaigns, other maps are #: not yet verified. -CAMPAIGN_FORMAT_VERSION = (7, 0) +#: +#: Version 7.1 +#: * Support for Mariana Islands terrain +CAMPAIGN_FORMAT_VERSION = (7, 1) diff --git a/gen/airfields.py b/gen/airfields.py index 7d499cf1..7998cbf4 100644 --- a/gen/airfields.py +++ b/gen/airfields.py @@ -1521,4 +1521,47 @@ AIRFIELD_DATA = { runway_length=3953, atc=AtcData(MHz(3, 850), MHz(118, 200), MHz(38, 600), MHz(250, 200)), ), + "Antonio B. Won Pat Intl": AirfieldData( + theater="MarianaIslands", + icao="PGUM", + elevation=255, + runway_length=9359, + atc=AtcData(MHz(3, 825), MHz(118, 100), MHz(38, 550), MHz(340, 200)), + ils={ + "06": ("IGUM", MHz(110, 30)), + }, + ), + "Andersen AFB": AirfieldData( + theater="MarianaIslands", + icao="PGUA", + elevation=545, + runway_length=10490, + tacan=TacanChannel(54, TacanBand.X), + tacan_callsign="UAM", + atc=AtcData(MHz(3, 850), MHz(126, 200), MHz(38, 600), MHz(250, 100)), + ), + "Rota Intl": AirfieldData( + theater="MarianaIslands", + icao="PGRO", + elevation=568, + runway_length=6105, + atc=AtcData(MHz(3, 750), MHz(123, 600), MHz(38, 400), MHz(250, 0)), + ), + "Tinian Intl": AirfieldData( + theater="MarianaIslands", + icao="PGWT", + elevation=240, + runway_length=7777, + atc=AtcData(MHz(3, 800), MHz(123, 650), MHz(38, 500), MHz(250, 50)), + ), + "Saipan Intl": AirfieldData( + theater="MarianaIslands", + icao="PGSN", + elevation=213, + runway_length=7790, + atc=AtcData(MHz(3, 775), MHz(125, 700), MHz(38, 450), MHz(256, 900)), + ils={ + "07": ("IGSN", MHz(109, 90)), + }, + ), } diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index e36ffb7e..ad4f26e4 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -68,6 +68,7 @@ def load_icons(): ICONS["Terrain_Normandy"] = QPixmap("./resources/ui/terrain_normandy.gif") ICONS["Terrain_TheChannel"] = QPixmap("./resources/ui/terrain_channel.gif") ICONS["Terrain_Syria"] = QPixmap("./resources/ui/terrain_syria.gif") + ICONS["Terrain_Mariana"] = QPixmap("./resources/ui/terrain_mariana.gif") ICONS["Dawn"] = QPixmap("./resources/ui/conditions/timeofday/dawn.png") ICONS["Day"] = QPixmap("./resources/ui/conditions/timeofday/day.png") diff --git a/requirements.txt b/requirements.txt index 75bf846a..eeed5fc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ pathspec==0.8.1 pefile==2019.4.18 Pillow==8.2.0 pre-commit==2.10.1 --e git://github.com/pydcs/dcs@7dea4f516d943c1f48454a46043b5f38d42a35f0#egg=pydcs +-e git://github.com/pydcs/dcs@75a8dd35331e8fd337ba05fe950732077433f378#egg=pydcs pyinstaller==4.3 pyinstaller-hooks-contrib==2021.1 pyparsing==2.4.7 diff --git a/resources/dcs/beacons/marianaislands.json b/resources/dcs/beacons/marianaislands.json new file mode 100644 index 00000000..da67641b --- /dev/null +++ b/resources/dcs/beacons/marianaislands.json @@ -0,0 +1,135 @@ +[ + { + "name": "MTMACAJNA", + "callsign": "AJA", + "beacon_type": 9, + "hertz": 385000, + "channel": null + }, + { + "name": "Nimitz", + "callsign": "UNZ", + "beacon_type": 6, + "hertz": 115800000, + "channel": 105 + }, + { + "name": "SAIPAN", + "callsign": "SN", + "beacon_type": 9, + "hertz": 312000, + "channel": null + }, + { + "name": "ANDERSEN", + "callsign": "UAM", + "beacon_type": 5, + "hertz": null, + "channel": 54 + }, + { + "name": "", + "callsign": "IPMY", + "beacon_type": 15, + "hertz": 110150000, + "channel": null + }, + { + "name": "", + "callsign": "IUAM", + "beacon_type": 15, + "hertz": 110100000, + "channel": null + }, + { + "name": "", + "callsign": "IYIG", + "beacon_type": 15, + "hertz": 109350000, + "channel": null + }, + { + "name": "", + "callsign": "IAND", + "beacon_type": 15, + "hertz": 109300000, + "channel": null + }, + { + "name": "", + "callsign": "IUAM", + "beacon_type": 14, + "hertz": 110100000, + "channel": null + }, + { + "name": "", + "callsign": "IAND", + "beacon_type": 14, + "hertz": 109300000, + "channel": null + }, + { + "name": "", + "callsign": "IYIG", + "beacon_type": 14, + "hertz": 109350000, + "channel": null + }, + { + "name": "", + "callsign": "IPMY", + "beacon_type": 14, + "hertz": 110150000, + "channel": null + }, + { + "name": "", + "callsign": "IGUM", + "beacon_type": 14, + "hertz": 110300000, + "channel": null + }, + { + "name": "", + "callsign": "PGUM", + "beacon_type": 15, + "hertz": 110300000, + "channel": null + }, + { + "name": "", + "callsign": "IAWD", + "beacon_type": 14, + "hertz": 110900000, + "channel": null + }, + { + "name": "", + "callsign": "PGUM", + "beacon_type": 15, + "hertz": 110900000, + "channel": null + }, + { + "name": "ROTA", + "callsign": "GRO", + "beacon_type": 9, + "hertz": 332000, + "channel": null + }, + { + "name": "", + "callsign": "IGSN", + "beacon_type": 14, + "hertz": 109900000, + "channel": null + }, + { + "name": "", + "callsign": "PGSN", + "beacon_type": 15, + "hertz": 109900000, + "channel": null + } +] \ No newline at end of file diff --git a/resources/marianaislandslandmap.p b/resources/marianaislandslandmap.p new file mode 100644 index 0000000000000000000000000000000000000000..d11e03684b2693e4a41d5734cbad7d15065a220c GIT binary patch literal 47753 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%0Au%aPWPU*V8GC{ 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 MultiPolygon: raise RuntimeError(f"Not sure how to convert collection to multipoly: {obj.wkt}") -for terrain in ["cau", "nev", "syria", "channel", "normandy", "gulf"]: +for terrain in ["cau", "nev", "syria", "channel", "normandy", "gulf", "marianaislands"]: print("Terrain " + terrain) m = Mission() m.load_file("./{}_terrain.miz".format(terrain)) diff --git a/resources/tools/marianaislands_terrain.miz b/resources/tools/marianaislands_terrain.miz new file mode 100644 index 0000000000000000000000000000000000000000..696901913694d1526ebe72e2f1ee895ed3632c80 GIT binary patch literal 147341 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 literal 0 HcmV?d00001 diff --git a/resources/ui/terrain_marianas.gif b/resources/ui/terrain_marianas.gif new file mode 100644 index 0000000000000000000000000000000000000000..2546cb88777389552c9c864aa591f6177c15211f GIT binary patch literal 1091 zcmZ?wbhEHblwgox_|Cu}n^i88RVk9yES*&;n^h*8RUw;MW}FcrkyUGz9b8c=&uob4`}(IA^$DW6%akXfja9OIm7XCG^36KUuZ zCJ`7doscP|mmDgV)u5W5p_GxQoSADLWfhYoURo&|nIP^SAmkn-77!*B5T~e}kt~^A zCzD;JlAdKBre+-DU>mMeU8_=Dtq~kA=N%^L9xUk;XQY&oE16v@l~bpZmTn$y=oTcN zTcVPjs~no75F9CN5$`CO(;%H)Es@)765|k^AeNf16qBat87$!)CZ(2(fxDP*Jbx5~=P6bXcUgTfClhW`$OKa7UYZ zXSa4#nzDbCw0oeieYBxeMwx$-R(rcfX0b|Sib7zdxMz&IRg`I6opN!tYG{JIe=wg_ zthI51S8AF_R)M@vkbq@`p2 zdGp}n;p6;Wyi5#^0xl;6lw5mc94E189%k^GW{~;l3Fj1zAUmIhK}#JDGlZ>6IJqd5 zd%9lSz6MU^Wsf9`QqRnBoa}a1$}IQJ9!pO3zB!g9uWsyYR_~uTulmQAkDu8C6daqY z`V>P>Tu^lB6w#ekH7QWpy-(E0;$(gJaOjsH3Z literal 0 HcmV?d00001 From ec88d07ef18e1e7ceaf68cdb52e34b863953f134 Mon Sep 17 00:00:00 2001 From: Khopa Date: Sun, 4 Jul 2021 19:34:58 +0200 Subject: [PATCH 029/167] Corrected some bugs preventing marianas campaigns from running --- game/theater/conflicttheater.py | 1 + qt_ui/uiconstants.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 1db5cee5..2eefdbc9 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -699,6 +699,7 @@ class ConflictTheater: "Normandy": NormandyTheater, "The Channel": TheChannelTheater, "Syria": SyriaTheater, + "MarianaIslands": MarianaIslandsTheater, } theater = theaters[data["theater"]] t = theater() diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index ad4f26e4..761cbc19 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -68,7 +68,7 @@ def load_icons(): ICONS["Terrain_Normandy"] = QPixmap("./resources/ui/terrain_normandy.gif") ICONS["Terrain_TheChannel"] = QPixmap("./resources/ui/terrain_channel.gif") ICONS["Terrain_Syria"] = QPixmap("./resources/ui/terrain_syria.gif") - ICONS["Terrain_Mariana"] = QPixmap("./resources/ui/terrain_mariana.gif") + ICONS["Terrain_MarianaIslands"] = QPixmap("./resources/ui/terrain_marianas.gif") ICONS["Dawn"] = QPixmap("./resources/ui/conditions/timeofday/dawn.png") ICONS["Day"] = QPixmap("./resources/ui/conditions/timeofday/day.png") From 1c2411a0fc7a39a38de96096f5626e9f12bae467 Mon Sep 17 00:00:00 2001 From: Khopa Date: Sun, 4 Jul 2021 19:35:36 +0200 Subject: [PATCH 030/167] Added a basic campaign on Mariana Islands theater to try it out. --- resources/campaigns/guam.json | 11 +++++++++++ resources/campaigns/guam.miz | Bin 0 -> 16612 bytes 2 files changed, 11 insertions(+) create mode 100644 resources/campaigns/guam.json create mode 100644 resources/campaigns/guam.miz diff --git a/resources/campaigns/guam.json b/resources/campaigns/guam.json new file mode 100644 index 00000000..17b312a1 --- /dev/null +++ b/resources/campaigns/guam.json @@ -0,0 +1,11 @@ +{ + "name": "Mariana Islands - Battle for Guam", + "theater": "MarianaIslands", + "authors": "Khopa", + "recommended_player_faction": "USA 2005", + "recommended_enemy_faction": "China 2010", + "description": "

As USA, repel a Chinese invasion of Guam Island.

", + "miz": "guam.miz", + "performance": 1, + "version": "7.0" +} diff --git a/resources/campaigns/guam.miz b/resources/campaigns/guam.miz new file mode 100644 index 0000000000000000000000000000000000000000..b40ec2463a0cba476314ef09f005556d2e995167 GIT binary patch literal 16612 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 Z?-o~({sQ^;0^}!*K3m25^AZTi{{iC%4h8@K literal 0 HcmV?d00001 From 8ba27cdaeae84914aced36851d78f070b1ee1f63 Mon Sep 17 00:00:00 2001 From: RndName Date: Mon, 5 Jul 2021 22:36:01 +0200 Subject: [PATCH 031/167] remove completely destroyed units from the convoy --- game/transfers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/game/transfers.py b/game/transfers.py index fadbf3dc..64870b50 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -130,7 +130,10 @@ class TransferOrder: def kill_unit(self, unit_type: GroundUnitType) -> None: if unit_type not in self.units or not self.units[unit_type]: raise KeyError(f"{self} has no {unit_type} remaining") - self.units[unit_type] -= 1 + if self.units[unit_type] == 1: + del self.units[unit_type] + else: + self.units[unit_type] -= 1 @property def size(self) -> int: From 8b70d2674f67ca1aed85ac4294989091428cda0f Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 5 Jul 2021 15:54:23 -0700 Subject: [PATCH 032/167] Note fix for empty convoy groups. --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 5aab0539..a8350c4c 100644 --- a/changelog.md +++ b/changelog.md @@ -29,6 +29,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles. * **[Mission Generation]** The lua data for other plugins is now generated correctly * **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation. +* **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed. * **[UI]** Statistics window tick marks are now always integers. * **[UI]** Toggling custom loadout for an aircraft with no preset loadouts no longer breaks the flight. From 7229b886e0eaad14d6c59fda47399d0d65b10e70 Mon Sep 17 00:00:00 2001 From: RndName Date: Thu, 24 Jun 2021 19:06:32 +0200 Subject: [PATCH 033/167] replan opfor mission on sell or buy of tgos --- changelog.md | 1 + game/game.py | 102 ++++++++++-------- qt_ui/windows/basemenu/QBaseMenu2.py | 1 - .../ground_forces/QGroundForcesStrategy.py | 1 - .../windows/groundobject/QGroundObjectMenu.py | 17 +++ 5 files changed, 77 insertions(+), 45 deletions(-) diff --git a/changelog.md b/changelog.md index a8350c4c..ffd51f2b 100644 --- a/changelog.md +++ b/changelog.md @@ -28,6 +28,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money * **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles. * **[Mission Generation]** The lua data for other plugins is now generated correctly +* **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs * **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation. * **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed. * **[UI]** Statistics window tick marks are now always integers. diff --git a/game/game.py b/game/game.py index 6d2aa329..11cc45ba 100644 --- a/game/game.py +++ b/game/game.py @@ -386,26 +386,49 @@ class Game: self.blue_bullseye = Bullseye(enemy_cp.position) self.red_bullseye = Bullseye(player_cp.position) - def initialize_turn(self) -> None: + def initialize_turn(self, for_red: bool = True, for_blue: bool = True): self.events = [] self._generate_events() - self.set_bullseye() # Update statistics self.game_stats.update(self) - self.blue_air_wing.reset() - self.red_air_wing.reset() - self.aircraft_inventory.reset() - for cp in self.theater.controlpoints: - self.aircraft_inventory.set_from_control_point(cp) - # Check for win or loss condition turn_state = self.check_win_loss() if turn_state in (TurnState.LOSS, TurnState.WIN): return self.process_win_loss(turn_state) + # Plan Coalition specific turn + if for_red: + self.initialize_turn_for(player=False) + if for_blue: + self.initialize_turn_for(player=True) + + # Plan GroundWar + for cp in self.theater.controlpoints: + if cp.has_frontline: + gplanner = GroundPlanner(cp, self) + gplanner.plan_groundwar() + self.ground_planners[cp.id] = gplanner + + def initialize_turn_for(self, player: bool) -> None: + + self.ato_for(player).clear() + self.air_wing_for(player).reset() + + self.aircraft_inventory.reset() + for cp in self.theater.controlpoints: + self.aircraft_inventory.set_from_control_point(cp) + # Refund all pending deliveries for opfor and if player + # has automate_aircraft_reinforcements + if (not player and not cp.captured) or ( + player + and cp.captured + and self.settings.automate_aircraft_reinforcements + ): + cp.pending_unit_deliveries.refund_all(self) + # Plan flights & combat for next turn with logged_duration("Computing conflict positions"): self.compute_conflicts_position() @@ -415,55 +438,48 @@ class Game: self.compute_transit_networks() self.ground_planners = {} - self.blue_procurement_requests.clear() - self.red_procurement_requests.clear() + self.procurement_requests_for(player).clear() with logged_duration("Procurement of airlift assets"): self.transfers.order_airlift_assets() with logged_duration("Transport planning"): self.transfers.plan_transports() - with logged_duration("Blue mission planning"): - if self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled: - blue_planner = CoalitionMissionPlanner(self, is_player=True) - blue_planner.plan_missions() + if not player or ( + player and self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled + ): + color = "Blue" if player else "Red" + with logged_duration(f"{color} mission planning"): + mission_planner = CoalitionMissionPlanner(self, player) + mission_planner.plan_missions() - with logged_duration("Red mission planning"): - red_planner = CoalitionMissionPlanner(self, is_player=False) - red_planner.plan_missions() + self.plan_procurement_for(player) - for cp in self.theater.controlpoints: - if cp.has_frontline: - gplanner = GroundPlanner(cp, self) - gplanner.plan_groundwar() - self.ground_planners[cp.id] = gplanner - - self.plan_procurement() - - def plan_procurement(self) -> None: + def plan_procurement_for(self, for_player: bool) -> None: # The first turn needs to buy a *lot* of aircraft to fill CAPs, so it # gets much more of the budget that turn. Otherwise budget (after # repairs) is split evenly between air and ground. For the default # starting budget of 2000 this gives 600 to ground forces and 1400 to # aircraft. After that the budget will be spend proportionally based on how much is already invested - self.budget = ProcurementAi( - self, - for_player=True, - faction=self.player_faction, - manage_runways=self.settings.automate_runway_repair, - manage_front_line=self.settings.automate_front_line_reinforcements, - manage_aircraft=self.settings.automate_aircraft_reinforcements, - ).spend_budget(self.budget) - - self.enemy_budget = ProcurementAi( - self, - for_player=False, - faction=self.enemy_faction, - manage_runways=True, - manage_front_line=True, - manage_aircraft=True, - ).spend_budget(self.enemy_budget) + if for_player: + self.budget = ProcurementAi( + self, + for_player=True, + faction=self.player_faction, + manage_runways=self.settings.automate_runway_repair, + manage_front_line=self.settings.automate_front_line_reinforcements, + manage_aircraft=self.settings.automate_aircraft_reinforcements, + ).spend_budget(self.budget) + else: + self.enemy_budget = ProcurementAi( + self, + for_player=False, + faction=self.enemy_faction, + manage_runways=True, + manage_front_line=True, + manage_aircraft=True, + ).spend_budget(self.enemy_budget) def message(self, text: str) -> None: self.informations.append(Information(text, turn=self.turn)) diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index 8a913280..5361350e 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -124,7 +124,6 @@ class QBaseMenu2(QDialog): self.cp.capture(self.game_model.game, for_player=not self.cp.captured) # Reinitialized ground planners and the like. The ATO needs to be reset because # missions planned against the flipped base are no longer valid. - self.game_model.game.reset_ato() self.game_model.game.initialize_turn() GameUpdateSignal.get_instance().updateGame(self.game_model.game) diff --git a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py index ec467b92..166f7b4b 100644 --- a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py +++ b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py @@ -56,6 +56,5 @@ class QGroundForcesStrategy(QGroupBox): self.cp.base.affect_strength(amount) enemy_point.base.affect_strength(-amount) # Clear the ATO to replan missions affected by the front line. - self.game.reset_ato() self.game.initialize_turn() GameUpdateSignal.get_instance().updateGame(self.game) diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py index 7f955f3d..46b2cb53 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -259,6 +259,14 @@ class QGroundObjectMenu(QDialog): self.update_total_value() self.game.budget = self.game.budget + self.total_value self.ground_object.groups = [] + + # Replan if the tgo was a target of the redfor + if any( + package.target == self.ground_object + for package in self.game.ato_for(player=False).packages + ): + self.game.initialize_turn(for_red=True, for_blue=False) + self.do_refresh_layout() GameUpdateSignal.get_instance().updateGame(self.game) @@ -439,6 +447,9 @@ class QBuyGroupForGroundObjectDialog(QDialog): ) self.ground_object.groups = [group] + # Replan redfor missions + self.game.initialize_turn(for_red=True, for_blue=False) + GameUpdateSignal.get_instance().updateGame(self.game) def buySam(self): @@ -452,6 +463,9 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.ground_object.groups = list(sam_generator.groups) + # Replan redfor missions + self.game.initialize_turn(for_red=True, for_blue=False) + GameUpdateSignal.get_instance().updateGame(self.game) def buy_ewr(self): @@ -465,6 +479,9 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.ground_object.groups = [ewr_generator.vg] + # Replan redfor missions + self.game.initialize_turn(for_red=True, for_blue=False) + GameUpdateSignal.get_instance().updateGame(self.game) def error_money(self): From 05fab1f79d4c1f1402c4f9583efdbc3068fe2b1b Mon Sep 17 00:00:00 2001 From: RndName Date: Tue, 6 Jul 2021 11:42:44 +0200 Subject: [PATCH 034/167] correct display of turn statistics --- changelog.md | 1 + game/game.py | 1 - game/models/game_stats.py | 4 ++++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index ffd51f2b..1acecb85 100644 --- a/changelog.md +++ b/changelog.md @@ -32,6 +32,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation. * **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed. * **[UI]** Statistics window tick marks are now always integers. +* **[UI]** Statistics window now shows the correct info for the turn * **[UI]** Toggling custom loadout for an aircraft with no preset loadouts no longer breaks the flight. # 4.0.0 diff --git a/game/game.py b/game/game.py index 11cc45ba..73433aac 100644 --- a/game/game.py +++ b/game/game.py @@ -109,7 +109,6 @@ class Game: # NB: This is the *start* date. It is never updated. self.date = date(start_date.year, start_date.month, start_date.day) self.game_stats = GameStats() - self.game_stats.update(self) self.notes = "" self.ground_planners: dict[int, GroundPlanner] = {} self.informations = [] diff --git a/game/models/game_stats.py b/game/models/game_stats.py index a4d8e623..c2be800f 100644 --- a/game/models/game_stats.py +++ b/game/models/game_stats.py @@ -43,6 +43,10 @@ class GameStats: :param game: Game we want to save the data about """ + # Remove the current turn if its just an update for this turn + if 0 < game.turn < len(self.data_per_turn): + del self.data_per_turn[-1] + turn_data = GameTurnMetadata() for cp in game.theater.controlpoints: From 7983cd8d623d5733d50dc85ea55824a1dfc952df Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 7 Jul 2021 14:44:38 -0700 Subject: [PATCH 035/167] Add documentation for turn processing. --- game/game.py | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/game/game.py b/game/game.py index 73433aac..6ec7257c 100644 --- a/game/game.py +++ b/game/game.py @@ -316,6 +316,33 @@ class Game: self.red_ato.clear() def finish_turn(self, skipped: bool = False) -> None: + """Finalizes the current turn and advances to the next turn. + + This handles the turn-end portion of passing a turn. Initialization of the next + turn is handled by `initialize_turn`. These are separate processes because while + turns may be initialized more than once under some circumstances (see the + documentation for `initialize_turn`), `finish_turn` performs the work that + should be guaranteed to happen only once per turn: + + * Turn counter increment. + * Delivering units ordered the previous turn. + * Transfer progress. + * Squadron replenishment. + * Income distribution. + * Base strength (front line position) adjustment. + * Weather/time-of-day generation. + + Some actions (like transit network assembly) will happen both here and in + `initialize_turn`. We need the network to be up to date so we can account for + base captures when processing the transfers that occurred last turn, but we also + need it to be up to date in the case of a re-initialization in `initialize_turn` + (such as to account for a cheat base capture) so that orders are only placed + where a supply route exists to the destination. This is a relatively cheap + operation so duplicating the effort is not a problem. + + Args: + skipped: True if the turn was skipped. + """ self.informations.append( Information("End of turn #" + str(self.turn), "-" * 40, 0) ) @@ -352,10 +379,18 @@ class Game: self.process_player_income() def begin_turn_0(self) -> None: + """Initialization for the first turn of the game.""" self.turn = 0 self.initialize_turn() def pass_turn(self, no_action: bool = False) -> None: + """Ends the current turn and initializes the new turn. + + Called both when skipping a turn or by ending the turn as the result of combat. + + Args: + no_action: True if the turn was skipped. + """ logging.info("Pass turn") with logged_duration("Turn finalization"): self.finish_turn(no_action) @@ -385,7 +420,40 @@ class Game: self.blue_bullseye = Bullseye(enemy_cp.position) self.red_bullseye = Bullseye(player_cp.position) - def initialize_turn(self, for_red: bool = True, for_blue: bool = True): + def initialize_turn(self, for_red: bool = True, for_blue: bool = True) -> None: + """Performs turn initialization for the specified players. + + Turn initialization performs all of the beginning-of-turn actions. *End-of-turn* + processing happens in `pass_turn` (despite the name, it's called both for + skipping the turn and ending the turn after combat). + + Special care needs to be taken here because initialization can occur more than + once per turn. A number of events can require re-initializing a turn: + + * Cheat capture. Bases changing hands invalidates many missions in both ATOs, + purchase orders, threat zones, transit networks, etc. Practically speaking, + after a base capture the turn needs to be treated as fully new. The game might + even be over after a capture. + * Cheat front line position. CAS missions are no longer in the correct location, + and the ground planner may also need changes. + * Selling/buying units at TGOs. Selling a TGO might leave missions in the ATO + with invalid targets. Buying a new SAM (or even replacing some units in a SAM) + potentially changes the threat zone and may alter mission priorities and + flight planning. + + Most of the work is delegated to initialize_turn_for, which handles the + coalition-specific turn initialization. In some cases only one coalition will be + (re-) initialized. This is the case when buying or selling TGO units, since we + don't want to force the player to redo all their planning just because they + repaired a SAM, but should replan opfor when that happens. On the other hand, + base captures are significant enough (and likely enough to be the first thing + the player does in a turn) that we replan blue as well. Front lines are less + impactful but also likely to be early, so they also cause a blue replan. + + Args: + for_red: True if opfor should be re-initialized. + for_blue: True if the player coalition should be re-initialized. + """ self.events = [] self._generate_events() self.set_bullseye() @@ -412,7 +480,15 @@ class Game: self.ground_planners[cp.id] = gplanner def initialize_turn_for(self, player: bool) -> None: + """Processes coalition-specific turn initialization. + For more information on turn initialization in general, see the documentation + for `Game.initialize_turn`. + + Args: + player: True if the player coalition is being initialized. False for opfor + initialization. + """ self.ato_for(player).clear() self.air_wing_for(player).reset() From 29753a6aa9e38a2a2a9a0741ff9bda31b33498c2 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 7 Jul 2021 15:04:52 -0700 Subject: [PATCH 036/167] Add (mostly disabled) mypy configs. We're missing a lot of checking right now. Most of it requires additional cleanup. For now I've enabled what I could and will follow up to clean up and enable more checking. --- mypy.ini | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/mypy.ini b/mypy.ini index b8bb3a89..b63df6a9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,8 +1,22 @@ [mypy] +# TODO: Cleanup so we can enable the checks commented out here. +# check_untyped_defs = True +# disallow_any_decorated = True +# disallow_any_expr = True +# disallow_any_generics = True +# disallow_any_unimported = True +disallow_untyped_decorators = True +# disallow_untyped_defs = True +follow_imports = silent +# implicit_reexport = False namespace_packages = True +no_implicit_optional = True +warn_redundant_casts = True +# warn_return_any = True +# warn_unreachable = True +warn_unused_ignores = True [mypy-dcs.*] -follow_imports=silent ignore_missing_imports = True [mypy-faker.*] From 299ed88f09d506df1a10e5454b237263e5cbeeab Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 7 Jul 2021 15:10:02 -0700 Subject: [PATCH 037/167] Fix unreachable code issues, enable checking. The loadout case actually could (and previously did) hide bugs from the type checker, since mypy was smart enough to see that we were removing None from the input it assumed that the member was non-optional, but later modifications could cause null values, and since those came from the UI mypy couldn't reason about this. This meant that mypy assumed the type could not be optional and wouldn't check that case. --- gen/flights/flightplan.py | 6 +----- gen/flights/loadouts.py | 6 +++++- mypy.ini | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 80a7f6e1..07498342 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -219,11 +219,7 @@ class FlightPlan: tot_waypoint = self.tot_waypoint if tot_waypoint is None: return None - - time = self.tot - if time is None: - return None - return time - self._travel_time_to_waypoint(tot_waypoint) + return self.tot - self._travel_time_to_waypoint(tot_waypoint) def startup_time(self) -> Optional[timedelta]: takeoff_time = self.takeoff_time() diff --git a/gen/flights/loadouts.py b/gen/flights/loadouts.py index 5906315e..8b34f9ef 100644 --- a/gen/flights/loadouts.py +++ b/gen/flights/loadouts.py @@ -19,7 +19,11 @@ class Loadout: is_custom: bool = False, ) -> None: self.name = name - self.pylons = {k: v for k, v in pylons.items() if v is not None} + # We clear unused pylon entries on initialization, but UI actions can still + # cause a pylon to be emptied, so make the optional type explicit. + self.pylons: Mapping[int, Optional[Weapon]] = { + k: v for k, v in pylons.items() if v is not None + } self.date = date self.is_custom = is_custom diff --git a/mypy.ini b/mypy.ini index b63df6a9..f4888eca 100644 --- a/mypy.ini +++ b/mypy.ini @@ -13,7 +13,7 @@ namespace_packages = True no_implicit_optional = True warn_redundant_casts = True # warn_return_any = True -# warn_unreachable = True +warn_unreachable = True warn_unused_ignores = True [mypy-dcs.*] From fc32b983413dd741a23d33db6da89ddcba7bed64 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 7 Jul 2021 15:47:19 -0700 Subject: [PATCH 038/167] Type check the contents of untyped functions. By default mypy doesn't type check the code within an untyped function. This enables that and fixes typing errors to accomodate it. This did uncover a very old bug: https://github.com/dcs-liberation/dcs_liberation/issues/1417 --- game/theater/controlpoint.py | 2 +- gen/armor.py | 10 +++++++++- gen/ground_forces/ai_ground_planner.py | 20 +++----------------- gen/groundobjectsgen.py | 1 + mypy.ini | 2 +- 5 files changed, 15 insertions(+), 20 deletions(-) diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 7631b3eb..d43e8a2f 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -323,7 +323,7 @@ class ControlPoint(MissionTarget, ABC): self.target_position: Optional[Point] = None def __repr__(self): - return f"<{__class__}: {self.name}>" + return f"<{self.__class__}: {self.name}>" @property def ground_objects(self) -> List[TheaterGroundObject]: diff --git a/gen/armor.py b/gen/armor.py index bae64166..ff8e8180 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -136,6 +136,7 @@ class GroundConflictGenerator: position = Conflict.frontline_position( self.conflict.front_line, self.game.theater ) + frontline_vector = Conflict.frontline_vector( self.conflict.front_line, self.game.theater ) @@ -150,6 +151,13 @@ class GroundConflictGenerator: self.enemy_planned_combat_groups, frontline_vector, False ) + # TODO: Differentiate AirConflict and GroundConflict classes. + if self.conflict.heading is None: + raise RuntimeError( + "Cannot generate ground units for non-ground conflict. Ground unit " + "conflicts cannot have the heading `None`." + ) + # Plan combat actions for groups self.plan_action_for_groups( self.player_stance, @@ -174,7 +182,7 @@ class GroundConflictGenerator: code = 1688 - len(self.jtacs) utype = self.game.player_faction.jtac_unit - if self.game.player_faction.jtac_unit is None: + if utype is None: utype = AircraftType.named("MQ-9 Reaper") jtac = self.mission.flight_group( diff --git a/gen/ground_forces/ai_ground_planner.py b/gen/ground_forces/ai_ground_planner.py index 045c4b39..63d5b1ab 100644 --- a/gen/ground_forces/ai_ground_planner.py +++ b/gen/ground_forces/ai_ground_planner.py @@ -52,7 +52,6 @@ class CombatGroup: self.unit_type = unit_type self.size = size self.role = role - self.assigned_enemy_cp = None self.start_position = None def __str__(self): @@ -89,11 +88,9 @@ class GroundPlanner: remaining_available_frontline_units = ground_unit_limit - if hasattr(self.cp, "stance"): - group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[self.cp.stance] - else: - self.cp.stance = CombatStance.DEFENSIVE - group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE] + # TODO: Fix to handle the per-front stances. + # https://github.com/dcs-liberation/dcs_liberation/issues/1417 + group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE] # Create combat groups and assign them randomly to each enemy CP for unit_type in self.cp.base.armor: @@ -152,20 +149,9 @@ class GroundPlanner: if len(self.connected_enemy_cp) > 0: enemy_cp = random.choice(self.connected_enemy_cp).id self.units_per_cp[enemy_cp].append(group) - group.assigned_enemy_cp = enemy_cp else: self.reserve.append(group) - group.assigned_enemy_cp = "__reserve__" collection.append(group) if remaining_available_frontline_units == 0: break - - print("------------------") - print("Ground Planner : ") - print(self.cp.name) - print("------------------") - for unit_type in self.units_per_cp.keys(): - print("For : #" + str(unit_type)) - for group in self.units_per_cp[unit_type]: - print(str(group)) diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index f3cf94f3..2350f160 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -637,6 +637,7 @@ class GroundObjectsGenerator: ).generate() for ground_object in cp.ground_objects: + generator: GenericGroundObjectGenerator if isinstance(ground_object, FactoryGroundObject): generator = FactoryGenerator( ground_object, country, self.game, self.m, self.unit_map diff --git a/mypy.ini b/mypy.ini index f4888eca..3549d3b7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,6 @@ [mypy] # TODO: Cleanup so we can enable the checks commented out here. -# check_untyped_defs = True +check_untyped_defs = True # disallow_any_decorated = True # disallow_any_expr = True # disallow_any_generics = True From 69c3d41a8ad967db27e99235c2f7b9196d29e6b9 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 7 Jul 2021 16:01:20 -0700 Subject: [PATCH 039/167] Disallow partially specified generics. --- game/theater/base.py | 4 +++- game/transfers.py | 4 ++-- game/unitdelivery.py | 20 +++++++++++--------- gen/naming.py | 9 ++++++--- mypy.ini | 2 +- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/game/theater/base.py b/game/theater/base.py index 4547e3d3..7d1bff11 100644 --- a/game/theater/base.py +++ b/game/theater/base.py @@ -2,6 +2,8 @@ import itertools import logging from typing import Any +from dcs.unit import UnitType as DcsUnitType + from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.dcs.unittype import UnitType @@ -31,7 +33,7 @@ class Base: total += unit_type.price * count return total - def total_units_of_type(self, unit_type: UnitType) -> int: + def total_units_of_type(self, unit_type: UnitType[DcsUnitType]) -> int: return sum( [ c diff --git a/game/transfers.py b/game/transfers.py index 64870b50..6774964c 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -519,14 +519,14 @@ class TransportMap(Generic[TransportType]): yield from destination_dict.values() -class ConvoyMap(TransportMap): +class ConvoyMap(TransportMap[Convoy]): def create_transport( self, origin: ControlPoint, destination: ControlPoint ) -> Convoy: return Convoy(origin, destination) -class CargoShipMap(TransportMap): +class CargoShipMap(TransportMap[CargoShip]): def create_transport( self, origin: ControlPoint, destination: ControlPoint ) -> CargoShip: diff --git a/game/unitdelivery.py b/game/unitdelivery.py index a6de6a71..0a2a3db3 100644 --- a/game/unitdelivery.py +++ b/game/unitdelivery.py @@ -5,6 +5,8 @@ from collections import defaultdict from dataclasses import dataclass from typing import Optional, TYPE_CHECKING, Any +from dcs.unittype import UnitType as DcsUnitType + from game.theater import ControlPoint from .dcs.groundunittype import GroundUnitType from .dcs.unittype import UnitType @@ -28,16 +30,16 @@ class PendingUnitDeliveries: self.destination = destination # Maps unit type to order quantity. - self.units: dict[UnitType, int] = defaultdict(int) + self.units: dict[UnitType[DcsUnitType], int] = defaultdict(int) def __str__(self) -> str: return f"Pending delivery to {self.destination}" - def order(self, units: dict[UnitType, int]) -> None: + def order(self, units: dict[UnitType[DcsUnitType], int]) -> None: for k, v in units.items(): self.units[k] += v - def sell(self, units: dict[UnitType, int]) -> None: + def sell(self, units: dict[UnitType[DcsUnitType], int]) -> None: for k, v in units.items(): self.units[k] -= v @@ -46,27 +48,27 @@ class PendingUnitDeliveries: self.units = defaultdict(int) def refund_ground_units(self, game: Game) -> None: - ground_units: dict[UnitType[Any], int] = { + ground_units: dict[UnitType[DcsUnitType], int] = { u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType) } self.refund(game, ground_units) for gu in ground_units.keys(): del self.units[gu] - def refund(self, game: Game, units: dict[UnitType, int]) -> None: + def refund(self, game: Game, units: dict[UnitType[DcsUnitType], int]) -> None: for unit_type, count in units.items(): logging.info(f"Refunding {count} {unit_type} at {self.destination.name}") game.adjust_budget( unit_type.price * count, player=self.destination.captured ) - def pending_orders(self, unit_type: UnitType) -> int: + def pending_orders(self, unit_type: UnitType[DcsUnitType]) -> 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: UnitType) -> int: + def available_next_turn(self, unit_type: UnitType[DcsUnitType]) -> int: current_units = self.destination.base.total_units_of_type(unit_type) return self.pending_orders(unit_type) + current_units @@ -79,9 +81,9 @@ class PendingUnitDeliveries: ) self.refund_ground_units(game) - bought_units: dict[UnitType, int] = {} + bought_units: dict[UnitType[DcsUnitType], int] = {} units_needing_transfer: dict[GroundUnitType, int] = {} - sold_units: dict[UnitType, int] = {} + sold_units: dict[UnitType[DcsUnitType], int] = {} for unit_type, count in self.units.items(): coalition = "Ally" if self.destination.captured else "Enemy" d: dict[Any, int] diff --git a/gen/naming.py b/gen/naming.py index df56ab64..f43c0a2e 100644 --- a/gen/naming.py +++ b/gen/naming.py @@ -3,6 +3,7 @@ import time from typing import List from dcs.country import Country +from dcs.unittype import UnitType as DcsUnitType from game.dcs.aircrafttype import AircraftType from game.dcs.unittype import UnitType @@ -293,7 +294,9 @@ class NameGenerator: ) @classmethod - def next_unit_name(cls, country: Country, parent_base_id: int, unit_type: UnitType): + def next_unit_name( + cls, country: Country, parent_base_id: int, unit_type: UnitType[DcsUnitType] + ) -> str: cls.number += 1 return "unit|{}|{}|{}|{}|".format( country.id, cls.number, parent_base_id, unit_type.name @@ -301,8 +304,8 @@ class NameGenerator: @classmethod def next_infantry_name( - cls, country: Country, parent_base_id: int, unit_type: UnitType - ): + cls, country: Country, parent_base_id: int, unit_type: UnitType[DcsUnitType] + ) -> str: cls.infantry_number += 1 return "infantry|{}|{}|{}|{}|".format( country.id, diff --git a/mypy.ini b/mypy.ini index 3549d3b7..480b478b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,7 +3,7 @@ check_untyped_defs = True # disallow_any_decorated = True # disallow_any_expr = True -# disallow_any_generics = True +disallow_any_generics = True # disallow_any_unimported = True disallow_untyped_decorators = True # disallow_untyped_defs = True From fb9a0fe833198d41a911f22d5efc4e3c4829c316 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 7 Jul 2021 17:41:29 -0700 Subject: [PATCH 040/167] Flesh out typing information, enforce. --- game/db.py | 5 +- game/debriefing.py | 14 +-- game/event/airwar.py | 6 +- game/event/event.py | 6 +- game/event/frontlineattack.py | 2 +- game/factions/faction.py | 15 +-- game/game.py | 81 +++++++--------- game/infos/information.py | 4 +- game/models/destroyed_units.py | 13 --- game/models/game_stats.py | 15 ++- game/operation/operation.py | 17 ++-- game/persistency.py | 14 ++- game/plugins/luaplugin.py | 4 +- game/point_with_heading.py | 4 +- game/profiling.py | 10 +- game/settings.py | 4 +- game/squadrons.py | 5 +- game/theater/base.py | 14 +-- game/theater/conflicttheater.py | 13 ++- game/theater/controlpoint.py | 36 ++++---- game/theater/frontline.py | 6 +- game/theater/landmap.py | 12 +-- game/theater/theatergroundobject.py | 4 +- game/threatzones.py | 21 ++++- game/transfers.py | 8 +- game/utils.py | 9 +- game/weather.py | 2 +- gen/aircraft.py | 20 +++- gen/airsupportgen.py | 17 ++-- gen/armor.py | 18 +--- gen/coastal/coastal_group_generator.py | 14 ++- gen/coastal/silkworm.py | 12 ++- gen/conflictgen.py | 8 +- gen/defenses/armor_group_generator.py | 5 +- gen/defenses/armored_group_generator.py | 8 +- gen/environmentgen.py | 2 +- gen/fleet/carrier_group.py | 2 +- gen/fleet/cn_dd_group.py | 2 +- gen/fleet/dd_group.py | 2 +- gen/fleet/lacombattanteII.py | 5 +- gen/fleet/lha_group.py | 2 +- gen/fleet/ru_dd_group.py | 2 +- gen/fleet/schnellboot.py | 2 +- gen/fleet/ship_group_generator.py | 47 +++++++--- gen/fleet/uboat.py | 2 +- gen/fleet/ww2lst.py | 2 +- gen/flights/ai_flight_planner.py | 2 +- gen/flights/flight.py | 5 +- gen/forcedoptionsgen.py | 2 +- gen/ground_forces/ai_ground_planner.py | 13 ++- gen/groundobjectsgen.py | 2 +- gen/kneeboard.py | 5 +- .../preset_control_point_locations.py | 22 ----- gen/locations/preset_locations.py | 21 ----- gen/missiles/missiles_group_generator.py | 11 ++- gen/missiles/scud_site.py | 12 ++- gen/missiles/v1_group.py | 12 ++- gen/naming.py | 16 ++-- gen/radios.py | 2 +- gen/sam/aaa_bofors.py | 2 +- gen/sam/aaa_flak.py | 2 +- gen/sam/aaa_flak18.py | 2 +- gen/sam/aaa_ks19.py | 2 +- gen/sam/aaa_ww2_ally_flak.py | 2 +- gen/sam/aaa_zsu57.py | 2 +- gen/sam/aaa_zu23_insurgent.py | 2 +- gen/sam/airdefensegroupgenerator.py | 2 +- gen/sam/cold_war_flak.py | 4 +- gen/sam/ewrs.py | 3 +- gen/sam/freya_ewr.py | 2 +- gen/sam/group_generator.py | 49 ++++++---- gen/sam/sam_avenger.py | 2 +- gen/sam/sam_chaparral.py | 2 +- gen/sam/sam_gepard.py | 2 +- gen/sam/sam_hawk.py | 2 +- gen/sam/sam_hq7.py | 2 +- gen/sam/sam_linebacker.py | 2 +- gen/sam/sam_patriot.py | 2 +- gen/sam/sam_rapier.py | 2 +- gen/sam/sam_roland.py | 2 +- gen/sam/sam_sa10.py | 2 +- gen/sam/sam_sa11.py | 2 +- gen/sam/sam_sa13.py | 2 +- gen/sam/sam_sa15.py | 2 +- gen/sam/sam_sa17.py | 2 +- gen/sam/sam_sa19.py | 2 +- gen/sam/sam_sa2.py | 2 +- gen/sam/sam_sa3.py | 2 +- gen/sam/sam_sa6.py | 2 +- gen/sam/sam_sa8.py | 2 +- gen/sam/sam_sa9.py | 2 +- gen/sam/sam_vulcan.py | 2 +- gen/sam/sam_zsu23.py | 2 +- gen/sam/sam_zu23.py | 2 +- gen/sam/sam_zu23_ural.py | 2 +- gen/sam/sam_zu23_ural_insurgent.py | 10 +- gen/triggergen.py | 12 +-- gen/visualgen.py | 92 ++----------------- mypy.ini | 2 +- 99 files changed, 426 insertions(+), 453 deletions(-) delete mode 100644 game/models/destroyed_units.py delete mode 100644 gen/locations/preset_control_point_locations.py delete mode 100644 gen/locations/preset_locations.py diff --git a/game/db.py b/game/db.py index 0a63c056..c5554ac8 100644 --- a/game/db.py +++ b/game/db.py @@ -29,6 +29,7 @@ from dcs.ships import ( CV_1143_5, ) from dcs.terrain.terrain import Airport +from dcs.unit import Ship from dcs.unitgroup import ShipGroup, StaticGroup from dcs.unittype import UnitType from dcs.vehicles import ( @@ -328,7 +329,7 @@ REWARDS = { StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point] -def upgrade_to_supercarrier(unit, name: str): +def upgrade_to_supercarrier(unit: Type[Ship], name: str) -> Type[Ship]: if unit == Stennis: if name == "CVN-71 Theodore Roosevelt": return CVN_71 @@ -361,7 +362,7 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]: return None -def country_id_from_name(name): +def country_id_from_name(name: str) -> int: for k, v in country_dict.items(): if v.name == name: return k diff --git a/game/debriefing.py b/game/debriefing.py index 212ea7a4..21927d8e 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -15,6 +15,7 @@ from typing import ( Iterator, List, TYPE_CHECKING, + Union, ) from game import db @@ -104,8 +105,9 @@ class StateData: #: Names of vehicle (and ship) units that were killed during the mission. killed_ground_units: List[str] - #: Names of static units that were destroyed during the mission. - destroyed_statics: List[str] + #: List of descriptions of destroyed statics. Format of each element is a mapping of + #: the coordinate type ("x", "y", "z", "type", "orientation") to the value. + destroyed_statics: List[dict[str, Union[float, str]]] #: Mangled names of bases that were captured during the mission. base_capture_events: List[str] @@ -370,13 +372,13 @@ class PollDebriefingFileThread(threading.Thread): self.game = game self.unit_map = unit_map - def stop(self): + def stop(self) -> None: self._stop_event.set() - def stopped(self): + def stopped(self) -> bool: return self._stop_event.is_set() - def run(self): + def run(self) -> None: if os.path.isfile("state.json"): last_modified = os.path.getmtime("state.json") else: @@ -401,7 +403,7 @@ class PollDebriefingFileThread(threading.Thread): def wait_for_debriefing( - callback: Callable[[Debriefing], None], game: Game, unit_map + callback: Callable[[Debriefing], None], game: Game, unit_map: UnitMap ) -> PollDebriefingFileThread: thread = PollDebriefingFileThread(callback, game, unit_map) thread.start() diff --git a/game/event/airwar.py b/game/event/airwar.py index ed22f3af..7b860a1b 100644 --- a/game/event/airwar.py +++ b/game/event/airwar.py @@ -1,14 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING from .event import Event -if TYPE_CHECKING: - from game.theater import ConflictTheater - class AirWarEvent(Event): """Event handler for the air battle""" - def __str__(self): + def __str__(self) -> str: return "AirWar" diff --git a/game/event/event.py b/game/event/event.py index 1d554fca..daac7f27 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -38,13 +38,13 @@ class Event: def __init__( self, - game, + game: Game, from_cp: ControlPoint, target_cp: ControlPoint, location: Point, attacker_name: str, defender_name: str, - ): + ) -> None: self.game = game self.from_cp = from_cp self.to_cp = target_cp @@ -265,7 +265,7 @@ class Event: except Exception: logging.exception(f"Could not process base capture {captured}") - def commit(self, debriefing: Debriefing): + def commit(self, debriefing: Debriefing) -> None: logging.info("Committing mission results") self.commit_air_losses(debriefing) diff --git a/game/event/frontlineattack.py b/game/event/frontlineattack.py index d7749a2a..fefb3617 100644 --- a/game/event/frontlineattack.py +++ b/game/event/frontlineattack.py @@ -8,5 +8,5 @@ class FrontlineAttackEvent(Event): future unique Event handling """ - def __str__(self): + def __str__(self) -> str: return "Frontline attack" diff --git a/game/factions/faction.py b/game/factions/faction.py index 83a8f0fa..2a0156c2 100644 --- a/game/factions/faction.py +++ b/game/factions/faction.py @@ -3,7 +3,7 @@ from __future__ import annotations import itertools import logging from dataclasses import dataclass, field -from typing import Optional, Dict, Type, List, Any, Iterator +from typing import Optional, Dict, Type, List, Any, Iterator, TYPE_CHECKING import dcs from dcs.countries import country_dict @@ -25,6 +25,9 @@ from game.data.groundunitclass import GroundUnitClass from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType +if TYPE_CHECKING: + from game.theater.start_generator import ModSettings + @dataclass class Faction: @@ -257,7 +260,7 @@ class Faction: if unit.unit_class is unit_class: yield unit - def apply_mod_settings(self, mod_settings) -> Faction: + def apply_mod_settings(self, mod_settings: ModSettings) -> Faction: # aircraft if not mod_settings.a4_skyhawk: self.remove_aircraft("A-4E-C") @@ -319,17 +322,17 @@ class Faction: self.remove_air_defenses("KS19Generator") return self - def remove_aircraft(self, name): + def remove_aircraft(self, name: str) -> None: for i in self.aircrafts: if i.dcs_unit_type.id == name: self.aircrafts.remove(i) - def remove_air_defenses(self, name): + def remove_air_defenses(self, name: str) -> None: for i in self.air_defenses: if i == name: self.air_defenses.remove(i) - def remove_vehicle(self, name): + def remove_vehicle(self, name: str) -> None: for i in self.frontline_units: if i.dcs_unit_type.id == name: self.frontline_units.remove(i) @@ -342,7 +345,7 @@ def load_ship(name: str) -> Optional[Type[ShipType]]: return None -def load_all_ships(data) -> List[Type[ShipType]]: +def load_all_ships(data: list[str]) -> List[Type[ShipType]]: items = [] for name in data: item = load_ship(name) diff --git a/game/game.py b/game/game.py index 6ec7257c..810f8831 100644 --- a/game/game.py +++ b/game/game.py @@ -1,24 +1,21 @@ -from game.dcs.aircrafttype import AircraftType import itertools import logging import random import sys from datetime import date, datetime, timedelta from enum import Enum -from typing import Any, List +from typing import Any, List, Type, Union from dcs.action import Coalition from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike from dcs.vehicles import AirDefence -from pydcs_extensions.a4ec.a4ec import A_4E_C from faker import Faker -from game import db from game.inventory import GlobalAircraftInventory from game.models.game_stats import GameStats from game.plugins import LuaPluginManager -from gen import aircraft, naming +from gen import naming from gen.ato import AirTaskingOrder from gen.conflictgen import Conflict from gen.flights.ai_flight_planner import CoalitionMissionPlanner @@ -37,7 +34,7 @@ from .procurement import AircraftProcurementRequest, ProcurementAi from .profiling import logged_duration from .settings import Settings, AutoAtoBehavior from .squadrons import AirWing -from .theater import ConflictTheater +from .theater import ConflictTheater, ControlPoint from .theater.bullseye import Bullseye from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder from .threatzones import ThreatZones @@ -115,7 +112,7 @@ class Game: self.informations.append(Information("Game Start", "-" * 40, 0)) # Culling Zones are for areas around points of interest that contain things we may not wish to cull. self.__culling_zones: List[Point] = [] - self.__destroyed_units: List[str] = [] + self.__destroyed_units: list[dict[str, Union[float, str]]] = [] self.savepath = "" self.budget = player_budget self.enemy_budget = enemy_budget @@ -190,7 +187,7 @@ class Game: self.theater, self.current_day, self.current_turn_time_of_day, self.settings ) - def sanitize_sides(self): + def sanitize_sides(self) -> None: """ Make sure the opposing factions are using different countries :return: @@ -228,14 +225,9 @@ class Game: return self.blue_bullseye return self.red_bullseye - def _roll(self, prob, mult): - if self.settings.version == "dev": - # always generate all events for dev - return 100 - else: - return random.randint(1, 100) <= prob * mult - - def _generate_player_event(self, event_class, player_cp, enemy_cp): + def _generate_player_event( + self, event_class: Type[Event], player_cp: ControlPoint, enemy_cp: ControlPoint + ) -> None: self.events.append( event_class( self, @@ -247,7 +239,7 @@ class Game: ) ) - def _generate_events(self): + def _generate_events(self) -> None: for front_line in self.theater.conflicts(): self._generate_player_event( FrontlineAttackEvent, @@ -261,21 +253,22 @@ class Game: else: self.enemy_budget += amount - def process_player_income(self): + def process_player_income(self) -> None: self.budget += Income(self, player=True).total - def process_enemy_income(self): + def process_enemy_income(self) -> None: # TODO: Clean up save compat. if not hasattr(self, "enemy_budget"): self.enemy_budget = 0 self.enemy_budget += Income(self, player=False).total - def initiate_event(self, event: Event) -> UnitMap: + @staticmethod + def initiate_event(event: Event) -> UnitMap: # assert event in self.events logging.info("Generating {} (regular)".format(event)) return event.generate() - def finish_event(self, event: Event, debriefing: Debriefing): + def finish_event(self, event: Event, debriefing: Debriefing) -> None: logging.info("Finishing event {}".format(event)) event.commit(debriefing) @@ -284,16 +277,6 @@ class Game: else: logging.info("finish_event: event not in the events!") - def is_player_attack(self, event): - if isinstance(event, Event): - return ( - event - and event.attacker_name - and event.attacker_name == self.player_faction.name - ) - else: - raise RuntimeError(f"{event} was passed when an Event type was expected") - def on_load(self, game_still_initializing: bool = False) -> None: if not hasattr(self, "name_generator"): self.name_generator = naming.namegen @@ -400,7 +383,7 @@ class Game: # Autosave progress persistency.autosave(self) - def check_win_loss(self): + def check_win_loss(self) -> TurnState: player_airbases = { cp for cp in self.theater.player_points() if cp.runway_is_operational() } @@ -567,7 +550,7 @@ class Game: def current_day(self) -> date: return self.date + timedelta(days=self.turn // 4) - def next_unit_id(self): + def next_unit_id(self) -> int: """ Next unit id for pre-generated units """ @@ -608,7 +591,7 @@ class Game: return self.blue_navmesh return self.red_navmesh - def compute_conflicts_position(self): + def compute_conflicts_position(self) -> None: """ Compute the current conflict center position(s), mainly used for culling calculation :return: List of points of interests @@ -667,15 +650,15 @@ class Game: self.__culling_zones = zones - def add_destroyed_units(self, data): + def add_destroyed_units(self, data: dict[str, Union[float, str]]) -> None: pos = Point(data["x"], data["z"]) if self.theater.is_on_land(pos): self.__destroyed_units.append(data) - def get_destroyed_units(self): + def get_destroyed_units(self) -> list[dict[str, Union[float, str]]]: return self.__destroyed_units - def position_culled(self, pos): + def position_culled(self, pos: Point) -> bool: """ Check if unit can be generated at given position depending on culling performance settings :param pos: Position you are tryng to spawn stuff at @@ -688,7 +671,7 @@ class Game: return False return True - def get_culling_zones(self): + def get_culling_zones(self) -> list[Point]: """ Check culling points :return: List of culling zones @@ -696,30 +679,28 @@ class Game: return self.__culling_zones # 1 = red, 2 = blue - def get_player_coalition_id(self): + def get_player_coalition_id(self) -> int: return 2 - def get_enemy_coalition_id(self): + def get_enemy_coalition_id(self) -> int: return 1 - def get_player_coalition(self): + def get_player_coalition(self) -> Coalition: return Coalition.Blue - def get_enemy_coalition(self): + def get_enemy_coalition(self) -> Coalition: return Coalition.Red - def get_player_color(self): + def get_player_color(self) -> str: return "blue" - def get_enemy_color(self): + def get_enemy_color(self) -> str: return "red" - def process_win_loss(self, turn_state: TurnState): + def process_win_loss(self, turn_state: TurnState) -> None: if turn_state is TurnState.WIN: - return self.message( - "Congratulations, you are victorious! Start a new campaign to continue." + self.message( + "Congratulations, you are victorious! Start a new campaign to continue." ) elif turn_state is TurnState.LOSS: - return self.message( - "Game Over, you lose. Start a new campaign to continue." - ) + self.message("Game Over, you lose. Start a new campaign to continue.") diff --git a/game/infos/information.py b/game/infos/information.py index 1c132d46..efc3fb96 100644 --- a/game/infos/information.py +++ b/game/infos/information.py @@ -2,13 +2,13 @@ import datetime class Information: - def __init__(self, title="", text="", turn=0): + def __init__(self, title: str = "", text: str = "", turn: int = 0) -> None: self.title = title self.text = text self.turn = turn self.timestamp = datetime.datetime.now() - def __str__(self): + def __str__(self) -> str: return "[{}][{}] {} {}".format( self.timestamp.strftime("%Y-%m-%d %H:%M:%S") if self.timestamp is not None diff --git a/game/models/destroyed_units.py b/game/models/destroyed_units.py deleted file mode 100644 index 7d0de042..00000000 --- a/game/models/destroyed_units.py +++ /dev/null @@ -1,13 +0,0 @@ -class DestroyedUnit: - """ - Store info about a destroyed unit - """ - - x: int - y: int - name: str - - def __init__(self, x, y, name): - self.x = x - self.y = y - self.name = name diff --git a/game/models/game_stats.py b/game/models/game_stats.py index c2be800f..7e828021 100644 --- a/game/models/game_stats.py +++ b/game/models/game_stats.py @@ -1,4 +1,9 @@ -from typing import List +from __future__ import annotations + +from typing import List, TYPE_CHECKING + +if TYPE_CHECKING: + from game import Game class FactionTurnMetadata: @@ -10,7 +15,7 @@ class FactionTurnMetadata: vehicles_count: int = 0 sam_count: int = 0 - def __init__(self): + def __init__(self) -> None: self.aircraft_count = 0 self.vehicles_count = 0 self.sam_count = 0 @@ -24,7 +29,7 @@ class GameTurnMetadata: allied_units: FactionTurnMetadata enemy_units: FactionTurnMetadata - def __init__(self): + def __init__(self) -> None: self.allied_units = FactionTurnMetadata() self.enemy_units = FactionTurnMetadata() @@ -34,10 +39,10 @@ class GameStats: Store statistics for the current game """ - def __init__(self): + def __init__(self) -> None: self.data_per_turn: List[GameTurnMetadata] = [] - def update(self, game): + def update(self, game: Game) -> None: """ Save data for current turn :param game: Game we want to save the data about diff --git a/game/operation/operation.py b/game/operation/operation.py index a44dd5aa..b976c5e7 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -62,7 +62,7 @@ class Operation: plugin_scripts: List[str] = [] @classmethod - def prepare(cls, game: Game): + def prepare(cls, game: Game) -> None: with open("resources/default_options.lua", "r") as f: options_dict = loads(f.read())["options"] cls._set_mission(Mission(game.theater.terrain)) @@ -107,7 +107,7 @@ class Operation: cls.current_mission = mission @classmethod - def _setup_mission_coalitions(cls): + def _setup_mission_coalitions(cls) -> None: cls.current_mission.coalition["blue"] = Coalition( "blue", bullseye=cls.game.blue_bullseye.to_pydcs() ) @@ -163,7 +163,7 @@ class Operation: airsupportgen: AirSupportConflictGenerator, jtacs: List[JtacInfo], airgen: AircraftConflictGenerator, - ): + ) -> None: """Generates subscribed MissionInfoGenerator objects (currently kneeboards and briefings)""" gens: List[MissionInfoGenerator] = [ @@ -251,7 +251,7 @@ class Operation: # beacon list. @classmethod - def _generate_ground_units(cls): + def _generate_ground_units(cls) -> None: cls.groundobjectgen = GroundObjectsGenerator( cls.current_mission, cls.game, @@ -266,7 +266,12 @@ class Operation: """Add destroyed units to the Mission""" for d in cls.game.get_destroyed_units(): try: - utype = db.unit_type_from_name(d["type"]) + type_name = d["type"] + if not isinstance(type_name, str): + raise TypeError( + "Expected the type of the destroyed static to be a string" + ) + utype = db.unit_type_from_name(type_name) except KeyError: continue @@ -418,7 +423,7 @@ class Operation: CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate() @classmethod - def reset_naming_ids(cls): + def reset_naming_ids(cls) -> None: namegen.reset_numbers() @classmethod diff --git a/game/persistency.py b/game/persistency.py index d9d9d135..7685dd09 100644 --- a/game/persistency.py +++ b/game/persistency.py @@ -1,15 +1,19 @@ +from __future__ import annotations + import logging import os import pickle import shutil from pathlib import Path -from typing import Optional +from typing import Optional, TYPE_CHECKING +if TYPE_CHECKING: + from game import Game _dcs_saved_game_folder: Optional[str] = None -def setup(user_folder: str): +def setup(user_folder: str) -> None: global _dcs_saved_game_folder _dcs_saved_game_folder = user_folder if not save_dir().exists(): @@ -38,7 +42,7 @@ def mission_path_for(name: str) -> str: return os.path.join(base_path(), "Missions", name) -def load_game(path): +def load_game(path: str) -> Optional[Game]: with open(path, "rb") as f: try: save = pickle.load(f) @@ -49,7 +53,7 @@ def load_game(path): return None -def save_game(game) -> bool: +def save_game(game: Game) -> bool: try: with open(_temporary_save_file(), "wb") as f: pickle.dump(game, f) @@ -60,7 +64,7 @@ def save_game(game) -> bool: return False -def autosave(game) -> bool: +def autosave(game: Game) -> bool: """ Autosave to the autosave location :param game: Game to save diff --git a/game/plugins/luaplugin.py b/game/plugins/luaplugin.py index b58446a9..5799c748 100644 --- a/game/plugins/luaplugin.py +++ b/game/plugins/luaplugin.py @@ -38,7 +38,7 @@ class PluginSettings: self.settings = Settings() self.initialize_settings() - def set_settings(self, settings: Settings): + def set_settings(self, settings: Settings) -> None: self.settings = settings self.initialize_settings() @@ -146,7 +146,7 @@ class LuaPlugin(PluginSettings): return cls(definition) - def set_settings(self, settings: Settings): + def set_settings(self, settings: Settings) -> None: super().set_settings(settings) for option in self.definition.options: option.set_settings(self.settings) diff --git a/game/point_with_heading.py b/game/point_with_heading.py index fa322723..69d62e9c 100644 --- a/game/point_with_heading.py +++ b/game/point_with_heading.py @@ -2,12 +2,12 @@ from dcs import Point class PointWithHeading(Point): - def __init__(self): + def __init__(self) -> None: super(PointWithHeading, self).__init__(0, 0) self.heading = 0 @staticmethod - def from_point(point: Point, heading: int): + def from_point(point: Point, heading: int) -> Point: p = PointWithHeading() p.x = point.x p.y = point.y diff --git a/game/profiling.py b/game/profiling.py index 82c2c326..219453d9 100644 --- a/game/profiling.py +++ b/game/profiling.py @@ -5,7 +5,8 @@ import timeit from collections import defaultdict from contextlib import contextmanager from datetime import timedelta -from typing import Iterator +from types import TracebackType +from typing import Iterator, Optional, Type @contextmanager @@ -23,7 +24,12 @@ class MultiEventTracer: def __enter__(self) -> MultiEventTracer: return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: for event, duration in self.events.items(): logging.debug("%s took %s", event, duration) diff --git a/game/settings.py b/game/settings.py index 49aee945..fc297cb9 100644 --- a/game/settings.py +++ b/game/settings.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from datetime import timedelta from enum import Enum, unique -from typing import Dict, Optional +from typing import Dict, Optional, Any from dcs.forcedoptions import ForcedOptions @@ -104,7 +104,7 @@ class Settings: def set_plugin_option(self, identifier: str, enabled: bool) -> None: self.plugins[self.plugin_settings_key(identifier)] = enabled - def __setstate__(self, state) -> None: + def __setstate__(self, state: dict[str, Any]) -> None: # __setstate__ is called with the dict of the object being unpickled. We # can provide save compatibility for new settings options (which # normally would not be present in the unpickled object) by creating a diff --git a/game/squadrons.py b/game/squadrons.py index 9b13eed3..f3793916 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -13,6 +13,7 @@ from typing import ( Optional, Iterator, Sequence, + Any, ) import yaml @@ -196,7 +197,7 @@ class Squadron: def send_on_leave(pilot: Pilot) -> None: pilot.send_on_leave() - def return_from_leave(self, pilot: Pilot): + def return_from_leave(self, pilot: Pilot) -> None: if not self.has_unfilled_pilot_slots: raise RuntimeError( f"Cannot return {pilot} from leave because {self} is full" @@ -290,7 +291,7 @@ class Squadron: player=player, ) - def __setstate__(self, state) -> None: + def __setstate__(self, state: dict[str, Any]) -> None: # TODO: Remove save compat. if "auto_assignable_mission_types" not in state: state["auto_assignable_mission_types"] = set(state["mission_types"]) diff --git a/game/theater/base.py b/game/theater/base.py index 7d1bff11..4e727825 100644 --- a/game/theater/base.py +++ b/game/theater/base.py @@ -8,15 +8,15 @@ from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.dcs.unittype import UnitType -BASE_MAX_STRENGTH = 1 -BASE_MIN_STRENGTH = 0 +BASE_MAX_STRENGTH = 1.0 +BASE_MIN_STRENGTH = 0.0 class Base: - def __init__(self): + def __init__(self) -> None: self.aircraft: dict[AircraftType, int] = {} self.armor: dict[GroundUnitType, int] = {} - self.strength = 1 + self.strength = 1.0 @property def total_aircraft(self) -> int: @@ -42,7 +42,7 @@ class Base: ] ) - def commission_units(self, units: dict[Any, int]): + def commission_units(self, units: dict[Any, int]) -> None: for unit_type, unit_count in units.items(): if unit_count <= 0: continue @@ -58,7 +58,7 @@ class Base: target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count - def commit_losses(self, units_lost: dict[Any, int]): + def commit_losses(self, units_lost: dict[Any, int]) -> None: for unit_type, count in units_lost.items(): target_dict: dict[Any, int] if unit_type in self.aircraft: @@ -77,7 +77,7 @@ class Base: if target_dict[unit_type] == 0: del target_dict[unit_type] - def affect_strength(self, amount): + def affect_strength(self, amount: float) -> None: self.strength += amount if self.strength > BASE_MAX_STRENGTH: self.strength = BASE_MAX_STRENGTH diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 2eefdbc9..cd8574f9 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -5,7 +5,7 @@ import math from dataclasses import dataclass from functools import cached_property from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING from dcs import Mission from dcs.countries import ( @@ -61,6 +61,9 @@ from ..profiling import logged_duration from ..scenery_group import SceneryGroup from ..utils import Distance, meters +if TYPE_CHECKING: + from . import TheaterGroundObject + SIZE_TINY = 150 SIZE_SMALL = 600 SIZE_REGULAR = 1000 @@ -505,7 +508,7 @@ class ConflictTheater: """ daytime_map: Dict[str, Tuple[int, int]] - def __init__(self): + def __init__(self) -> None: self.controlpoints: List[ControlPoint] = [] self.point_to_ll_transformer = Transformer.from_crs( self.projection_parameters.to_crs(), CRS("WGS84") @@ -537,10 +540,12 @@ class ConflictTheater: CRS("WGS84"), self.projection_parameters.to_crs() ) - def add_controlpoint(self, point: ControlPoint): + def add_controlpoint(self, point: ControlPoint) -> None: self.controlpoints.append(point) - def find_ground_objects_by_obj_name(self, obj_name): + def find_ground_objects_by_obj_name( + self, obj_name: str + ) -> list[TheaterGroundObject]: found = [] for cp in self.controlpoints: for g in cp.ground_objects: diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index d43e8a2f..e7daf471 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -290,9 +290,9 @@ class ControlPoint(MissionTarget, ABC): at: db.StartingPosition, size: int, importance: float, - has_frontline=True, - cptype=ControlPointType.AIRBASE, - ): + has_frontline: bool = True, + cptype: ControlPointType = ControlPointType.AIRBASE, + ) -> None: super().__init__(name, position) # TODO: Should be Airbase specific. self.id = cp_id @@ -322,7 +322,7 @@ class ControlPoint(MissionTarget, ABC): self.target_position: Optional[Point] = None - def __repr__(self): + def __repr__(self) -> str: return f"<{self.__class__}: {self.name}>" @property @@ -334,11 +334,11 @@ class ControlPoint(MissionTarget, ABC): def heading(self) -> int: ... - def __str__(self): + def __str__(self) -> str: return self.name @property - def is_global(self): + def is_global(self) -> bool: return not self.connected_points def transitive_connected_friendly_points( @@ -405,21 +405,21 @@ class ControlPoint(MissionTarget, ABC): return False @property - def is_carrier(self): + def is_carrier(self) -> bool: """ :return: Whether this control point is an aircraft carrier """ return False @property - def is_fleet(self): + def is_fleet(self) -> bool: """ :return: Whether this control point is a boat (mobile) """ return False @property - def is_lha(self): + def is_lha(self) -> bool: """ :return: Whether this control point is an LHA """ @@ -439,7 +439,7 @@ class ControlPoint(MissionTarget, ABC): @property @abstractmethod - def total_aircraft_parking(self): + def total_aircraft_parking(self) -> int: """ :return: The maximum number of aircraft that can be stored in this control point @@ -471,7 +471,7 @@ class ControlPoint(MissionTarget, ABC): ... # TODO: Should be naval specific. - def get_carrier_group_name(self): + def get_carrier_group_name(self) -> Optional[str]: """ Get the carrier group name if the airbase is a carrier :return: Carrier group name @@ -497,10 +497,12 @@ class ControlPoint(MissionTarget, ABC): return None # TODO: Should be Airbase specific. - def is_connected(self, to) -> bool: + def is_connected(self, to: ControlPoint) -> bool: return to in self.connected_points - def find_ground_objects_by_obj_name(self, obj_name): + def find_ground_objects_by_obj_name( + self, obj_name: str + ) -> list[TheaterGroundObject]: found = [] for g in self.ground_objects: if g.obj_name == obj_name: @@ -522,7 +524,7 @@ class ControlPoint(MissionTarget, ABC): f"vehicles have been captured and sold for ${total}M." ) - def retreat_ground_units(self, game: Game): + def retreat_ground_units(self, game: Game) -> None: # When there are multiple valid destinations, deliver units to whichever # base is least defended first. The closest approximation of unit # strength we have is price @@ -764,8 +766,8 @@ class ControlPoint(MissionTarget, ABC): class Airfield(ControlPoint): def __init__( - self, airport: Airport, size: int, importance: float, has_frontline=True - ): + self, airport: Airport, size: int, importance: float, has_frontline: bool = True + ) -> None: super().__init__( airport.id, airport.name, @@ -960,7 +962,7 @@ class Carrier(NavalControlPoint): raise RuntimeError("Carriers cannot be captured") @property - def is_carrier(self): + def is_carrier(self) -> bool: return True def can_operate(self, aircraft: AircraftType) -> bool: diff --git a/game/theater/frontline.py b/game/theater/frontline.py index 8d46327c..7002913f 100644 --- a/game/theater/frontline.py +++ b/game/theater/frontline.py @@ -88,7 +88,7 @@ class FrontLine(MissionTarget): yield from super().mission_types(for_player) @property - def position(self): + def position(self) -> Point: """ The position where the conflict should occur according to the current strength of each control point. @@ -107,12 +107,12 @@ class FrontLine(MissionTarget): return self.blue_cp, self.red_cp @property - def attack_distance(self): + def attack_distance(self) -> float: """The total distance of all segments""" return sum(i.attack_distance for i in self.segments) @property - def attack_heading(self): + def attack_heading(self) -> float: """The heading of the active attack segment from player to enemy control point""" return self.active_segment.attack_heading diff --git a/game/theater/landmap.py b/game/theater/landmap.py index 29d551b3..2cc3867c 100644 --- a/game/theater/landmap.py +++ b/game/theater/landmap.py @@ -14,7 +14,7 @@ class Landmap: exclusion_zones: MultiPolygon sea_zones: MultiPolygon - def __post_init__(self): + def __post_init__(self) -> None: if not self.inclusion_zones.is_valid: raise RuntimeError("Inclusion zones not valid") if not self.exclusion_zones.is_valid: @@ -36,13 +36,5 @@ def load_landmap(filename: str) -> Optional[Landmap]: return None -def poly_contains(x, y, poly: Union[MultiPolygon, Polygon]): +def poly_contains(x: float, y: float, poly: Union[MultiPolygon, Polygon]) -> bool: return poly.contains(geometry.Point(x, y)) - - -def poly_centroid(poly) -> Tuple[float, float]: - x_list = [vertex[0] for vertex in poly] - y_list = [vertex[1] for vertex in poly] - x = sum(x_list) / len(poly) - y = sum(y_list) / len(poly) - return (x, y) diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index 49fb8fd9..4a7c8990 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -217,7 +217,7 @@ class BuildingGroundObject(TheaterGroundObject): heading: int, control_point: ControlPoint, dcs_identifier: str, - is_fob_structure=False, + is_fob_structure: bool = False, ) -> None: super().__init__( name=name, @@ -438,7 +438,7 @@ class CoastalSiteGroundObject(TheaterGroundObject): group_id: int, position: Point, control_point: ControlPoint, - heading, + heading: int, ) -> None: super().__init__( name=name, diff --git a/game/threatzones.py b/game/threatzones.py index 4d29c6c3..14ee8599 100644 --- a/game/threatzones.py +++ b/game/threatzones.py @@ -27,7 +27,10 @@ ThreatPoly = Union[MultiPolygon, Polygon] class ThreatZones: def __init__( - self, airbases: ThreatPoly, air_defenses: ThreatPoly, radar_sam_threats + self, + airbases: ThreatPoly, + air_defenses: ThreatPoly, + radar_sam_threats: ThreatPoly, ) -> None: self.airbases = airbases self.air_defenses = air_defenses @@ -44,8 +47,10 @@ class ThreatZones: boundary = self.closest_boundary(point) return meters(boundary.distance_to_point(point)) + # Type checking ignored because singledispatchmethod doesn't work with required type + # definitions. The implementation methods are all typed, so should be fine. @singledispatchmethod - def threatened(self, position) -> bool: + def threatened(self, position) -> bool: # type: ignore raise NotImplementedError @threatened.register @@ -61,8 +66,10 @@ class ThreatZones: LineString([self.dcs_to_shapely_point(a), self.dcs_to_shapely_point(b)]) ) + # Type checking ignored because singledispatchmethod doesn't work with required type + # definitions. The implementation methods are all typed, so should be fine. @singledispatchmethod - def threatened_by_aircraft(self, target) -> bool: + def threatened_by_aircraft(self, target) -> bool: # type: ignore raise NotImplementedError @threatened_by_aircraft.register @@ -82,8 +89,10 @@ class ThreatZones: LineString((self.dcs_to_shapely_point(p.position) for p in waypoints)) ) + # Type checking ignored because singledispatchmethod doesn't work with required type + # definitions. The implementation methods are all typed, so should be fine. @singledispatchmethod - def threatened_by_air_defense(self, target) -> bool: + def threatened_by_air_defense(self, target) -> bool: # type: ignore raise NotImplementedError @threatened_by_air_defense.register @@ -102,8 +111,10 @@ class ThreatZones: self.dcs_to_shapely_point(target.position) ) + # Type checking ignored because singledispatchmethod doesn't work with required type + # definitions. The implementation methods are all typed, so should be fine. @singledispatchmethod - def threatened_by_radar_sam(self, target) -> bool: + def threatened_by_radar_sam(self, target) -> bool: # type: ignore raise NotImplementedError @threatened_by_radar_sam.register diff --git a/game/transfers.py b/game/transfers.py index 6774964c..a575964f 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -592,8 +592,14 @@ class PendingTransfers: self.pending_transfers.append(new_transfer) return new_transfer + # Type checking ignored because singledispatchmethod doesn't work with required type + # definitions. The implementation methods are all typed, so should be fine. @singledispatchmethod - def cancel_transport(self, transport, transfer: TransferOrder) -> None: + def cancel_transport( # type: ignore + self, + transport, + transfer: TransferOrder, + ) -> None: pass @cancel_transport.register diff --git a/game/utils.py b/game/utils.py index 68e38d31..2370c56f 100644 --- a/game/utils.py +++ b/game/utils.py @@ -2,8 +2,9 @@ from __future__ import annotations import itertools import math +from collections import Iterable from dataclasses import dataclass -from typing import Union +from typing import Union, Any METERS_TO_FEET = 3.28084 FEET_TO_METERS = 1 / METERS_TO_FEET @@ -16,12 +17,12 @@ MS_TO_KPH = 3.6 KPH_TO_MS = 1 / MS_TO_KPH -def heading_sum(h, a) -> int: +def heading_sum(h: int, a: int) -> int: h += a return h % 360 -def opposite_heading(h): +def opposite_heading(h: int) -> int: return heading_sum(h, 180) @@ -180,7 +181,7 @@ def mach(value: float, altitude: Distance) -> Speed: SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5) -def pairwise(iterable): +def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]: """ itertools recipe s -> (s0,s1), (s1,s2), (s2, s3), ... diff --git a/game/weather.py b/game/weather.py index fc077634..97c58f97 100644 --- a/game/weather.py +++ b/game/weather.py @@ -83,7 +83,7 @@ class Weather: raise NotImplementedError @staticmethod - def random_wind(minimum: int, maximum) -> WindConditions: + def random_wind(minimum: int, maximum: int) -> WindConditions: wind_direction = random.randint(0, 360) at_0m_factor = 1 at_2000m_factor = 2 diff --git a/gen/aircraft.py b/gen/aircraft.py index 949e5e98..668b8d95 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -536,7 +536,11 @@ class AircraftConflictGenerator: ) def _add_radio_waypoint( - self, group: FlyingGroup, position, altitude: Distance, airspeed: int = 600 + self, + group: FlyingGroup, + position: Point, + altitude: Distance, + airspeed: int = 600, ) -> MovingPoint: point = group.add_waypoint(position, altitude.meters, airspeed) point.alt_type = "RADIO" @@ -547,7 +551,7 @@ class AircraftConflictGenerator: group: FlyingGroup, cp: ControlPoint, at: Optional[db.StartingPosition] = None, - ): + ) -> MovingPoint: if at is None: at = cp.at position = at if isinstance(at, Point) else at.position @@ -563,7 +567,8 @@ class AircraftConflictGenerator: group.land_at(at) return destination_waypoint - def _at_position(self, at) -> Point: + @staticmethod + def _at_position(at: Union[Point, ShipGroup, Type[Airport]]) -> Point: if isinstance(at, Point): return at elif isinstance(at, ShipGroup): @@ -593,7 +598,10 @@ class AircraftConflictGenerator: parking_slot.unit_id = None def generate_flights( - self, country, ato: AirTaskingOrder, dynamic_runways: Dict[str, RunwayData] + self, + country: Country, + ato: AirTaskingOrder, + dynamic_runways: Dict[str, RunwayData], ) -> None: for package in ato.packages: @@ -719,7 +727,9 @@ class AircraftConflictGenerator: trigger.add_condition(CoalitionHasAirdrome(coalition, flight.from_cp.id)) - def generate_planned_flight(self, cp, country, flight: Flight): + def generate_planned_flight( + self, cp: ControlPoint, country: Country, flight: Flight + ) -> FlyingGroup: name = namegen.next_aircraft_name(country, cp.id, flight) try: if flight.start_type == "In Flight": diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 7ed159e2..3bb95d26 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -1,11 +1,12 @@ +from __future__ import annotations + import logging from dataclasses import dataclass, field from datetime import timedelta -from typing import List, Type, Tuple, Optional +from typing import List, Type, Tuple, Optional, TYPE_CHECKING from dcs.mission import Mission, StartType from dcs.planes import IL_78M, KC130, KC135MPRS, KC_135 -from dcs.unittype import UnitType from dcs.task import ( AWACS, ActivateBeaconCommand, @@ -14,15 +15,17 @@ from dcs.task import ( SetImmortalCommand, SetInvisibleCommand, ) +from dcs.unittype import UnitType -from game import db -from .flights.ai_flight_planner_db import AEWC_CAPABLE -from .naming import namegen from .callsigns import callsign_for_support_unit from .conflictgen import Conflict +from .flights.ai_flight_planner_db import AEWC_CAPABLE +from .naming import namegen from .radios import RadioFrequency, RadioRegistry from .tacan import TacanBand, TacanChannel, TacanRegistry +if TYPE_CHECKING: + from game import Game TANKER_DISTANCE = 15000 TANKER_ALT = 4572 @@ -70,7 +73,7 @@ class AirSupportConflictGenerator: self, mission: Mission, conflict: Conflict, - game, + game: Game, radio_registry: RadioRegistry, tacan_registry: TacanRegistry, ) -> None: @@ -95,7 +98,7 @@ class AirSupportConflictGenerator: return (TANKER_ALT + 500, 596) return (TANKER_ALT, 574) - def generate(self): + def generate(self) -> None: player_cp = ( self.conflict.blue_cp if self.conflict.blue_cp.captured diff --git a/gen/armor.py b/gen/armor.py index ff8e8180..5fc691d8 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -97,7 +97,7 @@ class GroundConflictGenerator: self.unit_map = unit_map self.jtacs: List[JtacInfo] = [] - def _enemy_stance(self): + def _enemy_stance(self) -> CombatStance: """Picks the enemy stance according to the number of planned groups on the frontline for each side""" if len(self.enemy_planned_combat_groups) > len( self.player_planned_combat_groups @@ -122,17 +122,7 @@ class GroundConflictGenerator: ] ) - @staticmethod - def _group_point(point: Point, base_distance) -> Point: - distance = random.randint( - int(base_distance * SPREAD_DISTANCE_FACTOR[0]), - int(base_distance * SPREAD_DISTANCE_FACTOR[1]), - ) - return point.random_point_within( - distance, base_distance * SPREAD_DISTANCE_SIZE_FACTOR - ) - - def generate(self): + def generate(self) -> None: position = Conflict.frontline_position( self.conflict.front_line, self.game.theater ) @@ -724,7 +714,7 @@ class GroundConflictGenerator: distance_from_frontline: int, heading: int, spawn_heading: int, - ): + ) -> Point: shifted = conflict_position.point_from_heading( heading, random.randint(0, combat_width) ) @@ -798,7 +788,7 @@ class GroundConflictGenerator: count: int, at: Point, move_formation: PointAction = PointAction.OffRoad, - heading=0, + heading: int = 0, ) -> VehicleGroup: if side == self.conflict.attackers_country: diff --git a/gen/coastal/coastal_group_generator.py b/gen/coastal/coastal_group_generator.py index 160712e0..0d263e3b 100644 --- a/gen/coastal/coastal_group_generator.py +++ b/gen/coastal/coastal_group_generator.py @@ -1,6 +1,11 @@ import logging import random -from game import db +from typing import Optional + +from dcs.unitgroup import VehicleGroup + +from game import db, Game +from game.theater.theatergroundobject import CoastalSiteGroundObject from gen.coastal.silkworm import SilkwormGenerator COASTAL_MAP = { @@ -8,10 +13,13 @@ COASTAL_MAP = { } -def generate_coastal_group(game, ground_object, faction_name: str): +def generate_coastal_group( + game: Game, ground_object: CoastalSiteGroundObject, faction_name: str +) -> Optional[VehicleGroup]: """ This generate a coastal defenses group - :return: Nothing, but put the group reference inside the ground object + :return: The generated group, or None if this faction does not support coastal + defenses. """ faction = db.FACTIONS[faction_name] if len(faction.coastal_defenses) > 0: diff --git a/gen/coastal/silkworm.py b/gen/coastal/silkworm.py index 1ffe6b04..ccb1374d 100644 --- a/gen/coastal/silkworm.py +++ b/gen/coastal/silkworm.py @@ -1,14 +1,20 @@ +from dcs.unitgroup import VehicleGroup from dcs.vehicles import MissilesSS, Unarmed, AirDefence +from game import Game +from game.factions.faction import Faction +from game.theater.theatergroundobject import CoastalSiteGroundObject from gen.sam.group_generator import GroupGenerator -class SilkwormGenerator(GroupGenerator): - def __init__(self, game, ground_object, faction): +class SilkwormGenerator(GroupGenerator[VehicleGroup]): + def __init__( + self, game: Game, ground_object: CoastalSiteGroundObject, faction: Faction + ) -> None: super(SilkwormGenerator, self).__init__(game, ground_object) self.faction = faction - def generate(self): + def generate(self) -> None: positions = self.get_circular_position(5, launcher_distance=120, coverage=180) diff --git a/gen/conflictgen.py b/gen/conflictgen.py index eabf4e4e..723898cc 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import Tuple, Optional @@ -54,7 +56,7 @@ class Conflict: def frontline_position( cls, frontline: FrontLine, theater: ConflictTheater ) -> Tuple[Point, int]: - attack_heading = frontline.attack_heading + attack_heading = int(frontline.attack_heading) position = cls.find_ground_position( frontline.position, FRONTLINE_LENGTH, @@ -91,7 +93,7 @@ class Conflict: defender: Country, front_line: FrontLine, theater: ConflictTheater, - ): + ) -> Conflict: assert cls.has_frontline_between(front_line.blue_cp, front_line.red_cp) position, heading, distance = cls.frontline_vector(front_line, theater) conflict = cls( @@ -138,7 +140,7 @@ class Conflict: max_distance: int, heading: int, theater: ConflictTheater, - coerce=True, + coerce: bool = True, ) -> Optional[Point]: """ Finds the nearest valid ground position along a provided heading and it's inverse up to max_distance. diff --git a/gen/defenses/armor_group_generator.py b/gen/defenses/armor_group_generator.py index fc549ca9..1ed04e06 100644 --- a/gen/defenses/armor_group_generator.py +++ b/gen/defenses/armor_group_generator.py @@ -1,4 +1,5 @@ import random +from typing import Optional from dcs.unitgroup import VehicleGroup @@ -12,7 +13,9 @@ from gen.defenses.armored_group_generator import ( ) -def generate_armor_group(faction: str, game, ground_object): +def generate_armor_group( + faction: str, game: Game, ground_object: VehicleGroupGroundObject +) -> Optional[VehicleGroup]: """ This generate a group of ground units :return: Generated group diff --git a/gen/defenses/armored_group_generator.py b/gen/defenses/armored_group_generator.py index f68b520b..51058b88 100644 --- a/gen/defenses/armored_group_generator.py +++ b/gen/defenses/armored_group_generator.py @@ -1,12 +1,14 @@ import random +from dcs.unitgroup import VehicleGroup + from game import Game from game.dcs.groundunittype import GroundUnitType from game.theater.theatergroundobject import VehicleGroupGroundObject from gen.sam.group_generator import GroupGenerator -class ArmoredGroupGenerator(GroupGenerator): +class ArmoredGroupGenerator(GroupGenerator[VehicleGroup]): def __init__( self, game: Game, @@ -35,7 +37,7 @@ class ArmoredGroupGenerator(GroupGenerator): ) -class FixedSizeArmorGroupGenerator(GroupGenerator): +class FixedSizeArmorGroupGenerator(GroupGenerator[VehicleGroup]): def __init__( self, game: Game, @@ -47,7 +49,7 @@ class FixedSizeArmorGroupGenerator(GroupGenerator): self.unit_type = unit_type self.size = size - def generate(self): + def generate(self) -> None: spacing = random.randint(20, 70) index = 0 diff --git a/gen/environmentgen.py b/gen/environmentgen.py index 5e393e04..65e053ed 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -30,7 +30,7 @@ class EnvironmentGenerator: self.mission.weather.wind_at_2000 = wind.at_2000m self.mission.weather.wind_at_8000 = wind.at_8000m - def generate(self): + def generate(self) -> None: self.mission.start_time = self.conditions.start_time self.set_clouds(self.conditions.weather.clouds) self.set_fog(self.conditions.weather.fog) diff --git a/gen/fleet/carrier_group.py b/gen/fleet/carrier_group.py index 4200caca..b25902a9 100644 --- a/gen/fleet/carrier_group.py +++ b/gen/fleet/carrier_group.py @@ -6,7 +6,7 @@ from dcs.ships import USS_Arleigh_Burke_IIa, TICONDEROG class CarrierGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: # Carrier Strike Group 8 if self.faction.carrier_names[0] == "Carrier Strike Group 8": diff --git a/gen/fleet/cn_dd_group.py b/gen/fleet/cn_dd_group.py index 91c710a0..c47cc6ac 100644 --- a/gen/fleet/cn_dd_group.py +++ b/gen/fleet/cn_dd_group.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: class ChineseNavyGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: include_frigate = random.choice([True, True, False]) include_dd = random.choice([True, False]) diff --git a/gen/fleet/dd_group.py b/gen/fleet/dd_group.py index db5dd0dd..d3875088 100644 --- a/gen/fleet/dd_group.py +++ b/gen/fleet/dd_group.py @@ -23,7 +23,7 @@ class DDGroupGenerator(ShipGroupGenerator): super(DDGroupGenerator, self).__init__(game, ground_object, faction) self.ddtype = ddtype - def generate(self): + def generate(self) -> None: self.add_unit( self.ddtype, "DD1", diff --git a/gen/fleet/lacombattanteII.py b/gen/fleet/lacombattanteII.py index 7de47da1..6638dd4a 100644 --- a/gen/fleet/lacombattanteII.py +++ b/gen/fleet/lacombattanteII.py @@ -1,12 +1,15 @@ from dcs.ships import La_Combattante_II +from game import Game from game.factions.faction import Faction from game.theater import TheaterGroundObject from gen.fleet.dd_group import DDGroupGenerator class LaCombattanteIIGroupGenerator(DDGroupGenerator): - def __init__(self, game, ground_object: TheaterGroundObject, faction: Faction): + def __init__( + self, game: Game, ground_object: TheaterGroundObject, faction: Faction + ): super(LaCombattanteIIGroupGenerator, self).__init__( game, ground_object, faction, La_Combattante_II ) diff --git a/gen/fleet/lha_group.py b/gen/fleet/lha_group.py index a1a78d37..a7a896b9 100644 --- a/gen/fleet/lha_group.py +++ b/gen/fleet/lha_group.py @@ -4,7 +4,7 @@ from gen.sam.group_generator import ShipGroupGenerator class LHAGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: # Add carrier if len(self.faction.helicopter_carrier) > 0: diff --git a/gen/fleet/ru_dd_group.py b/gen/fleet/ru_dd_group.py index 8ec15d26..4354b5fb 100644 --- a/gen/fleet/ru_dd_group.py +++ b/gen/fleet/ru_dd_group.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: class RussianNavyGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: include_frigate = random.choice([True, True, False]) include_dd = random.choice([True, False]) diff --git a/gen/fleet/schnellboot.py b/gen/fleet/schnellboot.py index 83a83fdf..d5fe16a6 100644 --- a/gen/fleet/schnellboot.py +++ b/gen/fleet/schnellboot.py @@ -6,7 +6,7 @@ from gen.sam.group_generator import ShipGroupGenerator class SchnellbootGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: for i in range(random.randint(2, 4)): self.add_unit( diff --git a/gen/fleet/ship_group_generator.py b/gen/fleet/ship_group_generator.py index 03ab852f..1cd8338c 100644 --- a/gen/fleet/ship_group_generator.py +++ b/gen/fleet/ship_group_generator.py @@ -1,7 +1,17 @@ +from __future__ import annotations + import logging import random +from typing import TYPE_CHECKING, Optional + +from dcs.unitgroup import ShipGroup from game import db +from game.theater.theatergroundobject import ( + LhaGroundObject, + CarrierGroundObject, + ShipGroundObject, +) from gen.fleet.carrier_group import CarrierGroupGenerator from gen.fleet.cn_dd_group import ChineseNavyGroupGenerator, Type54GroupGenerator from gen.fleet.dd_group import ( @@ -21,6 +31,9 @@ from gen.fleet.schnellboot import SchnellbootGroupGenerator from gen.fleet.uboat import UBoatGroupGenerator from gen.fleet.ww2lst import WW2LSTGroupGenerator +if TYPE_CHECKING: + from game import Game + SHIP_MAP = { "SchnellbootGroupGenerator": SchnellbootGroupGenerator, @@ -39,10 +52,12 @@ SHIP_MAP = { } -def generate_ship_group(game, ground_object, faction_name: str): +def generate_ship_group( + game: Game, ground_object: ShipGroundObject, faction_name: str +) -> Optional[ShipGroup]: """ This generate a ship group - :return: Nothing, but put the group reference inside the ground object + :return: The generated group, or None if this faction does not support ships. """ faction = db.FACTIONS[faction_name] if len(faction.navy_generators) > 0: @@ -61,26 +76,30 @@ def generate_ship_group(game, ground_object, faction_name: str): return None -def generate_carrier_group(faction: str, game, ground_object): - """ - This generate a carrier group - :param parentCp: The parent control point +def generate_carrier_group( + faction: str, game: Game, ground_object: CarrierGroundObject +) -> ShipGroup: + """Generates a carrier group. + + :param faction: The faction the TGO belongs to. + :param game: The Game the group is being generated for. :param ground_object: The ground object which will own the ship group - :param country: Owner country - :return: Nothing, but put the group reference inside the ground object + :return: The generated group. """ generator = CarrierGroupGenerator(game, ground_object, db.FACTIONS[faction]) generator.generate() return generator.get_generated_group() -def generate_lha_group(faction: str, game, ground_object): - """ - This generate a lha carrier group - :param parentCp: The parent control point +def generate_lha_group( + faction: str, game: Game, ground_object: LhaGroundObject +) -> ShipGroup: + """Generate an LHA group. + + :param faction: The faction the TGO belongs to. + :param game: The Game the group is being generated for. :param ground_object: The ground object which will own the ship group - :param country: Owner country - :return: Nothing, but put the group reference inside the ground object + :return: The generated group. """ generator = LHAGroupGenerator(game, ground_object, db.FACTIONS[faction]) generator.generate() diff --git a/gen/fleet/uboat.py b/gen/fleet/uboat.py index 6333021f..ee8c3114 100644 --- a/gen/fleet/uboat.py +++ b/gen/fleet/uboat.py @@ -6,7 +6,7 @@ from gen.sam.group_generator import ShipGroupGenerator class UBoatGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: for i in range(random.randint(1, 4)): self.add_unit( diff --git a/gen/fleet/ww2lst.py b/gen/fleet/ww2lst.py index 7ed63fbe..e3ac7de6 100644 --- a/gen/fleet/ww2lst.py +++ b/gen/fleet/ww2lst.py @@ -6,7 +6,7 @@ from gen.sam.group_generator import ShipGroupGenerator class WW2LSTGroupGenerator(ShipGroupGenerator): - def generate(self): + def generate(self) -> None: # Add LS Samuel Chase self.add_unit( diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 7bbee129..29c9ae96 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -1057,7 +1057,7 @@ class CoalitionMissionPlanner: # delayed until their takeoff time by AirConflictGenerator. package.time_over_target = next(start_time) + tot - def message(self, title, text) -> None: + def message(self, title: str, text: str) -> None: """Emits a planning message to the player. If the mission planner belongs to the players coalition, this emits a diff --git a/gen/flights/flight.py b/gen/flights/flight.py index cf5c011f..855fad95 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -8,7 +8,6 @@ from dcs.mapping import Point from dcs.point import MovingPoint, PointAction from dcs.unit import Unit -from game import db from game.dcs.aircrafttype import AircraftType from game.squadrons import Pilot, Squadron from game.theater.controlpoint import ControlPoint, MissionTarget @@ -323,12 +322,12 @@ class Flight: def clear_roster(self) -> None: self.roster.clear() - def __repr__(self): + def __repr__(self) -> str: if self.custom_name: return f"{self.custom_name} {self.count} x {self.unit_type}" return f"[{self.flight_type}] {self.count} x {self.unit_type}" - def __str__(self): + def __str__(self) -> str: if self.custom_name: return f"{self.custom_name} {self.count} x {self.unit_type}" return f"[{self.flight_type}] {self.count} x {self.unit_type}" diff --git a/gen/forcedoptionsgen.py b/gen/forcedoptionsgen.py index d18db095..ea1c854b 100644 --- a/gen/forcedoptionsgen.py +++ b/gen/forcedoptionsgen.py @@ -43,7 +43,7 @@ class ForcedOptionsGenerator: if blue.unrestricted_satnav or red.unrestricted_satnav: self.mission.forced_options.unrestricted_satnav = True - def generate(self): + def generate(self) -> None: self._set_options_view() self._set_external_views() self._set_labels() diff --git a/gen/ground_forces/ai_ground_planner.py b/gen/ground_forces/ai_ground_planner.py index 63d5b1ab..45d98c01 100644 --- a/gen/ground_forces/ai_ground_planner.py +++ b/gen/ground_forces/ai_ground_planner.py @@ -1,13 +1,18 @@ +from __future__ import annotations + import logging import random from enum import Enum -from typing import Dict, List +from typing import Dict, List, TYPE_CHECKING from game.data.groundunitclass import GroundUnitClass from game.dcs.groundunittype import GroundUnitType from game.theater import ControlPoint from gen.ground_forces.combat_stance import CombatStance +if TYPE_CHECKING: + from game import Game + MAX_COMBAT_GROUP_PER_CP = 10 @@ -54,7 +59,7 @@ class CombatGroup: self.role = role self.start_position = None - def __str__(self): + def __str__(self) -> str: s = f"ROLE : {self.role}\n" if self.size: s += f"UNITS {self.unit_type} * {self.size}" @@ -62,7 +67,7 @@ class CombatGroup: class GroundPlanner: - def __init__(self, cp: ControlPoint, game): + def __init__(self, cp: ControlPoint, game: Game) -> None: self.cp = cp self.game = game self.connected_enemy_cp = [ @@ -82,7 +87,7 @@ class GroundPlanner: self.units_per_cp[cp.id] = [] self.reserve: List[CombatGroup] = [] - def plan_groundwar(self): + def plan_groundwar(self) -> None: ground_unit_limit = self.cp.frontline_unit_count_limit diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index 2350f160..55e7aee5 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -624,7 +624,7 @@ class GroundObjectsGenerator: self.icls_alloc = iter(range(1, 21)) self.runways: Dict[str, RunwayData] = {} - def generate(self): + def generate(self) -> None: for cp in self.game.theater.controlpoints: if cp.captured: country_name = self.game.player_country diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 9a074940..35c22799 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -91,7 +91,10 @@ class KneeboardPageWriter: return self.x, self.y def text( - self, text: str, font=None, fill: Tuple[int, int, int] = (0, 0, 0) + self, + text: str, + font: Optional[ImageFont.ImageFont] = None, + fill: Tuple[int, int, int] = (0, 0, 0), ) -> None: if font is None: font = self.content_font diff --git a/gen/locations/preset_control_point_locations.py b/gen/locations/preset_control_point_locations.py deleted file mode 100644 index e4be5136..00000000 --- a/gen/locations/preset_control_point_locations.py +++ /dev/null @@ -1,22 +0,0 @@ -from dataclasses import dataclass, field - -from typing import List - -from gen.locations.preset_locations import PresetLocation - - -@dataclass -class PresetControlPointLocations: - """A repository of preset locations for a given control point""" - - # List of possible ashore locations to generate objects (Represented in miz file by an APC_AAV_7_Amphibious) - ashore_locations: List[PresetLocation] = field(default_factory=list) - - # List of possible offshore locations to generate ship groups (Represented in miz file by an Oliver Hazard Perry) - offshore_locations: List[PresetLocation] = field(default_factory=list) - - # Possible antiship missiles sites locations (Represented in miz file by Iranian Silkworm missiles) - antiship_locations: List[PresetLocation] = field(default_factory=list) - - # List of possible powerplants locations (Represented in miz file by static Workshop A object, USA) - powerplant_locations: List[PresetLocation] = field(default_factory=list) diff --git a/gen/locations/preset_locations.py b/gen/locations/preset_locations.py deleted file mode 100644 index 89bdffbc..00000000 --- a/gen/locations/preset_locations.py +++ /dev/null @@ -1,21 +0,0 @@ -from dataclasses import dataclass - -from dcs import Point - - -@dataclass -class PresetLocation: - """A preset location""" - - position: Point - heading: int - id: str - - def __str__(self): - return ( - "-" * 10 - + "X: {}\n Y: {}\nHdg: {}°\nId: {}".format( - self.position.x, self.position.y, self.heading, self.id - ) - + "-" * 10 - ) diff --git a/gen/missiles/missiles_group_generator.py b/gen/missiles/missiles_group_generator.py index 72251516..63f1bb80 100644 --- a/gen/missiles/missiles_group_generator.py +++ b/gen/missiles/missiles_group_generator.py @@ -1,13 +1,20 @@ import logging import random -from game import db +from typing import Optional + +from dcs.unitgroup import VehicleGroup + +from game import db, Game +from game.theater.theatergroundobject import MissileSiteGroundObject from gen.missiles.scud_site import ScudGenerator from gen.missiles.v1_group import V1GroupGenerator MISSILES_MAP = {"V1GroupGenerator": V1GroupGenerator, "ScudGenerator": ScudGenerator} -def generate_missile_group(game, ground_object, faction_name: str): +def generate_missile_group( + game: Game, ground_object: MissileSiteGroundObject, faction_name: str +) -> Optional[VehicleGroup]: """ This generate a missiles group :return: Nothing, but put the group reference inside the ground object diff --git a/gen/missiles/scud_site.py b/gen/missiles/scud_site.py index 67c9a0ad..0c5fd953 100644 --- a/gen/missiles/scud_site.py +++ b/gen/missiles/scud_site.py @@ -1,16 +1,22 @@ import random +from dcs.unitgroup import VehicleGroup from dcs.vehicles import Unarmed, MissilesSS, AirDefence +from game import Game +from game.factions.faction import Faction +from game.theater.theatergroundobject import MissileSiteGroundObject from gen.sam.group_generator import GroupGenerator -class ScudGenerator(GroupGenerator): - def __init__(self, game, ground_object, faction): +class ScudGenerator(GroupGenerator[VehicleGroup]): + def __init__( + self, game: Game, ground_object: MissileSiteGroundObject, faction: Faction + ) -> None: super(ScudGenerator, self).__init__(game, ground_object) self.faction = faction - def generate(self): + def generate(self) -> None: # Scuds self.add_unit( diff --git a/gen/missiles/v1_group.py b/gen/missiles/v1_group.py index 60c94db8..8cfb1dda 100644 --- a/gen/missiles/v1_group.py +++ b/gen/missiles/v1_group.py @@ -1,16 +1,22 @@ import random +from dcs.unitgroup import VehicleGroup from dcs.vehicles import Unarmed, MissilesSS, AirDefence +from game import Game +from game.factions.faction import Faction +from game.theater.theatergroundobject import MissileSiteGroundObject from gen.sam.group_generator import GroupGenerator -class V1GroupGenerator(GroupGenerator): - def __init__(self, game, ground_object, faction): +class V1GroupGenerator(GroupGenerator[VehicleGroup]): + def __init__( + self, game: Game, ground_object: MissileSiteGroundObject, faction: Faction + ) -> None: super(V1GroupGenerator, self).__init__(game, ground_object) self.faction = faction - def generate(self): + def generate(self) -> None: # Ramps self.add_unit( diff --git a/gen/naming.py b/gen/naming.py index f43c0a2e..b342ecaa 100644 --- a/gen/naming.py +++ b/gen/naming.py @@ -257,7 +257,7 @@ class NameGenerator: existing_alphas: List[str] = [] @classmethod - def reset(cls): + def reset(cls) -> None: cls.number = 0 cls.infantry_number = 0 cls.convoy_number = 0 @@ -266,7 +266,7 @@ class NameGenerator: cls.existing_alphas = [] @classmethod - def reset_numbers(cls): + def reset_numbers(cls) -> None: cls.number = 0 cls.infantry_number = 0 cls.aircraft_number = 0 @@ -274,7 +274,9 @@ class NameGenerator: cls.cargo_ship_number = 0 @classmethod - def next_aircraft_name(cls, country: Country, parent_base_id: int, flight: Flight): + def next_aircraft_name( + cls, country: Country, parent_base_id: int, flight: Flight + ) -> str: cls.aircraft_number += 1 try: if flight.custom_name: @@ -315,17 +317,17 @@ class NameGenerator: ) @classmethod - def next_awacs_name(cls, country: Country): + def next_awacs_name(cls, country: Country) -> str: cls.number += 1 return "awacs|{}|{}|0|".format(country.id, cls.number) @classmethod - def next_tanker_name(cls, country: Country, unit_type: AircraftType): + def next_tanker_name(cls, country: Country, unit_type: AircraftType) -> str: cls.number += 1 return "tanker|{}|{}|0|{}".format(country.id, cls.number, unit_type.name) @classmethod - def next_carrier_name(cls, country: Country): + def next_carrier_name(cls, country: Country) -> str: cls.number += 1 return "carrier|{}|{}|0|".format(country.id, cls.number) @@ -340,7 +342,7 @@ class NameGenerator: return f"Cargo Ship {cls.cargo_ship_number:03}" @classmethod - def random_objective_name(cls): + def random_objective_name(cls) -> str: if cls.animals: animal = random.choice(cls.animals) cls.animals.remove(animal) diff --git a/gen/radios.py b/gen/radios.py index 22968397..ced4ac9c 100644 --- a/gen/radios.py +++ b/gen/radios.py @@ -15,7 +15,7 @@ class RadioFrequency: #: The frequency in kilohertz. hertz: int - def __str__(self): + def __str__(self) -> str: if self.hertz >= 1000000: return self.format("MHz", 1000000) return self.format("kHz", 1000) diff --git a/gen/sam/aaa_bofors.py b/gen/sam/aaa_bofors.py index b4c87e34..f6e21977 100644 --- a/gen/sam/aaa_bofors.py +++ b/gen/sam/aaa_bofors.py @@ -15,7 +15,7 @@ class BoforsGenerator(AirDefenseGroupGenerator): name = "Bofors AAA" - def generate(self): + def generate(self) -> None: index = 0 for i in range(4): diff --git a/gen/sam/aaa_flak.py b/gen/sam/aaa_flak.py index 882e5ad3..68dee391 100644 --- a/gen/sam/aaa_flak.py +++ b/gen/sam/aaa_flak.py @@ -24,7 +24,7 @@ class FlakGenerator(AirDefenseGroupGenerator): name = "Flak Site" - def generate(self): + def generate(self) -> None: index = 0 mixed = random.choice([True, False]) unit_type = random.choice(GFLAK) diff --git a/gen/sam/aaa_flak18.py b/gen/sam/aaa_flak18.py index 60f1d389..17725a33 100644 --- a/gen/sam/aaa_flak18.py +++ b/gen/sam/aaa_flak18.py @@ -15,7 +15,7 @@ class Flak18Generator(AirDefenseGroupGenerator): name = "WW2 Flak Site" - def generate(self): + def generate(self) -> None: spacing = random.randint(30, 60) index = 0 diff --git a/gen/sam/aaa_ks19.py b/gen/sam/aaa_ks19.py index f173dab2..7f062bfe 100644 --- a/gen/sam/aaa_ks19.py +++ b/gen/sam/aaa_ks19.py @@ -14,7 +14,7 @@ class KS19Generator(AirDefenseGroupGenerator): name = "KS-19 AAA Site" - def generate(self): + def generate(self) -> None: self.add_unit( highdigitsams.AAA_SON_9_Fire_Can, "TR", diff --git a/gen/sam/aaa_ww2_ally_flak.py b/gen/sam/aaa_ww2_ally_flak.py index 7e58718f..5fc18ddc 100644 --- a/gen/sam/aaa_ww2_ally_flak.py +++ b/gen/sam/aaa_ww2_ally_flak.py @@ -15,7 +15,7 @@ class AllyWW2FlakGenerator(AirDefenseGroupGenerator): name = "WW2 Ally Flak Site" - def generate(self): + def generate(self) -> None: positions = self.get_circular_position(4, launcher_distance=30, coverage=360) for i, position in enumerate(positions): diff --git a/gen/sam/aaa_zsu57.py b/gen/sam/aaa_zsu57.py index 5da161c8..909ce549 100644 --- a/gen/sam/aaa_zsu57.py +++ b/gen/sam/aaa_zsu57.py @@ -13,7 +13,7 @@ class ZSU57Generator(AirDefenseGroupGenerator): name = "ZSU-57-2 Group" - def generate(self): + def generate(self) -> None: num_launchers = 4 positions = self.get_circular_position( num_launchers, launcher_distance=110, coverage=360 diff --git a/gen/sam/aaa_zu23_insurgent.py b/gen/sam/aaa_zu23_insurgent.py index a91d143e..ef2ec419 100644 --- a/gen/sam/aaa_zu23_insurgent.py +++ b/gen/sam/aaa_zu23_insurgent.py @@ -15,7 +15,7 @@ class ZU23InsurgentGenerator(AirDefenseGroupGenerator): name = "Zu-23 Site" - def generate(self): + def generate(self) -> None: index = 0 for i in range(4): index = index + 1 diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py index bc192691..d74027c1 100644 --- a/gen/sam/airdefensegroupgenerator.py +++ b/gen/sam/airdefensegroupgenerator.py @@ -38,7 +38,7 @@ class AirDefenseRange(Enum): self.default_role = default_role -class AirDefenseGroupGenerator(GroupGenerator, ABC): +class AirDefenseGroupGenerator(GroupGenerator[VehicleGroup], ABC): """ This is the base for all SAM group generators """ diff --git a/gen/sam/cold_war_flak.py b/gen/sam/cold_war_flak.py index 4030321b..788482ec 100644 --- a/gen/sam/cold_war_flak.py +++ b/gen/sam/cold_war_flak.py @@ -18,7 +18,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator): name = "Early Cold War Flak Site" - def generate(self): + def generate(self) -> None: spacing = random.randint(30, 60) index = 0 @@ -90,7 +90,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator): name = "Cold War Flak Site" - def generate(self): + def generate(self) -> None: spacing = random.randint(30, 60) index = 0 diff --git a/gen/sam/ewrs.py b/gen/sam/ewrs.py index 3678fe79..e20adc72 100644 --- a/gen/sam/ewrs.py +++ b/gen/sam/ewrs.py @@ -1,12 +1,13 @@ from typing import Type +from dcs.unitgroup import VehicleGroup from dcs.vehicles import AirDefence from dcs.unittype import VehicleType from gen.sam.group_generator import GroupGenerator -class EwrGenerator(GroupGenerator): +class EwrGenerator(GroupGenerator[VehicleGroup]): unit_type: Type[VehicleType] @classmethod diff --git a/gen/sam/freya_ewr.py b/gen/sam/freya_ewr.py index fbd8dbfb..7c61a25c 100644 --- a/gen/sam/freya_ewr.py +++ b/gen/sam/freya_ewr.py @@ -13,7 +13,7 @@ class FreyaGenerator(AirDefenseGroupGenerator): name = "Freya EWR Site" - def generate(self): + def generate(self) -> None: # TODO : would be better with the Concrete structure that is supposed to protect it self.add_unit( diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index f41b9543..2c7bc54d 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -3,13 +3,15 @@ from __future__ import annotations import logging import math import random -from typing import TYPE_CHECKING, Type +from collections import Iterable +from typing import TYPE_CHECKING, Type, TypeVar, Generic from dcs import unitgroup from dcs.mapping import Point from dcs.point import PointAction from dcs.unit import Ship, Vehicle -from dcs.unittype import VehicleType +from dcs.unitgroup import MovingGroup, ShipGroup +from dcs.unittype import VehicleType, UnitType from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction @@ -19,12 +21,15 @@ if TYPE_CHECKING: from game.game import Game +GroupType = TypeVar("GroupType", bound=MovingGroup) + + # TODO: Generate a group description rather than a pydcs group. # It appears that all of this work gets redone at miz generation time (see # groundobjectsgen for an example). We can do less work and include the data we # care about in the format we want if we just generate our own group description # types rather than pydcs groups. -class GroupGenerator: +class GroupGenerator(Generic[GroupType]): price: int @@ -38,10 +43,10 @@ class GroupGenerator: wp = self.vg.add_waypoint(self.position, PointAction.OffRoad, 0) wp.ETA_locked = True - def generate(self): + def generate(self) -> None: raise NotImplementedError - def get_generated_group(self) -> unitgroup.VehicleGroup: + def get_generated_group(self) -> GroupType: return self.vg def add_unit( @@ -58,7 +63,7 @@ class GroupGenerator: def add_unit_to_group( self, - group: unitgroup.VehicleGroup, + group: GroupType, unit_type: Type[VehicleType], name: str, position: Point, @@ -78,7 +83,9 @@ class GroupGenerator: return unit - def get_circular_position(self, num_units, launcher_distance, coverage=90): + def get_circular_position( + self, num_units: int, launcher_distance: int, coverage: int = 90 + ) -> Iterable[tuple[float, float, int]]: """ Given a position on the map, array a group of units in a circle a uniform distance from the unit :param num_units: @@ -104,21 +111,20 @@ class GroupGenerator: else: current_offset = self.heading current_offset -= outer_offset * (math.ceil(num_units / 2) - 1) - for x in range(1, num_units + 1): - positions.append( - ( - self.position.x - + launcher_distance * math.cos(math.radians(current_offset)), - self.position.y - + launcher_distance * math.sin(math.radians(current_offset)), - current_offset, - ) + for _ in range(1, num_units + 1): + x: float = self.position.x + launcher_distance * math.cos( + math.radians(current_offset) ) + y: float = self.position.y + launcher_distance * math.sin( + math.radians(current_offset) + ) + heading = current_offset + positions.append((x, y, int(heading))) current_offset += outer_offset return positions -class ShipGroupGenerator(GroupGenerator): +class ShipGroupGenerator(GroupGenerator[ShipGroup]): """Abstract class for other ship generator classes""" def __init__( @@ -133,7 +139,14 @@ class ShipGroupGenerator(GroupGenerator): wp = self.vg.add_waypoint(self.position, 0) wp.ETA_locked = True - def add_unit(self, unit_type, name, pos_x, pos_y, heading) -> Ship: + def add_unit( + self, + unit_type: Type[UnitType], + name: str, + pos_x: float, + pos_y: float, + heading: int, + ) -> Ship: unit = Ship(self.game.next_unit_id(), f"{self.go.group_name}|{name}", unit_type) unit.position.x = pos_x unit.position.y = pos_y diff --git a/gen/sam/sam_avenger.py b/gen/sam/sam_avenger.py index 75df690f..ac72b709 100644 --- a/gen/sam/sam_avenger.py +++ b/gen/sam/sam_avenger.py @@ -15,7 +15,7 @@ class AvengerGenerator(AirDefenseGroupGenerator): name = "Avenger Group" - def generate(self): + def generate(self) -> None: num_launchers = 2 self.add_unit( diff --git a/gen/sam/sam_chaparral.py b/gen/sam/sam_chaparral.py index 3489fd8b..2a746f95 100644 --- a/gen/sam/sam_chaparral.py +++ b/gen/sam/sam_chaparral.py @@ -15,7 +15,7 @@ class ChaparralGenerator(AirDefenseGroupGenerator): name = "Chaparral Group" - def generate(self): + def generate(self) -> None: num_launchers = 2 self.add_unit( diff --git a/gen/sam/sam_gepard.py b/gen/sam/sam_gepard.py index cb752f90..05b04068 100644 --- a/gen/sam/sam_gepard.py +++ b/gen/sam/sam_gepard.py @@ -15,7 +15,7 @@ class GepardGenerator(AirDefenseGroupGenerator): name = "Gepard Group" - def generate(self): + def generate(self) -> None: num_launchers = 2 positions = self.get_circular_position( diff --git a/gen/sam/sam_hawk.py b/gen/sam/sam_hawk.py index 007b0b99..f65faf09 100644 --- a/gen/sam/sam_hawk.py +++ b/gen/sam/sam_hawk.py @@ -17,7 +17,7 @@ class HawkGenerator(AirDefenseGroupGenerator): name = "Hawk Site" - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.Hawk_sr, "SR", diff --git a/gen/sam/sam_hq7.py b/gen/sam/sam_hq7.py index 8c6f5d2a..89a81097 100644 --- a/gen/sam/sam_hq7.py +++ b/gen/sam/sam_hq7.py @@ -17,7 +17,7 @@ class HQ7Generator(AirDefenseGroupGenerator): name = "HQ-7 Site" - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.HQ_7_STR_SP, "STR", diff --git a/gen/sam/sam_linebacker.py b/gen/sam/sam_linebacker.py index 224e09bf..397c38a7 100644 --- a/gen/sam/sam_linebacker.py +++ b/gen/sam/sam_linebacker.py @@ -15,7 +15,7 @@ class LinebackerGenerator(AirDefenseGroupGenerator): name = "Linebacker Group" - def generate(self): + def generate(self) -> None: num_launchers = 2 self.add_unit( diff --git a/gen/sam/sam_patriot.py b/gen/sam/sam_patriot.py index 3fb8a995..55c4be2b 100644 --- a/gen/sam/sam_patriot.py +++ b/gen/sam/sam_patriot.py @@ -15,7 +15,7 @@ class PatriotGenerator(AirDefenseGroupGenerator): name = "Patriot Battery" - def generate(self): + def generate(self) -> None: # Command Post self.add_unit( AirDefence.Patriot_str, diff --git a/gen/sam/sam_rapier.py b/gen/sam/sam_rapier.py index a2c1d07b..aac88d64 100644 --- a/gen/sam/sam_rapier.py +++ b/gen/sam/sam_rapier.py @@ -16,7 +16,7 @@ class RapierGenerator(AirDefenseGroupGenerator): name = "Rapier AA Site" - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.Rapier_fsa_blindfire_radar, "BT", diff --git a/gen/sam/sam_roland.py b/gen/sam/sam_roland.py index 30d51aa5..57c3ab0e 100644 --- a/gen/sam/sam_roland.py +++ b/gen/sam/sam_roland.py @@ -14,7 +14,7 @@ class RolandGenerator(AirDefenseGroupGenerator): name = "Roland Site" - def generate(self): + def generate(self) -> None: num_launchers = 2 self.add_unit( AirDefence.Roland_Radar, diff --git a/gen/sam/sam_sa10.py b/gen/sam/sam_sa10.py index 282515ab..ef633226 100644 --- a/gen/sam/sam_sa10.py +++ b/gen/sam/sam_sa10.py @@ -28,7 +28,7 @@ class SA10Generator(AirDefenseGroupGenerator): self.ln1 = AirDefence.S_300PS_5P85C_ln self.ln2 = AirDefence.S_300PS_5P85D_ln - def generate(self): + def generate(self) -> None: # Search Radar self.add_unit( self.sr1, "SR1", self.position.x, self.position.y + 40, self.heading diff --git a/gen/sam/sam_sa11.py b/gen/sam/sam_sa11.py index 8e53e6b5..873ee0d5 100644 --- a/gen/sam/sam_sa11.py +++ b/gen/sam/sam_sa11.py @@ -15,7 +15,7 @@ class SA11Generator(AirDefenseGroupGenerator): name = "SA-11 Buk Battery" - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.SA_11_Buk_SR_9S18M1, "SR", diff --git a/gen/sam/sam_sa13.py b/gen/sam/sam_sa13.py index d364edf3..0c81e042 100644 --- a/gen/sam/sam_sa13.py +++ b/gen/sam/sam_sa13.py @@ -15,7 +15,7 @@ class SA13Generator(AirDefenseGroupGenerator): name = "SA-13 Strela Group" - def generate(self): + def generate(self) -> None: self.add_unit( Unarmed.UAZ_469, "UAZ", diff --git a/gen/sam/sam_sa15.py b/gen/sam/sam_sa15.py index ca0d4b22..c0a6d852 100644 --- a/gen/sam/sam_sa15.py +++ b/gen/sam/sam_sa15.py @@ -13,7 +13,7 @@ class SA15Generator(AirDefenseGroupGenerator): name = "SA-15 Tor Group" - def generate(self): + def generate(self) -> None: num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=360 diff --git a/gen/sam/sam_sa17.py b/gen/sam/sam_sa17.py index c59eb263..1544a043 100644 --- a/gen/sam/sam_sa17.py +++ b/gen/sam/sam_sa17.py @@ -14,7 +14,7 @@ class SA17Generator(AirDefenseGroupGenerator): name = "SA-17 Grizzly Battery" - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.SA_11_Buk_SR_9S18M1, "SR", diff --git a/gen/sam/sam_sa19.py b/gen/sam/sam_sa19.py index fb0fabe8..8611a310 100644 --- a/gen/sam/sam_sa19.py +++ b/gen/sam/sam_sa19.py @@ -15,7 +15,7 @@ class SA19Generator(AirDefenseGroupGenerator): name = "SA-19 Tunguska Group" - def generate(self): + def generate(self) -> None: num_launchers = 2 if num_launchers == 1: diff --git a/gen/sam/sam_sa2.py b/gen/sam/sam_sa2.py index 70d57f2d..0d2546c5 100644 --- a/gen/sam/sam_sa2.py +++ b/gen/sam/sam_sa2.py @@ -15,7 +15,7 @@ class SA2Generator(AirDefenseGroupGenerator): name = "SA-2/S-75 Site" - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.P_19_s_125_sr, "SR", diff --git a/gen/sam/sam_sa3.py b/gen/sam/sam_sa3.py index 802a28b6..b75555d1 100644 --- a/gen/sam/sam_sa3.py +++ b/gen/sam/sam_sa3.py @@ -15,7 +15,7 @@ class SA3Generator(AirDefenseGroupGenerator): name = "SA-3/S-125 Site" - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.P_19_s_125_sr, "SR", diff --git a/gen/sam/sam_sa6.py b/gen/sam/sam_sa6.py index 67975914..af9a6ffc 100644 --- a/gen/sam/sam_sa6.py +++ b/gen/sam/sam_sa6.py @@ -15,7 +15,7 @@ class SA6Generator(AirDefenseGroupGenerator): name = "SA-6 Kub Site" - def generate(self): + def generate(self) -> None: self.add_unit( AirDefence.Kub_1S91_str, "STR", diff --git a/gen/sam/sam_sa8.py b/gen/sam/sam_sa8.py index dc6184c2..35afab86 100644 --- a/gen/sam/sam_sa8.py +++ b/gen/sam/sam_sa8.py @@ -13,7 +13,7 @@ class SA8Generator(AirDefenseGroupGenerator): name = "SA-8 OSA Site" - def generate(self): + def generate(self) -> None: num_launchers = 2 positions = self.get_circular_position( num_launchers, launcher_distance=120, coverage=180 diff --git a/gen/sam/sam_sa9.py b/gen/sam/sam_sa9.py index add7358c..6ee35518 100644 --- a/gen/sam/sam_sa9.py +++ b/gen/sam/sam_sa9.py @@ -15,7 +15,7 @@ class SA9Generator(AirDefenseGroupGenerator): name = "SA-9 Group" - def generate(self): + def generate(self) -> None: self.add_unit( Unarmed.UAZ_469, "UAZ", diff --git a/gen/sam/sam_vulcan.py b/gen/sam/sam_vulcan.py index ea0b8834..9a458db0 100644 --- a/gen/sam/sam_vulcan.py +++ b/gen/sam/sam_vulcan.py @@ -15,7 +15,7 @@ class VulcanGenerator(AirDefenseGroupGenerator): name = "Vulcan Group" - def generate(self): + def generate(self) -> None: num_launchers = 2 positions = self.get_circular_position( diff --git a/gen/sam/sam_zsu23.py b/gen/sam/sam_zsu23.py index 8f9d0529..5e64d5df 100644 --- a/gen/sam/sam_zsu23.py +++ b/gen/sam/sam_zsu23.py @@ -15,7 +15,7 @@ class ZSU23Generator(AirDefenseGroupGenerator): name = "ZSU-23 Group" - def generate(self): + def generate(self) -> None: num_launchers = 4 positions = self.get_circular_position( diff --git a/gen/sam/sam_zu23.py b/gen/sam/sam_zu23.py index 7c73da0f..2a2e2f4b 100644 --- a/gen/sam/sam_zu23.py +++ b/gen/sam/sam_zu23.py @@ -15,7 +15,7 @@ class ZU23Generator(AirDefenseGroupGenerator): name = "ZU-23 Group" - def generate(self): + def generate(self) -> None: index = 0 for i in range(4): index = index + 1 diff --git a/gen/sam/sam_zu23_ural.py b/gen/sam/sam_zu23_ural.py index fe2f38fa..85ca1d20 100644 --- a/gen/sam/sam_zu23_ural.py +++ b/gen/sam/sam_zu23_ural.py @@ -15,7 +15,7 @@ class ZU23UralGenerator(AirDefenseGroupGenerator): name = "ZU-23 Ural Group" - def generate(self): + def generate(self) -> None: num_launchers = 4 positions = self.get_circular_position( diff --git a/gen/sam/sam_zu23_ural_insurgent.py b/gen/sam/sam_zu23_ural_insurgent.py index aea7c92b..7d70300a 100644 --- a/gen/sam/sam_zu23_ural_insurgent.py +++ b/gen/sam/sam_zu23_ural_insurgent.py @@ -15,7 +15,11 @@ class ZU23UralInsurgentGenerator(AirDefenseGroupGenerator): name = "ZU-23 Ural Insurgent Group" - def generate(self): + @classmethod + def range(cls) -> AirDefenseRange: + return AirDefenseRange.AAA + + def generate(self) -> None: num_launchers = 4 positions = self.get_circular_position( @@ -29,7 +33,3 @@ class ZU23UralInsurgentGenerator(AirDefenseGroupGenerator): position[1], position[2], ) - - @classmethod - def range(cls) -> AirDefenseRange: - return AirDefenseRange.AAA diff --git a/gen/triggergen.py b/gen/triggergen.py index a8e29a42..6616456d 100644 --- a/gen/triggergen.py +++ b/gen/triggergen.py @@ -51,11 +51,11 @@ class TriggersGenerator: capture_zone_types = (Fob,) capture_zone_flag = 600 - def __init__(self, mission: Mission, game: Game): + def __init__(self, mission: Mission, game: Game) -> None: self.mission = mission self.game = game - def _set_allegiances(self, player_coalition: str, enemy_coalition: str): + def _set_allegiances(self, player_coalition: str, enemy_coalition: str) -> None: """ Set airbase initial coalition """ @@ -87,7 +87,7 @@ class TriggersGenerator: cp.captured and player_coalition or enemy_coalition ) - def _set_skill(self, player_coalition: str, enemy_coalition: str): + def _set_skill(self, player_coalition: str, enemy_coalition: str) -> None: """ Set skill level for all aircraft in the mission """ @@ -103,7 +103,7 @@ class TriggersGenerator: for vehicle_group in country.vehicle_group: vehicle_group.set_skill(skill_level) - def _gen_markers(self): + def _gen_markers(self) -> None: """ Generate markers on F10 map for each existing objective """ @@ -188,7 +188,7 @@ class TriggersGenerator: recapture_trigger.add_action(ClearFlag(flag=flag)) self.mission.triggerrules.triggers.append(recapture_trigger) - def generate(self): + def generate(self) -> None: player_coalition = "blue" enemy_coalition = "red" @@ -198,7 +198,7 @@ class TriggersGenerator: self._generate_capture_triggers(player_coalition, enemy_coalition) @classmethod - def get_capture_zone_flag(cls): + def get_capture_zone_flag(cls) -> int: flag = cls.capture_zone_flag cls.capture_zone_flag += 1 return flag diff --git a/gen/visualgen.py b/gen/visualgen.py index 0fa9c335..765a8f3c 100644 --- a/gen/visualgen.py +++ b/gen/visualgen.py @@ -1,9 +1,8 @@ from __future__ import annotations import random -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from dcs.mapping import Point from dcs.mission import Mission from dcs.unit import Static from dcs.unittype import StaticType @@ -11,7 +10,7 @@ from dcs.unittype import StaticType if TYPE_CHECKING: from game import Game -from .conflictgen import Conflict, FRONTLINE_LENGTH +from .conflictgen import Conflict class MarkerSmoke(StaticType): @@ -46,13 +45,7 @@ class MassiveSmoke(StaticType): rate = 1 -class Outpost(StaticType): - id = "outpost" - name = "outpost" - category = "Fortifications" - - -def __monkey_static_dict(self: Static): +def __monkey_static_dict(self: Static) -> dict[str, Any]: global __original_static_dict d = __original_static_dict(self) @@ -65,7 +58,6 @@ def __monkey_static_dict(self: Static): __original_static_dict = Static.dict Static.dict = __monkey_static_dict -FRONT_SMOKE_SPACING = 800 FRONT_SMOKE_RANDOM_SPREAD = 4000 FRONT_SMOKE_TYPE_CHANCES = { 2: MassiveSmoke, @@ -74,29 +66,13 @@ FRONT_SMOKE_TYPE_CHANCES = { 100: Smoke, } -DESTINATION_SMOKE_AMOUNT_FACTOR = 0.03 -DESTINATION_SMOKE_DISTANCE_FACTOR = 1 -DESTINATION_SMOKE_TYPE_CHANCES = { - 5: BigSmoke, - 100: Smoke, -} - - -def turn_heading(heading, fac): - heading += fac - if heading > 359: - heading = heading - 359 - if heading < 0: - heading = 359 + heading - return heading - class VisualGenerator: - def __init__(self, mission: Mission, game: Game): + def __init__(self, mission: Mission, game: Game) -> None: self.mission = mission self.game = game - def _generate_frontline_smokes(self): + def _generate_frontline_smokes(self) -> None: for front_line in self.game.theater.conflicts(): from_cp = front_line.blue_cp to_cp = front_line.red_cp @@ -128,61 +104,5 @@ class VisualGenerator: ) break - def _generate_stub_planes(self): - pass - """ - mission_units = set() - for coalition_name, coalition in self.mission.coalition.items(): - for country in coalition.countries.values(): - for group in country.plane_group + country.helicopter_group + country.vehicle_group: - for unit in group.units: - mission_units.add(db.unit_type_of(unit)) - - for unit_type in mission_units: - self.mission.static_group(self.mission.country(self.game.player_country), "a", unit_type, Point(0, 300000), hidden=True)""" - - def generate_target_smokes(self, target): - spread = target.size * DESTINATION_SMOKE_DISTANCE_FACTOR - for _ in range( - 0, - int( - target.size - * DESTINATION_SMOKE_AMOUNT_FACTOR - * (1.1 - target.base.strength) - ), - ): - for k, v in DESTINATION_SMOKE_TYPE_CHANCES.items(): - if random.randint(0, 100) <= k: - position = target.position.random_point_within(0, spread) - if not self.game.theater.is_on_land(position): - break - - self.mission.static_group( - self.mission.country(self.game.enemy_country), - "", - _type=v, - position=position, - hidden=True, - ) - break - - def generate_transportation_marker(self, at: Point): - self.mission.static_group( - self.mission.country(self.game.player_country), - "", - _type=MarkerSmoke, - position=at, - ) - - def generate_transportation_destination(self, at: Point): - self.generate_transportation_marker(at.point_from_heading(0, 20)) - self.mission.static_group( - self.mission.country(self.game.player_country), - "", - _type=Outpost, - position=at, - ) - - def generate(self): + def generate(self) -> None: self._generate_frontline_smokes() - self._generate_stub_planes() diff --git a/mypy.ini b/mypy.ini index 480b478b..b4d0925b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -6,7 +6,7 @@ check_untyped_defs = True disallow_any_generics = True # disallow_any_unimported = True disallow_untyped_decorators = True -# disallow_untyped_defs = True +disallow_untyped_defs = True follow_imports = silent # implicit_reexport = False namespace_packages = True From 53f6a0b32b922110ccc163e3221a0f6cfd445ef9 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 8 Jul 2021 22:50:55 -0700 Subject: [PATCH 041/167] Fix some typing in preparation for pydcs types. Not complete, but progress. --- game/data/weapons.py | 4 +- game/dcs/aircrafttype.py | 2 +- game/dcs/groundunittype.py | 2 +- game/dcs/unittype.py | 4 +- game/event/event.py | 1 - game/factions/faction.py | 4 +- game/operation/operation.py | 18 +---- game/theater/base.py | 4 +- game/theater/conflicttheater.py | 2 +- game/theater/controlpoint.py | 14 ++-- game/theater/theatergroundobject.py | 27 +++---- game/unitdelivery.py | 6 +- game/unitmap.py | 6 +- gen/aircraft.py | 58 +++++++-------- gen/coastal/silkworm.py | 5 +- gen/defenses/armored_group_generator.py | 8 +-- gen/fleet/cn_dd_group.py | 7 +- gen/fleet/dd_group.py | 21 +++--- gen/fleet/lacombattanteII.py | 6 +- gen/fleet/ru_dd_group.py | 22 ++---- gen/flights/ai_flight_planner.py | 11 +-- gen/flights/flightplan.py | 2 +- gen/flights/waypointbuilder.py | 5 +- gen/groundobjectsgen.py | 29 +++++--- gen/missiles/scud_site.py | 5 +- gen/missiles/v1_group.py | 5 +- gen/sam/airdefensegroupgenerator.py | 4 +- gen/sam/ewrs.py | 8 +-- gen/sam/group_generator.py | 94 ++++++++++++++++--------- gen/sam/sam_sa10.py | 17 +++-- 30 files changed, 208 insertions(+), 193 deletions(-) diff --git a/game/data/weapons.py b/game/data/weapons.py index 6b9164a7..b3e3b059 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -5,14 +5,14 @@ import inspect import logging from collections import defaultdict from dataclasses import dataclass, field -from typing import Dict, Iterator, Optional, Set, Tuple, Union, cast +from typing import Dict, Iterator, Optional, Set, Tuple, Union, cast, Any from dcs.unitgroup import FlyingGroup from dcs.weapons_data import Weapons, weapon_ids from game.dcs.aircrafttype import AircraftType -PydcsWeapon = Dict[str, Union[int, str]] +PydcsWeapon = Dict[str, Any] PydcsWeaponAssignment = Tuple[int, PydcsWeapon] diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index 9b5fedae..a851abfd 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -106,7 +106,7 @@ class PatrolConfig: @dataclass(frozen=True) -class AircraftType(UnitType[FlyingType]): +class AircraftType(UnitType[Type[FlyingType]]): carrier_capable: bool lha_capable: bool always_keeps_gun: bool diff --git a/game/dcs/groundunittype.py b/game/dcs/groundunittype.py index 908e0e18..c22d8a21 100644 --- a/game/dcs/groundunittype.py +++ b/game/dcs/groundunittype.py @@ -15,7 +15,7 @@ from game.dcs.unittype import UnitType @dataclass(frozen=True) -class GroundUnitType(UnitType[VehicleType]): +class GroundUnitType(UnitType[Type[VehicleType]]): unit_class: Optional[GroundUnitClass] spawn_weight: int diff --git a/game/dcs/unittype.py b/game/dcs/unittype.py index 25181a66..2fc6ec9f 100644 --- a/game/dcs/unittype.py +++ b/game/dcs/unittype.py @@ -4,12 +4,12 @@ from typing import TypeVar, Generic, Type from dcs.unittype import UnitType as DcsUnitType -DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=DcsUnitType) +DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType]) @dataclass(frozen=True) class UnitType(Generic[DcsUnitTypeT]): - dcs_unit_type: Type[DcsUnitTypeT] + dcs_unit_type: DcsUnitTypeT name: str description: str year_introduced: str diff --git a/game/event/event.py b/game/event/event.py index daac7f27..1076da9c 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -5,7 +5,6 @@ from typing import List, TYPE_CHECKING, Type from dcs.mapping import Point from dcs.task import Task -from dcs.unittype import VehicleType from game import persistency from game.debriefing import AirLosses, Debriefing diff --git a/game/factions/faction.py b/game/factions/faction.py index 2a0156c2..382525de 100644 --- a/game/factions/faction.py +++ b/game/factions/faction.py @@ -84,10 +84,10 @@ class Faction: requirements: Dict[str, str] = field(default_factory=dict) # possible aircraft carrier units - aircraft_carrier: List[Type[UnitType]] = field(default_factory=list) + aircraft_carrier: List[Type[ShipType]] = field(default_factory=list) # possible helicopter carrier units - helicopter_carrier: List[Type[UnitType]] = field(default_factory=list) + helicopter_carrier: List[Type[ShipType]] = field(default_factory=list) # Possible carrier names carrier_names: List[str] = field(default_factory=list) diff --git a/game/operation/operation.py b/game/operation/operation.py index b976c5e7..36f2f54d 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -70,20 +70,6 @@ class Operation: cls._setup_mission_coalitions() cls.current_mission.options.load_from_dict(options_dict) - @classmethod - def conflicts(cls) -> Iterable[Conflict]: - assert cls.game - for frontline in cls.game.theater.conflicts(): - yield Conflict( - cls.game.theater, - frontline, - cls.game.player_faction.name, - cls.game.enemy_faction.name, - cls.game.player_country, - cls.game.enemy_country, - frontline.position, - ) - @classmethod def air_conflict(cls) -> Conflict: assert cls.game @@ -97,8 +83,8 @@ class Operation: FrontLine(player_cp, enemy_cp), cls.game.player_faction.name, cls.game.enemy_faction.name, - cls.game.player_country, - cls.game.enemy_country, + cls.current_mission.country(cls.game.player_country), + cls.current_mission.country(cls.game.enemy_country), mid_point, ) diff --git a/game/theater/base.py b/game/theater/base.py index 4e727825..02839481 100644 --- a/game/theater/base.py +++ b/game/theater/base.py @@ -2,8 +2,6 @@ import itertools import logging from typing import Any -from dcs.unit import UnitType as DcsUnitType - from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.dcs.unittype import UnitType @@ -33,7 +31,7 @@ class Base: total += unit_type.price * count return total - def total_units_of_type(self, unit_type: UnitType[DcsUnitType]) -> int: + def total_units_of_type(self, unit_type: UnitType[Any]) -> int: return sum( [ c diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index cd8574f9..29400a16 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -545,7 +545,7 @@ class ConflictTheater: def find_ground_objects_by_obj_name( self, obj_name: str - ) -> list[TheaterGroundObject]: + ) -> list[TheaterGroundObject[Any]]: found = [] for cp in self.controlpoints: for g in cp.ground_objects: diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index e7daf471..ea5d981a 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -43,6 +43,7 @@ from .missiontarget import MissionTarget from .theatergroundobject import ( GenericCarrierGroundObject, TheaterGroundObject, + NavalGroundObject, ) from ..dcs.aircrafttype import AircraftType from ..dcs.groundunittype import GroundUnitType @@ -298,7 +299,7 @@ class ControlPoint(MissionTarget, ABC): self.id = cp_id self.full_name = name self.at = at - self.connected_objectives: List[TheaterGroundObject] = [] + self.connected_objectives: List[TheaterGroundObject[Any]] = [] self.preset_locations = PresetLocations() self.helipads: List[PointWithHeading] = [] @@ -326,7 +327,7 @@ class ControlPoint(MissionTarget, ABC): return f"<{self.__class__}: {self.name}>" @property - def ground_objects(self) -> List[TheaterGroundObject]: + def ground_objects(self) -> List[TheaterGroundObject[Any]]: return list(self.connected_objectives) @property @@ -502,7 +503,7 @@ class ControlPoint(MissionTarget, ABC): def find_ground_objects_by_obj_name( self, obj_name: str - ) -> list[TheaterGroundObject]: + ) -> list[TheaterGroundObject[Any]]: found = [] for g in self.ground_objects: if g.obj_name == obj_name: @@ -881,9 +882,12 @@ class NavalControlPoint(ControlPoint, ABC): def heading(self) -> int: return 0 # TODO compute heading - def find_main_tgo(self) -> TheaterGroundObject: + def find_main_tgo(self) -> GenericCarrierGroundObject: for g in self.ground_objects: - if g.dcs_identifier in ["CARRIER", "LHA"]: + if isinstance(g, GenericCarrierGroundObject) and g.dcs_identifier in [ + "CARRIER", + "LHA", + ]: return g raise RuntimeError(f"Found no carrier/LHA group for {self.name}") diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index 4a7c8990..a49b63ab 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -2,12 +2,12 @@ from __future__ import annotations import itertools import logging -from typing import Iterator, List, TYPE_CHECKING, Union +from typing import Iterator, List, TYPE_CHECKING, Union, Generic, TypeVar, Any from dcs.mapping import Point from dcs.triggers import TriggerZone from dcs.unit import Unit -from dcs.unitgroup import Group +from dcs.unitgroup import Group, ShipGroup, VehicleGroup from dcs.unittype import VehicleType from .. import db @@ -47,7 +47,10 @@ NAME_BY_CATEGORY = { } -class TheaterGroundObject(MissionTarget): +GroupT = TypeVar("GroupT", bound=Group) + + +class TheaterGroundObject(MissionTarget, Generic[GroupT]): def __init__( self, name: str, @@ -66,7 +69,7 @@ class TheaterGroundObject(MissionTarget): self.control_point = control_point self.dcs_identifier = dcs_identifier self.sea_object = sea_object - self.groups: List[Group] = [] + self.groups: List[GroupT] = [] @property def is_dead(self) -> bool: @@ -206,7 +209,7 @@ class TheaterGroundObject(MissionTarget): raise NotImplementedError -class BuildingGroundObject(TheaterGroundObject): +class BuildingGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, @@ -253,7 +256,7 @@ class BuildingGroundObject(TheaterGroundObject): def kill(self) -> None: self._dead = True - def iter_building_group(self) -> Iterator[TheaterGroundObject]: + def iter_building_group(self) -> Iterator[TheaterGroundObject[Any]]: for tgo in self.control_point.ground_objects: if tgo.obj_name == self.obj_name and not tgo.is_dead: yield tgo @@ -338,7 +341,7 @@ class FactoryGroundObject(BuildingGroundObject): ) -class NavalGroundObject(TheaterGroundObject): +class NavalGroundObject(TheaterGroundObject[ShipGroup]): def mission_types(self, for_player: bool) -> Iterator[FlightType]: from gen.flights.flight import FlightType @@ -407,7 +410,7 @@ class LhaGroundObject(GenericCarrierGroundObject): return f"{self.faction_color}|EWR|{super().group_name}" -class MissileSiteGroundObject(TheaterGroundObject): +class MissileSiteGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, group_id: int, position: Point, control_point: ControlPoint ) -> None: @@ -431,7 +434,7 @@ class MissileSiteGroundObject(TheaterGroundObject): return False -class CoastalSiteGroundObject(TheaterGroundObject): +class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, @@ -463,7 +466,7 @@ class CoastalSiteGroundObject(TheaterGroundObject): # The SamGroundObject represents all type of AA # The TGO can have multiple types of units (AAA,SAM,Support...) # Differentiation can be made during generation with the airdefensegroupgenerator -class SamGroundObject(TheaterGroundObject): +class SamGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, @@ -535,7 +538,7 @@ class SamGroundObject(TheaterGroundObject): return True -class VehicleGroupGroundObject(TheaterGroundObject): +class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, @@ -563,7 +566,7 @@ class VehicleGroupGroundObject(TheaterGroundObject): return True -class EwrGroundObject(TheaterGroundObject): +class EwrGroundObject(TheaterGroundObject[VehicleGroup]): def __init__( self, name: str, diff --git a/game/unitdelivery.py b/game/unitdelivery.py index 0a2a3db3..dfbc2409 100644 --- a/game/unitdelivery.py +++ b/game/unitdelivery.py @@ -30,16 +30,16 @@ class PendingUnitDeliveries: self.destination = destination # Maps unit type to order quantity. - self.units: dict[UnitType[DcsUnitType], int] = defaultdict(int) + self.units: dict[UnitType[Any], int] = defaultdict(int) def __str__(self) -> str: return f"Pending delivery to {self.destination}" - def order(self, units: dict[UnitType[DcsUnitType], int]) -> None: + def order(self, units: dict[UnitType[Any], int]) -> None: for k, v in units.items(): self.units[k] += v - def sell(self, units: dict[UnitType[DcsUnitType], int]) -> None: + def sell(self, units: dict[UnitType[Any], int]) -> None: for k, v in units.items(): self.units[k] -= v diff --git a/game/unitmap.py b/game/unitmap.py index 98793991..a0485a9c 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -2,7 +2,7 @@ import itertools import math from dataclasses import dataclass -from typing import Dict, Optional +from typing import Dict, Optional, Any from dcs.unit import Unit from dcs.unitgroup import FlyingGroup, Group, VehicleGroup @@ -29,7 +29,7 @@ class FrontLineUnit: @dataclass(frozen=True) class GroundObjectUnit: - ground_object: TheaterGroundObject + ground_object: TheaterGroundObject[Any] group: Group unit: Unit @@ -100,7 +100,7 @@ class UnitMap: def add_ground_object_units( self, - ground_object: TheaterGroundObject, + ground_object: TheaterGroundObject[Any], persistence_group: Group, miz_group: Group, ) -> None: diff --git a/gen/aircraft.py b/gen/aircraft.py index 668b8d95..3fa1b94d 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -5,7 +5,7 @@ import random from dataclasses import dataclass from datetime import timedelta from functools import cached_property -from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union, Iterable +from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union, Iterable, Any from dcs import helicopters from dcs.action import AITaskPush, ActivateGroup @@ -351,7 +351,7 @@ class AircraftConflictGenerator: def _setup_group( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -537,7 +537,7 @@ class AircraftConflictGenerator: def _add_radio_waypoint( self, - group: FlyingGroup, + group: FlyingGroup[Any], position: Point, altitude: Distance, airspeed: int = 600, @@ -548,7 +548,7 @@ class AircraftConflictGenerator: def _rtb_for( self, - group: FlyingGroup, + group: FlyingGroup[Any], cp: ControlPoint, at: Optional[db.StartingPosition] = None, ) -> MovingPoint: @@ -680,7 +680,7 @@ class AircraftConflictGenerator: self.unit_map.add_aircraft(group, flight) def set_activation_time( - self, flight: Flight, group: FlyingGroup, delay: timedelta + self, flight: Flight, group: FlyingGroup[Any], delay: timedelta ) -> None: # Note: Late activation causes the waypoint TOTs to look *weird* in the # mission editor. Waypoint times will be relative to the group @@ -699,7 +699,7 @@ class AircraftConflictGenerator: self.m.triggerrules.triggers.append(activation_trigger) def set_startup_time( - self, flight: Flight, group: FlyingGroup, delay: timedelta + self, flight: Flight, group: FlyingGroup[Any], delay: timedelta ) -> None: # Uncontrolled causes the AI unit to spawn, but not begin startup. group.uncontrolled = True @@ -775,7 +775,7 @@ class AircraftConflictGenerator: @staticmethod def set_reduced_fuel( - flight: Flight, group: FlyingGroup, unit_type: Type[PlaneType] + flight: Flight, group: FlyingGroup[Any], unit_type: Type[PlaneType] ) -> None: if unit_type is Su_33: for unit in group.units: @@ -801,7 +801,7 @@ class AircraftConflictGenerator: def configure_behavior( self, flight: Flight, - group: FlyingGroup, + group: FlyingGroup[Any], react_on_threat: Optional[OptReactOnThreat.Values] = None, roe: Optional[OptROE.Values] = None, rtb_winchester: Optional[OptRTBOnOutOfAmmo.Values] = None, @@ -834,13 +834,13 @@ class AircraftConflictGenerator: # https://forums.eagle.ru/forum/english/digital-combat-simulator/dcs-world-2-5/bugs-and-problems-ai/ai-ad/7121294-ai-stuck-at-high-aoa-after-making-sharp-turn-if-afterburner-is-restricted @staticmethod - def configure_eplrs(group: FlyingGroup, flight: Flight) -> None: + def configure_eplrs(group: FlyingGroup[Any], flight: Flight) -> None: if flight.unit_type.eplrs_capable: group.points[0].tasks.append(EPLRS(group.id)) def configure_cap( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -857,7 +857,7 @@ class AircraftConflictGenerator: def configure_sweep( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -874,7 +874,7 @@ class AircraftConflictGenerator: def configure_cas( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -892,7 +892,7 @@ class AircraftConflictGenerator: def configure_dead( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -917,7 +917,7 @@ class AircraftConflictGenerator: def configure_sead( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -941,7 +941,7 @@ class AircraftConflictGenerator: def configure_strike( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -959,7 +959,7 @@ class AircraftConflictGenerator: def configure_anti_ship( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -977,7 +977,7 @@ class AircraftConflictGenerator: def configure_runway_attack( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -995,7 +995,7 @@ class AircraftConflictGenerator: def configure_oca_strike( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1012,7 +1012,7 @@ class AircraftConflictGenerator: def configure_awacs( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1040,7 +1040,7 @@ class AircraftConflictGenerator: def configure_refueling( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1066,7 +1066,7 @@ class AircraftConflictGenerator: def configure_escort( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1082,7 +1082,7 @@ class AircraftConflictGenerator: def configure_sead_escort( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1105,7 +1105,7 @@ class AircraftConflictGenerator: def configure_transport( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1120,13 +1120,13 @@ class AircraftConflictGenerator: restrict_jettison=True, ) - def configure_unknown_task(self, group: FlyingGroup, flight: Flight) -> None: + def configure_unknown_task(self, group: FlyingGroup[Any], flight: Flight) -> None: logging.error(f"Unhandled flight type: {flight.flight_type}") self.configure_behavior(flight, group) def setup_flight_group( self, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], @@ -1170,7 +1170,7 @@ class AircraftConflictGenerator: self.configure_eplrs(group, flight) def create_waypoints( - self, group: FlyingGroup, package: Package, flight: Flight + self, group: FlyingGroup[Any], package: Package, flight: Flight ) -> None: for waypoint in flight.points: @@ -1238,7 +1238,7 @@ class AircraftConflictGenerator: waypoint: FlightWaypoint, package: Package, flight: Flight, - group: FlyingGroup, + group: FlyingGroup[Any], ) -> None: estimator = TotEstimator(package) start_time = estimator.mission_start_time(flight) @@ -1281,7 +1281,7 @@ class PydcsWaypointBuilder: def __init__( self, waypoint: FlightWaypoint, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, mission: Mission, @@ -1324,7 +1324,7 @@ class PydcsWaypointBuilder: def for_waypoint( cls, waypoint: FlightWaypoint, - group: FlyingGroup, + group: FlyingGroup[Any], package: Package, flight: Flight, mission: Mission, diff --git a/gen/coastal/silkworm.py b/gen/coastal/silkworm.py index ccb1374d..6712762a 100644 --- a/gen/coastal/silkworm.py +++ b/gen/coastal/silkworm.py @@ -1,13 +1,12 @@ -from dcs.unitgroup import VehicleGroup from dcs.vehicles import MissilesSS, Unarmed, AirDefence from game import Game from game.factions.faction import Faction from game.theater.theatergroundobject import CoastalSiteGroundObject -from gen.sam.group_generator import GroupGenerator +from gen.sam.group_generator import VehicleGroupGenerator -class SilkwormGenerator(GroupGenerator[VehicleGroup]): +class SilkwormGenerator(VehicleGroupGenerator[CoastalSiteGroundObject]): def __init__( self, game: Game, ground_object: CoastalSiteGroundObject, faction: Faction ) -> None: diff --git a/gen/defenses/armored_group_generator.py b/gen/defenses/armored_group_generator.py index 51058b88..c7404d0d 100644 --- a/gen/defenses/armored_group_generator.py +++ b/gen/defenses/armored_group_generator.py @@ -1,14 +1,12 @@ import random -from dcs.unitgroup import VehicleGroup - from game import Game from game.dcs.groundunittype import GroundUnitType from game.theater.theatergroundobject import VehicleGroupGroundObject -from gen.sam.group_generator import GroupGenerator +from gen.sam.group_generator import VehicleGroupGenerator -class ArmoredGroupGenerator(GroupGenerator[VehicleGroup]): +class ArmoredGroupGenerator(VehicleGroupGenerator[VehicleGroupGroundObject]): def __init__( self, game: Game, @@ -37,7 +35,7 @@ class ArmoredGroupGenerator(GroupGenerator[VehicleGroup]): ) -class FixedSizeArmorGroupGenerator(GroupGenerator[VehicleGroup]): +class FixedSizeArmorGroupGenerator(VehicleGroupGenerator[VehicleGroupGroundObject]): def __init__( self, game: Game, diff --git a/gen/fleet/cn_dd_group.py b/gen/fleet/cn_dd_group.py index c47cc6ac..144df4b4 100644 --- a/gen/fleet/cn_dd_group.py +++ b/gen/fleet/cn_dd_group.py @@ -3,7 +3,6 @@ from __future__ import annotations import random from typing import TYPE_CHECKING - from dcs.ships import ( Type_052C, Type_052B, @@ -11,9 +10,9 @@ from dcs.ships import ( ) from game.factions.faction import Faction +from game.theater.theatergroundobject import ShipGroundObject from gen.fleet.dd_group import DDGroupGenerator from gen.sam.group_generator import ShipGroupGenerator -from game.theater.theatergroundobject import TheaterGroundObject if TYPE_CHECKING: from game.game import Game @@ -65,9 +64,7 @@ class ChineseNavyGroupGenerator(ShipGroupGenerator): class Type54GroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(Type54GroupGenerator, self).__init__( game, ground_object, faction, Type_054A ) diff --git a/gen/fleet/dd_group.py b/gen/fleet/dd_group.py index d3875088..db766a0d 100644 --- a/gen/fleet/dd_group.py +++ b/gen/fleet/dd_group.py @@ -1,12 +1,13 @@ from __future__ import annotations + from typing import TYPE_CHECKING, Type -from game.factions.faction import Faction -from game.theater.theatergroundobject import TheaterGroundObject - -from gen.sam.group_generator import ShipGroupGenerator -from dcs.unittype import ShipType from dcs.ships import PERRY, USS_Arleigh_Burke_IIa +from dcs.unittype import ShipType + +from game.factions.faction import Faction +from game.theater.theatergroundobject import ShipGroundObject +from gen.sam.group_generator import ShipGroupGenerator if TYPE_CHECKING: from game.game import Game @@ -16,7 +17,7 @@ class DDGroupGenerator(ShipGroupGenerator): def __init__( self, game: Game, - ground_object: TheaterGroundObject, + ground_object: ShipGroundObject, faction: Faction, ddtype: Type[ShipType], ): @@ -42,18 +43,14 @@ class DDGroupGenerator(ShipGroupGenerator): class OliverHazardPerryGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(OliverHazardPerryGroupGenerator, self).__init__( game, ground_object, faction, PERRY ) class ArleighBurkeGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(ArleighBurkeGroupGenerator, self).__init__( game, ground_object, faction, USS_Arleigh_Burke_IIa ) diff --git a/gen/fleet/lacombattanteII.py b/gen/fleet/lacombattanteII.py index 6638dd4a..bd476f45 100644 --- a/gen/fleet/lacombattanteII.py +++ b/gen/fleet/lacombattanteII.py @@ -2,14 +2,12 @@ from dcs.ships import La_Combattante_II from game import Game from game.factions.faction import Faction -from game.theater import TheaterGroundObject +from game.theater.theatergroundobject import ShipGroundObject from gen.fleet.dd_group import DDGroupGenerator class LaCombattanteIIGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(LaCombattanteIIGroupGenerator, self).__init__( game, ground_object, faction, La_Combattante_II ) diff --git a/gen/fleet/ru_dd_group.py b/gen/fleet/ru_dd_group.py index 4354b5fb..67f9c923 100644 --- a/gen/fleet/ru_dd_group.py +++ b/gen/fleet/ru_dd_group.py @@ -1,4 +1,5 @@ from __future__ import annotations + import random from typing import TYPE_CHECKING @@ -12,11 +13,10 @@ from dcs.ships import ( SOM, ) +from game.factions.faction import Faction +from game.theater.theatergroundobject import ShipGroundObject from gen.fleet.dd_group import DDGroupGenerator from gen.sam.group_generator import ShipGroupGenerator -from game.factions.faction import Faction -from game.theater.theatergroundobject import TheaterGroundObject - if TYPE_CHECKING: from game.game import Game @@ -85,32 +85,24 @@ class RussianNavyGroupGenerator(ShipGroupGenerator): class GrishaGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(GrishaGroupGenerator, self).__init__( game, ground_object, faction, ALBATROS ) class MolniyaGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(MolniyaGroupGenerator, self).__init__( game, ground_object, faction, MOLNIYA ) class KiloSubGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(KiloSubGroupGenerator, self).__init__(game, ground_object, faction, KILO) class TangoSubGroupGenerator(DDGroupGenerator): - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): + def __init__(self, game: Game, ground_object: ShipGroundObject, faction: Faction): super(TangoSubGroupGenerator, self).__init__(game, ground_object, faction, SOM) diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 29c9ae96..6d259cd2 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -18,6 +18,7 @@ from typing import ( TYPE_CHECKING, Tuple, TypeVar, + Any, ) from game.dcs.aircrafttype import AircraftType @@ -284,7 +285,7 @@ class ObjectiveFinder: self.game = game self.is_player = is_player - def enemy_air_defenses(self) -> Iterator[tuple[TheaterGroundObject, Distance]]: + def enemy_air_defenses(self) -> Iterator[tuple[TheaterGroundObject[Any], Distance]]: """Iterates over all enemy SAM sites.""" doctrine = self.game.faction_for(self.is_player).doctrine threat_zones = self.game.threat_zone_for(not self.is_player) @@ -314,14 +315,14 @@ class ObjectiveFinder: yield ground_object, target_range - def threatening_air_defenses(self) -> Iterator[TheaterGroundObject]: + def threatening_air_defenses(self) -> Iterator[TheaterGroundObject[Any]]: """Iterates over enemy SAMs in threat range of friendly control points. SAM sites are sorted by their closest proximity to any friendly control point (airfield or fleet). """ - target_ranges: list[tuple[TheaterGroundObject, Distance]] = [] + target_ranges: list[tuple[TheaterGroundObject[Any], Distance]] = [] for target, threat_range in self.enemy_air_defenses(): ranges: list[Distance] = [] for cp in self.friendly_control_points(): @@ -385,13 +386,13 @@ class ObjectiveFinder: for target, _range in target_ranges: yield target - def strike_targets(self) -> Iterator[TheaterGroundObject]: + def strike_targets(self) -> Iterator[TheaterGroundObject[Any]]: """Iterates over enemy strike targets. Targets are sorted by their closest proximity to any friendly control point (airfield or fleet). """ - targets: List[Tuple[TheaterGroundObject, int]] = [] + targets: List[Tuple[TheaterGroundObject[Any], int]] = [] # Building objectives are made of several individual TGOs (one per # building). found_targets: Set[str] = set() diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 07498342..d7ad8b9e 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -1130,7 +1130,7 @@ class FlightPlanBuilder: ) @staticmethod - def anti_ship_targets_for_tgo(tgo: TheaterGroundObject) -> List[StrikeTarget]: + def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> List[StrikeTarget]: return [StrikeTarget(f"{g.name} at {tgo.name}", g) for g in tgo.groups] def generate_anti_ship(self, flight: Flight) -> StrikeFlightPlan: diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index f8380897..f861b4b8 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -10,6 +10,7 @@ from typing import ( TYPE_CHECKING, Tuple, Union, + Any, ) from dcs.mapping import Point @@ -33,7 +34,9 @@ from .flight import Flight, FlightWaypoint, FlightWaypointType @dataclass(frozen=True) class StrikeTarget: name: str - target: Union[VehicleGroup, TheaterGroundObject, Unit, Group, MultiGroupTransport] + target: Union[ + VehicleGroup, TheaterGroundObject[Any], Unit, Group, MultiGroupTransport + ] class WaypointBuilder: diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index 55e7aee5..e14544e2 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -9,7 +9,17 @@ from __future__ import annotations import logging import random -from typing import Dict, Iterator, Optional, TYPE_CHECKING, Type, List +from typing import ( + Dict, + Iterator, + Optional, + TYPE_CHECKING, + Type, + List, + TypeVar, + Any, + Generic, +) from dcs import Mission, Point, unitgroup from dcs.action import SceneryDestructionZone @@ -56,7 +66,10 @@ FARP_FRONTLINE_DISTANCE = 10000 AA_CP_MIN_DISTANCE = 40000 -class GenericGroundObjectGenerator: +TgoT = TypeVar("TgoT", bound=TheaterGroundObject[Any]) + + +class GenericGroundObjectGenerator(Generic[TgoT]): """An unspecialized ground object generator. Currently used only for SAM @@ -64,7 +77,7 @@ class GenericGroundObjectGenerator: def __init__( self, - ground_object: TheaterGroundObject, + ground_object: TgoT, country: Country, game: Game, mission: Mission, @@ -133,7 +146,7 @@ class GenericGroundObjectGenerator: ) -class MissileSiteGenerator(GenericGroundObjectGenerator): +class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject]): @property def culled(self) -> bool: # Don't cull missile sites - their range is long enough to make them easily @@ -196,7 +209,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator): return site_range -class BuildingSiteGenerator(GenericGroundObjectGenerator): +class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]): """Generator for building sites. Building sites are the primary type of non-airbase objective locations that @@ -324,7 +337,7 @@ class SceneryGenerator(BuildingSiteGenerator): self.unit_map.add_scenery(scenery) -class GenericCarrierGenerator(GenericGroundObjectGenerator): +class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundObject]): """Base type for carrier group generation. Used by both CV(N) groups and LHA groups. @@ -518,7 +531,7 @@ class LhaGenerator(GenericCarrierGenerator): ) -class ShipObjectGenerator(GenericGroundObjectGenerator): +class ShipObjectGenerator(GenericGroundObjectGenerator[ShipGroundObject]): """Generator for non-carrier naval groups.""" def generate(self) -> None: @@ -637,7 +650,7 @@ class GroundObjectsGenerator: ).generate() for ground_object in cp.ground_objects: - generator: GenericGroundObjectGenerator + generator: GenericGroundObjectGenerator[Any] if isinstance(ground_object, FactoryGroundObject): generator = FactoryGenerator( ground_object, country, self.game, self.m, self.unit_map diff --git a/gen/missiles/scud_site.py b/gen/missiles/scud_site.py index 0c5fd953..ca7f9b94 100644 --- a/gen/missiles/scud_site.py +++ b/gen/missiles/scud_site.py @@ -1,15 +1,14 @@ import random -from dcs.unitgroup import VehicleGroup from dcs.vehicles import Unarmed, MissilesSS, AirDefence from game import Game from game.factions.faction import Faction from game.theater.theatergroundobject import MissileSiteGroundObject -from gen.sam.group_generator import GroupGenerator +from gen.sam.group_generator import VehicleGroupGenerator -class ScudGenerator(GroupGenerator[VehicleGroup]): +class ScudGenerator(VehicleGroupGenerator[MissileSiteGroundObject]): def __init__( self, game: Game, ground_object: MissileSiteGroundObject, faction: Faction ) -> None: diff --git a/gen/missiles/v1_group.py b/gen/missiles/v1_group.py index 8cfb1dda..9d377754 100644 --- a/gen/missiles/v1_group.py +++ b/gen/missiles/v1_group.py @@ -1,15 +1,14 @@ import random -from dcs.unitgroup import VehicleGroup from dcs.vehicles import Unarmed, MissilesSS, AirDefence from game import Game from game.factions.faction import Faction from game.theater.theatergroundobject import MissileSiteGroundObject -from gen.sam.group_generator import GroupGenerator +from gen.sam.group_generator import VehicleGroupGenerator -class V1GroupGenerator(GroupGenerator[VehicleGroup]): +class V1GroupGenerator(VehicleGroupGenerator[MissileSiteGroundObject]): def __init__( self, game: Game, ground_object: MissileSiteGroundObject, faction: Faction ) -> None: diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py index d74027c1..36755036 100644 --- a/gen/sam/airdefensegroupgenerator.py +++ b/gen/sam/airdefensegroupgenerator.py @@ -8,7 +8,7 @@ from dcs.unitgroup import VehicleGroup from game import Game from game.theater.theatergroundobject import SamGroundObject -from gen.sam.group_generator import GroupGenerator +from gen.sam.group_generator import VehicleGroupGenerator class SkynetRole(Enum): @@ -38,7 +38,7 @@ class AirDefenseRange(Enum): self.default_role = default_role -class AirDefenseGroupGenerator(GroupGenerator[VehicleGroup], ABC): +class AirDefenseGroupGenerator(VehicleGroupGenerator[SamGroundObject], ABC): """ This is the base for all SAM group generators """ diff --git a/gen/sam/ewrs.py b/gen/sam/ewrs.py index e20adc72..fdcdf061 100644 --- a/gen/sam/ewrs.py +++ b/gen/sam/ewrs.py @@ -1,13 +1,13 @@ from typing import Type -from dcs.unitgroup import VehicleGroup -from dcs.vehicles import AirDefence from dcs.unittype import VehicleType +from dcs.vehicles import AirDefence -from gen.sam.group_generator import GroupGenerator +from game.theater.theatergroundobject import EwrGroundObject +from gen.sam.group_generator import VehicleGroupGenerator -class EwrGenerator(GroupGenerator[VehicleGroup]): +class EwrGenerator(VehicleGroupGenerator[EwrGroundObject]): unit_type: Type[VehicleType] @classmethod diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index 2c7bc54d..7795036e 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -4,24 +4,27 @@ import logging import math import random from collections import Iterable -from typing import TYPE_CHECKING, Type, TypeVar, Generic +from typing import TYPE_CHECKING, Type, TypeVar, Generic, Any from dcs import unitgroup from dcs.mapping import Point from dcs.point import PointAction -from dcs.unit import Ship, Vehicle -from dcs.unitgroup import MovingGroup, ShipGroup -from dcs.unittype import VehicleType, UnitType +from dcs.unit import Ship, Vehicle, Unit +from dcs.unitgroup import ShipGroup, VehicleGroup +from dcs.unittype import VehicleType, UnitType, ShipType from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction -from game.theater.theatergroundobject import TheaterGroundObject +from game.theater.theatergroundobject import TheaterGroundObject, NavalGroundObject if TYPE_CHECKING: from game.game import Game -GroupType = TypeVar("GroupType", bound=MovingGroup) +GroupT = TypeVar("GroupT", VehicleGroup, ShipGroup) +UnitT = TypeVar("UnitT", bound=Unit) +UnitTypeT = TypeVar("UnitTypeT", bound=Type[UnitType]) +TgoT = TypeVar("TgoT", bound=TheaterGroundObject[Any]) # TODO: Generate a group description rather than a pydcs group. @@ -29,41 +32,62 @@ GroupType = TypeVar("GroupType", bound=MovingGroup) # groundobjectsgen for an example). We can do less work and include the data we # care about in the format we want if we just generate our own group description # types rather than pydcs groups. -class GroupGenerator(Generic[GroupType]): - - price: int - - def __init__(self, game: Game, ground_object: TheaterGroundObject) -> None: +class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): + def __init__(self, game: Game, ground_object: TgoT, group: GroupT) -> None: self.game = game self.go = ground_object self.position = ground_object.position self.heading = random.randint(0, 359) self.price = 0 - self.vg = unitgroup.VehicleGroup(self.game.next_group_id(), self.go.group_name) - wp = self.vg.add_waypoint(self.position, PointAction.OffRoad, 0) - wp.ETA_locked = True + self.vg: GroupT = group def generate(self) -> None: raise NotImplementedError - def get_generated_group(self) -> GroupType: + def get_generated_group(self) -> GroupT: return self.vg def add_unit( self, - unit_type: Type[VehicleType], + unit_type: UnitTypeT, name: str, pos_x: float, pos_y: float, heading: int, - ) -> Vehicle: + ) -> UnitT: return self.add_unit_to_group( self.vg, unit_type, name, Point(pos_x, pos_y), heading ) def add_unit_to_group( self, - group: GroupType, + group: GroupT, + unit_type: UnitTypeT, + name: str, + position: Point, + heading: int, + ) -> UnitT: + raise NotImplementedError + + +class VehicleGroupGenerator( + Generic[TgoT], GroupGenerator[VehicleGroup, Vehicle, Type[VehicleType], TgoT] +): + def __init__(self, game: Game, ground_object: TgoT) -> None: + super().__init__( + game, + ground_object, + unitgroup.VehicleGroup(self.game.next_group_id(), self.go.group_name), + ) + wp = self.vg.add_waypoint(self.position, PointAction.OffRoad, 0) + wp.ETA_locked = True + + def generate(self) -> None: + raise NotImplementedError + + def add_unit_to_group( + self, + group: VehicleGroup, unit_type: Type[VehicleType], name: str, position: Point, @@ -124,32 +148,34 @@ class GroupGenerator(Generic[GroupType]): return positions -class ShipGroupGenerator(GroupGenerator[ShipGroup]): +class ShipGroupGenerator( + GroupGenerator[ShipGroup, Ship, Type[ShipType], NavalGroundObject] +): """Abstract class for other ship generator classes""" - def __init__( - self, game: Game, ground_object: TheaterGroundObject, faction: Faction - ): - self.game = game - self.go = ground_object - self.position = ground_object.position - self.heading = random.randint(0, 359) + def __init__(self, game: Game, ground_object: NavalGroundObject, faction: Faction): + super().__init__( + game, + ground_object, + unitgroup.ShipGroup(self.game.next_group_id(), self.go.group_name), + ) self.faction = faction - self.vg = unitgroup.ShipGroup(self.game.next_group_id(), self.go.group_name) wp = self.vg.add_waypoint(self.position, 0) wp.ETA_locked = True - def add_unit( + def generate(self) -> None: + raise NotImplementedError + + def add_unit_to_group( self, - unit_type: Type[UnitType], + group: ShipGroup, + unit_type: Type[ShipType], name: str, - pos_x: float, - pos_y: float, + position: Point, heading: int, ) -> Ship: unit = Ship(self.game.next_unit_id(), f"{self.go.group_name}|{name}", unit_type) - unit.position.x = pos_x - unit.position.y = pos_y + unit.position = position unit.heading = heading - self.vg.add_unit(unit) + group.add_unit(unit) return unit diff --git a/gen/sam/sam_sa10.py b/gen/sam/sam_sa10.py index ef633226..6b277bfa 100644 --- a/gen/sam/sam_sa10.py +++ b/gen/sam/sam_sa10.py @@ -1,4 +1,7 @@ +from typing import Type + from dcs.mapping import Point +from dcs.unittype import VehicleType from dcs.vehicles import AirDefence from game import Game @@ -20,13 +23,13 @@ class SA10Generator(AirDefenseGroupGenerator): def __init__(self, game: Game, ground_object: SamGroundObject): super().__init__(game, ground_object) - self.sr1 = AirDefence.S_300PS_40B6MD_sr - self.sr2 = AirDefence.S_300PS_64H6E_sr - self.cp = AirDefence.S_300PS_54K6_cp - self.tr1 = AirDefence.S_300PS_40B6M_tr - self.tr2 = AirDefence.S_300PS_40B6M_tr - self.ln1 = AirDefence.S_300PS_5P85C_ln - self.ln2 = AirDefence.S_300PS_5P85D_ln + self.sr1: Type[VehicleType] = AirDefence.S_300PS_40B6MD_sr + self.sr2: Type[VehicleType] = AirDefence.S_300PS_64H6E_sr + self.cp: Type[VehicleType] = AirDefence.S_300PS_54K6_cp + self.tr1: Type[VehicleType] = AirDefence.S_300PS_40B6M_tr + self.tr2: Type[VehicleType] = AirDefence.S_300PS_40B6M_tr + self.ln1: Type[VehicleType] = AirDefence.S_300PS_5P85C_ln + self.ln2: Type[VehicleType] = AirDefence.S_300PS_5P85D_ln def generate(self) -> None: # Search Radar From 469dd49defabf7f7c607f6dc5f9c6a82beb04c29 Mon Sep 17 00:00:00 2001 From: Brock Greman Date: Fri, 9 Jul 2021 12:38:16 -0400 Subject: [PATCH 042/167] Fixing broken group generation. --- gen/sam/group_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index 7795036e..6a286888 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -77,7 +77,7 @@ class VehicleGroupGenerator( super().__init__( game, ground_object, - unitgroup.VehicleGroup(self.game.next_group_id(), self.go.group_name), + unitgroup.VehicleGroup(game.next_group_id(), ground_object.group_name), ) wp = self.vg.add_waypoint(self.position, PointAction.OffRoad, 0) wp.ETA_locked = True From 96c7b87ac7ef9382ffad9c3b5846fd8c0df8b0a2 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 9 Jul 2021 14:13:20 -0700 Subject: [PATCH 043/167] More adaptation for pydcs updates. This is as much as we can do until pydcs actually adds the py.typed file. Once that's added there are a few ugly monkey patching corners that will just need `# type: ignore` for now, but we can't pre-add those since we have mypy warning us about superfluous ignore comments. --- game/data/weapons.py | 6 +-- game/db.py | 14 +++-- game/dcs/aircrafttype.py | 16 +++++- game/debriefing.py | 6 +-- game/point_with_heading.py | 4 +- game/positioned.py | 9 ++++ game/theater/conflicttheater.py | 80 ++++++++++++++--------------- game/theater/controlpoint.py | 2 +- game/theater/frontline.py | 23 +++++---- game/theater/missiontarget.py | 3 +- game/theater/theatergroundobject.py | 32 +++++------- game/unitdelivery.py | 14 +++-- game/unitmap.py | 39 ++++++++------ gen/aircraft.py | 51 +++++++----------- gen/airsupportgen.py | 16 ++++-- gen/armor.py | 17 +++--- gen/callsigns.py | 3 +- gen/conflictgen.py | 2 + gen/environmentgen.py | 2 +- gen/flights/ai_flight_planner_db.py | 5 +- gen/flights/flight.py | 4 +- gen/flights/flightplan.py | 12 ++--- gen/flights/waypointbuilder.py | 10 ++-- gen/groundobjectsgen.py | 59 ++++++++++----------- gen/naming.py | 7 ++- gen/sam/group_generator.py | 2 +- gen/triggergen.py | 7 ++- 27 files changed, 238 insertions(+), 207 deletions(-) create mode 100644 game/positioned.py diff --git a/game/data/weapons.py b/game/data/weapons.py index b3e3b059..50042a1b 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -5,14 +5,14 @@ import inspect import logging from collections import defaultdict from dataclasses import dataclass, field -from typing import Dict, Iterator, Optional, Set, Tuple, Union, cast, Any +from typing import Dict, Iterator, Optional, Set, Tuple, cast, Any from dcs.unitgroup import FlyingGroup from dcs.weapons_data import Weapons, weapon_ids from game.dcs.aircrafttype import AircraftType -PydcsWeapon = Dict[str, Any] +PydcsWeapon = Any PydcsWeaponAssignment = Tuple[int, PydcsWeapon] @@ -83,7 +83,7 @@ class Pylon: # configuration. return weapon in self.allowed or weapon.cls_id == "" - def equip(self, group: FlyingGroup, weapon: Weapon) -> None: + def equip(self, group: FlyingGroup[Any], weapon: Weapon) -> None: if not self.can_equip(weapon): logging.error(f"Pylon {self.number} cannot equip {weapon.name}") group.load_pylon(self.make_pydcs_assignment(weapon), self.number) diff --git a/game/db.py b/game/db.py index c5554ac8..2504d4f3 100644 --- a/game/db.py +++ b/game/db.py @@ -31,7 +31,7 @@ from dcs.ships import ( from dcs.terrain.terrain import Airport from dcs.unit import Ship from dcs.unitgroup import ShipGroup, StaticGroup -from dcs.unittype import UnitType +from dcs.unittype import UnitType, FlyingType, ShipType, VehicleType from dcs.vehicles import ( vehicle_map, ) @@ -256,7 +256,7 @@ Aircraft livery overrides. Syntax as follows: `Identifier` is aircraft identifier (as used troughout the file) and "LiveryName" (with double quotes) is livery name as found in mission editor. """ -PLANE_LIVERY_OVERRIDES = { +PLANE_LIVERY_OVERRIDES: dict[Type[FlyingType], str] = { FA_18C_hornet: "VFA-34", # default livery for the hornet is blue angels one } @@ -329,7 +329,7 @@ REWARDS = { StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point] -def upgrade_to_supercarrier(unit: Type[Ship], name: str) -> Type[Ship]: +def upgrade_to_supercarrier(unit: Type[ShipType], name: str) -> Type[ShipType]: if unit == Stennis: if name == "CVN-71 Theodore Roosevelt": return CVN_71 @@ -362,6 +362,14 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]: return None +def vehicle_type_from_name(name: str) -> Type[VehicleType]: + return vehicle_map[name] + + +def ship_type_from_name(name: str) -> Type[ShipType]: + return ship_map[name] + + def country_id_from_name(name: str) -> int: for k, v in country_dict.items(): if v.name == name: diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index a851abfd..dd9b5282 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -105,6 +105,7 @@ class PatrolConfig: ) +# TODO: Split into PlaneType and HelicopterType? @dataclass(frozen=True) class AircraftType(UnitType[Type[FlyingType]]): carrier_capable: bool @@ -144,12 +145,23 @@ class AircraftType(UnitType[Type[FlyingType]]): return kph(self.dcs_unit_type.max_speed) def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency: - from gen.radios import ChannelInUseError, MHz + from gen.radios import ChannelInUseError, kHz if self.intra_flight_radio is not None: return radio_registry.alloc_for_radio(self.intra_flight_radio) - freq = MHz(self.dcs_unit_type.radio_frequency) + # The default radio frequency is set in megahertz. For some aircraft, it is a + # floating point value. For all current aircraft, adjusting to kilohertz will be + # sufficient to convert to an integer. + in_khz = float(self.dcs_unit_type.radio_frequency) * 1000 + if not in_khz.is_integer(): + logging.warning( + f"Found unexpected sub-kHz default radio for {self}: {in_khz} kHz. " + "Truncating to integer. The truncated frequency may not be valid for " + "the aircraft." + ) + + freq = kHz(int(in_khz)) try: radio_registry.reserve(freq) except ChannelInUseError: diff --git a/game/debriefing.py b/game/debriefing.py index 21927d8e..e4e3bf0f 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -78,8 +78,8 @@ class GroundLosses: player_airlifts: List[AirliftUnits] = field(default_factory=list) enemy_airlifts: List[AirliftUnits] = field(default_factory=list) - player_ground_objects: List[GroundObjectUnit] = field(default_factory=list) - enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list) + player_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list) + enemy_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list) player_buildings: List[Building] = field(default_factory=list) enemy_buildings: List[Building] = field(default_factory=list) @@ -166,7 +166,7 @@ class Debriefing: yield from self.ground_losses.enemy_airlifts @property - def ground_object_losses(self) -> Iterator[GroundObjectUnit]: + def ground_object_losses(self) -> Iterator[GroundObjectUnit[Any]]: yield from self.ground_losses.player_ground_objects yield from self.ground_losses.enemy_ground_objects diff --git a/game/point_with_heading.py b/game/point_with_heading.py index 69d62e9c..a87914a1 100644 --- a/game/point_with_heading.py +++ b/game/point_with_heading.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dcs import Point @@ -7,7 +9,7 @@ class PointWithHeading(Point): self.heading = 0 @staticmethod - def from_point(point: Point, heading: int) -> Point: + def from_point(point: Point, heading: int) -> PointWithHeading: p = PointWithHeading() p.x = point.x p.y = point.y diff --git a/game/positioned.py b/game/positioned.py new file mode 100644 index 00000000..09952351 --- /dev/null +++ b/game/positioned.py @@ -0,0 +1,9 @@ +from typing import Protocol + +from dcs import Point + + +class Positioned(Protocol): + @property + def position(self) -> Point: + raise NotImplementedError diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 29400a16..e0f4d69a 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -33,11 +33,10 @@ from dcs.terrain import ( ) from dcs.terrain.terrain import Airport, Terrain from dcs.unitgroup import ( - FlyingGroup, - Group, ShipGroup, StaticGroup, VehicleGroup, + PlaneGroup, ) from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed from pyproj import CRS, Transformer @@ -57,6 +56,7 @@ from .landmap import Landmap, load_landmap, poly_contains from .latlon import LatLon from .projections import TransverseMercator from ..point_with_heading import PointWithHeading +from ..positioned import Positioned from ..profiling import logged_duration from ..scenery_group import SceneryGroup from ..utils import Distance, meters @@ -185,7 +185,7 @@ class MizCampaignLoader: def red(self) -> Country: return self.country(blue=False) - def off_map_spawns(self, blue: bool) -> Iterator[FlyingGroup]: + def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]: for group in self.country(blue).plane_group: if group.units[0].type == self.OFF_MAP_UNIT_TYPE: yield group @@ -309,26 +309,26 @@ class MizCampaignLoader: control_point.captured = blue control_point.captured_invert = group.late_activation control_points[control_point.id] = control_point - for group in self.carriers(blue): + for ship in self.carriers(blue): # TODO: Name the carrier. control_point = Carrier( - "carrier", group.position, next(self.control_point_id) + "carrier", ship.position, next(self.control_point_id) ) control_point.captured = blue - control_point.captured_invert = group.late_activation + control_point.captured_invert = ship.late_activation control_points[control_point.id] = control_point - for group in self.lhas(blue): + for ship in self.lhas(blue): # TODO: Name the LHA.db - control_point = Lha("lha", group.position, next(self.control_point_id)) + control_point = Lha("lha", ship.position, next(self.control_point_id)) control_point.captured = blue - control_point.captured_invert = group.late_activation + control_point.captured_invert = ship.late_activation control_points[control_point.id] = control_point - for group in self.fobs(blue): + for fob in self.fobs(blue): control_point = Fob( - str(group.name), group.position, next(self.control_point_id) + str(fob.name), fob.position, next(self.control_point_id) ) control_point.captured = blue - control_point.captured_invert = group.late_activation + control_point.captured_invert = fob.late_activation control_points[control_point.id] = control_point return control_points @@ -389,22 +389,22 @@ class MizCampaignLoader: origin, list(reversed(waypoints)) ) - def objective_info(self, group: Group) -> Tuple[ControlPoint, Distance]: - closest = self.theater.closest_control_point(group.position) - distance = meters(closest.position.distance_to_point(group.position)) + def objective_info(self, near: Positioned) -> Tuple[ControlPoint, Distance]: + closest = self.theater.closest_control_point(near.position) + distance = meters(closest.position.distance_to_point(near.position)) return closest, distance def add_preset_locations(self) -> None: - for group in self.offshore_strike_targets: - closest, distance = self.objective_info(group) + for static in self.offshore_strike_targets: + closest, distance = self.objective_info(static) closest.preset_locations.offshore_strike_locations.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point(static.position, static.units[0].heading) ) - for group in self.ships: - closest, distance = self.objective_info(group) + for ship in self.ships: + closest, distance = self.objective_info(ship) closest.preset_locations.ships.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point(ship.position, ship.units[0].heading) ) for group in self.missile_sites: @@ -455,33 +455,33 @@ class MizCampaignLoader: PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.helipads: - closest, distance = self.objective_info(group) + for static in self.helipads: + closest, distance = self.objective_info(static) closest.helipads.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point(static.position, static.units[0].heading) ) - for group in self.factories: - closest, distance = self.objective_info(group) + for static in self.factories: + closest, distance = self.objective_info(static) closest.preset_locations.factories.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point(static.position, static.units[0].heading) ) - for group in self.ammunition_depots: - closest, distance = self.objective_info(group) + for static in self.ammunition_depots: + closest, distance = self.objective_info(static) closest.preset_locations.ammunition_depots.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point(static.position, static.units[0].heading) ) - for group in self.strike_targets: - closest, distance = self.objective_info(group) + for static in self.strike_targets: + closest, distance = self.objective_info(static) closest.preset_locations.strike_locations.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point(static.position, static.units[0].heading) ) - for group in self.scenery: - closest, distance = self.objective_info(group) - closest.preset_locations.scenery.append(group) + for scenery_group in self.scenery: + closest, distance = self.objective_info(scenery_group) + closest.preset_locations.scenery.append(scenery_group) def populate_theater(self) -> None: for control_point in self.control_points.values(): @@ -587,12 +587,12 @@ class ConflictTheater: return True - def nearest_land_pos(self, point: Point, extend_dist: int = 50) -> Point: + def nearest_land_pos(self, near: Point, extend_dist: int = 50) -> Point: """Returns the nearest point inside a land exclusion zone from point `extend_dist` determines how far inside the zone the point should be placed""" - if self.is_on_land(point): - return point - point = geometry.Point(point.x, point.y) + if self.is_on_land(near): + return near + point = geometry.Point(near.x, near.y) nearest_points = [] if not self.landmap: raise RuntimeError("Landmap not initialized") diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index ea5d981a..ecf77341 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -751,7 +751,7 @@ class ControlPoint(MissionTarget, ABC): return len([obj for obj in self.connected_objectives if obj.category == "ammo"]) @property - def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: return [] @property diff --git a/game/theater/frontline.py b/game/theater/frontline.py index 7002913f..c8f2fd6b 100644 --- a/game/theater/frontline.py +++ b/game/theater/frontline.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from dataclasses import dataclass -from typing import Iterator, List, Tuple +from typing import Iterator, List, Tuple, Any from dcs.mapping import Point @@ -66,7 +66,15 @@ class FrontLine(MissionTarget): self.segments: List[FrontLineSegment] = [ FrontLineSegment(a, b) for a, b in pairwise(route) ] - self.name = f"Front line {blue_point}/{red_point}" + super().__init__( + f"Front line {blue_point}/{red_point}", + self.point_from_a(self._position_distance), + ) + + def __setstate__(self, state: dict[str, Any]) -> None: + self.__dict__.update(state) + if not hasattr(self, "position"): + self.position = self.point_from_a(self._position_distance) def control_point_hostile_to(self, player: bool) -> ControlPoint: if player: @@ -87,14 +95,6 @@ class FrontLine(MissionTarget): ] yield from super().mission_types(for_player) - @property - def position(self) -> Point: - """ - The position where the conflict should occur - according to the current strength of each control point. - """ - return self.point_from_a(self._position_distance) - @property def points(self) -> Iterator[Point]: yield self.segments[0].point_a @@ -149,6 +149,9 @@ class FrontLine(MissionTarget): ) else: remaining_dist -= segment.attack_distance + raise RuntimeError( + f"Could not find front line point {distance} from {self.blue_cp}" + ) @property def _position_distance(self) -> float: diff --git a/game/theater/missiontarget.py b/game/theater/missiontarget.py index ea426603..813172fa 100644 --- a/game/theater/missiontarget.py +++ b/game/theater/missiontarget.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import Sequence from typing import Iterator, TYPE_CHECKING, List, Union from dcs.mapping import Point @@ -45,5 +46,5 @@ class MissionTarget: ] @property - def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: return [] diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index a49b63ab..d9aab7ea 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -2,13 +2,13 @@ from __future__ import annotations import itertools import logging +from collections import Sequence from typing import Iterator, List, TYPE_CHECKING, Union, Generic, TypeVar, Any from dcs.mapping import Point from dcs.triggers import TriggerZone from dcs.unit import Unit -from dcs.unitgroup import Group, ShipGroup, VehicleGroup -from dcs.unittype import VehicleType +from dcs.unitgroup import ShipGroup, VehicleGroup from .. import db from ..data.radar_db import ( @@ -47,7 +47,7 @@ NAME_BY_CATEGORY = { } -GroupT = TypeVar("GroupT", bound=Group) +GroupT = TypeVar("GroupT", ShipGroup, VehicleGroup) class TheaterGroundObject(MissionTarget, Generic[GroupT]): @@ -150,7 +150,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): return True return False - def _max_range_of_type(self, group: Group, range_type: str) -> Distance: + def _max_range_of_type(self, group: GroupT, range_type: str) -> Distance: if not self.might_have_aa: return meters(0) @@ -171,13 +171,13 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): def max_detection_range(self) -> Distance: return max(self.detection_range(g) for g in self.groups) - def detection_range(self, group: Group) -> Distance: + def detection_range(self, group: GroupT) -> Distance: return self._max_range_of_type(group, "detection_range") def max_threat_range(self) -> Distance: return max(self.threat_range(g) for g in self.groups) - def threat_range(self, group: Group, radar_only: bool = False) -> Distance: + def threat_range(self, group: GroupT, radar_only: bool = False) -> Distance: return self._max_range_of_type(group, "threat_range") @property @@ -190,7 +190,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): return False @property - def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: return self.units @property @@ -497,33 +497,25 @@ class SamGroundObject(TheaterGroundObject[VehicleGroup]): def might_have_aa(self) -> bool: return True - def threat_range(self, group: Group, radar_only: bool = False) -> Distance: + def threat_range(self, group: VehicleGroup, radar_only: bool = False) -> Distance: max_non_radar = meters(0) live_trs = set() max_telar_range = meters(0) launchers = set() for unit in group.units: - unit_type = db.unit_type_from_name(unit.type) - if unit_type is None or not issubclass(unit_type, VehicleType): - continue + unit_type = db.vehicle_type_from_name(unit.type) if unit_type in TRACK_RADARS: live_trs.add(unit_type) elif unit_type in TELARS: - max_telar_range = max( - max_telar_range, meters(getattr(unit_type, "threat_range", 0)) - ) + max_telar_range = max(max_telar_range, meters(unit_type.threat_range)) elif unit_type in LAUNCHER_TRACKER_PAIRS: launchers.add(unit_type) else: - max_non_radar = max( - max_non_radar, meters(getattr(unit_type, "threat_range", 0)) - ) + max_non_radar = max(max_non_radar, meters(unit_type.threat_range)) max_tel_range = meters(0) for launcher in launchers: if LAUNCHER_TRACKER_PAIRS[launcher] in live_trs: - max_tel_range = max( - max_tel_range, meters(getattr(launcher, "threat_range")) - ) + max_tel_range = max(max_tel_range, meters(unit_type.threat_range)) if radar_only: return max(max_tel_range, max_telar_range) else: diff --git a/game/unitdelivery.py b/game/unitdelivery.py index dfbc2409..ff7841c6 100644 --- a/game/unitdelivery.py +++ b/game/unitdelivery.py @@ -5,8 +5,6 @@ from collections import defaultdict from dataclasses import dataclass from typing import Optional, TYPE_CHECKING, Any -from dcs.unittype import UnitType as DcsUnitType - from game.theater import ControlPoint from .dcs.groundunittype import GroundUnitType from .dcs.unittype import UnitType @@ -48,27 +46,27 @@ class PendingUnitDeliveries: self.units = defaultdict(int) def refund_ground_units(self, game: Game) -> None: - ground_units: dict[UnitType[DcsUnitType], int] = { + ground_units: dict[UnitType[Any], int] = { u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType) } self.refund(game, ground_units) for gu in ground_units.keys(): del self.units[gu] - def refund(self, game: Game, units: dict[UnitType[DcsUnitType], int]) -> None: + def refund(self, game: Game, units: dict[UnitType[Any], int]) -> None: for unit_type, count in units.items(): logging.info(f"Refunding {count} {unit_type} at {self.destination.name}") game.adjust_budget( unit_type.price * count, player=self.destination.captured ) - def pending_orders(self, unit_type: UnitType[DcsUnitType]) -> int: + def pending_orders(self, unit_type: UnitType[Any]) -> 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: UnitType[DcsUnitType]) -> int: + def available_next_turn(self, unit_type: UnitType[Any]) -> int: current_units = self.destination.base.total_units_of_type(unit_type) return self.pending_orders(unit_type) + current_units @@ -81,9 +79,9 @@ class PendingUnitDeliveries: ) self.refund_ground_units(game) - bought_units: dict[UnitType[DcsUnitType], int] = {} + bought_units: dict[UnitType[Any], int] = {} units_needing_transfer: dict[GroundUnitType, int] = {} - sold_units: dict[UnitType[DcsUnitType], int] = {} + sold_units: dict[UnitType[Any], int] = {} for unit_type, count in self.units.items(): coalition = "Ally" if self.destination.captured else "Enemy" d: dict[Any, int] diff --git a/game/unitmap.py b/game/unitmap.py index a0485a9c..a1d5c110 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -2,10 +2,10 @@ import itertools import math from dataclasses import dataclass -from typing import Dict, Optional, Any +from typing import Dict, Optional, Any, Union, TypeVar, Generic -from dcs.unit import Unit -from dcs.unitgroup import FlyingGroup, Group, VehicleGroup +from dcs.unit import Vehicle, Ship +from dcs.unitgroup import FlyingGroup, VehicleGroup, StaticGroup, ShipGroup, MovingGroup from game.dcs.groundunittype import GroundUnitType from game.squadrons import Pilot @@ -27,11 +27,14 @@ class FrontLineUnit: origin: ControlPoint +UnitT = TypeVar("UnitT", Ship, Vehicle) + + @dataclass(frozen=True) -class GroundObjectUnit: +class GroundObjectUnit(Generic[UnitT]): ground_object: TheaterGroundObject[Any] - group: Group - unit: Unit + group: MovingGroup[UnitT] + unit: UnitT @dataclass(frozen=True) @@ -56,13 +59,13 @@ class UnitMap: self.aircraft: Dict[str, FlyingUnit] = {} self.airfields: Dict[str, Airfield] = {} self.front_line_units: Dict[str, FrontLineUnit] = {} - self.ground_object_units: Dict[str, GroundObjectUnit] = {} + self.ground_object_units: Dict[str, GroundObjectUnit[Any]] = {} self.buildings: Dict[str, Building] = {} self.convoys: Dict[str, ConvoyUnit] = {} self.cargo_ships: Dict[str, CargoShip] = {} self.airlifts: Dict[str, AirliftUnits] = {} - def add_aircraft(self, group: FlyingGroup, flight: Flight) -> None: + def add_aircraft(self, group: FlyingGroup[Any], flight: Flight) -> None: for pilot, unit in zip(flight.roster.pilots, group.units): # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. @@ -85,7 +88,7 @@ class UnitMap: return self.airfields.get(name, None) def add_front_line_units( - self, group: Group, origin: ControlPoint, unit_type: GroundUnitType + self, group: VehicleGroup, origin: ControlPoint, unit_type: GroundUnitType ) -> None: for unit in group.units: # The actual name is a String (the pydcs translatable string), which @@ -101,8 +104,8 @@ class UnitMap: def add_ground_object_units( self, ground_object: TheaterGroundObject[Any], - persistence_group: Group, - miz_group: Group, + persistence_group: Union[ShipGroup, VehicleGroup], + miz_group: Union[ShipGroup, VehicleGroup], ) -> None: """Adds a group associated with a TGO to the unit map. @@ -131,10 +134,10 @@ class UnitMap: ground_object, persistence_group, persistent_unit ) - def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit]: + def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit[Any]]: return self.ground_object_units.get(name, None) - def add_convoy_units(self, group: Group, convoy: Convoy) -> None: + def add_convoy_units(self, group: VehicleGroup, convoy: Convoy) -> None: for unit, unit_type in zip(group.units, convoy.iter_units()): # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. @@ -146,7 +149,7 @@ class UnitMap: def convoy_unit(self, name: str) -> Optional[ConvoyUnit]: return self.convoys.get(name, None) - def add_cargo_ship(self, group: Group, ship: CargoShip) -> None: + def add_cargo_ship(self, group: ShipGroup, ship: CargoShip) -> None: if len(group.units) > 1: # Cargo ship "groups" are single units. Killing the one ship kills the whole # transfer. If we ever want to add escorts or create multiple cargo ships in @@ -163,7 +166,9 @@ class UnitMap: def cargo_ship(self, name: str) -> Optional[CargoShip]: return self.cargo_ships.get(name, None) - def add_airlift_units(self, group: FlyingGroup, transfer: TransferOrder) -> None: + def add_airlift_units( + self, group: FlyingGroup[Any], transfer: TransferOrder + ) -> None: capacity_each = math.ceil(transfer.size / len(group.units)) for idx, transport in enumerate(group.units): # Slice the units in groups based on the capacity of each unit. Cargo is @@ -186,7 +191,9 @@ class UnitMap: def airlift_unit(self, name: str) -> Optional[AirliftUnits]: return self.airlifts.get(name, None) - def add_building(self, ground_object: BuildingGroundObject, group: Group) -> None: + def add_building( + self, ground_object: BuildingGroundObject, group: StaticGroup + ) -> None: # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. # The name of the initiator in the DCS dead event will have " object" diff --git a/gen/aircraft.py b/gen/aircraft.py index 3fa1b94d..c0caa0a0 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -321,7 +321,7 @@ class AircraftConflictGenerator: @staticmethod def livery_from_db(flight: Flight) -> Optional[str]: - return db.PLANE_LIVERY_OVERRIDES.get(flight.unit_type) + return db.PLANE_LIVERY_OVERRIDES.get(flight.unit_type.dcs_unit_type) def livery_from_faction(self, flight: Flight) -> Optional[str]: faction = self.game.faction_for(player=flight.departure.captured) @@ -342,7 +342,7 @@ class AircraftConflictGenerator: return livery return None - def _setup_livery(self, flight: Flight, group: FlyingGroup) -> None: + def _setup_livery(self, flight: Flight, group: FlyingGroup[Any]) -> None: livery = self.livery_for(flight) if livery is None: return @@ -458,8 +458,8 @@ class AircraftConflictGenerator: unit_type: Type[FlyingType], count: int, start_type: str, - airport: Optional[Airport] = None, - ) -> FlyingGroup: + airport: Airport, + ) -> FlyingGroup[Any]: assert count > 0 logging.info("airgen: {} for {} at {}".format(unit_type, side.id, airport)) @@ -476,7 +476,7 @@ class AircraftConflictGenerator: def _generate_inflight( self, name: str, side: Country, flight: Flight, origin: ControlPoint - ) -> FlyingGroup: + ) -> FlyingGroup[Any]: assert flight.count > 0 at = origin.position @@ -521,7 +521,7 @@ class AircraftConflictGenerator: count: int, start_type: str, at: Union[ShipGroup, StaticGroup], - ) -> FlyingGroup: + ) -> FlyingGroup[Any]: assert count > 0 logging.info("airgen: {} for {} at unit {}".format(unit_type, side.id, at)) @@ -546,27 +546,6 @@ class AircraftConflictGenerator: point.alt_type = "RADIO" return point - def _rtb_for( - self, - group: FlyingGroup[Any], - cp: ControlPoint, - at: Optional[db.StartingPosition] = None, - ) -> MovingPoint: - if at is None: - at = cp.at - position = at if isinstance(at, Point) else at.position - - last_waypoint = group.points[-1] - if last_waypoint is not None: - heading = position.heading_between_point(last_waypoint.position) - tod_location = position.point_from_heading(heading, RTB_DISTANCE) - self._add_radio_waypoint(group, tod_location, last_waypoint.alt) - - destination_waypoint = self._add_radio_waypoint(group, position, RTB_ALTITUDE) - if isinstance(at, Airport): - group.land_at(at) - return destination_waypoint - @staticmethod def _at_position(at: Union[Point, ShipGroup, Type[Airport]]) -> Point: if isinstance(at, Point): @@ -578,7 +557,7 @@ class AircraftConflictGenerator: else: assert False - def _setup_payload(self, flight: Flight, group: FlyingGroup) -> None: + def _setup_payload(self, flight: Flight, group: FlyingGroup[Any]) -> None: for p in group.units: p.pylons.clear() @@ -729,7 +708,7 @@ class AircraftConflictGenerator: def generate_planned_flight( self, cp: ControlPoint, country: Country, flight: Flight - ) -> FlyingGroup: + ) -> FlyingGroup[Any]: name = namegen.next_aircraft_name(country, cp.id, flight) try: if flight.start_type == "In Flight": @@ -738,13 +717,19 @@ class AircraftConflictGenerator: ) elif isinstance(cp, NavalControlPoint): group_name = cp.get_carrier_group_name() + carrier_group = self.m.find_group(group_name) + if not isinstance(carrier_group, ShipGroup): + raise RuntimeError( + f"Carrier group {carrier_group} is a " + "{carrier_group.__class__.__name__}, expected a ShipGroup" + ) group = self._generate_at_group( name=name, side=country, unit_type=flight.unit_type.dcs_unit_type, count=flight.count, start_type=flight.start_type, - at=self.m.find_group(group_name), + at=carrier_group, ) else: if not isinstance(cp, Airfield): @@ -775,7 +760,7 @@ class AircraftConflictGenerator: @staticmethod def set_reduced_fuel( - flight: Flight, group: FlyingGroup[Any], unit_type: Type[PlaneType] + flight: Flight, group: FlyingGroup[Any], unit_type: Type[FlyingType] ) -> None: if unit_type is Su_33: for unit in group.units: @@ -803,7 +788,7 @@ class AircraftConflictGenerator: flight: Flight, group: FlyingGroup[Any], react_on_threat: Optional[OptReactOnThreat.Values] = None, - roe: Optional[OptROE.Values] = None, + roe: Optional[int] = None, rtb_winchester: Optional[OptRTBOnOutOfAmmo.Values] = None, restrict_jettison: Optional[bool] = None, mission_uses_gun: bool = True, @@ -1438,7 +1423,7 @@ class CasIngressBuilder(PydcsWaypointBuilder): if isinstance(self.flight.flight_plan, CasFlightPlan): waypoint.add_task( EngageTargetsInZone( - position=self.flight.flight_plan.target, + position=self.flight.flight_plan.target.position, radius=int(self.flight.flight_plan.engagement_distance.meters), targets=[ Targets.All.GroundUnits.GroundVehicles, diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 3bb95d26..37fc30bb 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -6,7 +6,7 @@ from datetime import timedelta from typing import List, Type, Tuple, Optional, TYPE_CHECKING from dcs.mission import Mission, StartType -from dcs.planes import IL_78M, KC130, KC135MPRS, KC_135 +from dcs.planes import IL_78M, KC130, KC135MPRS, KC_135, PlaneType from dcs.task import ( AWACS, ActivateBeaconCommand, @@ -111,6 +111,11 @@ class AirSupportConflictGenerator: for i, tanker_unit_type in enumerate( self.game.faction_for(player=True).tankers ): + unit_type = tanker_unit_type.dcs_unit_type + if not issubclass(unit_type, PlaneType): + logging.warning(f"Refueling aircraft {unit_type} must be a plane") + continue + # TODO: Make loiter altitude a property of the unit type. alt, airspeed = self._get_tanker_params(tanker_unit_type.dcs_unit_type) freq = self.radio_registry.alloc_uhf() @@ -130,7 +135,7 @@ class AirSupportConflictGenerator: self.mission.country(self.game.player_country), tanker_unit_type ), airport=None, - plane_type=tanker_unit_type.dcs_unit_type, + plane_type=unit_type, position=tanker_position, altitude=alt, race_distance=58000, @@ -200,12 +205,17 @@ class AirSupportConflictGenerator: awacs_unit = possible_awacs[0] freq = self.radio_registry.alloc_uhf() + unit_type = awacs_unit.dcs_unit_type + if not issubclass(unit_type, PlaneType): + logging.warning(f"AWACS aircraft {unit_type} must be a plane") + return + awacs_flight = self.mission.awacs_flight( country=self.mission.country(self.game.player_country), name=namegen.next_awacs_name( self.mission.country(self.game.player_country) ), - plane_type=awacs_unit, + plane_type=unit_type, altitude=AWACS_ALT, airport=None, position=self.conflict.position.random_point_within( diff --git a/gen/armor.py b/gen/armor.py index 5fc691d8..2777e585 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -23,7 +23,7 @@ from dcs.task import ( SetInvisibleCommand, ) from dcs.triggers import Event, TriggerOnce -from dcs.unit import Vehicle +from dcs.unit import Vehicle, Skill from dcs.unitgroup import VehicleGroup from game.data.groundunitclass import GroundUnitClass @@ -359,7 +359,6 @@ class GroundConflictGenerator: self.mission.triggerrules.triggers.append(artillery_fallback) for u in dcs_group.units: - u.initial = True u.heading = forward_heading + random.randint(-5, 5) return True return False @@ -568,10 +567,10 @@ class GroundConflictGenerator: ) # Fallback task - fallback = ControlledTask(GoToWaypoint(to_index=len(dcs_group.points))) - fallback.enabled = False + task = ControlledTask(GoToWaypoint(to_index=len(dcs_group.points))) + task.enabled = False dcs_group.add_trigger_action(Hold()) - dcs_group.add_trigger_action(fallback) + dcs_group.add_trigger_action(task) # Create trigger fallback = TriggerOnce(Event.NoEvent, "Morale manager #" + str(dcs_group.id)) @@ -632,7 +631,7 @@ class GroundConflictGenerator: @param enemy_groups Potential enemy groups @param n number of nearby groups to take """ - targets = [] # type: List[Optional[VehicleGroup]] + targets = [] # type: List[VehicleGroup] sorted_list = sorted( enemy_groups, key=lambda group: player_group.points[0].position.distance_to_point( @@ -714,7 +713,7 @@ class GroundConflictGenerator: distance_from_frontline: int, heading: int, spawn_heading: int, - ) -> Point: + ) -> Optional[Point]: shifted = conflict_position.point_from_heading( heading, random.randint(0, combat_width) ) @@ -764,9 +763,9 @@ class GroundConflictGenerator: heading=opposite_heading(spawn_heading), ) if is_player: - g.set_skill(self.game.settings.player_skill) + g.set_skill(Skill(self.game.settings.player_skill)) else: - g.set_skill(self.game.settings.enemy_vehicle_skill) + g.set_skill(Skill(self.game.settings.enemy_vehicle_skill)) positioned_groups.append((g, group)) if group.role in [CombatGroupRole.APC, CombatGroupRole.IFV]: diff --git a/gen/callsigns.py b/gen/callsigns.py index 8ebda467..a722606f 100644 --- a/gen/callsigns.py +++ b/gen/callsigns.py @@ -1,12 +1,13 @@ """Support for working with DCS group callsigns.""" import logging import re +from typing import Any from dcs.unitgroup import FlyingGroup from dcs.flyingunit import FlyingUnit -def callsign_for_support_unit(group: FlyingGroup) -> str: +def callsign_for_support_unit(group: FlyingGroup[Any]) -> str: # Either something like Overlord11 for Western AWACS, or else just a number. # Convert to either "Overlord" or "Flight 123". lead = group.units[0] diff --git a/gen/conflictgen.py b/gen/conflictgen.py index 723898cc..5576805a 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -63,6 +63,8 @@ class Conflict: heading_sum(attack_heading, 90), theater, ) + if position is None: + raise RuntimeError("Could not find front line position") return position, opposite_heading(attack_heading) @classmethod diff --git a/gen/environmentgen.py b/gen/environmentgen.py index 65e053ed..cd4d09ff 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -22,7 +22,7 @@ class EnvironmentGenerator: def set_fog(self, fog: Optional[Fog]) -> None: if fog is None: return - self.mission.weather.fog_visibility = fog.visibility.meters + self.mission.weather.fog_visibility = int(fog.visibility.meters) self.mission.weather.fog_thickness = fog.thickness def set_wind(self, wind: WindConditions) -> None: diff --git a/gen/flights/ai_flight_planner_db.py b/gen/flights/ai_flight_planner_db.py index 6ed26bd4..9569cfaa 100644 --- a/gen/flights/ai_flight_planner_db.py +++ b/gen/flights/ai_flight_planner_db.py @@ -1,5 +1,6 @@ import logging -from typing import List, Type +from collections import Sequence +from typing import Type from dcs.helicopters import ( AH_1W, @@ -415,7 +416,7 @@ REFUELING_CAPABALE = [ ] -def dcs_types_for_task(task: FlightType) -> list[Type[FlyingType]]: +def dcs_types_for_task(task: FlightType) -> Sequence[Type[FlyingType]]: cap_missions = (FlightType.BARCAP, FlightType.TARCAP, FlightType.SWEEP) if task in cap_missions: return CAP_CAPABLE diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 855fad95..05ee4c19 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta from enum import Enum -from typing import List, Optional, TYPE_CHECKING, Union +from typing import List, Optional, TYPE_CHECKING, Union, Sequence from dcs.mapping import Point from dcs.point import MovingPoint, PointAction @@ -153,7 +153,7 @@ class FlightWaypoint: # Only used in the waypoint list in the flight edit page. No sense # having three names. A short and long form is enough. self.description = "" - self.targets: List[Union[MissionTarget, Unit]] = [] + self.targets: Sequence[Union[MissionTarget, Unit]] = [] self.obj_name = "" self.pretty_name = "" self.only_for_player = False diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index d7ad8b9e..afc8657f 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -1084,22 +1084,22 @@ class FlightPlanBuilder: patrol_alt = feet(25000) builder = WaypointBuilder(flight, self.game, self.is_player) - orbit_location = builder.orbit(orbit_location, patrol_alt) + orbit = builder.orbit(orbit_location, patrol_alt) return AwacsFlightPlan( package=self.package, flight=flight, takeoff=builder.takeoff(flight.departure), nav_to=builder.nav_path( - flight.departure.position, orbit_location.position, patrol_alt + flight.departure.position, orbit.position, patrol_alt ), nav_from=builder.nav_path( - orbit_location.position, flight.arrival.position, patrol_alt + orbit.position, flight.arrival.position, patrol_alt ), land=builder.land(flight.arrival), divert=builder.divert(flight.divert), bullseye=builder.bullseye(), - hold=orbit_location, + hold=orbit, hold_duration=timedelta(hours=4), ) @@ -1167,7 +1167,7 @@ class FlightPlanBuilder: if isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) - start, end = self.racetrack_for_objective(location, barcap=True) + start_pos, end_pos = self.racetrack_for_objective(location, barcap=True) patrol_alt = meters( random.randint( int(self.doctrine.min_patrol_altitude.meters), @@ -1176,7 +1176,7 @@ class FlightPlanBuilder: ) builder = WaypointBuilder(flight, self.game, self.is_player) - start, end = builder.race_track(start, end, patrol_alt) + start, end = builder.race_track(start_pos, end_pos, patrol_alt) return BarCapFlightPlan( package=self.package, diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index f861b4b8..e911bebd 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -15,7 +15,7 @@ from typing import ( from dcs.mapping import Point from dcs.unit import Unit -from dcs.unitgroup import Group, VehicleGroup +from dcs.unitgroup import Group, VehicleGroup, ShipGroup if TYPE_CHECKING: from game import Game @@ -35,7 +35,7 @@ from .flight import Flight, FlightWaypoint, FlightWaypointType class StrikeTarget: name: str target: Union[ - VehicleGroup, TheaterGroundObject[Any], Unit, Group, MultiGroupTransport + VehicleGroup, TheaterGroundObject[Any], Unit, ShipGroup, MultiGroupTransport ] @@ -444,7 +444,7 @@ class WaypointBuilder: # description in gen.aircraft.JoinPointBuilder), so instead we give # the escort flights a flight plan including the ingress point, target # area, and egress point. - ingress = self.ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target) + ingress_wp = self.ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target) waypoint = FlightWaypoint( FlightWaypointType.TARGET_GROUP_LOC, @@ -458,8 +458,8 @@ class WaypointBuilder: waypoint.description = "Escort the package" waypoint.pretty_name = "Target area" - egress = self.egress(egress, target) - return ingress, waypoint, egress + egress_wp = self.egress(egress, target) + return ingress_wp, waypoint, egress_wp @staticmethod def pickup(control_point: ControlPoint) -> FlightWaypoint: diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index e14544e2..ad6d0262 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -19,12 +19,13 @@ from typing import ( TypeVar, Any, Generic, + Union, ) from dcs import Mission, Point, unitgroup from dcs.action import SceneryDestructionZone from dcs.country import Country -from dcs.point import StaticPoint +from dcs.point import StaticPoint, MovingPoint from dcs.statics import Fortification, fortification_map, warehouse_map from dcs.task import ( ActivateBeaconCommand, @@ -36,12 +37,12 @@ from dcs.task import ( from dcs.triggers import TriggerStart, TriggerZone from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad from dcs.unitgroup import Group, ShipGroup, StaticGroup, VehicleGroup -from dcs.unittype import StaticType, UnitType +from dcs.unittype import StaticType, UnitType, ShipType, VehicleType from dcs.vehicles import vehicle_map from game import db from game.data.building_data import FORTIFICATION_UNITS, FORTIFICATION_UNITS_ID -from game.db import unit_type_from_name +from game.db import unit_type_from_name, ship_type_from_name, vehicle_type_from_name from game.theater import ControlPoint, TheaterGroundObject from game.theater.theatergroundobject import ( BuildingGroundObject, @@ -102,10 +103,7 @@ class GenericGroundObjectGenerator(Generic[TgoT]): logging.warning(f"Found empty group in {self.ground_object}") continue - unit_type = unit_type_from_name(group.units[0].type) - if unit_type is None: - raise RuntimeError(f"Unrecognized unit type: {group.units[0].type}") - + unit_type = vehicle_type_from_name(group.units[0].type) vg = self.m.vehicle_group( self.country, group.name, @@ -129,18 +127,21 @@ class GenericGroundObjectGenerator(Generic[TgoT]): self._register_unit_group(group, vg) @staticmethod - def enable_eplrs(group: Group, unit_type: Type[UnitType]) -> None: - if hasattr(unit_type, "eplrs"): - if unit_type.eplrs: - group.points[0].tasks.append(EPLRS(group.id)) + def enable_eplrs(group: VehicleGroup, unit_type: Type[VehicleType]) -> None: + if unit_type.eplrs: + group.points[0].tasks.append(EPLRS(group.id)) - def set_alarm_state(self, group: Group) -> None: + def set_alarm_state(self, group: Union[ShipGroup, VehicleGroup]) -> None: if self.game.settings.perf_red_alert_state: group.points[0].tasks.append(OptAlarmState(2)) else: group.points[0].tasks.append(OptAlarmState(1)) - def _register_unit_group(self, persistence_group: Group, miz_group: Group) -> None: + def _register_unit_group( + self, + persistence_group: Union[ShipGroup, VehicleGroup], + miz_group: Union[ShipGroup, VehicleGroup], + ) -> None: self.unit_map.add_ground_object_units( self.ground_object, persistence_group, miz_group ) @@ -161,7 +162,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject] for group in self.ground_object.groups: vg = self.m.find_group(group.name) if vg is not None: - targets = self.possible_missile_targets(vg) + targets = self.possible_missile_targets() if targets: target = random.choice(targets) real_target = target.point_from_heading( @@ -178,7 +179,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject] "Couldn't setup missile site to fire, group was not generated." ) - def possible_missile_targets(self, vg: Group) -> List[Point]: + def possible_missile_targets(self) -> List[Point]: """ Find enemy control points in range :param vg: Vehicle group we are searching a target for (There is always only oe group right now) @@ -187,7 +188,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject] targets: List[Point] = [] for cp in self.game.theater.controlpoints: if cp.captured != self.ground_object.control_point.captured: - distance = cp.position.distance_to_point(vg.position) + distance = cp.position.distance_to_point(self.ground_object.position) if distance < self.missile_site_range: targets.append(cp.position) return targets @@ -238,7 +239,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]): f"{self.ground_object.dcs_identifier} not found in static maps" ) - def generate_vehicle_group(self, unit_type: Type[UnitType]) -> None: + def generate_vehicle_group(self, unit_type: Type[VehicleType]) -> None: if not self.ground_object.is_dead: group = self.m.vehicle_group( country=self.country, @@ -389,13 +390,12 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO self.add_runway_data(brc or 0, atc, tacan, tacan_callsign, icls) self._register_unit_group(group, ship_group) - def get_carrier_type(self, group: Group) -> Type[UnitType]: - unit_type = unit_type_from_name(group.units[0].type) - if unit_type is None: - raise RuntimeError(f"Unrecognized carrier name: {group.units[0].type}") - return unit_type + def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]: + return ship_type_from_name(group.units[0].type) - def configure_carrier(self, group: Group, atc_channel: RadioFrequency) -> ShipGroup: + def configure_carrier( + self, group: ShipGroup, atc_channel: RadioFrequency + ) -> ShipGroup: unit_type = self.get_carrier_type(group) ship_group = self.m.ship_group( @@ -487,7 +487,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO class CarrierGenerator(GenericCarrierGenerator): """Generator for CV(N) groups.""" - def get_carrier_type(self, group: Group) -> UnitType: + def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]: unit_type = super().get_carrier_type(group) if self.game.settings.supercarrier: unit_type = db.upgrade_to_supercarrier(unit_type, self.control_point.name) @@ -542,14 +542,11 @@ class ShipObjectGenerator(GenericGroundObjectGenerator[ShipGroundObject]): if not group.units: logging.warning(f"Found empty group in {self.ground_object}") continue + self.generate_group(group, ship_type_from_name(group.units[0].type)) - unit_type = unit_type_from_name(group.units[0].type) - if unit_type is None: - raise RuntimeError(f"Unrecognized unit type: {group.units[0].type}") - - self.generate_group(group, unit_type) - - def generate_group(self, group_def: Group, first_unit_type: Type[UnitType]) -> None: + def generate_group( + self, group_def: ShipGroup, first_unit_type: Type[ShipType] + ) -> None: group = self.m.ship_group( self.country, group_def.name, diff --git a/gen/naming.py b/gen/naming.py index b342ecaa..e43d629c 100644 --- a/gen/naming.py +++ b/gen/naming.py @@ -1,9 +1,8 @@ import random import time -from typing import List +from typing import List, Any from dcs.country import Country -from dcs.unittype import UnitType as DcsUnitType from game.dcs.aircrafttype import AircraftType from game.dcs.unittype import UnitType @@ -297,7 +296,7 @@ class NameGenerator: @classmethod def next_unit_name( - cls, country: Country, parent_base_id: int, unit_type: UnitType[DcsUnitType] + cls, country: Country, parent_base_id: int, unit_type: UnitType[Any] ) -> str: cls.number += 1 return "unit|{}|{}|{}|{}|".format( @@ -306,7 +305,7 @@ class NameGenerator: @classmethod def next_infantry_name( - cls, country: Country, parent_base_id: int, unit_type: UnitType[DcsUnitType] + cls, country: Country, parent_base_id: int, unit_type: UnitType[Any] ) -> str: cls.infantry_number += 1 return "infantry|{}|{}|{}|{}|".format( diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index 6a286888..2fb800f8 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -157,7 +157,7 @@ class ShipGroupGenerator( super().__init__( game, ground_object, - unitgroup.ShipGroup(self.game.next_group_id(), self.go.group_name), + unitgroup.ShipGroup(game.next_group_id(), ground_object.group_name), ) self.faction = faction wp = self.vg.add_waypoint(self.position, 0) diff --git a/gen/triggergen.py b/gen/triggergen.py index 6616456d..2a70d204 100644 --- a/gen/triggergen.py +++ b/gen/triggergen.py @@ -83,7 +83,12 @@ class TriggersGenerator: for cp in self.game.theater.controlpoints: if isinstance(cp, Airfield): - self.mission.terrain.airport_by_id(cp.at.id).set_coalition( + cp_airport = self.mission.terrain.airport_by_id(cp.airport.id) + if cp_airport is None: + raise RuntimeError( + f"Could not find {cp.airport.name} in the mission" + ) + cp_airport.set_coalition( cp.captured and player_coalition or enemy_coalition ) From 9de08dc83ff1ed30b15f5c7acc21da316fce9b6b Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 11 Jul 2021 13:35:52 -0700 Subject: [PATCH 044/167] Update to latest pydcs. This includes the basics that we need to get type checking for pydcs calls. Type checking has been disabled in a few monkey-patching cases. Patches ought to be sent upstream (or in the case of dead unit tracking, replaced with a better model). --- game/db.py | 2 +- game/event/event.py | 4 ++-- gen/visualgen.py | 12 ++++++------ mypy.ini | 3 --- requirements.txt | 2 +- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/game/db.py b/game/db.py index 2504d4f3..4a2fbf75 100644 --- a/game/db.py +++ b/game/db.py @@ -383,7 +383,7 @@ class DefaultLiveries: OH_58D.Liveries = DefaultLiveries -F_16C_50.Liveries = DefaultLiveries +F_16C_50.Liveries = DefaultLiveries # type: ignore P_51D_30_NA.Liveries = DefaultLiveries Ju_88A4.Liveries = DefaultLiveries B_17G.Liveries = DefaultLiveries diff --git a/game/event/event.py b/game/event/event.py index 1076da9c..ad20e06c 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -219,10 +219,10 @@ class Event: for loss in debriefing.ground_object_losses: # TODO: This should be stored in the TGO, not in the pydcs Group. if not hasattr(loss.group, "units_losts"): - loss.group.units_losts = [] + loss.group.units_losts = [] # type: ignore loss.group.units.remove(loss.unit) - loss.group.units_losts.append(loss.unit) + loss.group.units_losts.append(loss.unit) # type: ignore def commit_building_losses(self, debriefing: Debriefing) -> None: for loss in debriefing.building_losses: diff --git a/gen/visualgen.py b/gen/visualgen.py index 765a8f3c..5d8ffead 100644 --- a/gen/visualgen.py +++ b/gen/visualgen.py @@ -17,15 +17,15 @@ class MarkerSmoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" - shape_name = 5 - rate = 0.1 + shape_name = 5 # type: ignore + rate = 0.1 # type: ignore class Smoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" - shape_name = 2 + shape_name = 2 # type: ignore rate = 1 @@ -33,7 +33,7 @@ class BigSmoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" - shape_name = 3 + shape_name = 3 # type: ignore rate = 1 @@ -41,7 +41,7 @@ class MassiveSmoke(StaticType): id = "big_smoke" category = "Effects" name = "big_smoke" - shape_name = 4 + shape_name = 4 # type: ignore rate = 1 @@ -56,7 +56,7 @@ def __monkey_static_dict(self: Static) -> dict[str, Any]: __original_static_dict = Static.dict -Static.dict = __monkey_static_dict +Static.dict = __monkey_static_dict # type: ignore FRONT_SMOKE_RANDOM_SPREAD = 4000 FRONT_SMOKE_TYPE_CHANCES = { diff --git a/mypy.ini b/mypy.ini index b4d0925b..8e8d05ca 100644 --- a/mypy.ini +++ b/mypy.ini @@ -16,9 +16,6 @@ warn_redundant_casts = True warn_unreachable = True warn_unused_ignores = True -[mypy-dcs.*] -ignore_missing_imports = True - [mypy-faker.*] ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt index eeed5fc3..c43966a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ pathspec==0.8.1 pefile==2019.4.18 Pillow==8.2.0 pre-commit==2.10.1 --e git://github.com/pydcs/dcs@75a8dd35331e8fd337ba05fe950732077433f378#egg=pydcs +-e git://github.com/pydcs/dcs@1fa15385d2f8300a125155c8ac307d5c37e70152#egg=pydcs pyinstaller==4.3 pyinstaller-hooks-contrib==2021.1 pyparsing==2.4.7 From a19a0b678974632549b418764c86b4714354cee1 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 11 Jul 2021 13:53:58 -0700 Subject: [PATCH 045/167] Use Pillow types from typeshed. --- gen/kneeboard.py | 2 +- mypy.ini | 6 ------ requirements.txt | 1 + 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 35c22799..110dff55 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -93,7 +93,7 @@ class KneeboardPageWriter: def text( self, text: str, - font: Optional[ImageFont.ImageFont] = None, + font: Optional[ImageFont.FreeTypeFont] = None, fill: Tuple[int, int, int] = (0, 0, 0), ) -> None: if font is None: diff --git a/mypy.ini b/mypy.ini index 8e8d05ca..2efd6b99 100644 --- a/mypy.ini +++ b/mypy.ini @@ -19,11 +19,5 @@ warn_unused_ignores = True [mypy-faker.*] ignore_missing_imports = True -[mypy-PIL.*] -ignore_missing_imports = True - -[mypy-winreg.*] -ignore_missing_imports = True - [mypy-shapely.*] ignore_missing_imports = True \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c43966a1..fe4a96f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,5 +36,6 @@ tabulate==0.8.7 text-unidecode==1.3 toml==0.10.2 typed-ast==1.4.2 +types-Pillow==8.3.1 typing-extensions==3.7.4.3 virtualenv==20.4.2 From 6ce02282e74fd3455c3a63880330c05b63a35bff Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 11 Jul 2021 14:33:46 -0700 Subject: [PATCH 046/167] Correct int/float confusion in Point APIs. The heading and distance calculations always return floats. --- game/game.py | 7 ++++--- game/operation/operation.py | 4 ++-- game/theater/missiontarget.py | 2 +- gen/armor.py | 5 +++-- gen/flights/ai_flight_planner.py | 8 ++++---- gen/flights/flightplan.py | 16 +++++++++------- 6 files changed, 23 insertions(+), 19 deletions(-) diff --git a/game/game.py b/game/game.py index 810f8831..567ab2ab 100644 --- a/game/game.py +++ b/game/game.py @@ -1,10 +1,11 @@ import itertools import logging +import math import random import sys from datetime import date, datetime, timedelta from enum import Enum -from typing import Any, List, Type, Union +from typing import Any, List, Type, Union, cast from dcs.action import Coalition from dcs.mapping import Point @@ -614,7 +615,7 @@ class Game: # If there is no conflict take the center point between the two nearest opposing bases if len(zones) == 0: cpoint = None - min_distance = sys.maxsize + min_distance = math.inf for cp in self.theater.player_points(): for cp2 in self.theater.enemy_points(): d = cp.position.distance_to_point(cp2.position) @@ -651,7 +652,7 @@ class Game: self.__culling_zones = zones def add_destroyed_units(self, data: dict[str, Union[float, str]]) -> None: - pos = Point(data["x"], data["z"]) + pos = Point(cast(float, data["x"]), cast(float, data["z"])) if self.theater.is_on_land(pos): self.__destroyed_units.append(data) diff --git a/game/operation/operation.py b/game/operation/operation.py index 36f2f54d..da3f1c4a 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import os from pathlib import Path -from typing import Iterable, List, Set, TYPE_CHECKING +from typing import Iterable, List, Set, TYPE_CHECKING, cast from dcs import Mission from dcs.action import DoScript, DoScriptFile @@ -261,7 +261,7 @@ class Operation: except KeyError: continue - pos = Point(d["x"], d["z"]) + pos = Point(cast(float, d["x"]), cast(float, d["z"])) if ( utype is not None and not cls.game.position_culled(pos) diff --git a/game/theater/missiontarget.py b/game/theater/missiontarget.py index 813172fa..a475bc9f 100644 --- a/game/theater/missiontarget.py +++ b/game/theater/missiontarget.py @@ -21,7 +21,7 @@ class MissionTarget: self.name = name self.position = position - def distance_to(self, other: MissionTarget) -> int: + def distance_to(self, other: MissionTarget) -> float: """Computes the distance to the given mission target.""" return self.position.distance_to_point(other.position) diff --git a/gen/armor.py b/gen/armor.py index 2777e585..f9fb1a8a 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import math import random from dataclasses import dataclass from typing import TYPE_CHECKING, List, Optional, Tuple @@ -655,7 +656,7 @@ class GroundConflictGenerator: @param group Group for which we should find the nearest ennemy @param enemy_groups Potential enemy groups """ - min_distance = 99999999 + min_distance = math.inf target = None for dcs_group, _ in enemy_groups: dist = player_group.points[0].position.distance_to_point( @@ -693,7 +694,7 @@ class GroundConflictGenerator: """ For artilery group, decide the distance from frontline with the range of the unit """ - rg = getattr(group.unit_type.dcs_unit_type, "threat_range", 0) - 7500 + rg = group.unit_type.dcs_unit_type.threat_range - 7500 if rg > DISTANCE_FROM_FRONTLINE[CombatGroupRole.ARTILLERY][1]: rg = random.randint( DISTANCE_FROM_FRONTLINE[CombatGroupRole.ARTILLERY][0], diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 6d259cd2..3833abb8 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -375,9 +375,9 @@ class ObjectiveFinder: def _targets_by_range( self, targets: Iterable[MissionTargetType] ) -> Iterator[MissionTargetType]: - target_ranges: List[Tuple[MissionTargetType, int]] = [] + target_ranges: list[tuple[MissionTargetType, float]] = [] for target in targets: - ranges: List[int] = [] + ranges: list[float] = [] for cp in self.friendly_control_points(): ranges.append(target.distance_to(cp)) target_ranges.append((target, min(ranges))) @@ -392,7 +392,7 @@ class ObjectiveFinder: Targets are sorted by their closest proximity to any friendly control point (airfield or fleet). """ - targets: List[Tuple[TheaterGroundObject[Any], int]] = [] + targets: list[tuple[TheaterGroundObject[Any], float]] = [] # Building objectives are made of several individual TGOs (one per # building). found_targets: Set[str] = set() @@ -431,7 +431,7 @@ class ObjectiveFinder: continue if ground_object.name in found_targets: continue - ranges: List[int] = [] + ranges: list[float] = [] for friendly_cp in self.friendly_control_points(): ranges.append(ground_object.distance_to(friendly_cp)) targets.append((ground_object, min(ranges))) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index afc8657f..248846d6 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -1207,10 +1207,12 @@ class FlightPlanBuilder: target = self.package.target.position heading = self.package.waypoints.join.heading_between_point(target) - start = target.point_from_heading(heading, -self.doctrine.sweep_distance.meters) + start_pos = target.point_from_heading( + heading, -self.doctrine.sweep_distance.meters + ) builder = WaypointBuilder(flight, self.game, self.is_player) - start, end = builder.sweep(start, target, self.doctrine.ingress_altitude) + start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude) hold = builder.hold(self._hold_point(flight)) @@ -1865,23 +1867,23 @@ class FlightPlanBuilder: return self._retreating_rendezvous_point(attack_transition) return self._advancing_rendezvous_point(attack_transition) - def _ingress_point(self, heading: int) -> Point: + def _ingress_point(self, heading: float) -> Point: return self.package.target.position.point_from_heading( heading - 180 + 15, self.doctrine.ingress_egress_distance.meters ) - def _egress_point(self, heading: int) -> Point: + def _egress_point(self, heading: float) -> Point: return self.package.target.position.point_from_heading( heading - 180 - 15, self.doctrine.ingress_egress_distance.meters ) - def _target_heading_to_package_airfield(self) -> int: + def _target_heading_to_package_airfield(self) -> float: return self._heading_to_package_airfield(self.package.target.position) - def _heading_to_package_airfield(self, point: Point) -> int: + def _heading_to_package_airfield(self, point: Point) -> float: return self.package_airfield().position.heading_between_point(point) - def _distance_to_package_airfield(self, point: Point) -> int: + def _distance_to_package_airfield(self, point: Point) -> float: return self.package_airfield().position.distance_to_point(point) def package_airfield(self) -> ControlPoint: From 81c80524494284a53a90cf09d9e6b1fb93a73e2e Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 11 Jul 2021 14:41:40 -0700 Subject: [PATCH 047/167] Note tracking bug for shapely type annotations. --- mypy.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/mypy.ini b/mypy.ini index 2efd6b99..da81307c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -20,4 +20,5 @@ warn_unused_ignores = True ignore_missing_imports = True [mypy-shapely.*] +# https://github.com/Toblerity/Shapely/issues/721 ignore_missing_imports = True \ No newline at end of file From 783ac182220c031b91da0b23ce75a56c49a84997 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 21 Jun 2021 01:25:48 -0700 Subject: [PATCH 048/167] Replace existing campaign planner with an HTN. An HTN (https://en.wikipedia.org/wiki/Hierarchical_task_network) is similar to a decision tree, but it is able to reset to an earlier stage if a subtask fails and tasks are able to account for the changes in world state caused by earlier tasks. Currently this just uses exactly the same strategy as before so we can prove the system, but it should make it simpler to improve on task planning. --- game/commander/__init__.py | 1 + game/commander/missionproposals.py | 62 ++ game/commander/objectivefinder.py | 312 +++++++++ game/commander/tasks/compound/aewcsupport.py | 11 + .../tasks/compound/attackairinfrastructure.py | 15 + .../tasks/compound/attackbuildings.py | 11 + .../tasks/compound/attackgarrisons.py | 11 + game/commander/tasks/compound/degradeiads.py | 11 + game/commander/tasks/compound/destroyships.py | 11 + .../tasks/compound/frontlinedefense.py | 11 + .../tasks/compound/interdictreinforcements.py | 27 + game/commander/tasks/compound/nextaction.py | 36 + .../tasks/compound/protectairspace.py | 11 + .../tasks/compound/refuelingsupport.py | 11 + game/commander/tasks/packageplanningtask.py | 73 +++ game/commander/tasks/primitive/aewc.py | 26 + game/commander/tasks/primitive/antiship.py | 28 + .../commander/tasks/primitive/antishipping.py | 22 + game/commander/tasks/primitive/bai.py | 22 + game/commander/tasks/primitive/barcap.py | 55 ++ game/commander/tasks/primitive/cas.py | 22 + .../tasks/primitive/convoyinterdiction.py | 22 + game/commander/tasks/primitive/dead.py | 51 ++ game/commander/tasks/primitive/oca.py | 28 + game/commander/tasks/primitive/refueling.py | 21 + game/commander/tasks/primitive/strike.py | 23 + game/commander/tasks/theatercommandertask.py | 20 + game/commander/theatercommander.py | 91 +++ game/commander/theaterstate.py | 67 ++ game/data/doctrine.py | 16 +- game/htn.py | 125 ++++ game/theater/theatergroundobject.py | 27 +- gen/flights/ai_flight_planner.py | 613 +----------------- 33 files changed, 1283 insertions(+), 610 deletions(-) create mode 100644 game/commander/__init__.py create mode 100644 game/commander/missionproposals.py create mode 100644 game/commander/objectivefinder.py create mode 100644 game/commander/tasks/compound/aewcsupport.py create mode 100644 game/commander/tasks/compound/attackairinfrastructure.py create mode 100644 game/commander/tasks/compound/attackbuildings.py create mode 100644 game/commander/tasks/compound/attackgarrisons.py create mode 100644 game/commander/tasks/compound/degradeiads.py create mode 100644 game/commander/tasks/compound/destroyships.py create mode 100644 game/commander/tasks/compound/frontlinedefense.py create mode 100644 game/commander/tasks/compound/interdictreinforcements.py create mode 100644 game/commander/tasks/compound/nextaction.py create mode 100644 game/commander/tasks/compound/protectairspace.py create mode 100644 game/commander/tasks/compound/refuelingsupport.py create mode 100644 game/commander/tasks/packageplanningtask.py create mode 100644 game/commander/tasks/primitive/aewc.py create mode 100644 game/commander/tasks/primitive/antiship.py create mode 100644 game/commander/tasks/primitive/antishipping.py create mode 100644 game/commander/tasks/primitive/bai.py create mode 100644 game/commander/tasks/primitive/barcap.py create mode 100644 game/commander/tasks/primitive/cas.py create mode 100644 game/commander/tasks/primitive/convoyinterdiction.py create mode 100644 game/commander/tasks/primitive/dead.py create mode 100644 game/commander/tasks/primitive/oca.py create mode 100644 game/commander/tasks/primitive/refueling.py create mode 100644 game/commander/tasks/primitive/strike.py create mode 100644 game/commander/tasks/theatercommandertask.py create mode 100644 game/commander/theatercommander.py create mode 100644 game/commander/theaterstate.py create mode 100644 game/htn.py diff --git a/game/commander/__init__.py b/game/commander/__init__.py new file mode 100644 index 00000000..ac46c5ef --- /dev/null +++ b/game/commander/__init__.py @@ -0,0 +1 @@ +from .theatercommander import TheaterCommander diff --git a/game/commander/missionproposals.py b/game/commander/missionproposals.py new file mode 100644 index 00000000..2b8fc074 --- /dev/null +++ b/game/commander/missionproposals.py @@ -0,0 +1,62 @@ +from dataclasses import field, dataclass +from enum import Enum, auto +from typing import Optional + +from game.theater import MissionTarget +from game.utils import Distance +from gen.flights.flight import FlightType + + +class EscortType(Enum): + AirToAir = auto() + Sead = auto() + + +@dataclass(frozen=True) +class ProposedFlight: + """A flight outline proposed by the mission planner. + + Proposed flights haven't been assigned specific aircraft yet. They have only + a task, a required number of aircraft, and a maximum distance allowed + between the objective and the departure airfield. + """ + + #: The flight's role. + task: FlightType + + #: The number of aircraft required. + num_aircraft: int + + #: The maximum distance between the objective and the departure airfield. + max_distance: Distance + + #: The type of threat this flight defends against if it is an escort. Escort + #: flights will be pruned if the rest of the package is not threatened by + #: the threat they defend against. If this flight is not an escort, this + #: field is None. + escort_type: Optional[EscortType] = field(default=None) + + def __str__(self) -> str: + return f"{self.task} {self.num_aircraft} ship" + + +@dataclass(frozen=True) +class ProposedMission: + """A mission outline proposed by the mission planner. + + Proposed missions haven't been assigned aircraft yet. They have only an + objective location and a list of proposed flights that are required for the + mission. + """ + + #: The mission objective. + location: MissionTarget + + #: The proposed flights that are required for the mission. + flights: list[ProposedFlight] + + asap: bool = field(default=False) + + def __str__(self) -> str: + flights = ", ".join([str(f) for f in self.flights]) + return f"{self.location.name}: {flights}" diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py new file mode 100644 index 00000000..193205c2 --- /dev/null +++ b/game/commander/objectivefinder.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import math +import operator +from collections import Iterator, Iterable +from typing import TypeVar, TYPE_CHECKING, Any + +from game.theater import ( + ControlPoint, + OffMapSpawn, + TheaterGroundObject, + MissionTarget, + Fob, + FrontLine, + Airfield, +) +from game.theater.theatergroundobject import ( + EwrGroundObject, + SamGroundObject, + VehicleGroupGroundObject, + NavalGroundObject, + BuildingGroundObject, + IadsGroundObject, +) +from game.transfers import CargoShip, Convoy +from game.utils import meters, nautical_miles, Distance +from gen.flights.closestairfields import ObjectiveDistanceCache, ClosestAirfields + +if TYPE_CHECKING: + from game import Game + +MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget) + + +class ObjectiveFinder: + """Identifies potential objectives for the mission planner.""" + + # TODO: Merge into doctrine. + AIRFIELD_THREAT_RANGE = nautical_miles(150) + SAM_THREAT_RANGE = nautical_miles(100) + + def __init__(self, game: Game, is_player: bool) -> None: + self.game = game + self.is_player = is_player + + def enemy_air_defenses(self) -> Iterator[tuple[IadsGroundObject, Distance]]: + """Iterates over all enemy SAM sites.""" + doctrine = self.game.faction_for(self.is_player).doctrine + threat_zones = self.game.threat_zone_for(not self.is_player) + for cp in self.enemy_control_points(): + for ground_object in cp.ground_objects: + if ground_object.is_dead: + continue + + if isinstance(ground_object, EwrGroundObject): + if threat_zones.threatened_by_air_defense(ground_object): + # This is a very weak heuristic for determining whether the EWR + # is close enough to be worth targeting before a SAM that is + # covering it. Ingress distance corresponds to the beginning of + # the attack range and is sufficient for most standoff weapons, + # so treating the ingress distance as the threat distance sorts + # these EWRs such that they will be attacked before SAMs that do + # not threaten the ingress point, but after those that do. + target_range = doctrine.ingress_egress_distance + else: + # But if the EWR isn't covered then we should only be worrying + # about its detection range. + target_range = ground_object.max_detection_range() + elif isinstance(ground_object, SamGroundObject): + target_range = ground_object.max_threat_range() + else: + continue + + yield ground_object, target_range + + def threatening_air_defenses(self) -> Iterator[IadsGroundObject]: + """Iterates over enemy SAMs in threat range of friendly control points. + + SAM sites are sorted by their closest proximity to any friendly control + point (airfield or fleet). + """ + + target_ranges: list[tuple[IadsGroundObject, Distance]] = [] + for target, threat_range in self.enemy_air_defenses(): + ranges: list[Distance] = [] + for cp in self.friendly_control_points(): + ranges.append(meters(target.distance_to(cp)) - threat_range) + target_ranges.append((target, min(ranges))) + + target_ranges = sorted(target_ranges, key=operator.itemgetter(1)) + for target, _range in target_ranges: + yield target + + def enemy_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]: + """Iterates over all enemy vehicle groups.""" + for cp in self.enemy_control_points(): + for ground_object in cp.ground_objects: + if not isinstance(ground_object, VehicleGroupGroundObject): + continue + + if ground_object.is_dead: + continue + + yield ground_object + + def threatening_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]: + """Iterates over enemy vehicle groups near friendly control points. + + Groups are sorted by their closest proximity to any friendly control + point (airfield or fleet). + """ + return self._targets_by_range(self.enemy_vehicle_groups()) + + def enemy_ships(self) -> Iterator[NavalGroundObject]: + for cp in self.enemy_control_points(): + for ground_object in cp.ground_objects: + if not isinstance(ground_object, NavalGroundObject): + continue + + if ground_object.is_dead: + continue + + yield ground_object + + def threatening_ships(self) -> Iterator[NavalGroundObject]: + """Iterates over enemy ships near friendly control points. + + Groups are sorted by their closest proximity to any friendly control + point (airfield or fleet). + """ + return self._targets_by_range(self.enemy_ships()) + + def _targets_by_range( + self, targets: Iterable[MissionTargetType] + ) -> Iterator[MissionTargetType]: + target_ranges: list[tuple[MissionTargetType, float]] = [] + for target in targets: + ranges: list[float] = [] + for cp in self.friendly_control_points(): + ranges.append(target.distance_to(cp)) + target_ranges.append((target, min(ranges))) + + target_ranges = sorted(target_ranges, key=operator.itemgetter(1)) + for target, _range in target_ranges: + yield target + + def strike_targets(self) -> Iterator[TheaterGroundObject[Any]]: + """Iterates over enemy strike targets. + + Targets are sorted by their closest proximity to any friendly control + point (airfield or fleet). + """ + targets: list[tuple[TheaterGroundObject[Any], float]] = [] + # Building objectives are made of several individual TGOs (one per + # building). + found_targets: set[str] = set() + for enemy_cp in self.enemy_control_points(): + for ground_object in enemy_cp.ground_objects: + # TODO: Reuse ground_object.mission_types. + # The mission types for ground objects are currently not + # accurate because we include things like strike and BAI for all + # targets since they have different planning behavior (waypoint + # generation is better for players with strike when the targets + # are stationary, AI behavior against weaker air defenses is + # better with BAI), so that's not a useful filter. Once we have + # better control over planning profiles and target dependent + # loadouts we can clean this up. + if isinstance(ground_object, VehicleGroupGroundObject): + # BAI target, not strike target. + continue + + if isinstance(ground_object, NavalGroundObject): + # Anti-ship target, not strike target. + continue + + if isinstance(ground_object, SamGroundObject): + # SAMs are targeted by DEAD. No need to double plan. + continue + + is_building = isinstance(ground_object, BuildingGroundObject) + is_fob = isinstance(enemy_cp, Fob) + if is_building and is_fob and ground_object.is_control_point: + # This is the FOB structure itself. Can't be repaired or + # targeted by the player, so shouldn't be targetable by the + # AI. + continue + + if ground_object.is_dead: + continue + if ground_object.name in found_targets: + continue + ranges: list[float] = [] + for friendly_cp in self.friendly_control_points(): + ranges.append(ground_object.distance_to(friendly_cp)) + targets.append((ground_object, min(ranges))) + found_targets.add(ground_object.name) + targets = sorted(targets, key=operator.itemgetter(1)) + for target, _range in targets: + yield target + + def front_lines(self) -> Iterator[FrontLine]: + """Iterates over all active front lines in the theater.""" + yield from self.game.theater.conflicts() + + def vulnerable_control_points(self) -> Iterator[ControlPoint]: + """Iterates over friendly CPs that are vulnerable to enemy CPs. + + Vulnerability is defined as any enemy CP within threat range of of the + CP. + """ + for cp in self.friendly_control_points(): + if isinstance(cp, OffMapSpawn): + # Off-map spawn locations don't need protection. + continue + airfields_in_proximity = self.closest_airfields_to(cp) + airfields_in_threat_range = ( + airfields_in_proximity.operational_airfields_within( + self.AIRFIELD_THREAT_RANGE + ) + ) + for airfield in airfields_in_threat_range: + if not airfield.is_friendly(self.is_player): + yield cp + break + + def oca_targets(self, min_aircraft: int) -> Iterator[ControlPoint]: + airfields = [] + for control_point in self.enemy_control_points(): + if not isinstance(control_point, Airfield): + continue + if control_point.base.total_aircraft >= min_aircraft: + airfields.append(control_point) + return self._targets_by_range(airfields) + + def convoys(self) -> Iterator[Convoy]: + for front_line in self.front_lines(): + yield from self.game.transfers.convoys.travelling_to( + front_line.control_point_hostile_to(self.is_player) + ) + + def cargo_ships(self) -> Iterator[CargoShip]: + for front_line in self.front_lines(): + yield from self.game.transfers.cargo_ships.travelling_to( + front_line.control_point_hostile_to(self.is_player) + ) + + def friendly_control_points(self) -> Iterator[ControlPoint]: + """Iterates over all friendly control points.""" + return ( + c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player) + ) + + def farthest_friendly_control_point(self) -> ControlPoint: + """Finds the friendly control point that is farthest from any threats.""" + threat_zones = self.game.threat_zone_for(not self.is_player) + + farthest = None + max_distance = meters(0) + for cp in self.friendly_control_points(): + if isinstance(cp, OffMapSpawn): + continue + distance = threat_zones.distance_to_threat(cp.position) + if distance > max_distance: + farthest = cp + max_distance = distance + + if farthest is None: + raise RuntimeError("Found no friendly control points. You probably lost.") + return farthest + + def closest_friendly_control_point(self) -> ControlPoint: + """Finds the friendly control point that is closest to any threats.""" + threat_zones = self.game.threat_zone_for(not self.is_player) + + closest = None + min_distance = meters(math.inf) + for cp in self.friendly_control_points(): + if isinstance(cp, OffMapSpawn): + continue + distance = threat_zones.distance_to_threat(cp.position) + if distance < min_distance: + closest = cp + min_distance = distance + + if closest is None: + raise RuntimeError("Found no friendly control points. You probably lost.") + return closest + + def enemy_control_points(self) -> Iterator[ControlPoint]: + """Iterates over all enemy control points.""" + return ( + c + for c in self.game.theater.controlpoints + if not c.is_friendly(self.is_player) + ) + + def all_possible_targets(self) -> Iterator[MissionTarget]: + """Iterates over all possible mission targets in the theater. + + Valid mission targets are control points (airfields and carriers), front + lines, and ground objects (SAM sites, factories, resource extraction + sites, etc). + """ + for cp in self.game.theater.controlpoints: + yield cp + yield from cp.ground_objects + yield from self.front_lines() + + @staticmethod + def closest_airfields_to(location: MissionTarget) -> ClosestAirfields: + """Returns the closest airfields to the given location.""" + return ObjectiveDistanceCache.get_closest_airfields(location) diff --git a/game/commander/tasks/compound/aewcsupport.py b/game/commander/tasks/compound/aewcsupport.py new file mode 100644 index 00000000..5e66cb01 --- /dev/null +++ b/game/commander/tasks/compound/aewcsupport.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.aewc import PlanAewc +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class PlanAewcSupport(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for target in state.aewc_targets: + yield [PlanAewc(target)] diff --git a/game/commander/tasks/compound/attackairinfrastructure.py b/game/commander/tasks/compound/attackairinfrastructure.py new file mode 100644 index 00000000..993ce73e --- /dev/null +++ b/game/commander/tasks/compound/attackairinfrastructure.py @@ -0,0 +1,15 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.primitive.oca import PlanOcaStrike +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class AttackAirInfrastructure(CompoundTask[TheaterState]): + aircraft_cold_start: bool + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for garrison in state.oca_targets: + yield [PlanOcaStrike(garrison, self.aircraft_cold_start)] diff --git a/game/commander/tasks/compound/attackbuildings.py b/game/commander/tasks/compound/attackbuildings.py new file mode 100644 index 00000000..fe80dbf0 --- /dev/null +++ b/game/commander/tasks/compound/attackbuildings.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.strike import PlanStrike +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class AttackBuildings(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for garrison in state.strike_targets: + yield [PlanStrike(garrison)] diff --git a/game/commander/tasks/compound/attackgarrisons.py b/game/commander/tasks/compound/attackgarrisons.py new file mode 100644 index 00000000..f8281597 --- /dev/null +++ b/game/commander/tasks/compound/attackgarrisons.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.bai import PlanBai +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class AttackGarrisons(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for garrison in state.enemy_garrisons: + yield [PlanBai(garrison)] diff --git a/game/commander/tasks/compound/degradeiads.py b/game/commander/tasks/compound/degradeiads.py new file mode 100644 index 00000000..10560058 --- /dev/null +++ b/game/commander/tasks/compound/degradeiads.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.dead import PlanDead +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class DegradeIads(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for air_defense in state.threatening_air_defenses: + yield [PlanDead(air_defense)] diff --git a/game/commander/tasks/compound/destroyships.py b/game/commander/tasks/compound/destroyships.py new file mode 100644 index 00000000..e857f05e --- /dev/null +++ b/game/commander/tasks/compound/destroyships.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.antiship import PlanAntiShip +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class DestroyShips(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for ship in state.threatening_ships: + yield [PlanAntiShip(ship)] diff --git a/game/commander/tasks/compound/frontlinedefense.py b/game/commander/tasks/compound/frontlinedefense.py new file mode 100644 index 00000000..11ed083e --- /dev/null +++ b/game/commander/tasks/compound/frontlinedefense.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.cas import PlanCas +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class FrontLineDefense(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for front_line in state.vulnerable_front_lines: + yield [PlanCas(front_line)] diff --git a/game/commander/tasks/compound/interdictreinforcements.py b/game/commander/tasks/compound/interdictreinforcements.py new file mode 100644 index 00000000..a76921db --- /dev/null +++ b/game/commander/tasks/compound/interdictreinforcements.py @@ -0,0 +1,27 @@ +from collections import Iterator + +from game.commander.tasks.primitive.antishipping import PlanAntiShipping +from game.commander.tasks.primitive.convoyinterdiction import PlanConvoyInterdiction +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class InterdictReinforcements(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + # These will only rarely get planned. When a convoy is travelling multiple legs, + # they're targetable after the first leg. The reason for this is that + # procurement happens *after* mission planning so that the missions that could + # not be filled will guide the procurement process. Procurement is the stage + # that convoys are created (because they're created to move ground units that + # were just purchased), so we haven't created any yet. Any incomplete transfers + # from the previous turn (multi-leg journeys) will still be present though so + # they can be targeted. + # + # Even after this is fixed, the player's convoys that were created through the + # UI will never be targeted on the first turn of their journey because the AI + # stops planning after the start of the turn. We could potentially fix this by + # moving opfor mission planning until the takeoff button is pushed. + for convoy in state.enemy_convoys: + yield [PlanConvoyInterdiction(convoy)] + for ship in state.enemy_shipping: + yield [PlanAntiShipping(ship)] diff --git a/game/commander/tasks/compound/nextaction.py b/game/commander/tasks/compound/nextaction.py new file mode 100644 index 00000000..bdfc4e46 --- /dev/null +++ b/game/commander/tasks/compound/nextaction.py @@ -0,0 +1,36 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.aewcsupport import PlanAewcSupport +from game.commander.tasks.compound.attackairinfrastructure import ( + AttackAirInfrastructure, +) +from game.commander.tasks.compound.attackbuildings import AttackBuildings +from game.commander.tasks.compound.attackgarrisons import AttackGarrisons +from game.commander.tasks.compound.degradeiads import DegradeIads +from game.commander.tasks.compound.destroyships import DestroyShips +from game.commander.tasks.compound.frontlinedefense import FrontLineDefense +from game.commander.tasks.compound.interdictreinforcements import ( + InterdictReinforcements, +) +from game.commander.tasks.compound.protectairspace import ProtectAirSpace +from game.commander.tasks.compound.refuelingsupport import PlanRefuelingSupport +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class PlanNextAction(CompoundTask[TheaterState]): + aircraft_cold_start: bool + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [PlanAewcSupport()] + yield [PlanRefuelingSupport()] + yield [ProtectAirSpace()] + yield [FrontLineDefense()] + yield [DegradeIads()] + yield [InterdictReinforcements()] + yield [DestroyShips()] + yield [AttackGarrisons()] + yield [AttackAirInfrastructure(self.aircraft_cold_start)] + yield [AttackBuildings()] diff --git a/game/commander/tasks/compound/protectairspace.py b/game/commander/tasks/compound/protectairspace.py new file mode 100644 index 00000000..9e3c0d56 --- /dev/null +++ b/game/commander/tasks/compound/protectairspace.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.barcap import PlanBarcap +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class ProtectAirSpace(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for cp in state.vulnerable_control_points: + yield [PlanBarcap(cp)] diff --git a/game/commander/tasks/compound/refuelingsupport.py b/game/commander/tasks/compound/refuelingsupport.py new file mode 100644 index 00000000..6e2b141a --- /dev/null +++ b/game/commander/tasks/compound/refuelingsupport.py @@ -0,0 +1,11 @@ +from collections import Iterator + +from game.commander.tasks.primitive.refueling import PlanRefueling +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class PlanRefuelingSupport(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for target in state.refueling_targets: + yield [PlanRefueling(target)] diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py new file mode 100644 index 00000000..26f2db3a --- /dev/null +++ b/game/commander/tasks/packageplanningtask.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from abc import abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Optional, Generic, TypeVar + +from game.commander.missionproposals import ProposedFlight, EscortType, ProposedMission +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.htn import PrimitiveTask +from game.profiling import MultiEventTracer +from game.theater import MissionTarget +from game.utils import Distance +from gen.flights.flight import FlightType + +if TYPE_CHECKING: + from gen.flights.ai_flight_planner import CoalitionMissionPlanner + + +MissionTargetT = TypeVar("MissionTargetT", bound=MissionTarget) + + +# TODO: Refactor so that we don't need to call up to the mission planner. +# Bypass type checker due to https://github.com/python/mypy/issues/5374 +@dataclass # type: ignore +class PackagePlanningTask(PrimitiveTask[TheaterState], Generic[MissionTargetT]): + target: MissionTargetT + flights: list[ProposedFlight] = field(init=False) + + def __post_init__(self) -> None: + self.flights = [] + + def execute( + self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer + ) -> None: + self.propose_flights(mission_planner.doctrine) + mission_planner.plan_mission(ProposedMission(self.target, self.flights), tracer) + + @abstractmethod + def propose_flights(self, doctrine: Doctrine) -> None: + ... + + def propose_flight( + self, + task: FlightType, + num_aircraft: int, + max_distance: Optional[Distance], + escort_type: Optional[EscortType] = None, + ) -> None: + if max_distance is None: + max_distance = Distance.inf() + self.flights.append( + ProposedFlight(task, num_aircraft, max_distance, escort_type) + ) + + @property + def asap(self) -> bool: + return False + + def propose_common_escorts(self, doctrine: Doctrine) -> None: + self.propose_flight( + FlightType.SEAD_ESCORT, + 2, + doctrine.mission_ranges.offensive, + EscortType.Sead, + ) + + self.propose_flight( + FlightType.ESCORT, + 2, + doctrine.mission_ranges.offensive, + EscortType.AirToAir, + ) diff --git a/game/commander/tasks/primitive/aewc.py b/game/commander/tasks/primitive/aewc.py new file mode 100644 index 00000000..77ec0901 --- /dev/null +++ b/game/commander/tasks/primitive/aewc.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.theater import MissionTarget +from gen.flights.flight import FlightType + + +@dataclass +class PlanAewc(PackagePlanningTask[MissionTarget]): + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.aewc_targets + + def apply_effects(self, state: TheaterState) -> None: + state.aewc_targets.remove(self.target) + + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.AEWC, 1, doctrine.mission_ranges.aewc) + + @property + def asap(self) -> bool: + # Supports all the early CAP flights, so should be in the air ASAP. + return True diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py new file mode 100644 index 00000000..48e84628 --- /dev/null +++ b/game/commander/tasks/primitive/antiship.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.missionproposals import EscortType +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.theater import NavalGroundObject +from gen.flights.flight import FlightType + + +@dataclass +class PlanAntiShip(PackagePlanningTask[NavalGroundObject]): + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.threatening_ships + + def apply_effects(self, state: TheaterState) -> None: + state.threatening_ships.remove(self.target) + + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive) + self.propose_flight( + FlightType.ESCORT, + 2, + doctrine.mission_ranges.offensive, + EscortType.AirToAir, + ) diff --git a/game/commander/tasks/primitive/antishipping.py b/game/commander/tasks/primitive/antishipping.py new file mode 100644 index 00000000..b5d31c7e --- /dev/null +++ b/game/commander/tasks/primitive/antishipping.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.transfers import CargoShip +from gen.flights.flight import FlightType + + +@dataclass +class PlanAntiShipping(PackagePlanningTask[CargoShip]): + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.enemy_shipping + + def apply_effects(self, state: TheaterState) -> None: + state.enemy_shipping.remove(self.target) + + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive) + self.propose_common_escorts(doctrine) diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py new file mode 100644 index 00000000..3d6c50d5 --- /dev/null +++ b/game/commander/tasks/primitive/bai.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.theater.theatergroundobject import VehicleGroupGroundObject +from gen.flights.flight import FlightType + + +@dataclass +class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.enemy_garrisons + + def apply_effects(self, state: TheaterState) -> None: + state.enemy_garrisons.remove(self.target) + + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive) + self.propose_common_escorts(doctrine) diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py new file mode 100644 index 00000000..40fcc684 --- /dev/null +++ b/game/commander/tasks/primitive/barcap.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from game.commander.missionproposals import ProposedMission, ProposedFlight +from game.commander.tasks.theatercommandertask import TheaterCommanderTask +from game.commander.theaterstate import TheaterState +from game.profiling import MultiEventTracer +from game.theater import ControlPoint +from gen.flights.flight import FlightType + +if TYPE_CHECKING: + from gen.flights.ai_flight_planner import CoalitionMissionPlanner + + +@dataclass +class PlanBarcap(TheaterCommanderTask): + target: ControlPoint + + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.vulnerable_control_points + + def apply_effects(self, state: TheaterState) -> None: + state.vulnerable_control_points.remove(self.target) + + def execute( + self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer + ) -> None: + # Plan enough rounds of CAP that the target has coverage over the expected + # mission duration. + mission_duration = int( + mission_planner.game.settings.desired_player_mission_duration.total_seconds() + ) + barcap_duration = int( + mission_planner.faction.doctrine.cap_duration.total_seconds() + ) + for _ in range( + 0, + mission_duration, + barcap_duration, + ): + mission_planner.plan_mission( + ProposedMission( + self.target, + [ + ProposedFlight( + FlightType.BARCAP, + 2, + mission_planner.doctrine.mission_ranges.cap, + ), + ], + ), + tracer, + ) diff --git a/game/commander/tasks/primitive/cas.py b/game/commander/tasks/primitive/cas.py new file mode 100644 index 00000000..63f1812a --- /dev/null +++ b/game/commander/tasks/primitive/cas.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.theater import FrontLine +from gen.flights.flight import FlightType + + +@dataclass +class PlanCas(PackagePlanningTask[FrontLine]): + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.vulnerable_front_lines + + def apply_effects(self, state: TheaterState) -> None: + state.vulnerable_front_lines.remove(self.target) + + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.CAS, 2, doctrine.mission_ranges.cas) + self.propose_flight(FlightType.TARCAP, 2, doctrine.mission_ranges.cap) diff --git a/game/commander/tasks/primitive/convoyinterdiction.py b/game/commander/tasks/primitive/convoyinterdiction.py new file mode 100644 index 00000000..bc652590 --- /dev/null +++ b/game/commander/tasks/primitive/convoyinterdiction.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.transfers import Convoy +from gen.flights.flight import FlightType + + +@dataclass +class PlanConvoyInterdiction(PackagePlanningTask[Convoy]): + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.enemy_convoys + + def apply_effects(self, state: TheaterState) -> None: + state.enemy_convoys.remove(self.target) + + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive) + self.propose_common_escorts(doctrine) diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py new file mode 100644 index 00000000..8784800f --- /dev/null +++ b/game/commander/tasks/primitive/dead.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.missionproposals import EscortType +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.theater.theatergroundobject import IadsGroundObject +from gen.flights.flight import FlightType + + +@dataclass +class PlanDead(PackagePlanningTask[IadsGroundObject]): + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.threatening_air_defenses + + def apply_effects(self, state: TheaterState) -> None: + state.threatening_air_defenses.remove(self.target) + + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.DEAD, 2, doctrine.mission_ranges.offensive) + + # Only include SEAD against SAMs that still have emitters. No need to + # suppress an EWR, and SEAD isn't useful against a SAM that no longer has a + # working track radar. + # + # For SAMs without track radars and EWRs, we still want a SEAD escort if + # needed. + # + # Note that there is a quirk here: we should potentially be included a SEAD + # escort *and* SEAD when the target is a radar SAM but the flight path is + # also threatened by SAMs. We don't want to include a SEAD escort if the + # package is *only* threatened by the target though. Could be improved, but + # needs a decent refactor to the escort planning to do so. + if self.target.has_live_radar_sam: + self.propose_flight(FlightType.SEAD, 2, doctrine.mission_ranges.offensive) + else: + self.propose_flight( + FlightType.SEAD_ESCORT, + 2, + doctrine.mission_ranges.offensive, + EscortType.Sead, + ) + + self.propose_flight( + FlightType.ESCORT, + 2, + doctrine.mission_ranges.offensive, + EscortType.AirToAir, + ) diff --git a/game/commander/tasks/primitive/oca.py b/game/commander/tasks/primitive/oca.py new file mode 100644 index 00000000..11f8bfa8 --- /dev/null +++ b/game/commander/tasks/primitive/oca.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.theater import ControlPoint +from gen.flights.flight import FlightType + + +@dataclass +class PlanOcaStrike(PackagePlanningTask[ControlPoint]): + aircraft_cold_start: bool + + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.oca_targets + + def apply_effects(self, state: TheaterState) -> None: + state.oca_targets.remove(self.target) + + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.OCA_RUNWAY, 2, doctrine.mission_ranges.offensive) + if self.aircraft_cold_start: + self.propose_flight( + FlightType.OCA_AIRCRAFT, 2, doctrine.mission_ranges.offensive + ) + self.propose_common_escorts(doctrine) diff --git a/game/commander/tasks/primitive/refueling.py b/game/commander/tasks/primitive/refueling.py new file mode 100644 index 00000000..0b78c86d --- /dev/null +++ b/game/commander/tasks/primitive/refueling.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.theater import MissionTarget +from gen.flights.flight import FlightType + + +@dataclass +class PlanRefueling(PackagePlanningTask[MissionTarget]): + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.refueling_targets + + def apply_effects(self, state: TheaterState) -> None: + state.refueling_targets.remove(self.target) + + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.REFUELING, 1, doctrine.mission_ranges.refueling) diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py new file mode 100644 index 00000000..07f30f5a --- /dev/null +++ b/game/commander/tasks/primitive/strike.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.data.doctrine import Doctrine +from game.theater.theatergroundobject import TheaterGroundObject +from gen.flights.flight import FlightType + + +@dataclass +class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]): + def preconditions_met(self, state: TheaterState) -> bool: + return self.target in state.strike_targets + + def apply_effects(self, state: TheaterState) -> None: + state.strike_targets.remove(self.target) + + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.STRIKE, 2, doctrine.mission_ranges.offensive) + self.propose_common_escorts(doctrine) diff --git a/game/commander/tasks/theatercommandertask.py b/game/commander/tasks/theatercommandertask.py new file mode 100644 index 00000000..aefaeea5 --- /dev/null +++ b/game/commander/tasks/theatercommandertask.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING + +from game.commander.theaterstate import TheaterState +from game.htn import PrimitiveTask +from game.profiling import MultiEventTracer + +if TYPE_CHECKING: + from gen.flights.ai_flight_planner import CoalitionMissionPlanner + + +# TODO: Refactor so that we don't need to call up to the mission planner. +class TheaterCommanderTask(PrimitiveTask[TheaterState]): + @abstractmethod + def execute( + self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer + ) -> None: + ... diff --git a/game/commander/theatercommander.py b/game/commander/theatercommander.py new file mode 100644 index 00000000..1e085410 --- /dev/null +++ b/game/commander/theatercommander.py @@ -0,0 +1,91 @@ +"""The Theater Commander is the highest level campaign AI. + +Target selection is performed with a hierarchical-task-network (HTN, linked below). +These work by giving the planner an initial "task" which decomposes into other tasks +until a concrete set of actions is formed. For example, the "capture base" task may +decompose in the following manner: + +* Defend + * Reinforce front line + * Set front line stance to defend + * Destroy enemy front line units + * Set front line stance to elimination + * Plan CAS at front line +* Prepare + * Destroy enemy IADS + * Plan DEAD against SAM Armadillo + * ... + * Destroy enemy front line units + * Set front line stance to elimination + * Plan CAS at front line +* Inhibit + * Destroy enemy unit production infrastructure + * Destroy factory at Palmyra + * ... + * Destroy enemy front line units + * Set front line stance to elimination + * Plan CAS at front line +* Attack + * Set front line stance to breakthrough + * Destroy enemy front line units + * Set front line stance to elimination + * Plan CAS at front line + +This is not a reflection of the actual task composition but illustrates the capability +of the system. Each task has preconditions which are checked before the task is +decomposed. If preconditions are not met the task is ignored and the next is considered. +For example the task to destroy the factory at Palmyra might be excluded until the air +defenses protecting it are eliminated; or defensive air operations might be excluded if +the enemy does not have sufficient air forces, or if the protected target has sufficient +SAM coverage. + +Each action updates the world state, which causes each action to account for the result +of the tasks executed before it. Above, the preconditions for attacking the factory at +Palmyra may not have been met due to the IADS coverage, leading the planning to decide +on an attack against the IADS in the area instead. When planning the next task in the +same turn, the world state will have been updated to account for the (hopefully) +destroyed SAM sites, allowing the planner to choose the mission to attack the factory. + +Preconditions can be aware of previous actions as well. A precondition for "Plan CAS at +front line" can be "No CAS missions planned at front line" to avoid over-planning CAS +even though it is a primitive task used by many other tasks. + +https://en.wikipedia.org/wiki/Hierarchical_task_network +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from game.commander.tasks.compound.nextaction import PlanNextAction +from game.commander.tasks.theatercommandertask import TheaterCommanderTask +from game.commander.theaterstate import TheaterState +from game.htn import Planner +from game.profiling import MultiEventTracer + +if TYPE_CHECKING: + from game import Game + from gen.flights.ai_flight_planner import CoalitionMissionPlanner + + +class TheaterCommander(Planner[TheaterState, TheaterCommanderTask]): + def __init__(self, game: Game, player: bool) -> None: + super().__init__( + PlanNextAction( + aircraft_cold_start=game.settings.default_start_type == "Cold" + ) + ) + self.game = game + self.player = player + + def plan_missions( + self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer + ) -> None: + state = TheaterState.from_game(self.game, self.player) + while True: + result = self.plan(state) + if result is None: + # Planned all viable tasks this turn. + return + for task in result.tasks: + task.execute(mission_planner, tracer) + state = result.end_state diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py new file mode 100644 index 00000000..891139ec --- /dev/null +++ b/game/commander/theaterstate.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from game.commander.objectivefinder import ObjectiveFinder +from game.htn import WorldState +from game.theater import ControlPoint, FrontLine, MissionTarget +from game.theater.theatergroundobject import ( + TheaterGroundObject, + VehicleGroupGroundObject, + NavalGroundObject, + IadsGroundObject, +) +from game.transfers import Convoy, CargoShip + +if TYPE_CHECKING: + from game import Game + + +@dataclass +class TheaterState(WorldState["TheaterState"]): + vulnerable_control_points: list[ControlPoint] + vulnerable_front_lines: list[FrontLine] + aewc_targets: list[MissionTarget] + refueling_targets: list[MissionTarget] + threatening_air_defenses: list[IadsGroundObject] + enemy_convoys: list[Convoy] + enemy_shipping: list[CargoShip] + threatening_ships: list[NavalGroundObject] + enemy_garrisons: list[VehicleGroupGroundObject] + oca_targets: list[ControlPoint] + strike_targets: list[TheaterGroundObject[Any]] + + def clone(self) -> TheaterState: + # Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly + # expensive. + return TheaterState( + vulnerable_control_points=list(self.vulnerable_control_points), + vulnerable_front_lines=list(self.vulnerable_front_lines), + aewc_targets=list(self.aewc_targets), + refueling_targets=list(self.refueling_targets), + threatening_air_defenses=list(self.threatening_air_defenses), + enemy_convoys=list(self.enemy_convoys), + enemy_shipping=list(self.enemy_shipping), + threatening_ships=list(self.threatening_ships), + enemy_garrisons=list(self.enemy_garrisons), + oca_targets=list(self.oca_targets), + strike_targets=list(self.strike_targets), + ) + + @classmethod + def from_game(cls, game: Game, player: bool) -> TheaterState: + finder = ObjectiveFinder(game, player) + return TheaterState( + vulnerable_control_points=list(finder.vulnerable_control_points()), + vulnerable_front_lines=list(finder.front_lines()), + aewc_targets=[finder.farthest_friendly_control_point()], + refueling_targets=[finder.closest_friendly_control_point()], + threatening_air_defenses=list(finder.threatening_air_defenses()), + enemy_convoys=list(finder.convoys()), + enemy_shipping=list(finder.cargo_ships()), + threatening_ships=list(finder.threatening_ships()), + enemy_garrisons=list(finder.threatening_vehicle_groups()), + oca_targets=list(finder.oca_targets(min_aircraft=20)), + strike_targets=list(finder.strike_targets()), + ) diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 262d5fa5..3c648259 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,9 +1,8 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import timedelta -from dcs.task import Reconnaissance -from game.utils import Distance, feet, nautical_miles from game.data.groundunitclass import GroundUnitClass +from game.utils import Distance, feet, nautical_miles @dataclass @@ -17,6 +16,15 @@ class GroundUnitProcurementRatios: return 0.0 +@dataclass(frozen=True) +class MissionPlannerMaxRanges: + cap: Distance = field(default=nautical_miles(100)) + cas: Distance = field(default=nautical_miles(50)) + offensive: Distance = field(default=nautical_miles(150)) + aewc: Distance = field(default=Distance.inf()) + refueling: Distance = field(default=nautical_miles(200)) + + @dataclass(frozen=True) class Doctrine: cas: bool @@ -65,6 +73,8 @@ class Doctrine: ground_unit_procurement_ratios: GroundUnitProcurementRatios + mission_ranges: MissionPlannerMaxRanges = field(default=MissionPlannerMaxRanges()) + MODERN_DOCTRINE = Doctrine( cap=True, diff --git a/game/htn.py b/game/htn.py new file mode 100644 index 00000000..9399175a --- /dev/null +++ b/game/htn.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections import Iterator, deque +from dataclasses import dataclass +from typing import Any, Generic, Optional, TypeVar + +WorldStateT = TypeVar("WorldStateT", bound="WorldState[Any]") + + +class WorldState(ABC, Generic[WorldStateT]): + @abstractmethod + def clone(self) -> WorldStateT: + ... + + +class Task(Generic[WorldStateT]): + pass + + +Method = list[Task[WorldStateT]] + + +class PrimitiveTask(Task[WorldStateT], Generic[WorldStateT], ABC): + @abstractmethod + def preconditions_met(self, state: WorldStateT) -> bool: + ... + + @abstractmethod + def apply_effects(self, state: WorldStateT) -> None: + ... + + +class CompoundTask(Task[WorldStateT], Generic[WorldStateT], ABC): + @abstractmethod + def each_valid_method(self, state: WorldStateT) -> Iterator[Method[WorldStateT]]: + ... + + +PrimitiveTaskT = TypeVar("PrimitiveTaskT", bound=PrimitiveTask[Any]) + + +@dataclass +class PlanningState(Generic[WorldStateT, PrimitiveTaskT]): + state: WorldStateT + tasks_to_process: deque[Task[WorldStateT]] + plan: list[PrimitiveTaskT] + methods: Optional[Iterator[Method[WorldStateT]]] + + +@dataclass(frozen=True) +class PlanningResult(Generic[WorldStateT, PrimitiveTaskT]): + tasks: list[PrimitiveTaskT] + end_state: WorldStateT + + +class PlanningHistory(Generic[WorldStateT, PrimitiveTaskT]): + def __init__(self) -> None: + self.states: list[PlanningState[WorldStateT, PrimitiveTaskT]] = [] + + def push(self, planning_state: PlanningState[WorldStateT, PrimitiveTaskT]) -> None: + self.states.append(planning_state) + + def pop(self) -> PlanningState[WorldStateT, PrimitiveTaskT]: + return self.states.pop() + + +class Planner(Generic[WorldStateT, PrimitiveTaskT]): + def __init__(self, main_task: Task[WorldStateT]) -> None: + self.main_task = main_task + + def plan( + self, initial_state: WorldStateT + ) -> Optional[PlanningResult[WorldStateT, PrimitiveTaskT]]: + planning_state: PlanningState[WorldStateT, PrimitiveTaskT] = PlanningState( + initial_state, deque([self.main_task]), [], None + ) + history: PlanningHistory[WorldStateT, PrimitiveTaskT] = PlanningHistory() + while planning_state.tasks_to_process: + task = planning_state.tasks_to_process.popleft() + if isinstance(task, PrimitiveTask): + if task.preconditions_met(planning_state.state): + task.apply_effects(planning_state.state) + # Ignore type erasure. We've already verified that this is a Planner + # with a WorldStateT and a PrimitiveTaskT, so we know that the task + # list is a list of CompoundTask[WorldStateT] and PrimitiveTaskT. We + # could scatter more unions throughout to be more explicit but + # there's no way around the type erasure that mypy uses for + # isinstance. + planning_state.plan.append(task) # type: ignore + else: + planning_state = history.pop() + else: + assert isinstance(task, CompoundTask) + # If the methods field of our current state is not None that means we're + # resuming a prior attempt to execute this task after a subtask of the + # previously selected method failed. + # + # Otherwise this is the first exectution of this task so we need to + # create the generator. + if planning_state.methods is None: + methods = task.each_valid_method(planning_state.state) + else: + methods = planning_state.methods + try: + method = next(methods) + history.push( + PlanningState( + planning_state.state.clone(), + # Push the current node back onto the stack so that we + # resume handling this task when we pop back to this state. + planning_state.tasks_to_process + deque([task]), + planning_state.plan, + methods, + ) + ) + planning_state.methods = None + planning_state.tasks_to_process.extend(method) + except StopIteration: + try: + planning_state = history.pop() + except IndexError: + # No valid plan was found. + return None + return PlanningResult(planning_state.plan, planning_state.state) diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index d9aab7ea..fb6f015f 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -2,6 +2,7 @@ from __future__ import annotations import itertools import logging +from abc import ABC from collections import Sequence from typing import Iterator, List, TYPE_CHECKING, Union, Generic, TypeVar, Any @@ -463,10 +464,19 @@ class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]): return False +class IadsGroundObject(TheaterGroundObject[VehicleGroup], ABC): + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + from gen.flights.flight import FlightType + + if not self.is_friendly(for_player): + yield FlightType.DEAD + yield from super().mission_types(for_player) + + # The SamGroundObject represents all type of AA # The TGO can have multiple types of units (AAA,SAM,Support...) # Differentiation can be made during generation with the airdefensegroupgenerator -class SamGroundObject(TheaterGroundObject[VehicleGroup]): +class SamGroundObject(IadsGroundObject): def __init__( self, name: str, @@ -491,7 +501,11 @@ class SamGroundObject(TheaterGroundObject[VehicleGroup]): if not self.is_friendly(for_player): yield FlightType.DEAD yield FlightType.SEAD - yield from super().mission_types(for_player) + for mission_type in super().mission_types(for_player): + # We yielded this ourselves to move it to the top of the list. Don't yield + # it twice. + if mission_type is not FlightType.DEAD: + yield mission_type @property def might_have_aa(self) -> bool: @@ -558,7 +572,7 @@ class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]): return True -class EwrGroundObject(TheaterGroundObject[VehicleGroup]): +class EwrGroundObject(IadsGroundObject): def __init__( self, name: str, @@ -583,13 +597,6 @@ class EwrGroundObject(TheaterGroundObject[VehicleGroup]): # Use Group Id and uppercase EWR return f"{self.faction_color}|EWR|{self.group_id}" - def mission_types(self, for_player: bool) -> Iterator[FlightType]: - from gen.flights.flight import FlightType - - if not self.is_friendly(for_player): - yield FlightType.DEAD - yield from super().mission_types(for_player) - @property def might_have_aa(self) -> bool: return True diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 3833abb8..de290661 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -1,54 +1,39 @@ from __future__ import annotations import logging -import math -import operator import random from collections import defaultdict -from dataclasses import dataclass, field from datetime import timedelta -from enum import Enum, auto from typing import ( Dict, Iterable, Iterator, - List, Optional, Set, TYPE_CHECKING, Tuple, - TypeVar, - Any, ) +from game.commander import TheaterCommander +from game.commander.missionproposals import ProposedFlight, ProposedMission, EscortType +from game.commander.objectivefinder import ObjectiveFinder +from game.data.doctrine import Doctrine from game.dcs.aircrafttype import AircraftType +from game.factions.faction import Faction from game.infos.information import Information from game.procurement import AircraftProcurementRequest from game.profiling import logged_duration, MultiEventTracer from game.squadrons import AirWing, Squadron from game.theater import ( - Airfield, ControlPoint, - Fob, - FrontLine, MissionTarget, OffMapSpawn, - SamGroundObject, - TheaterGroundObject, ) -from game.theater.theatergroundobject import ( - BuildingGroundObject, - EwrGroundObject, - NavalGroundObject, - VehicleGroupGroundObject, -) -from game.transfers import CargoShip, Convoy -from game.utils import Distance, nautical_miles, meters +from game.utils import nautical_miles from gen.ato import Package from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.closestairfields import ( ClosestAirfields, - ObjectiveDistanceCache, ) from gen.flights.flight import ( Flight, @@ -63,61 +48,6 @@ if TYPE_CHECKING: from game.inventory import GlobalAircraftInventory -class EscortType(Enum): - AirToAir = auto() - Sead = auto() - - -@dataclass(frozen=True) -class ProposedFlight: - """A flight outline proposed by the mission planner. - - Proposed flights haven't been assigned specific aircraft yet. They have only - a task, a required number of aircraft, and a maximum distance allowed - between the objective and the departure airfield. - """ - - #: The flight's role. - task: FlightType - - #: The number of aircraft required. - num_aircraft: int - - #: The maximum distance between the objective and the departure airfield. - max_distance: Distance - - #: The type of threat this flight defends against if it is an escort. Escort - #: flights will be pruned if the rest of the package is not threatened by - #: the threat they defend against. If this flight is not an escort, this - #: field is None. - escort_type: Optional[EscortType] = field(default=None) - - def __str__(self) -> str: - return f"{self.task} {self.num_aircraft} ship" - - -@dataclass(frozen=True) -class ProposedMission: - """A mission outline proposed by the mission planner. - - Proposed missions haven't been assigned aircraft yet. They have only an - objective location and a list of proposed flights that are required for the - mission. - """ - - #: The mission objective. - location: MissionTarget - - #: The proposed flights that are required for the mission. - flights: List[ProposedFlight] - - asap: bool = field(default=False) - - def __str__(self) -> str: - flights = ", ".join([str(f) for f in self.flights]) - return f"{self.location.name}: {flights}" - - class AircraftAllocator: """Finds suitable aircraft for proposed missions.""" @@ -271,289 +201,6 @@ class PackageBuilder: self.package.remove_flight(flight) -MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget) - - -class ObjectiveFinder: - """Identifies potential objectives for the mission planner.""" - - # TODO: Merge into doctrine. - AIRFIELD_THREAT_RANGE = nautical_miles(150) - SAM_THREAT_RANGE = nautical_miles(100) - - def __init__(self, game: Game, is_player: bool) -> None: - self.game = game - self.is_player = is_player - - def enemy_air_defenses(self) -> Iterator[tuple[TheaterGroundObject[Any], Distance]]: - """Iterates over all enemy SAM sites.""" - doctrine = self.game.faction_for(self.is_player).doctrine - threat_zones = self.game.threat_zone_for(not self.is_player) - for cp in self.enemy_control_points(): - for ground_object in cp.ground_objects: - if ground_object.is_dead: - continue - - if isinstance(ground_object, EwrGroundObject): - if threat_zones.threatened_by_air_defense(ground_object): - # This is a very weak heuristic for determining whether the EWR - # is close enough to be worth targeting before a SAM that is - # covering it. Ingress distance corresponds to the beginning of - # the attack range and is sufficient for most standoff weapons, - # so treating the ingress distance as the threat distance sorts - # these EWRs such that they will be attacked before SAMs that do - # not threaten the ingress point, but after those that do. - target_range = doctrine.ingress_egress_distance - else: - # But if the EWR isn't covered then we should only be worrying - # about its detection range. - target_range = ground_object.max_detection_range() - elif isinstance(ground_object, SamGroundObject): - target_range = ground_object.max_threat_range() - else: - continue - - yield ground_object, target_range - - def threatening_air_defenses(self) -> Iterator[TheaterGroundObject[Any]]: - """Iterates over enemy SAMs in threat range of friendly control points. - - SAM sites are sorted by their closest proximity to any friendly control - point (airfield or fleet). - """ - - target_ranges: list[tuple[TheaterGroundObject[Any], Distance]] = [] - for target, threat_range in self.enemy_air_defenses(): - ranges: list[Distance] = [] - for cp in self.friendly_control_points(): - ranges.append(meters(target.distance_to(cp)) - threat_range) - target_ranges.append((target, min(ranges))) - - target_ranges = sorted(target_ranges, key=operator.itemgetter(1)) - for target, _range in target_ranges: - yield target - - def enemy_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]: - """Iterates over all enemy vehicle groups.""" - for cp in self.enemy_control_points(): - for ground_object in cp.ground_objects: - if not isinstance(ground_object, VehicleGroupGroundObject): - continue - - if ground_object.is_dead: - continue - - yield ground_object - - def threatening_vehicle_groups(self) -> Iterator[MissionTarget]: - """Iterates over enemy vehicle groups near friendly control points. - - Groups are sorted by their closest proximity to any friendly control - point (airfield or fleet). - """ - return self._targets_by_range(self.enemy_vehicle_groups()) - - def enemy_ships(self) -> Iterator[NavalGroundObject]: - for cp in self.enemy_control_points(): - for ground_object in cp.ground_objects: - if not isinstance(ground_object, NavalGroundObject): - continue - - if ground_object.is_dead: - continue - - yield ground_object - - def threatening_ships(self) -> Iterator[MissionTarget]: - """Iterates over enemy ships near friendly control points. - - Groups are sorted by their closest proximity to any friendly control - point (airfield or fleet). - """ - return self._targets_by_range(self.enemy_ships()) - - def _targets_by_range( - self, targets: Iterable[MissionTargetType] - ) -> Iterator[MissionTargetType]: - target_ranges: list[tuple[MissionTargetType, float]] = [] - for target in targets: - ranges: list[float] = [] - for cp in self.friendly_control_points(): - ranges.append(target.distance_to(cp)) - target_ranges.append((target, min(ranges))) - - target_ranges = sorted(target_ranges, key=operator.itemgetter(1)) - for target, _range in target_ranges: - yield target - - def strike_targets(self) -> Iterator[TheaterGroundObject[Any]]: - """Iterates over enemy strike targets. - - Targets are sorted by their closest proximity to any friendly control - point (airfield or fleet). - """ - targets: list[tuple[TheaterGroundObject[Any], float]] = [] - # Building objectives are made of several individual TGOs (one per - # building). - found_targets: Set[str] = set() - for enemy_cp in self.enemy_control_points(): - for ground_object in enemy_cp.ground_objects: - # TODO: Reuse ground_object.mission_types. - # The mission types for ground objects are currently not - # accurate because we include things like strike and BAI for all - # targets since they have different planning behavior (waypoint - # generation is better for players with strike when the targets - # are stationary, AI behavior against weaker air defenses is - # better with BAI), so that's not a useful filter. Once we have - # better control over planning profiles and target dependent - # loadouts we can clean this up. - if isinstance(ground_object, VehicleGroupGroundObject): - # BAI target, not strike target. - continue - - if isinstance(ground_object, NavalGroundObject): - # Anti-ship target, not strike target. - continue - - if isinstance(ground_object, SamGroundObject): - # SAMs are targeted by DEAD. No need to double plan. - continue - - is_building = isinstance(ground_object, BuildingGroundObject) - is_fob = isinstance(enemy_cp, Fob) - if is_building and is_fob and ground_object.is_control_point: - # This is the FOB structure itself. Can't be repaired or - # targeted by the player, so shouldn't be targetable by the - # AI. - continue - - if ground_object.is_dead: - continue - if ground_object.name in found_targets: - continue - ranges: list[float] = [] - for friendly_cp in self.friendly_control_points(): - ranges.append(ground_object.distance_to(friendly_cp)) - targets.append((ground_object, min(ranges))) - found_targets.add(ground_object.name) - targets = sorted(targets, key=operator.itemgetter(1)) - for target, _range in targets: - yield target - - def front_lines(self) -> Iterator[FrontLine]: - """Iterates over all active front lines in the theater.""" - yield from self.game.theater.conflicts() - - def vulnerable_control_points(self) -> Iterator[ControlPoint]: - """Iterates over friendly CPs that are vulnerable to enemy CPs. - - Vulnerability is defined as any enemy CP within threat range of of the - CP. - """ - for cp in self.friendly_control_points(): - if isinstance(cp, OffMapSpawn): - # Off-map spawn locations don't need protection. - continue - airfields_in_proximity = self.closest_airfields_to(cp) - airfields_in_threat_range = ( - airfields_in_proximity.operational_airfields_within( - self.AIRFIELD_THREAT_RANGE - ) - ) - for airfield in airfields_in_threat_range: - if not airfield.is_friendly(self.is_player): - yield cp - break - - def oca_targets(self, min_aircraft: int) -> Iterator[MissionTarget]: - airfields = [] - for control_point in self.enemy_control_points(): - if not isinstance(control_point, Airfield): - continue - if control_point.base.total_aircraft >= min_aircraft: - airfields.append(control_point) - return self._targets_by_range(airfields) - - def convoys(self) -> Iterator[Convoy]: - for front_line in self.front_lines(): - yield from self.game.transfers.convoys.travelling_to( - front_line.control_point_hostile_to(self.is_player) - ) - - def cargo_ships(self) -> Iterator[CargoShip]: - for front_line in self.front_lines(): - yield from self.game.transfers.cargo_ships.travelling_to( - front_line.control_point_hostile_to(self.is_player) - ) - - def friendly_control_points(self) -> Iterator[ControlPoint]: - """Iterates over all friendly control points.""" - return ( - c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player) - ) - - def farthest_friendly_control_point(self) -> ControlPoint: - """Finds the friendly control point that is farthest from any threats.""" - threat_zones = self.game.threat_zone_for(not self.is_player) - - farthest = None - max_distance = meters(0) - for cp in self.friendly_control_points(): - if isinstance(cp, OffMapSpawn): - continue - distance = threat_zones.distance_to_threat(cp.position) - if distance > max_distance: - farthest = cp - max_distance = distance - - if farthest is None: - raise RuntimeError("Found no friendly control points. You probably lost.") - return farthest - - def closest_friendly_control_point(self) -> ControlPoint: - """Finds the friendly control point that is closest to any threats.""" - threat_zones = self.game.threat_zone_for(not self.is_player) - - closest = None - min_distance = meters(math.inf) - for cp in self.friendly_control_points(): - if isinstance(cp, OffMapSpawn): - continue - distance = threat_zones.distance_to_threat(cp.position) - if distance < min_distance: - closest = cp - min_distance = distance - - if closest is None: - raise RuntimeError("Found no friendly control points. You probably lost.") - return closest - - def enemy_control_points(self) -> Iterator[ControlPoint]: - """Iterates over all enemy control points.""" - return ( - c - for c in self.game.theater.controlpoints - if not c.is_friendly(self.is_player) - ) - - def all_possible_targets(self) -> Iterator[MissionTarget]: - """Iterates over all possible mission targets in the theater. - - Valid mission targets are control points (airfields and carriers), front - lines, and ground objects (SAM sites, factories, resource extraction - sites, etc). - """ - for cp in self.game.theater.controlpoints: - yield cp - yield from cp.ground_objects - yield from self.front_lines() - - @staticmethod - def closest_airfields_to(location: MissionTarget) -> ClosestAirfields: - """Returns the closest airfields to the given location.""" - return ObjectiveDistanceCache.get_closest_airfields(location) - - class CoalitionMissionPlanner: """Coalition flight planning AI. @@ -577,17 +224,6 @@ class CoalitionMissionPlanner: TODO: Stance and doctrine-specific planning behavior. """ - # TODO: Merge into doctrine, also limit by aircraft. - MAX_CAP_RANGE = nautical_miles(100) - MAX_CAS_RANGE = nautical_miles(50) - MAX_ANTISHIP_RANGE = nautical_miles(150) - MAX_BAI_RANGE = nautical_miles(150) - MAX_OCA_RANGE = nautical_miles(150) - MAX_SEAD_RANGE = nautical_miles(150) - MAX_STRIKE_RANGE = nautical_miles(150) - MAX_AWEC_RANGE = Distance.inf() - MAX_TANKER_RANGE = nautical_miles(200) - def __init__(self, game: Game, is_player: bool) -> None: self.game = game self.is_player = is_player @@ -595,7 +231,11 @@ class CoalitionMissionPlanner: self.ato = self.game.blue_ato if is_player else self.game.red_ato self.threat_zones = self.game.threat_zone_for(not self.is_player) self.procurement_requests = self.game.procurement_requests_for(self.is_player) - self.faction = self.game.faction_for(self.is_player) + self.faction: Faction = self.game.faction_for(self.is_player) + + @property + def doctrine(self) -> Doctrine: + return self.faction.doctrine def air_wing_can_plan(self, mission_type: FlightType) -> bool: """Returns True if it is possible for the air wing to plan this mission type. @@ -607,237 +247,13 @@ class CoalitionMissionPlanner: """ return self.game.air_wing_for(self.is_player).can_auto_plan(mission_type) - def critical_missions(self) -> Iterator[ProposedMission]: - """Identifies the most important missions to plan this turn. - - Non-critical missions that cannot be fulfilled will create purchase - orders for the next turn. Critical missions will create a purchase order - unless the mission can be doubly fulfilled. In other words, the AI will - attempt to have *double* the aircraft it needs for these missions to - ensure that they can be planned again next turn even if all aircraft are - eliminated this turn. - """ - - # Find farthest, friendly CP for AEWC. - yield ProposedMission( - self.objective_finder.farthest_friendly_control_point(), - [ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)], - # Supports all the early CAP flights, so should be in the air ASAP. - asap=True, - ) - - yield ProposedMission( - self.objective_finder.closest_friendly_control_point(), - [ProposedFlight(FlightType.REFUELING, 1, self.MAX_TANKER_RANGE)], - ) - - # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP. - for cp in self.objective_finder.vulnerable_control_points(): - # Plan CAP in such a way, that it is established during the whole desired mission length - for _ in range( - 0, - int(self.game.settings.desired_player_mission_duration.total_seconds()), - int(self.faction.doctrine.cap_duration.total_seconds()), - ): - yield ProposedMission( - cp, - [ - ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), - ], - ) - - # Find front lines, plan CAS. - for front_line in self.objective_finder.front_lines(): - yield ProposedMission( - front_line, - [ - ProposedFlight(FlightType.CAS, 2, self.MAX_CAS_RANGE), - # This is *not* an escort because front lines don't create a threat - # zone. Generating threat zones from front lines causes the front - # line to push back BARCAPs as it gets closer to the base. While - # front lines do have the same problem of potentially pulling - # BARCAPs off bases to engage a front line TARCAP, that's probably - # the one time where we do want that. - # - # TODO: Use intercepts and extra TARCAPs to cover bases near fronts. - # We don't have intercept missions yet so this isn't something we - # can do today, but we should probably return to having the front - # line project a threat zone (so that strike missions will route - # around it) and instead *not plan* a BARCAP at bases near the - # front, since there isn't a place to put a barrier. Instead, the - # aircraft that would have been a BARCAP could be used as additional - # interceptors and TARCAPs which will defend the base but won't be - # trying to avoid front line contacts. - ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE), - ], - ) - - def propose_missions(self) -> Iterator[ProposedMission]: - """Identifies and iterates over potential mission in priority order.""" - yield from self.critical_missions() - - # Find enemy SAM sites with ranges that cover friendly CPs, front lines, - # or objects, plan DEAD. - # Find enemy SAM sites with ranges that extend to within 50 nmi of - # friendly CPs, front, lines, or objects, plan DEAD. - for sam in self.objective_finder.threatening_air_defenses(): - flights = [ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE)] - - # Only include SEAD against SAMs that still have emitters. No need to - # suppress an EWR, and SEAD isn't useful against a SAM that no longer has a - # working track radar. - # - # For SAMs without track radars and EWRs, we still want a SEAD escort if - # needed. - # - # Note that there is a quirk here: we should potentially be included a SEAD - # escort *and* SEAD when the target is a radar SAM but the flight path is - # also threatened by SAMs. We don't want to include a SEAD escort if the - # package is *only* threatened by the target though. Could be improved, but - # needs a decent refactor to the escort planning to do so. - if sam.has_live_radar_sam: - flights.append(ProposedFlight(FlightType.SEAD, 2, self.MAX_SEAD_RANGE)) - else: - flights.append( - ProposedFlight( - FlightType.SEAD_ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.Sead - ) - ) - # TODO: Max escort range. - flights.append( - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.AirToAir - ) - ) - yield ProposedMission(sam, flights) - - # These will only rarely get planned. When a convoy is travelling multiple legs, - # they're targetable after the first leg. The reason for this is that - # procurement happens *after* mission planning so that the missions that could - # not be filled will guide the procurement process. Procurement is the stage - # that convoys are created (because they're created to move ground units that - # were just purchased), so we haven't created any yet. Any incomplete transfers - # from the previous turn (multi-leg journeys) will still be present though so - # they can be targeted. - # - # Even after this is fixed, the player's convoys that were created through the - # UI will never be targeted on the first turn of their journey because the AI - # stops planning after the start of the turn. We could potentially fix this by - # moving opfor mission planning until the takeoff button is pushed. - for convoy in self.objective_finder.convoys(): - yield ProposedMission( - convoy, - [ - ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE), - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir - ), - ProposedFlight( - FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead - ), - ], - ) - - for ship in self.objective_finder.cargo_ships(): - yield ProposedMission( - ship, - [ - ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE), - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir - ), - ProposedFlight( - FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead - ), - ], - ) - - for group in self.objective_finder.threatening_ships(): - yield ProposedMission( - group, - [ - ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE), - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, - 2, - self.MAX_ANTISHIP_RANGE, - EscortType.AirToAir, - ), - ], - ) - - for group in self.objective_finder.threatening_vehicle_groups(): - yield ProposedMission( - group, - [ - ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE), - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir - ), - ProposedFlight( - FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead - ), - ], - ) - - for target in self.objective_finder.oca_targets(min_aircraft=20): - flights = [ - ProposedFlight(FlightType.OCA_RUNWAY, 2, self.MAX_OCA_RANGE), - ] - if self.game.settings.default_start_type == "Cold": - # Only schedule if the default start type is Cold. If the player - # has set anything else there are no targets to hit. - flights.append( - ProposedFlight(FlightType.OCA_AIRCRAFT, 2, self.MAX_OCA_RANGE) - ) - flights.extend( - [ - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_OCA_RANGE, EscortType.AirToAir - ), - ProposedFlight( - FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead - ), - ] - ) - yield ProposedMission(target, flights) - - # Plan strike missions. - for target in self.objective_finder.strike_targets(): - yield ProposedMission( - target, - [ - ProposedFlight(FlightType.STRIKE, 2, self.MAX_STRIKE_RANGE), - # TODO: Max escort range. - ProposedFlight( - FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE, EscortType.AirToAir - ), - ProposedFlight( - FlightType.SEAD_ESCORT, - 2, - self.MAX_STRIKE_RANGE, - EscortType.Sead, - ), - ], - ) - def plan_missions(self) -> None: """Identifies and plans mission for the turn.""" player = "Blue" if self.is_player else "Red" with logged_duration(f"{player} mission identification and fulfillment"): with MultiEventTracer() as tracer: - for proposed_mission in self.propose_missions(): - self.plan_mission(proposed_mission, tracer) - - with logged_duration(f"{player} reserve mission planning"): - with MultiEventTracer() as tracer: - for critical_mission in self.critical_missions(): - self.plan_mission(critical_mission, tracer, reserves=True) + commander = TheaterCommander(self.game, self.is_player) + commander.plan_missions(self, tracer) with logged_duration(f"{player} mission scheduling"): self.stagger_missions() @@ -847,6 +263,9 @@ class CoalitionMissionPlanner: for aircraft, available in inventory.all_aircraft: self.message("Unused aircraft", f"{available} {aircraft} from {cp}") + coalition_text = "player" if self.is_player else "opfor" + logging.debug(f"Planned {len(self.ato.packages)} {coalition_text} missions") + def plan_flight( self, mission: ProposedMission, From dda595512150088fc8d0596e5fe3a62e3d966d78 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 22 Jun 2021 14:20:03 -0700 Subject: [PATCH 049/167] Improve DEAD mission prioritization. This alters the DEAD task planning to be the *least* preferred task, but prevents other tasks from being planned unless they are excepted to be clear of air defenses first. Even so, missions are a guaranteed success so those other missions will still get SEAD escorts if there's potential for a SAM in the area. This means that air defenses that are not protecting a more useful target (like a convoy, armor column, building, etc) will no longer be considered by the mission planner. This isn't *quite* right since we currently only check the target area for air defenses rather than the entire flight plan, so there's a chance that we ignore IADS that have threatened ingress points (though that's mostly solved by the flight plan layout). This also is still slightly limited because it's not checking for aircraft availability at this stage yet, so we may aggressively plan missions that we should be skipping unless we can guarantee that the DEAD mission was planned. However, that's not new behavior. --- changelog.md | 2 + game/commander/objectivefinder.py | 44 +--------- game/commander/tasks/compound/degradeiads.py | 7 +- game/commander/tasks/compound/destroyships.py | 11 --- game/commander/tasks/compound/nextaction.py | 4 +- game/commander/tasks/packageplanningtask.py | 52 ++++++++++- game/commander/tasks/primitive/antiship.py | 6 +- .../commander/tasks/primitive/antishipping.py | 4 +- game/commander/tasks/primitive/bai.py | 4 +- .../tasks/primitive/convoyinterdiction.py | 4 +- game/commander/tasks/primitive/dead.py | 6 +- game/commander/tasks/primitive/oca.py | 4 +- game/commander/tasks/primitive/strike.py | 4 +- game/commander/theaterstate.py | 53 ++++++++++-- game/threatzones.py | 86 +++++++++++++------ 15 files changed, 188 insertions(+), 103 deletions(-) delete mode 100644 game/commander/tasks/compound/destroyships.py diff --git a/changelog.md b/changelog.md index 1acecb85..8810dc72 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,8 @@ Saves from 3.x are not compatible with 5.0. ## Features/Improvements +* **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. + ## Fixes # 4.1.0 diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index 193205c2..e2bab894 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -43,53 +43,15 @@ class ObjectiveFinder: self.game = game self.is_player = is_player - def enemy_air_defenses(self) -> Iterator[tuple[IadsGroundObject, Distance]]: + def enemy_air_defenses(self) -> Iterator[IadsGroundObject]: """Iterates over all enemy SAM sites.""" - doctrine = self.game.faction_for(self.is_player).doctrine - threat_zones = self.game.threat_zone_for(not self.is_player) for cp in self.enemy_control_points(): for ground_object in cp.ground_objects: if ground_object.is_dead: continue - if isinstance(ground_object, EwrGroundObject): - if threat_zones.threatened_by_air_defense(ground_object): - # This is a very weak heuristic for determining whether the EWR - # is close enough to be worth targeting before a SAM that is - # covering it. Ingress distance corresponds to the beginning of - # the attack range and is sufficient for most standoff weapons, - # so treating the ingress distance as the threat distance sorts - # these EWRs such that they will be attacked before SAMs that do - # not threaten the ingress point, but after those that do. - target_range = doctrine.ingress_egress_distance - else: - # But if the EWR isn't covered then we should only be worrying - # about its detection range. - target_range = ground_object.max_detection_range() - elif isinstance(ground_object, SamGroundObject): - target_range = ground_object.max_threat_range() - else: - continue - - yield ground_object, target_range - - def threatening_air_defenses(self) -> Iterator[IadsGroundObject]: - """Iterates over enemy SAMs in threat range of friendly control points. - - SAM sites are sorted by their closest proximity to any friendly control - point (airfield or fleet). - """ - - target_ranges: list[tuple[IadsGroundObject, Distance]] = [] - for target, threat_range in self.enemy_air_defenses(): - ranges: list[Distance] = [] - for cp in self.friendly_control_points(): - ranges.append(meters(target.distance_to(cp)) - threat_range) - target_ranges.append((target, min(ranges))) - - target_ranges = sorted(target_ranges, key=operator.itemgetter(1)) - for target, _range in target_ranges: - yield target + if isinstance(ground_object, IadsGroundObject): + yield ground_object def enemy_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]: """Iterates over all enemy vehicle groups.""" diff --git a/game/commander/tasks/compound/degradeiads.py b/game/commander/tasks/compound/degradeiads.py index 10560058..ab50d5b8 100644 --- a/game/commander/tasks/compound/degradeiads.py +++ b/game/commander/tasks/compound/degradeiads.py @@ -1,11 +1,16 @@ from collections import Iterator +from game.commander.tasks.primitive.antiship import PlanAntiShip from game.commander.tasks.primitive.dead import PlanDead from game.commander.theaterstate import TheaterState from game.htn import CompoundTask, Method +from game.theater.theatergroundobject import IadsGroundObject class DegradeIads(CompoundTask[TheaterState]): def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: for air_defense in state.threatening_air_defenses: - yield [PlanDead(air_defense)] + if isinstance(air_defense, IadsGroundObject): + yield [PlanDead(air_defense)] + else: + yield [PlanAntiShip(air_defense)] diff --git a/game/commander/tasks/compound/destroyships.py b/game/commander/tasks/compound/destroyships.py deleted file mode 100644 index e857f05e..00000000 --- a/game/commander/tasks/compound/destroyships.py +++ /dev/null @@ -1,11 +0,0 @@ -from collections import Iterator - -from game.commander.tasks.primitive.antiship import PlanAntiShip -from game.commander.theaterstate import TheaterState -from game.htn import CompoundTask, Method - - -class DestroyShips(CompoundTask[TheaterState]): - def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - for ship in state.threatening_ships: - yield [PlanAntiShip(ship)] diff --git a/game/commander/tasks/compound/nextaction.py b/game/commander/tasks/compound/nextaction.py index bdfc4e46..8863600b 100644 --- a/game/commander/tasks/compound/nextaction.py +++ b/game/commander/tasks/compound/nextaction.py @@ -8,7 +8,6 @@ from game.commander.tasks.compound.attackairinfrastructure import ( from game.commander.tasks.compound.attackbuildings import AttackBuildings from game.commander.tasks.compound.attackgarrisons import AttackGarrisons from game.commander.tasks.compound.degradeiads import DegradeIads -from game.commander.tasks.compound.destroyships import DestroyShips from game.commander.tasks.compound.frontlinedefense import FrontLineDefense from game.commander.tasks.compound.interdictreinforcements import ( InterdictReinforcements, @@ -28,9 +27,8 @@ class PlanNextAction(CompoundTask[TheaterState]): yield [PlanRefuelingSupport()] yield [ProtectAirSpace()] yield [FrontLineDefense()] - yield [DegradeIads()] yield [InterdictReinforcements()] - yield [DestroyShips()] yield [AttackGarrisons()] yield [AttackAirInfrastructure(self.aircraft_cold_start)] yield [AttackBuildings()] + yield [DegradeIads()] diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index 26f2db3a..5013fde7 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -1,16 +1,19 @@ from __future__ import annotations +import itertools +import operator from abc import abstractmethod from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Optional, Generic, TypeVar +from typing import TYPE_CHECKING, Optional, Generic, TypeVar, Iterator, Union from game.commander.missionproposals import ProposedFlight, EscortType, ProposedMission +from game.commander.tasks.theatercommandertask import TheaterCommanderTask from game.commander.theaterstate import TheaterState from game.data.doctrine import Doctrine -from game.htn import PrimitiveTask from game.profiling import MultiEventTracer from game.theater import MissionTarget -from game.utils import Distance +from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject +from game.utils import Distance, meters from gen.flights.flight import FlightType if TYPE_CHECKING: @@ -23,7 +26,7 @@ MissionTargetT = TypeVar("MissionTargetT", bound=MissionTarget) # TODO: Refactor so that we don't need to call up to the mission planner. # Bypass type checker due to https://github.com/python/mypy/issues/5374 @dataclass # type: ignore -class PackagePlanningTask(PrimitiveTask[TheaterState], Generic[MissionTargetT]): +class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): target: MissionTargetT flights: list[ProposedFlight] = field(init=False) @@ -71,3 +74,44 @@ class PackagePlanningTask(PrimitiveTask[TheaterState], Generic[MissionTargetT]): doctrine.mission_ranges.offensive, EscortType.AirToAir, ) + + def iter_iads_threats( + self, state: TheaterState + ) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]: + target_ranges: list[ + tuple[Union[IadsGroundObject, NavalGroundObject], Distance] + ] = [] + all_iads: Iterator[ + Union[IadsGroundObject, NavalGroundObject] + ] = itertools.chain(state.enemy_air_defenses, state.enemy_ships) + for target in all_iads: + distance = meters(target.distance_to(self.target)) + threat_range = target.max_threat_range() + if not threat_range: + continue + # IADS out of range of our target area will have a positive + # distance_to_threat and should be pruned. The rest have a decreasing + # distance_to_threat as overlap increases. The most negative distance has + # the greatest coverage of the target and should be treated as the highest + # priority threat. + distance_to_threat = distance - threat_range + if distance_to_threat > meters(0): + continue + target_ranges.append((target, distance_to_threat)) + + # TODO: Prioritize IADS by vulnerability? + target_ranges = sorted(target_ranges, key=operator.itemgetter(1)) + for target, _range in target_ranges: + yield target + + def target_area_preconditions_met( + self, state: TheaterState, ignore_iads: bool = False + ) -> bool: + """Checks if the target area has been cleared of threats.""" + threatened = False + if not ignore_iads: + for iads_threat in self.iter_iads_threats(state): + threatened = True + if iads_threat not in state.threatening_air_defenses: + state.threatening_air_defenses.append(iads_threat) + return not threatened diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py index 48e84628..cf9741e5 100644 --- a/game/commander/tasks/primitive/antiship.py +++ b/game/commander/tasks/primitive/antiship.py @@ -13,10 +13,12 @@ from gen.flights.flight import FlightType @dataclass class PlanAntiShip(PackagePlanningTask[NavalGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: - return self.target in state.threatening_ships + if self.target not in state.threatening_air_defenses: + return False + return self.target_area_preconditions_met(state, ignore_iads=True) def apply_effects(self, state: TheaterState) -> None: - state.threatening_ships.remove(self.target) + state.eliminate_ship(self.target) def propose_flights(self, doctrine: Doctrine) -> None: self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive) diff --git a/game/commander/tasks/primitive/antishipping.py b/game/commander/tasks/primitive/antishipping.py index b5d31c7e..370afcfd 100644 --- a/game/commander/tasks/primitive/antishipping.py +++ b/game/commander/tasks/primitive/antishipping.py @@ -12,7 +12,9 @@ from gen.flights.flight import FlightType @dataclass class PlanAntiShipping(PackagePlanningTask[CargoShip]): def preconditions_met(self, state: TheaterState) -> bool: - return self.target in state.enemy_shipping + if self.target not in state.enemy_shipping: + return False + return self.target_area_preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.enemy_shipping.remove(self.target) diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py index 3d6c50d5..c0dc328c 100644 --- a/game/commander/tasks/primitive/bai.py +++ b/game/commander/tasks/primitive/bai.py @@ -12,7 +12,9 @@ from gen.flights.flight import FlightType @dataclass class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: - return self.target in state.enemy_garrisons + if self.target not in state.enemy_garrisons: + return False + return self.target_area_preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.enemy_garrisons.remove(self.target) diff --git a/game/commander/tasks/primitive/convoyinterdiction.py b/game/commander/tasks/primitive/convoyinterdiction.py index bc652590..7eb52716 100644 --- a/game/commander/tasks/primitive/convoyinterdiction.py +++ b/game/commander/tasks/primitive/convoyinterdiction.py @@ -12,7 +12,9 @@ from gen.flights.flight import FlightType @dataclass class PlanConvoyInterdiction(PackagePlanningTask[Convoy]): def preconditions_met(self, state: TheaterState) -> bool: - return self.target in state.enemy_convoys + if self.target not in state.enemy_convoys: + return False + return self.target_area_preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.enemy_convoys.remove(self.target) diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py index 8784800f..87c48b34 100644 --- a/game/commander/tasks/primitive/dead.py +++ b/game/commander/tasks/primitive/dead.py @@ -13,10 +13,12 @@ from gen.flights.flight import FlightType @dataclass class PlanDead(PackagePlanningTask[IadsGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: - return self.target in state.threatening_air_defenses + if self.target not in state.threatening_air_defenses: + return False + return self.target_area_preconditions_met(state, ignore_iads=True) def apply_effects(self, state: TheaterState) -> None: - state.threatening_air_defenses.remove(self.target) + state.eliminate_air_defense(self.target) def propose_flights(self, doctrine: Doctrine) -> None: self.propose_flight(FlightType.DEAD, 2, doctrine.mission_ranges.offensive) diff --git a/game/commander/tasks/primitive/oca.py b/game/commander/tasks/primitive/oca.py index 11f8bfa8..9a41a2e1 100644 --- a/game/commander/tasks/primitive/oca.py +++ b/game/commander/tasks/primitive/oca.py @@ -14,7 +14,9 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]): aircraft_cold_start: bool def preconditions_met(self, state: TheaterState) -> bool: - return self.target in state.oca_targets + if self.target not in state.oca_targets: + return False + return self.target_area_preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.oca_targets.remove(self.target) diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py index 07f30f5a..cb943c47 100644 --- a/game/commander/tasks/primitive/strike.py +++ b/game/commander/tasks/primitive/strike.py @@ -13,7 +13,9 @@ from gen.flights.flight import FlightType @dataclass class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]): def preconditions_met(self, state: TheaterState) -> bool: - return self.target in state.strike_targets + if self.target not in state.strike_targets: + return False + return self.target_area_preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.strike_targets.remove(self.target) diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index 891139ec..c1508e34 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -1,9 +1,11 @@ from __future__ import annotations +import itertools from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Union from game.commander.objectivefinder import ObjectiveFinder +from game.data.doctrine import Doctrine from game.htn import WorldState from game.theater import ControlPoint, FrontLine, MissionTarget from game.theater.theatergroundobject import ( @@ -12,6 +14,7 @@ from game.theater.theatergroundobject import ( NavalGroundObject, IadsGroundObject, ) +from game.threatzones import ThreatZones from game.transfers import Convoy, CargoShip if TYPE_CHECKING: @@ -24,13 +27,35 @@ class TheaterState(WorldState["TheaterState"]): vulnerable_front_lines: list[FrontLine] aewc_targets: list[MissionTarget] refueling_targets: list[MissionTarget] - threatening_air_defenses: list[IadsGroundObject] + enemy_air_defenses: list[IadsGroundObject] + threatening_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]] enemy_convoys: list[Convoy] enemy_shipping: list[CargoShip] - threatening_ships: list[NavalGroundObject] + enemy_ships: list[NavalGroundObject] enemy_garrisons: list[VehicleGroupGroundObject] oca_targets: list[ControlPoint] strike_targets: list[TheaterGroundObject[Any]] + enemy_barcaps: list[ControlPoint] + threat_zones: ThreatZones + opposing_doctrine: Doctrine + + def _rebuild_threat_zones(self) -> None: + """Recreates the theater's threat zones based on the current planned state.""" + self.threat_zones = ThreatZones.for_threats( + self.opposing_doctrine, + barcap_locations=self.enemy_barcaps, + air_defenses=itertools.chain(self.enemy_air_defenses, self.enemy_ships), + ) + + def eliminate_air_defense(self, target: IadsGroundObject) -> None: + self.threatening_air_defenses.remove(target) + self.enemy_air_defenses.remove(target) + self._rebuild_threat_zones() + + def eliminate_ship(self, target: NavalGroundObject) -> None: + self.threatening_air_defenses.remove(target) + self.enemy_ships.remove(target) + self._rebuild_threat_zones() def clone(self) -> TheaterState: # Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly @@ -40,13 +65,23 @@ class TheaterState(WorldState["TheaterState"]): vulnerable_front_lines=list(self.vulnerable_front_lines), aewc_targets=list(self.aewc_targets), refueling_targets=list(self.refueling_targets), - threatening_air_defenses=list(self.threatening_air_defenses), + enemy_air_defenses=list(self.enemy_air_defenses), enemy_convoys=list(self.enemy_convoys), enemy_shipping=list(self.enemy_shipping), - threatening_ships=list(self.threatening_ships), + enemy_ships=list(self.enemy_ships), enemy_garrisons=list(self.enemy_garrisons), oca_targets=list(self.oca_targets), strike_targets=list(self.strike_targets), + enemy_barcaps=list(self.enemy_barcaps), + threat_zones=self.threat_zones, + opposing_doctrine=self.opposing_doctrine, + # Persistent properties are not copied. These are a way for failed subtasks + # to communicate requirements to other tasks. For example, the task to + # attack enemy garrisons might fail because the target area has IADS + # protection. In that case, the preconditions of PlanBai would fail, but + # would add the IADS that prevented it from being planned to the list of + # IADS threats so that DegradeIads will consider it a threat later. + threatening_air_defenses=self.threatening_air_defenses, ) @classmethod @@ -57,11 +92,15 @@ class TheaterState(WorldState["TheaterState"]): vulnerable_front_lines=list(finder.front_lines()), aewc_targets=[finder.farthest_friendly_control_point()], refueling_targets=[finder.closest_friendly_control_point()], - threatening_air_defenses=list(finder.threatening_air_defenses()), + enemy_air_defenses=list(finder.enemy_air_defenses()), + threatening_air_defenses=[], enemy_convoys=list(finder.convoys()), enemy_shipping=list(finder.cargo_ships()), - threatening_ships=list(finder.threatening_ships()), + enemy_ships=list(finder.enemy_ships()), enemy_garrisons=list(finder.threatening_vehicle_groups()), oca_targets=list(finder.oca_targets(min_aircraft=20)), strike_targets=list(finder.strike_targets()), + enemy_barcaps=list(game.theater.control_points_for(not player)), + threat_zones=game.threat_zone_for(not player), + opposing_doctrine=game.faction_for(not player).doctrine, ) diff --git a/game/threatzones.py b/game/threatzones.py index 14ee8599..16416573 100644 --- a/game/threatzones.py +++ b/game/threatzones.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import singledispatchmethod -from typing import Optional, TYPE_CHECKING, Union, Iterable +from typing import Optional, TYPE_CHECKING, Union, Iterable, Any from dcs.mapping import Point as DcsPoint from shapely.geometry import ( @@ -13,7 +13,8 @@ from shapely.geometry import ( from shapely.geometry.base import BaseGeometry from shapely.ops import nearest_points, unary_union -from game.theater import ControlPoint, MissionTarget +from game.data.doctrine import Doctrine +from game.theater import ControlPoint, MissionTarget, TheaterGroundObject from game.utils import Distance, meters, nautical_miles from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import Flight, FlightWaypoint @@ -82,6 +83,10 @@ class ThreatZones: LineString((self.dcs_to_shapely_point(p.position) for p in flight.points)) ) + @threatened_by_aircraft.register + def _threatened_by_aircraft_mission_target(self, target: MissionTarget) -> bool: + return self.threatened_by_aircraft(self.dcs_to_shapely_point(target.position)) + def waypoints_threatened_by_aircraft( self, waypoints: Iterable[FlightWaypoint] ) -> bool: @@ -145,8 +150,9 @@ class ThreatZones: return None @classmethod - def barcap_threat_range(cls, game: Game, control_point: ControlPoint) -> Distance: - doctrine = game.faction_for(control_point.captured).doctrine + def barcap_threat_range( + cls, doctrine: Doctrine, control_point: ControlPoint + ) -> Distance: cap_threat_range = ( doctrine.cap_max_distance_from_cp + doctrine.cap_engagement_range ) @@ -185,33 +191,59 @@ class ThreatZones: """ air_threats = [] air_defenses = [] - radar_sam_threats = [] - for control_point in game.theater.controlpoints: - if control_point.captured != player: - continue - if control_point.runway_is_operational(): - point = ShapelyPoint(control_point.position.x, control_point.position.y) - cap_threat_range = cls.barcap_threat_range(game, control_point) - air_threats.append(point.buffer(cap_threat_range.meters)) + for control_point in game.theater.control_points_for(player): + air_threats.append(control_point) + air_defenses.extend(control_point.ground_objects) - for tgo in control_point.ground_objects: - for group in tgo.groups: - threat_range = tgo.threat_range(group) - # Any system with a shorter range than this is not worth - # even avoiding. - if threat_range > nautical_miles(3): - point = ShapelyPoint(tgo.position.x, tgo.position.y) - threat_zone = point.buffer(threat_range.meters) - air_defenses.append(threat_zone) - radar_threat_range = tgo.threat_range(group, radar_only=True) - if radar_threat_range > nautical_miles(3): - point = ShapelyPoint(tgo.position.x, tgo.position.y) - threat_zone = point.buffer(threat_range.meters) - radar_sam_threats.append(threat_zone) + return cls.for_threats( + game.faction_for(player).doctrine, air_threats, air_defenses + ) + + @classmethod + def for_threats( + cls, + doctrine: Doctrine, + barcap_locations: Iterable[ControlPoint], + air_defenses: Iterable[TheaterGroundObject[Any]], + ) -> ThreatZones: + """Generates the threat zones projected by the given locations. + + Args: + doctrine: The doctrine of the owning coalition. + barcap_locations: The locations that will be considered for BARCAP planning. + air_defenses: TGOs that may have air defenses. + + Returns: + The threat zones projected by the given locations. If the threat zone + belongs to the player, it is the zone that will be avoided by the enemy and + vice versa. + """ + air_threats = [] + air_defense_threats = [] + radar_sam_threats = [] + for barcap in barcap_locations: + point = ShapelyPoint(barcap.position.x, barcap.position.y) + cap_threat_range = cls.barcap_threat_range(doctrine, barcap) + air_threats.append(point.buffer(cap_threat_range.meters)) + + for tgo in air_defenses: + for group in tgo.groups: + threat_range = tgo.threat_range(group) + # Any system with a shorter range than this is not worth + # even avoiding. + if threat_range > nautical_miles(3): + point = ShapelyPoint(tgo.position.x, tgo.position.y) + threat_zone = point.buffer(threat_range.meters) + air_defense_threats.append(threat_zone) + radar_threat_range = tgo.threat_range(group, radar_only=True) + if radar_threat_range > nautical_miles(3): + point = ShapelyPoint(tgo.position.x, tgo.position.y) + threat_zone = point.buffer(threat_range.meters) + radar_sam_threats.append(threat_zone) return cls( airbases=unary_union(air_threats), - air_defenses=unary_union(air_defenses), + air_defenses=unary_union(air_defense_threats), radar_sam_threats=unary_union(radar_sam_threats), ) From cd558daf5a601f64e98ad4ae2e7f6351be702632 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 12 Jul 2021 13:35:31 -0700 Subject: [PATCH 050/167] Add decorator for tracking save compat. Used to decorate functions or methods that have save compat code for a given major version. ``` @has_save_compat_for(5) def foo() -> None: ... ``` This function will raise an error at startup if it is decorated as having save compat for a version other than the current major version of the game. A new major version is the definition of a save compat break, so keeping around the old compat code serves no purpose other than hiding initialization bugs. The compat code and the decorator should be removed in the branch raising the error. --- game/savecompat.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++ game/version.py | 9 ++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 game/savecompat.py diff --git a/game/savecompat.py b/game/savecompat.py new file mode 100644 index 00000000..5388dd24 --- /dev/null +++ b/game/savecompat.py @@ -0,0 +1,48 @@ +"""Tools for aiding in save compat removal after compatibility breaks.""" +from collections import Callable +from typing import TypeVar + +from game.version import MAJOR_VERSION + +ReturnT = TypeVar("ReturnT") + + +class DeprecatedSaveCompatError(RuntimeError): + def __init__(self, function_name: str) -> None: + super().__init__( + f"{function_name} has save compat code for a different major version." + ) + + +def has_save_compat_for( + major: int, +) -> Callable[[Callable[..., ReturnT]], Callable[..., ReturnT]]: + """Declares a function or method as having save compat code for a given version. + + If the function has save compatibility for the current major version, there is no + change in behavior. + + If the function has save compatibility for a *different* (future or past) major + version, DeprecatedSaveCompatError will be raised during startup. Since a break in + save compatibility is the definition of a major version break, there's no need to + keep around old save compat code; it only serves to mask initialization bugs. + + Args: + major: The major version for which the decorated function has save + compatibility. + + Returns: + The decorated function or method. + + Raises: + DeprecatedSaveCompatError: The decorated function has save compat code for + another version of liberation, and that code (and the decorator declaring it) + should be removed from this branch. + """ + + def decorator(func: Callable[..., ReturnT]) -> Callable[..., ReturnT]: + if major != MAJOR_VERSION: + raise DeprecatedSaveCompatError(func.__name__) + return func + + return decorator diff --git a/game/version.py b/game/version.py index c25490e0..7c989e2a 100644 --- a/game/version.py +++ b/game/version.py @@ -1,8 +1,15 @@ from pathlib import Path +MAJOR_VERSION = 5 +MINOR_VERSION = 0 +MICRO_VERSION = 0 + + def _build_version_string() -> str: - components = ["5.0.0"] + components = [ + ".".join(str(v) for v in (MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION)) + ] build_number_path = Path("resources/buildnumber") if build_number_path.exists(): with build_number_path.open("r") as build_number_file: From 7e4390d7435572d6c2d0ecf1f1072e26e2b78edd Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 12 Jul 2021 16:59:49 -0700 Subject: [PATCH 051/167] Improve prioritization of garrison targeting. Garrison groups should be preferred with the following priority: 1. Groups blocking base capture 2. Groups at bases connected to an active front line 3. Rear guard units Previously they were being prioritized based on the distance to the closest friendy control point, which is similar to this but an aggressively placed carrier could throw it off. --- game/commander/garrisons.py | 63 +++++++++++++++++++ game/commander/objectivefinder.py | 8 --- .../tasks/compound/attackgarrisons.py | 2 +- game/commander/tasks/primitive/bai.py | 2 +- game/commander/theaterstate.py | 9 +-- 5 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 game/commander/garrisons.py diff --git a/game/commander/garrisons.py b/game/commander/garrisons.py new file mode 100644 index 00000000..27b43de2 --- /dev/null +++ b/game/commander/garrisons.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections import Iterator +from dataclasses import dataclass + +from game.theater import ConflictTheater +from game.theater.theatergroundobject import VehicleGroupGroundObject +from game.utils import meters, nautical_miles + + +@dataclass +class Garrisons: + blocking_capture: list[VehicleGroupGroundObject] + defending_front_line: list[VehicleGroupGroundObject] + reserves: list[VehicleGroupGroundObject] + + @property + def in_priority_order(self) -> Iterator[VehicleGroupGroundObject]: + yield from self.blocking_capture + yield from self.defending_front_line + yield from self.reserves + + def eliminate(self, garrison: VehicleGroupGroundObject) -> None: + if garrison in self.blocking_capture: + self.blocking_capture.remove(garrison) + if garrison in self.defending_front_line: + self.defending_front_line.remove(garrison) + if garrison in self.reserves: + self.reserves.remove(garrison) + + def __contains__(self, item: VehicleGroupGroundObject) -> bool: + return item in self.in_priority_order + + @classmethod + def from_theater(cls, theater: ConflictTheater, player_owned: bool) -> Garrisons: + """Categorize garrison groups based on target priority. + + Any garrisons blocking base capture are the highest priority, followed by other + garrisons at front-line bases, and finally any garrisons in reserve at other + bases. + """ + blocking = [] + defending = [] + reserves = [] + for cp in theater.control_points_for(player_owned): + garrisons = [ + tgo + for tgo in cp.ground_objects + if isinstance(tgo, VehicleGroupGroundObject) + ] + if not cp.has_active_frontline: + reserves.extend(garrisons) + continue + + for garrison in garrisons: + # Not sure what distance DCS uses, but assuming it's about 2NM since + # that's roughly the distance of the circle on the map. + if meters(garrison.distance_to(cp)) < nautical_miles(2): + blocking.append(garrison) + else: + defending.append(garrison) + + return Garrisons(blocking, defending, reserves) diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index e2bab894..a565fc86 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -65,14 +65,6 @@ class ObjectiveFinder: yield ground_object - def threatening_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]: - """Iterates over enemy vehicle groups near friendly control points. - - Groups are sorted by their closest proximity to any friendly control - point (airfield or fleet). - """ - return self._targets_by_range(self.enemy_vehicle_groups()) - def enemy_ships(self) -> Iterator[NavalGroundObject]: for cp in self.enemy_control_points(): for ground_object in cp.ground_objects: diff --git a/game/commander/tasks/compound/attackgarrisons.py b/game/commander/tasks/compound/attackgarrisons.py index f8281597..89a0943c 100644 --- a/game/commander/tasks/compound/attackgarrisons.py +++ b/game/commander/tasks/compound/attackgarrisons.py @@ -7,5 +7,5 @@ from game.htn import CompoundTask, Method class AttackGarrisons(CompoundTask[TheaterState]): def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - for garrison in state.enemy_garrisons: + for garrison in state.enemy_garrisons.in_priority_order: yield [PlanBai(garrison)] diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py index c0dc328c..bbbabb96 100644 --- a/game/commander/tasks/primitive/bai.py +++ b/game/commander/tasks/primitive/bai.py @@ -17,7 +17,7 @@ class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): return self.target_area_preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: - state.enemy_garrisons.remove(self.target) + state.enemy_garrisons.eliminate(self.target) def propose_flights(self, doctrine: Doctrine) -> None: self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive) diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index c1508e34..a206b6e7 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -1,16 +1,17 @@ from __future__ import annotations +import dataclasses import itertools from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Union +from game.commander.garrisons import Garrisons from game.commander.objectivefinder import ObjectiveFinder from game.data.doctrine import Doctrine from game.htn import WorldState from game.theater import ControlPoint, FrontLine, MissionTarget from game.theater.theatergroundobject import ( TheaterGroundObject, - VehicleGroupGroundObject, NavalGroundObject, IadsGroundObject, ) @@ -32,7 +33,7 @@ class TheaterState(WorldState["TheaterState"]): enemy_convoys: list[Convoy] enemy_shipping: list[CargoShip] enemy_ships: list[NavalGroundObject] - enemy_garrisons: list[VehicleGroupGroundObject] + enemy_garrisons: Garrisons oca_targets: list[ControlPoint] strike_targets: list[TheaterGroundObject[Any]] enemy_barcaps: list[ControlPoint] @@ -69,7 +70,7 @@ class TheaterState(WorldState["TheaterState"]): enemy_convoys=list(self.enemy_convoys), enemy_shipping=list(self.enemy_shipping), enemy_ships=list(self.enemy_ships), - enemy_garrisons=list(self.enemy_garrisons), + enemy_garrisons=dataclasses.replace(self.enemy_garrisons), oca_targets=list(self.oca_targets), strike_targets=list(self.strike_targets), enemy_barcaps=list(self.enemy_barcaps), @@ -97,7 +98,7 @@ class TheaterState(WorldState["TheaterState"]): enemy_convoys=list(finder.convoys()), enemy_shipping=list(finder.cargo_ships()), enemy_ships=list(finder.enemy_ships()), - enemy_garrisons=list(finder.threatening_vehicle_groups()), + enemy_garrisons=Garrisons.from_theater(game.theater, not player), oca_targets=list(finder.oca_targets(min_aircraft=20)), strike_targets=list(finder.strike_targets()), enemy_barcaps=list(game.theater.control_points_for(not player)), From 78514b6c2ee5a5b1117973ce64506298b1172b88 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 12 Jul 2021 17:04:17 -0700 Subject: [PATCH 052/167] Only auto-target strike against buildings. The players can still manually assign strike missions on other target types since that's sometimes better for player waypoint generation (one waypoint per unit is nice for SAMs), but it's bad for the AI so by default we should exclude non-buildings. This also prevents double targeting of groups, since they might have been identified by other missions as well. We already did some of this, but since we were excluding specific TGO types rather than only allowing building TGOs we were often missing things (missile sites, coastal defenses, and EWRs, it seems). --- game/commander/objectivefinder.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index a565fc86..9333bebd 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -3,27 +3,24 @@ from __future__ import annotations import math import operator from collections import Iterator, Iterable -from typing import TypeVar, TYPE_CHECKING, Any +from typing import TypeVar, TYPE_CHECKING from game.theater import ( ControlPoint, OffMapSpawn, - TheaterGroundObject, MissionTarget, Fob, FrontLine, Airfield, ) from game.theater.theatergroundobject import ( - EwrGroundObject, - SamGroundObject, VehicleGroupGroundObject, NavalGroundObject, BuildingGroundObject, IadsGroundObject, ) from game.transfers import CargoShip, Convoy -from game.utils import meters, nautical_miles, Distance +from game.utils import meters, nautical_miles from gen.flights.closestairfields import ObjectiveDistanceCache, ClosestAirfields if TYPE_CHECKING: @@ -98,13 +95,13 @@ class ObjectiveFinder: for target, _range in target_ranges: yield target - def strike_targets(self) -> Iterator[TheaterGroundObject[Any]]: + def strike_targets(self) -> Iterator[BuildingGroundObject]: """Iterates over enemy strike targets. Targets are sorted by their closest proximity to any friendly control point (airfield or fleet). """ - targets: list[tuple[TheaterGroundObject[Any], float]] = [] + targets: list[tuple[BuildingGroundObject, float]] = [] # Building objectives are made of several individual TGOs (one per # building). found_targets: set[str] = set() @@ -119,21 +116,12 @@ class ObjectiveFinder: # better with BAI), so that's not a useful filter. Once we have # better control over planning profiles and target dependent # loadouts we can clean this up. - if isinstance(ground_object, VehicleGroupGroundObject): - # BAI target, not strike target. + if not isinstance(ground_object, BuildingGroundObject): + # Other group types (like ships, SAMs, garrisons, etc) have better + # suited mission types like anti-ship, DEAD, and BAI. continue - if isinstance(ground_object, NavalGroundObject): - # Anti-ship target, not strike target. - continue - - if isinstance(ground_object, SamGroundObject): - # SAMs are targeted by DEAD. No need to double plan. - continue - - is_building = isinstance(ground_object, BuildingGroundObject) - is_fob = isinstance(enemy_cp, Fob) - if is_building and is_fob and ground_object.is_control_point: + if isinstance(enemy_cp, Fob) and ground_object.is_control_point: # This is the FOB structure itself. Can't be repaired or # targeted by the player, so shouldn't be targetable by the # AI. From c0cc5657a7d9e148758b2a74d3f3e19a97d9b800 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 12 Jul 2021 17:33:45 -0700 Subject: [PATCH 053/167] Attack detecting radars with low priority. IADS that are in detection range (but not attack range) of missions will be targeted at very low priority. These will typically only be planned when no other targets are in range. --- game/commander/objectivefinder.py | 13 ------- game/commander/tasks/compound/degradeiads.py | 18 ++++++--- game/commander/tasks/packageplanningtask.py | 39 +++++++++++++++++--- game/commander/tasks/primitive/dead.py | 5 ++- game/commander/theaterstate.py | 13 ++++++- 5 files changed, 62 insertions(+), 26 deletions(-) diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index 9333bebd..5369b5dd 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -14,7 +14,6 @@ from game.theater import ( Airfield, ) from game.theater.theatergroundobject import ( - VehicleGroupGroundObject, NavalGroundObject, BuildingGroundObject, IadsGroundObject, @@ -50,18 +49,6 @@ class ObjectiveFinder: if isinstance(ground_object, IadsGroundObject): yield ground_object - def enemy_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]: - """Iterates over all enemy vehicle groups.""" - for cp in self.enemy_control_points(): - for ground_object in cp.ground_objects: - if not isinstance(ground_object, VehicleGroupGroundObject): - continue - - if ground_object.is_dead: - continue - - yield ground_object - def enemy_ships(self) -> Iterator[NavalGroundObject]: for cp in self.enemy_control_points(): for ground_object in cp.ground_objects: diff --git a/game/commander/tasks/compound/degradeiads.py b/game/commander/tasks/compound/degradeiads.py index ab50d5b8..21ddd02e 100644 --- a/game/commander/tasks/compound/degradeiads.py +++ b/game/commander/tasks/compound/degradeiads.py @@ -1,16 +1,24 @@ from collections import Iterator +from typing import Union from game.commander.tasks.primitive.antiship import PlanAntiShip from game.commander.tasks.primitive.dead import PlanDead from game.commander.theaterstate import TheaterState from game.htn import CompoundTask, Method -from game.theater.theatergroundobject import IadsGroundObject +from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject class DegradeIads(CompoundTask[TheaterState]): def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: for air_defense in state.threatening_air_defenses: - if isinstance(air_defense, IadsGroundObject): - yield [PlanDead(air_defense)] - else: - yield [PlanAntiShip(air_defense)] + yield [self.plan_against(air_defense)] + for detector in state.detecting_air_defenses: + yield [self.plan_against(detector)] + + @staticmethod + def plan_against( + target: Union[IadsGroundObject, NavalGroundObject] + ) -> Union[PlanDead, PlanAntiShip]: + if isinstance(target, IadsGroundObject): + return PlanDead(target) + return PlanAntiShip(target) diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index 5013fde7..6d310abb 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -4,6 +4,7 @@ import itertools import operator from abc import abstractmethod from dataclasses import dataclass, field +from enum import unique, IntEnum, auto from typing import TYPE_CHECKING, Optional, Generic, TypeVar, Iterator, Union from game.commander.missionproposals import ProposedFlight, EscortType, ProposedMission @@ -23,6 +24,12 @@ if TYPE_CHECKING: MissionTargetT = TypeVar("MissionTargetT", bound=MissionTarget) +@unique +class RangeType(IntEnum): + Detection = auto() + Threat = auto() + + # TODO: Refactor so that we don't need to call up to the mission planner. # Bypass type checker due to https://github.com/python/mypy/issues/5374 @dataclass # type: ignore @@ -75,8 +82,8 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): EscortType.AirToAir, ) - def iter_iads_threats( - self, state: TheaterState + def iter_iads_ranges( + self, state: TheaterState, range_type: RangeType ) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]: target_ranges: list[ tuple[Union[IadsGroundObject, NavalGroundObject], Distance] @@ -86,15 +93,21 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): ] = itertools.chain(state.enemy_air_defenses, state.enemy_ships) for target in all_iads: distance = meters(target.distance_to(self.target)) - threat_range = target.max_threat_range() - if not threat_range: + if range_type is RangeType.Detection: + target_range = target.max_detection_range() + elif range_type is RangeType.Threat: + target_range = target.max_threat_range() + else: + raise ValueError(f"Unknown RangeType: {range_type}") + if not target_range: continue + # IADS out of range of our target area will have a positive # distance_to_threat and should be pruned. The rest have a decreasing # distance_to_threat as overlap increases. The most negative distance has # the greatest coverage of the target and should be treated as the highest # priority threat. - distance_to_threat = distance - threat_range + distance_to_threat = distance - target_range if distance_to_threat > meters(0): continue target_ranges.append((target, distance_to_threat)) @@ -104,11 +117,27 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): for target, _range in target_ranges: yield target + def iter_detecting_iads( + self, state: TheaterState + ) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]: + return self.iter_iads_ranges(state, RangeType.Detection) + + def iter_iads_threats( + self, state: TheaterState + ) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]: + return self.iter_iads_ranges(state, RangeType.Threat) + def target_area_preconditions_met( self, state: TheaterState, ignore_iads: bool = False ) -> bool: """Checks if the target area has been cleared of threats.""" threatened = False + + # Non-blocking, but analyzed so we can pick detectors worth eliminating. + for detector in self.iter_detecting_iads(state): + if detector not in state.detecting_air_defenses: + state.detecting_air_defenses.append(detector) + if not ignore_iads: for iads_threat in self.iter_iads_threats(state): threatened = True diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py index 87c48b34..730eb832 100644 --- a/game/commander/tasks/primitive/dead.py +++ b/game/commander/tasks/primitive/dead.py @@ -13,7 +13,10 @@ from gen.flights.flight import FlightType @dataclass class PlanDead(PackagePlanningTask[IadsGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: - if self.target not in state.threatening_air_defenses: + if ( + self.target not in state.threatening_air_defenses + and self.target not in state.detecting_air_defenses + ): return False return self.target_area_preconditions_met(state, ignore_iads=True) diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index a206b6e7..bc168f04 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -30,6 +30,7 @@ class TheaterState(WorldState["TheaterState"]): refueling_targets: list[MissionTarget] enemy_air_defenses: list[IadsGroundObject] threatening_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]] + detecting_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]] enemy_convoys: list[Convoy] enemy_shipping: list[CargoShip] enemy_ships: list[NavalGroundObject] @@ -49,12 +50,18 @@ class TheaterState(WorldState["TheaterState"]): ) def eliminate_air_defense(self, target: IadsGroundObject) -> None: - self.threatening_air_defenses.remove(target) + if target in self.threatening_air_defenses: + self.threatening_air_defenses.remove(target) + if target in self.detecting_air_defenses: + self.detecting_air_defenses.remove(target) self.enemy_air_defenses.remove(target) self._rebuild_threat_zones() def eliminate_ship(self, target: NavalGroundObject) -> None: - self.threatening_air_defenses.remove(target) + if target in self.threatening_air_defenses: + self.threatening_air_defenses.remove(target) + if target in self.detecting_air_defenses: + self.detecting_air_defenses.remove(target) self.enemy_ships.remove(target) self._rebuild_threat_zones() @@ -83,6 +90,7 @@ class TheaterState(WorldState["TheaterState"]): # would add the IADS that prevented it from being planned to the list of # IADS threats so that DegradeIads will consider it a threat later. threatening_air_defenses=self.threatening_air_defenses, + detecting_air_defenses=self.detecting_air_defenses, ) @classmethod @@ -95,6 +103,7 @@ class TheaterState(WorldState["TheaterState"]): refueling_targets=[finder.closest_friendly_control_point()], enemy_air_defenses=list(finder.enemy_air_defenses()), threatening_air_defenses=[], + detecting_air_defenses=[], enemy_convoys=list(finder.convoys()), enemy_shipping=list(finder.cargo_ships()), enemy_ships=list(finder.enemy_ships()), From 575aca588696d4ac53dd5fb99b8436de9ce8cb4a Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 12 Jul 2021 20:44:19 -0700 Subject: [PATCH 054/167] Fix targeting dead BAI targets. --- game/commander/garrisons.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/commander/garrisons.py b/game/commander/garrisons.py index 27b43de2..192a6ea7 100644 --- a/game/commander/garrisons.py +++ b/game/commander/garrisons.py @@ -46,7 +46,7 @@ class Garrisons: garrisons = [ tgo for tgo in cp.ground_objects - if isinstance(tgo, VehicleGroupGroundObject) + if isinstance(tgo, VehicleGroupGroundObject) and not tgo.is_dead ] if not cp.has_active_frontline: reserves.extend(garrisons) From 0a416ab758b1d36856094ce2fa0d9af428bcacaa Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 12 Jul 2021 20:31:38 -0700 Subject: [PATCH 055/167] Let the TheaterCommander manage front line stance. This improves the AI behavior by choosing the stances non-randomly: * Breakthrough will be used if the base is expected to be capturable and the coalition outnumbers the enemy by 20%. * Elimination will be used if the coalition has at least as many units as the enemy. * Defensive will be used if the coalition has at least half as many units as the enemy. * Retreat will be used if the coalition is significantly outnumbers. This also exposes the option to the player. --- changelog.md | 1 + game/commander/garrisons.py | 6 +- game/commander/tasks/compound/capturebase.py | 19 +++++ game/commander/tasks/compound/capturebases.py | 13 ++++ game/commander/tasks/compound/defendbase.py | 19 +++++ game/commander/tasks/compound/defendbases.py | 13 ++++ .../tasks/compound/destroyenemygroundunits.py | 17 +++++ game/commander/tasks/compound/nextaction.py | 12 +-- .../tasks/compound/theatersupport.py | 14 ++++ game/commander/tasks/frontlinestancetask.py | 75 +++++++++++++++++++ game/commander/tasks/packageplanningtask.py | 3 + game/commander/tasks/primitive/aewc.py | 2 + game/commander/tasks/primitive/antiship.py | 2 + .../commander/tasks/primitive/antishipping.py | 2 + game/commander/tasks/primitive/bai.py | 2 + game/commander/tasks/primitive/barcap.py | 2 + .../tasks/primitive/breakthroughattack.py | 37 +++++++++ game/commander/tasks/primitive/cas.py | 2 + .../tasks/primitive/convoyinterdiction.py | 2 + game/commander/tasks/primitive/dead.py | 2 + .../tasks/primitive/defensivestance.py | 14 ++++ .../tasks/primitive/eliminationattack.py | 14 ++++ game/commander/tasks/primitive/oca.py | 2 + game/commander/tasks/primitive/refueling.py | 2 + .../tasks/primitive/retreatstance.py | 14 ++++ game/commander/tasks/primitive/strike.py | 2 + game/commander/theaterstate.py | 21 +++++- game/game.py | 19 +++-- game/operation/operation.py | 1 + game/settings.py | 1 + game/theater/controlpoint.py | 7 ++ game/theater/frontline.py | 17 ++++- gen/armor.py | 30 +------- gen/flights/ai_flight_planner.py | 7 +- qt_ui/windows/settings/QSettingsWindow.py | 22 +++++- 35 files changed, 361 insertions(+), 57 deletions(-) create mode 100644 game/commander/tasks/compound/capturebase.py create mode 100644 game/commander/tasks/compound/capturebases.py create mode 100644 game/commander/tasks/compound/defendbase.py create mode 100644 game/commander/tasks/compound/defendbases.py create mode 100644 game/commander/tasks/compound/destroyenemygroundunits.py create mode 100644 game/commander/tasks/compound/theatersupport.py create mode 100644 game/commander/tasks/frontlinestancetask.py create mode 100644 game/commander/tasks/primitive/breakthroughattack.py create mode 100644 game/commander/tasks/primitive/defensivestance.py create mode 100644 game/commander/tasks/primitive/eliminationattack.py create mode 100644 game/commander/tasks/primitive/retreatstance.py diff --git a/changelog.md b/changelog.md index 8810dc72..ce1855ac 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Saves from 3.x are not compatible with 5.0. ## Features/Improvements * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. +* **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. ## Fixes diff --git a/game/commander/garrisons.py b/game/commander/garrisons.py index 192a6ea7..ac685e24 100644 --- a/game/commander/garrisons.py +++ b/game/commander/garrisons.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import Iterator from dataclasses import dataclass -from game.theater import ConflictTheater +from game.theater import ConflictTheater, ControlPoint from game.theater.theatergroundobject import VehicleGroupGroundObject from game.utils import meters, nautical_miles @@ -53,9 +53,7 @@ class Garrisons: continue for garrison in garrisons: - # Not sure what distance DCS uses, but assuming it's about 2NM since - # that's roughly the distance of the circle on the map. - if meters(garrison.distance_to(cp)) < nautical_miles(2): + if meters(garrison.distance_to(cp)) < ControlPoint.CAPTURE_DISTANCE: blocking.append(garrison) else: defending.append(garrison) diff --git a/game/commander/tasks/compound/capturebase.py b/game/commander/tasks/compound/capturebase.py new file mode 100644 index 00000000..747b7599 --- /dev/null +++ b/game/commander/tasks/compound/capturebase.py @@ -0,0 +1,19 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.destroyenemygroundunits import ( + DestroyEnemyGroundUnits, +) +from game.commander.tasks.primitive.breakthroughattack import BreakthroughAttack +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import FrontLine + + +@dataclass(frozen=True) +class CaptureBase(CompoundTask[TheaterState]): + front_line: FrontLine + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [BreakthroughAttack(self.front_line, state.player)] + yield [DestroyEnemyGroundUnits(self.front_line)] diff --git a/game/commander/tasks/compound/capturebases.py b/game/commander/tasks/compound/capturebases.py new file mode 100644 index 00000000..3d338046 --- /dev/null +++ b/game/commander/tasks/compound/capturebases.py @@ -0,0 +1,13 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.capturebase import CaptureBase +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class CaptureBases(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for front in state.active_front_lines: + yield [CaptureBase(front)] diff --git a/game/commander/tasks/compound/defendbase.py b/game/commander/tasks/compound/defendbase.py new file mode 100644 index 00000000..69a008e5 --- /dev/null +++ b/game/commander/tasks/compound/defendbase.py @@ -0,0 +1,19 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.primitive.cas import PlanCas +from game.commander.tasks.primitive.defensivestance import DefensiveStance +from game.commander.tasks.primitive.retreatstance import RetreatStance +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import FrontLine + + +@dataclass(frozen=True) +class DefendBase(CompoundTask[TheaterState]): + front_line: FrontLine + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [DefensiveStance(self.front_line, state.player)] + yield [RetreatStance(self.front_line, state.player)] + yield [PlanCas(self.front_line)] diff --git a/game/commander/tasks/compound/defendbases.py b/game/commander/tasks/compound/defendbases.py new file mode 100644 index 00000000..df18fdc3 --- /dev/null +++ b/game/commander/tasks/compound/defendbases.py @@ -0,0 +1,13 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.defendbase import DefendBase +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class DefendBases(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for front in state.active_front_lines: + yield [DefendBase(front)] diff --git a/game/commander/tasks/compound/destroyenemygroundunits.py b/game/commander/tasks/compound/destroyenemygroundunits.py new file mode 100644 index 00000000..90bbbb9f --- /dev/null +++ b/game/commander/tasks/compound/destroyenemygroundunits.py @@ -0,0 +1,17 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.primitive.cas import PlanCas +from game.commander.tasks.primitive.eliminationattack import EliminationAttack +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import FrontLine + + +@dataclass(frozen=True) +class DestroyEnemyGroundUnits(CompoundTask[TheaterState]): + front_line: FrontLine + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [EliminationAttack(self.front_line, state.player)] + yield [PlanCas(self.front_line)] diff --git a/game/commander/tasks/compound/nextaction.py b/game/commander/tasks/compound/nextaction.py index 8863600b..3b4559d3 100644 --- a/game/commander/tasks/compound/nextaction.py +++ b/game/commander/tasks/compound/nextaction.py @@ -1,19 +1,19 @@ from collections import Iterator from dataclasses import dataclass -from game.commander.tasks.compound.aewcsupport import PlanAewcSupport from game.commander.tasks.compound.attackairinfrastructure import ( AttackAirInfrastructure, ) from game.commander.tasks.compound.attackbuildings import AttackBuildings from game.commander.tasks.compound.attackgarrisons import AttackGarrisons +from game.commander.tasks.compound.capturebases import CaptureBases +from game.commander.tasks.compound.defendbases import DefendBases from game.commander.tasks.compound.degradeiads import DegradeIads -from game.commander.tasks.compound.frontlinedefense import FrontLineDefense from game.commander.tasks.compound.interdictreinforcements import ( InterdictReinforcements, ) from game.commander.tasks.compound.protectairspace import ProtectAirSpace -from game.commander.tasks.compound.refuelingsupport import PlanRefuelingSupport +from game.commander.tasks.compound.theatersupport import TheaterSupport from game.commander.theaterstate import TheaterState from game.htn import CompoundTask, Method @@ -23,10 +23,10 @@ class PlanNextAction(CompoundTask[TheaterState]): aircraft_cold_start: bool def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - yield [PlanAewcSupport()] - yield [PlanRefuelingSupport()] + yield [TheaterSupport()] yield [ProtectAirSpace()] - yield [FrontLineDefense()] + yield [CaptureBases()] + yield [DefendBases()] yield [InterdictReinforcements()] yield [AttackGarrisons()] yield [AttackAirInfrastructure(self.aircraft_cold_start)] diff --git a/game/commander/tasks/compound/theatersupport.py b/game/commander/tasks/compound/theatersupport.py new file mode 100644 index 00000000..379ba7c2 --- /dev/null +++ b/game/commander/tasks/compound/theatersupport.py @@ -0,0 +1,14 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.compound.aewcsupport import PlanAewcSupport +from game.commander.tasks.compound.refuelingsupport import PlanRefuelingSupport +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +@dataclass(frozen=True) +class TheaterSupport(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [PlanAewcSupport()] + yield [PlanRefuelingSupport()] diff --git a/game/commander/tasks/frontlinestancetask.py b/game/commander/tasks/frontlinestancetask.py new file mode 100644 index 00000000..ae27f1a6 --- /dev/null +++ b/game/commander/tasks/frontlinestancetask.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import math +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from game.commander.tasks.theatercommandertask import TheaterCommanderTask +from game.commander.theaterstate import TheaterState +from game.profiling import MultiEventTracer +from game.theater import FrontLine +from gen.ground_forces.combat_stance import CombatStance + +if TYPE_CHECKING: + from gen.flights.ai_flight_planner import CoalitionMissionPlanner + + +class FrontLineStanceTask(TheaterCommanderTask, ABC): + def __init__(self, front_line: FrontLine, player: bool) -> None: + self.front_line = front_line + self.friendly_cp = self.front_line.control_point_friendly_to(player) + self.enemy_cp = self.front_line.control_point_hostile_to(player) + + @property + @abstractmethod + def stance(self) -> CombatStance: + ... + + @staticmethod + def management_allowed(state: TheaterState) -> bool: + return not state.player or state.stance_automation_enabled + + def better_stance_already_set(self, state: TheaterState) -> bool: + current_stance = state.front_line_stances[self.front_line] + if current_stance is None: + return False + preference = ( + CombatStance.RETREAT, + CombatStance.DEFENSIVE, + CombatStance.AMBUSH, + CombatStance.AGGRESSIVE, + CombatStance.ELIMINATION, + CombatStance.BREAKTHROUGH, + ) + current_rating = preference.index(current_stance) + new_rating = preference.index(self.stance) + return current_rating >= new_rating + + @property + @abstractmethod + def have_sufficient_front_line_advantage(self) -> bool: + ... + + @property + def ground_force_balance(self) -> float: + # TODO: Planned CAS missions should reduce the expected opposing force size. + friendly_forces = self.friendly_cp.deployable_front_line_units + enemy_forces = self.enemy_cp.deployable_front_line_units + if enemy_forces == 0: + return math.inf + return friendly_forces / enemy_forces + + def preconditions_met(self, state: TheaterState) -> bool: + if not self.management_allowed(state): + return False + if self.better_stance_already_set(state): + return False + return self.have_sufficient_front_line_advantage + + def apply_effects(self, state: TheaterState) -> None: + state.front_line_stances[self.front_line] = self.stance + + def execute( + self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer + ) -> None: + self.friendly_cp.stances[self.enemy_cp.id] = self.stance diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index 6d310abb..7196a0b9 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -40,6 +40,9 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): def __post_init__(self) -> None: self.flights = [] + def preconditions_met(self, state: TheaterState) -> bool: + return not state.player or state.ato_automation_enabled + def execute( self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer ) -> None: diff --git a/game/commander/tasks/primitive/aewc.py b/game/commander/tasks/primitive/aewc.py index 77ec0901..8153aac6 100644 --- a/game/commander/tasks/primitive/aewc.py +++ b/game/commander/tasks/primitive/aewc.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanAewc(PackagePlanningTask[MissionTarget]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False return self.target in state.aewc_targets def apply_effects(self, state: TheaterState) -> None: diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py index cf9741e5..fcdc2273 100644 --- a/game/commander/tasks/primitive/antiship.py +++ b/game/commander/tasks/primitive/antiship.py @@ -13,6 +13,8 @@ from gen.flights.flight import FlightType @dataclass class PlanAntiShip(PackagePlanningTask[NavalGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.threatening_air_defenses: return False return self.target_area_preconditions_met(state, ignore_iads=True) diff --git a/game/commander/tasks/primitive/antishipping.py b/game/commander/tasks/primitive/antishipping.py index 370afcfd..64be2cb9 100644 --- a/game/commander/tasks/primitive/antishipping.py +++ b/game/commander/tasks/primitive/antishipping.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanAntiShipping(PackagePlanningTask[CargoShip]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.enemy_shipping: return False return self.target_area_preconditions_met(state) diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py index bbbabb96..1b03ce53 100644 --- a/game/commander/tasks/primitive/bai.py +++ b/game/commander/tasks/primitive/bai.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.enemy_garrisons: return False return self.target_area_preconditions_met(state) diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py index 40fcc684..9707445c 100644 --- a/game/commander/tasks/primitive/barcap.py +++ b/game/commander/tasks/primitive/barcap.py @@ -19,6 +19,8 @@ class PlanBarcap(TheaterCommanderTask): target: ControlPoint def preconditions_met(self, state: TheaterState) -> bool: + if state.player and not state.ato_automation_enabled: + return False return self.target in state.vulnerable_control_points def apply_effects(self, state: TheaterState) -> None: diff --git a/game/commander/tasks/primitive/breakthroughattack.py b/game/commander/tasks/primitive/breakthroughattack.py new file mode 100644 index 00000000..3f32754d --- /dev/null +++ b/game/commander/tasks/primitive/breakthroughattack.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from game.commander.theaterstate import TheaterState +from game.theater import ControlPoint +from game.theater.theatergroundobject import VehicleGroupGroundObject +from game.utils import meters +from gen.ground_forces.combat_stance import CombatStance + + +class BreakthroughAttack(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.BREAKTHROUGH + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 1.2 + + @property + def opposing_garrisons_eliminated(self) -> bool: + # TODO: Should operate on TheaterState to account for BAIs planned this turn. + for tgo in self.enemy_cp.ground_objects: + if not isinstance(tgo, VehicleGroupGroundObject): + continue + if meters(tgo.distance_to(self.enemy_cp)) < ControlPoint.CAPTURE_DISTANCE: + return False + return True + + def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False + return self.opposing_garrisons_eliminated + + def apply_effects(self, state: TheaterState) -> None: + super().apply_effects(state) + state.active_front_lines.remove(self.front_line) diff --git a/game/commander/tasks/primitive/cas.py b/game/commander/tasks/primitive/cas.py index 63f1812a..14255c2e 100644 --- a/game/commander/tasks/primitive/cas.py +++ b/game/commander/tasks/primitive/cas.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanCas(PackagePlanningTask[FrontLine]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False return self.target in state.vulnerable_front_lines def apply_effects(self, state: TheaterState) -> None: diff --git a/game/commander/tasks/primitive/convoyinterdiction.py b/game/commander/tasks/primitive/convoyinterdiction.py index 7eb52716..9026057d 100644 --- a/game/commander/tasks/primitive/convoyinterdiction.py +++ b/game/commander/tasks/primitive/convoyinterdiction.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanConvoyInterdiction(PackagePlanningTask[Convoy]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.enemy_convoys: return False return self.target_area_preconditions_met(state) diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py index 730eb832..77ca80cb 100644 --- a/game/commander/tasks/primitive/dead.py +++ b/game/commander/tasks/primitive/dead.py @@ -13,6 +13,8 @@ from gen.flights.flight import FlightType @dataclass class PlanDead(PackagePlanningTask[IadsGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if ( self.target not in state.threatening_air_defenses and self.target not in state.detecting_air_defenses diff --git a/game/commander/tasks/primitive/defensivestance.py b/game/commander/tasks/primitive/defensivestance.py new file mode 100644 index 00000000..3e3510e2 --- /dev/null +++ b/game/commander/tasks/primitive/defensivestance.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class DefensiveStance(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.DEFENSIVE + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 0.5 diff --git a/game/commander/tasks/primitive/eliminationattack.py b/game/commander/tasks/primitive/eliminationattack.py new file mode 100644 index 00000000..674c5653 --- /dev/null +++ b/game/commander/tasks/primitive/eliminationattack.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class EliminationAttack(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.ELIMINATION + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 1.0 diff --git a/game/commander/tasks/primitive/oca.py b/game/commander/tasks/primitive/oca.py index 9a41a2e1..f3d43b18 100644 --- a/game/commander/tasks/primitive/oca.py +++ b/game/commander/tasks/primitive/oca.py @@ -14,6 +14,8 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]): aircraft_cold_start: bool def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.oca_targets: return False return self.target_area_preconditions_met(state) diff --git a/game/commander/tasks/primitive/refueling.py b/game/commander/tasks/primitive/refueling.py index 0b78c86d..005cbc3a 100644 --- a/game/commander/tasks/primitive/refueling.py +++ b/game/commander/tasks/primitive/refueling.py @@ -12,6 +12,8 @@ from gen.flights.flight import FlightType @dataclass class PlanRefueling(PackagePlanningTask[MissionTarget]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False return self.target in state.refueling_targets def apply_effects(self, state: TheaterState) -> None: diff --git a/game/commander/tasks/primitive/retreatstance.py b/game/commander/tasks/primitive/retreatstance.py new file mode 100644 index 00000000..d3e4bcc8 --- /dev/null +++ b/game/commander/tasks/primitive/retreatstance.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class RetreatStance(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.RETREAT + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return True diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py index cb943c47..7090eb86 100644 --- a/game/commander/tasks/primitive/strike.py +++ b/game/commander/tasks/primitive/strike.py @@ -13,6 +13,8 @@ from gen.flights.flight import FlightType @dataclass class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]): def preconditions_met(self, state: TheaterState) -> bool: + if not super().preconditions_met(state): + return False if self.target not in state.strike_targets: return False return self.target_area_preconditions_met(state) diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index bc168f04..d80e38e9 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -3,12 +3,13 @@ from __future__ import annotations import dataclasses import itertools from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Any, Union, Optional from game.commander.garrisons import Garrisons from game.commander.objectivefinder import ObjectiveFinder from game.data.doctrine import Doctrine from game.htn import WorldState +from game.settings import AutoAtoBehavior from game.theater import ControlPoint, FrontLine, MissionTarget from game.theater.theatergroundobject import ( TheaterGroundObject, @@ -17,6 +18,7 @@ from game.theater.theatergroundobject import ( ) from game.threatzones import ThreatZones from game.transfers import Convoy, CargoShip +from gen.ground_forces.combat_stance import CombatStance if TYPE_CHECKING: from game import Game @@ -24,7 +26,12 @@ if TYPE_CHECKING: @dataclass class TheaterState(WorldState["TheaterState"]): + player: bool + stance_automation_enabled: bool + ato_automation_enabled: bool vulnerable_control_points: list[ControlPoint] + active_front_lines: list[FrontLine] + front_line_stances: dict[FrontLine, Optional[CombatStance]] vulnerable_front_lines: list[FrontLine] aewc_targets: list[MissionTarget] refueling_targets: list[MissionTarget] @@ -69,7 +76,12 @@ class TheaterState(WorldState["TheaterState"]): # Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly # expensive. return TheaterState( + player=self.player, + stance_automation_enabled=self.stance_automation_enabled, + ato_automation_enabled=self.ato_automation_enabled, vulnerable_control_points=list(self.vulnerable_control_points), + active_front_lines=list(self.active_front_lines), + front_line_stances=dict(self.front_line_stances), vulnerable_front_lines=list(self.vulnerable_front_lines), aewc_targets=list(self.aewc_targets), refueling_targets=list(self.refueling_targets), @@ -96,8 +108,15 @@ class TheaterState(WorldState["TheaterState"]): @classmethod def from_game(cls, game: Game, player: bool) -> TheaterState: finder = ObjectiveFinder(game, player) + auto_stance = game.settings.automate_front_line_stance + auto_ato = game.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled return TheaterState( + player=player, + stance_automation_enabled=auto_stance, + ato_automation_enabled=auto_ato, vulnerable_control_points=list(finder.vulnerable_control_points()), + active_front_lines=list(finder.front_lines()), + front_line_stances={f: None for f in finder.front_lines()}, vulnerable_front_lines=list(finder.front_lines()), aewc_targets=[finder.farthest_friendly_control_point()], refueling_targets=[finder.closest_friendly_control_point()], diff --git a/game/game.py b/game/game.py index 567ab2ab..fce069f8 100644 --- a/game/game.py +++ b/game/game.py @@ -24,6 +24,7 @@ from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType from gen.ground_forces.ai_ground_planner import GroundPlanner from . import persistency +from .commander import TheaterCommander from .debriefing import Debriefing from .event.event import Event from .event.frontlineattack import FrontlineAttackEvent @@ -32,7 +33,7 @@ from .income import Income from .infos.information import Information from .navmesh import NavMesh from .procurement import AircraftProcurementRequest, ProcurementAi -from .profiling import logged_duration +from .profiling import logged_duration, MultiEventTracer from .settings import Settings, AutoAtoBehavior from .squadrons import AirWing from .theater import ConflictTheater, ControlPoint @@ -504,13 +505,15 @@ class Game: with logged_duration("Transport planning"): self.transfers.plan_transports() - if not player or ( - player and self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled - ): - color = "Blue" if player else "Red" - with logged_duration(f"{color} mission planning"): - mission_planner = CoalitionMissionPlanner(self, player) - mission_planner.plan_missions() + color = "Blue" if player else "Red" + with MultiEventTracer() as tracer: + mission_planner = CoalitionMissionPlanner(self, player) + with tracer.trace(f"{color} mission planning"): + with tracer.trace(f"{color} mission identification"): + commander = TheaterCommander(self, player) + commander.plan_missions(mission_planner, tracer) + with tracer.trace(f"{color} mission fulfillment"): + mission_planner.fulfill_missions() self.plan_procurement_for(player) diff --git a/game/operation/operation.py b/game/operation/operation.py index da3f1c4a..56cfcf66 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -397,6 +397,7 @@ class Operation: player_gp, enemy_gp, player_cp.stances[enemy_cp.id], + enemy_cp.stances[player_cp.id], cls.unit_map, ) ground_conflict_gen.generate() diff --git a/game/settings.py b/game/settings.py index fc297cb9..e76e0816 100644 --- a/game/settings.py +++ b/game/settings.py @@ -55,6 +55,7 @@ class Settings: automate_runway_repair: bool = False automate_front_line_reinforcements: bool = False automate_aircraft_reinforcements: bool = False + automate_front_line_stance: bool = True restrict_weapons_by_date: bool = False disable_legacy_aewc: bool = True disable_legacy_tanker: bool = True diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index ecf77341..8fb111f5 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -271,6 +271,9 @@ class ControlPointStatus(IntEnum): class ControlPoint(MissionTarget, ABC): + # Not sure what distance DCS uses, but assuming it's about 2NM since that's roughly + # the distance of the circle on the map. + CAPTURE_DISTANCE = nautical_miles(2) position = None # type: Point name = None # type: str @@ -727,6 +730,10 @@ class ControlPoint(MissionTarget, ABC): return self.captured != other.captured + @property + def deployable_front_line_units(self) -> int: + return min(self.frontline_unit_count_limit, self.base.total_armor) + @property def frontline_unit_count_limit(self) -> int: return ( diff --git a/game/theater/frontline.py b/game/theater/frontline.py index c8f2fd6b..2f1b6067 100644 --- a/game/theater/frontline.py +++ b/game/theater/frontline.py @@ -71,15 +71,26 @@ class FrontLine(MissionTarget): self.point_from_a(self._position_distance), ) + def __eq__(self, other: Any) -> bool: + if not isinstance(other, FrontLine): + return False + return (self.blue_cp, self.red_cp) == (other.blue_cp, other.red_cp) + + def __hash__(self) -> int: + return hash((self.blue_cp, self.red_cp)) + def __setstate__(self, state: dict[str, Any]) -> None: self.__dict__.update(state) if not hasattr(self, "position"): self.position = self.point_from_a(self._position_distance) - def control_point_hostile_to(self, player: bool) -> ControlPoint: + def control_point_friendly_to(self, player: bool) -> ControlPoint: if player: - return self.red_cp - return self.blue_cp + return self.blue_cp + return self.red_cp + + def control_point_hostile_to(self, player: bool) -> ControlPoint: + return self.control_point_friendly_to(not player) def is_friendly(self, to_player: bool) -> bool: """Returns True if the objective is in friendly territory.""" diff --git a/gen/armor.py b/gen/armor.py index f9fb1a8a..6db4f632 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -86,43 +86,19 @@ class GroundConflictGenerator: player_planned_combat_groups: List[CombatGroup], enemy_planned_combat_groups: List[CombatGroup], player_stance: CombatStance, + enemy_stance: CombatStance, unit_map: UnitMap, ) -> None: self.mission = mission self.conflict = conflict self.enemy_planned_combat_groups = enemy_planned_combat_groups self.player_planned_combat_groups = player_planned_combat_groups - self.player_stance = CombatStance(player_stance) - self.enemy_stance = self._enemy_stance() + self.player_stance = player_stance + self.enemy_stance = enemy_stance self.game = game self.unit_map = unit_map self.jtacs: List[JtacInfo] = [] - def _enemy_stance(self) -> CombatStance: - """Picks the enemy stance according to the number of planned groups on the frontline for each side""" - if len(self.enemy_planned_combat_groups) > len( - self.player_planned_combat_groups - ): - return random.choice( - [ - CombatStance.AGGRESSIVE, - CombatStance.AGGRESSIVE, - CombatStance.AGGRESSIVE, - CombatStance.ELIMINATION, - CombatStance.BREAKTHROUGH, - ] - ) - else: - return random.choice( - [ - CombatStance.DEFENSIVE, - CombatStance.DEFENSIVE, - CombatStance.DEFENSIVE, - CombatStance.AMBUSH, - CombatStance.AGGRESSIVE, - ] - ) - def generate(self) -> None: position = Conflict.frontline_position( self.conflict.front_line, self.game.theater diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index de290661..cbe234b1 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -247,14 +247,9 @@ class CoalitionMissionPlanner: """ return self.game.air_wing_for(self.is_player).can_auto_plan(mission_type) - def plan_missions(self) -> None: + def fulfill_missions(self) -> None: """Identifies and plans mission for the turn.""" player = "Blue" if self.is_player else "Red" - with logged_duration(f"{player} mission identification and fulfillment"): - with MultiEventTracer() as tracer: - commander = TheaterCommander(self.game, self.is_player) - commander.plan_missions(self, tracer) - with logged_duration(f"{player} mission scheduling"): self.stagger_missions() diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 67cc0e3b..5aba6a7d 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -101,7 +101,7 @@ class HqAutomationSettingsBox(QGroupBox): front_line = QCheckBox() front_line.setChecked(self.game.settings.automate_front_line_reinforcements) - front_line.toggled.connect(self.set_front_line_automation) + front_line.toggled.connect(self.set_front_line_reinforcement_automation) layout.addWidget(QLabel("Automate front-line purchases"), 1, 0) layout.addWidget(front_line, 1, 1, Qt.AlignRight) @@ -147,12 +147,30 @@ class HqAutomationSettingsBox(QGroupBox): ) layout.addWidget(self.auto_ato_player_missions_asap, 4, 1, Qt.AlignRight) + self.automate_front_line_stance = QCheckBox() + self.automate_front_line_stance.setChecked( + self.game.settings.automate_front_line_stance + ) + self.automate_front_line_stance.toggled.connect( + self.set_front_line_stance_automation + ) + + layout.addWidget( + QLabel("Automatically manage front line stances"), + 5, + 0, + ) + layout.addWidget(self.automate_front_line_stance, 5, 1, Qt.AlignRight) + def set_runway_automation(self, value: bool) -> None: self.game.settings.automate_runway_repair = value - def set_front_line_automation(self, value: bool) -> None: + def set_front_line_reinforcement_automation(self, value: bool) -> None: self.game.settings.automate_front_line_reinforcements = value + def set_front_line_stance_automation(self, value: bool) -> None: + self.game.settings.automate_front_line_stance = value + def set_aircraft_automation(self, value: bool) -> None: self.game.settings.automate_aircraft_reinforcements = value From c180eb466d20ec34f92e09cf2ca150d433c835ad Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 12 Jul 2021 21:28:00 -0700 Subject: [PATCH 056/167] Use aggressive stance for similar troop counts. Bumps the breakthrough requirement to 2x, elimination to 1.5x, and uses agressive for 0.8-1.5x. --- .../tasks/compound/destroyenemygroundunits.py | 2 ++ game/commander/tasks/primitive/aggressiveattack.py | 14 ++++++++++++++ .../tasks/primitive/breakthroughattack.py | 2 +- .../commander/tasks/primitive/eliminationattack.py | 2 +- 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 game/commander/tasks/primitive/aggressiveattack.py diff --git a/game/commander/tasks/compound/destroyenemygroundunits.py b/game/commander/tasks/compound/destroyenemygroundunits.py index 90bbbb9f..cf83213f 100644 --- a/game/commander/tasks/compound/destroyenemygroundunits.py +++ b/game/commander/tasks/compound/destroyenemygroundunits.py @@ -1,6 +1,7 @@ from collections import Iterator from dataclasses import dataclass +from game.commander.tasks.primitive.aggressiveattack import AggressiveAttack from game.commander.tasks.primitive.cas import PlanCas from game.commander.tasks.primitive.eliminationattack import EliminationAttack from game.commander.theaterstate import TheaterState @@ -14,4 +15,5 @@ class DestroyEnemyGroundUnits(CompoundTask[TheaterState]): def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: yield [EliminationAttack(self.front_line, state.player)] + yield [AggressiveAttack(self.front_line, state.player)] yield [PlanCas(self.front_line)] diff --git a/game/commander/tasks/primitive/aggressiveattack.py b/game/commander/tasks/primitive/aggressiveattack.py new file mode 100644 index 00000000..a5928dd3 --- /dev/null +++ b/game/commander/tasks/primitive/aggressiveattack.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from game.commander.tasks.frontlinestancetask import FrontLineStanceTask +from gen.ground_forces.combat_stance import CombatStance + + +class AggressiveAttack(FrontLineStanceTask): + @property + def stance(self) -> CombatStance: + return CombatStance.AGGRESSIVE + + @property + def have_sufficient_front_line_advantage(self) -> bool: + return self.ground_force_balance >= 0.8 diff --git a/game/commander/tasks/primitive/breakthroughattack.py b/game/commander/tasks/primitive/breakthroughattack.py index 3f32754d..6657bf23 100644 --- a/game/commander/tasks/primitive/breakthroughattack.py +++ b/game/commander/tasks/primitive/breakthroughattack.py @@ -15,7 +15,7 @@ class BreakthroughAttack(FrontLineStanceTask): @property def have_sufficient_front_line_advantage(self) -> bool: - return self.ground_force_balance >= 1.2 + return self.ground_force_balance >= 2.0 @property def opposing_garrisons_eliminated(self) -> bool: diff --git a/game/commander/tasks/primitive/eliminationattack.py b/game/commander/tasks/primitive/eliminationattack.py index 674c5653..409ecf97 100644 --- a/game/commander/tasks/primitive/eliminationattack.py +++ b/game/commander/tasks/primitive/eliminationattack.py @@ -11,4 +11,4 @@ class EliminationAttack(FrontLineStanceTask): @property def have_sufficient_front_line_advantage(self) -> bool: - return self.ground_force_balance >= 1.0 + return self.ground_force_balance >= 1.5 From 4534758c21f9a5c16402209d8ac51a7e4c8018db Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 13:50:50 -0700 Subject: [PATCH 057/167] Account for planned missions for breakthrough. Consider BAI missions planned this turn when determining if a control point is still garrisioned for preventing breakthrough. This isn't very accurate yet since the HTN isn't checking for aircraft fulfillment yet, so it might *plan* a mission to kill the garrison, but there's no way to know if it will be fulfilled. --- game/commander/garrisons.py | 45 ++++++++----------- game/commander/objectivefinder.py | 23 +++++----- .../tasks/compound/attackgarrisons.py | 5 ++- game/commander/tasks/primitive/bai.py | 4 +- .../tasks/primitive/breakthroughattack.py | 17 ++----- game/commander/theaterstate.py | 18 ++++++-- game/theater/controlpoint.py | 6 ++- 7 files changed, 59 insertions(+), 59 deletions(-) diff --git a/game/commander/garrisons.py b/game/commander/garrisons.py index ac685e24..8ed43843 100644 --- a/game/commander/garrisons.py +++ b/game/commander/garrisons.py @@ -3,59 +3,50 @@ from __future__ import annotations from collections import Iterator from dataclasses import dataclass -from game.theater import ConflictTheater, ControlPoint +from game.theater import ControlPoint from game.theater.theatergroundobject import VehicleGroupGroundObject -from game.utils import meters, nautical_miles +from game.utils import meters @dataclass class Garrisons: blocking_capture: list[VehicleGroupGroundObject] defending_front_line: list[VehicleGroupGroundObject] - reserves: list[VehicleGroupGroundObject] @property def in_priority_order(self) -> Iterator[VehicleGroupGroundObject]: yield from self.blocking_capture yield from self.defending_front_line - yield from self.reserves def eliminate(self, garrison: VehicleGroupGroundObject) -> None: if garrison in self.blocking_capture: self.blocking_capture.remove(garrison) if garrison in self.defending_front_line: self.defending_front_line.remove(garrison) - if garrison in self.reserves: - self.reserves.remove(garrison) def __contains__(self, item: VehicleGroupGroundObject) -> bool: return item in self.in_priority_order @classmethod - def from_theater(cls, theater: ConflictTheater, player_owned: bool) -> Garrisons: + def for_control_point(cls, control_point: ControlPoint) -> Garrisons: """Categorize garrison groups based on target priority. - Any garrisons blocking base capture are the highest priority, followed by other - garrisons at front-line bases, and finally any garrisons in reserve at other - bases. + Any garrisons blocking base capture are the highest priority. """ blocking = [] defending = [] - reserves = [] - for cp in theater.control_points_for(player_owned): - garrisons = [ - tgo - for tgo in cp.ground_objects - if isinstance(tgo, VehicleGroupGroundObject) and not tgo.is_dead - ] - if not cp.has_active_frontline: - reserves.extend(garrisons) - continue + garrisons = [ + tgo + for tgo in control_point.ground_objects + if isinstance(tgo, VehicleGroupGroundObject) and not tgo.is_dead + ] + for garrison in garrisons: + if ( + meters(garrison.distance_to(control_point)) + < ControlPoint.CAPTURE_DISTANCE + ): + blocking.append(garrison) + else: + defending.append(garrison) - for garrison in garrisons: - if meters(garrison.distance_to(cp)) < ControlPoint.CAPTURE_DISTANCE: - blocking.append(garrison) - else: - defending.append(garrison) - - return Garrisons(blocking, defending, reserves) + return Garrisons(blocking, defending) diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index 5369b5dd..34db8730 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -223,17 +223,18 @@ class ObjectiveFinder: if not c.is_friendly(self.is_player) ) - def all_possible_targets(self) -> Iterator[MissionTarget]: - """Iterates over all possible mission targets in the theater. - - Valid mission targets are control points (airfields and carriers), front - lines, and ground objects (SAM sites, factories, resource extraction - sites, etc). - """ - for cp in self.game.theater.controlpoints: - yield cp - yield from cp.ground_objects - yield from self.front_lines() + def prioritized_unisolated_points(self) -> list[ControlPoint]: + prioritized = [] + capturable_later = [] + for cp in self.game.theater.control_points_for(not self.is_player): + if cp.is_isolated: + continue + if cp.has_active_frontline: + prioritized.append(cp) + else: + capturable_later.append(cp) + prioritized.extend(self._targets_by_range(capturable_later)) + return prioritized @staticmethod def closest_airfields_to(location: MissionTarget) -> ClosestAirfields: diff --git a/game/commander/tasks/compound/attackgarrisons.py b/game/commander/tasks/compound/attackgarrisons.py index 89a0943c..479bcc71 100644 --- a/game/commander/tasks/compound/attackgarrisons.py +++ b/game/commander/tasks/compound/attackgarrisons.py @@ -7,5 +7,6 @@ from game.htn import CompoundTask, Method class AttackGarrisons(CompoundTask[TheaterState]): def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - for garrison in state.enemy_garrisons.in_priority_order: - yield [PlanBai(garrison)] + for garrisons in state.enemy_garrisons.values(): + for garrison in garrisons.in_priority_order: + yield [PlanBai(garrison)] diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py index 1b03ce53..352aa0b4 100644 --- a/game/commander/tasks/primitive/bai.py +++ b/game/commander/tasks/primitive/bai.py @@ -14,12 +14,12 @@ class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: if not super().preconditions_met(state): return False - if self.target not in state.enemy_garrisons: + if not state.has_garrison(self.target): return False return self.target_area_preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: - state.enemy_garrisons.eliminate(self.target) + state.eliminate_garrison(self.target) def propose_flights(self, doctrine: Doctrine) -> None: self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive) diff --git a/game/commander/tasks/primitive/breakthroughattack.py b/game/commander/tasks/primitive/breakthroughattack.py index 6657bf23..eb17b5ac 100644 --- a/game/commander/tasks/primitive/breakthroughattack.py +++ b/game/commander/tasks/primitive/breakthroughattack.py @@ -2,9 +2,6 @@ from __future__ import annotations from game.commander.tasks.frontlinestancetask import FrontLineStanceTask from game.commander.theaterstate import TheaterState -from game.theater import ControlPoint -from game.theater.theatergroundobject import VehicleGroupGroundObject -from game.utils import meters from gen.ground_forces.combat_stance import CombatStance @@ -17,20 +14,14 @@ class BreakthroughAttack(FrontLineStanceTask): def have_sufficient_front_line_advantage(self) -> bool: return self.ground_force_balance >= 2.0 - @property - def opposing_garrisons_eliminated(self) -> bool: - # TODO: Should operate on TheaterState to account for BAIs planned this turn. - for tgo in self.enemy_cp.ground_objects: - if not isinstance(tgo, VehicleGroupGroundObject): - continue - if meters(tgo.distance_to(self.enemy_cp)) < ControlPoint.CAPTURE_DISTANCE: - return False - return True + def opposing_garrisons_eliminated(self, state: TheaterState) -> bool: + garrisons = state.enemy_garrisons[self.enemy_cp] + return not bool(garrisons.blocking_capture) def preconditions_met(self, state: TheaterState) -> bool: if not super().preconditions_met(state): return False - return self.opposing_garrisons_eliminated + return self.opposing_garrisons_eliminated(state) def apply_effects(self, state: TheaterState) -> None: super().apply_effects(state) diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index d80e38e9..f737a2aa 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -15,6 +15,7 @@ from game.theater.theatergroundobject import ( TheaterGroundObject, NavalGroundObject, IadsGroundObject, + VehicleGroupGroundObject, ) from game.threatzones import ThreatZones from game.transfers import Convoy, CargoShip @@ -41,7 +42,7 @@ class TheaterState(WorldState["TheaterState"]): enemy_convoys: list[Convoy] enemy_shipping: list[CargoShip] enemy_ships: list[NavalGroundObject] - enemy_garrisons: Garrisons + enemy_garrisons: dict[ControlPoint, Garrisons] oca_targets: list[ControlPoint] strike_targets: list[TheaterGroundObject[Any]] enemy_barcaps: list[ControlPoint] @@ -72,6 +73,12 @@ class TheaterState(WorldState["TheaterState"]): self.enemy_ships.remove(target) self._rebuild_threat_zones() + def has_garrison(self, target: VehicleGroupGroundObject) -> bool: + return target in self.enemy_garrisons[target.control_point] + + def eliminate_garrison(self, target: VehicleGroupGroundObject) -> None: + self.enemy_garrisons[target.control_point].eliminate(target) + def clone(self) -> TheaterState: # Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly # expensive. @@ -89,7 +96,9 @@ class TheaterState(WorldState["TheaterState"]): enemy_convoys=list(self.enemy_convoys), enemy_shipping=list(self.enemy_shipping), enemy_ships=list(self.enemy_ships), - enemy_garrisons=dataclasses.replace(self.enemy_garrisons), + enemy_garrisons={ + cp: dataclasses.replace(g) for cp, g in self.enemy_garrisons.items() + }, oca_targets=list(self.oca_targets), strike_targets=list(self.strike_targets), enemy_barcaps=list(self.enemy_barcaps), @@ -110,6 +119,7 @@ class TheaterState(WorldState["TheaterState"]): finder = ObjectiveFinder(game, player) auto_stance = game.settings.automate_front_line_stance auto_ato = game.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled + ordered_capturable_points = finder.prioritized_unisolated_points() return TheaterState( player=player, stance_automation_enabled=auto_stance, @@ -126,7 +136,9 @@ class TheaterState(WorldState["TheaterState"]): enemy_convoys=list(finder.convoys()), enemy_shipping=list(finder.cargo_ships()), enemy_ships=list(finder.enemy_ships()), - enemy_garrisons=Garrisons.from_theater(game.theater, not player), + enemy_garrisons={ + cp: Garrisons.for_control_point(cp) for cp in ordered_capturable_points + }, oca_targets=list(finder.oca_targets(min_aircraft=20)), strike_targets=list(finder.strike_targets()), enemy_barcaps=list(game.theater.control_points_for(not player)), diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 8fb111f5..e1333dfc 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -342,9 +342,13 @@ class ControlPoint(MissionTarget, ABC): return self.name @property - def is_global(self) -> bool: + def is_isolated(self) -> bool: return not self.connected_points + @property + def is_global(self) -> bool: + return self.is_isolated + def transitive_connected_friendly_points( self, seen: Optional[Set[ControlPoint]] = None ) -> List[ControlPoint]: From 17c19d453b96669d890ae36b3f4f116a6cb87a13 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 12 Jul 2021 16:10:12 -0700 Subject: [PATCH 058/167] Factor out Coalition from Game. --- game/coalition.py | 215 +++++++++++++ game/commander/objectivefinder.py | 10 +- game/commander/tasks/primitive/antiship.py | 2 +- game/debriefing.py | 7 +- game/event/event.py | 10 +- game/game.py | 299 ++++-------------- game/inventory.py | 11 +- game/operation/operation.py | 40 +-- game/procurement.py | 16 +- game/squadrons.py | 55 ++-- game/theater/controlpoint.py | 14 +- game/theater/start_generator.py | 9 +- game/transfers.py | 13 +- game/unitdelivery.py | 37 ++- gen/aircraft.py | 14 +- gen/airsupportgen.py | 14 +- gen/armor.py | 8 +- gen/cargoshipgen.py | 7 +- gen/convoygen.py | 9 +- gen/flights/ai_flight_planner.py | 2 +- gen/forcedoptionsgen.py | 4 +- gen/groundobjectsgen.py | 21 +- gen/visualgen.py | 2 +- qt_ui/models.py | 25 +- qt_ui/widgets/QFactionsInfos.py | 4 +- qt_ui/widgets/QTopPanel.py | 8 +- qt_ui/widgets/combos/QAircraftTypeSelector.py | 7 +- qt_ui/widgets/map/mapmodel.py | 16 +- qt_ui/windows/AirWingDialog.py | 9 +- .../windows/basemenu/DepartingConvoysMenu.py | 8 +- qt_ui/windows/basemenu/QBaseMenu2.py | 4 +- .../airfield/QAircraftRecruitmentMenu.py | 2 +- .../windows/groundobject/QGroundObjectMenu.py | 3 +- .../windows/mission/flight/QFlightCreator.py | 3 +- 34 files changed, 471 insertions(+), 437 deletions(-) create mode 100644 game/coalition.py diff --git a/game/coalition.py b/game/coalition.py new file mode 100644 index 00000000..f7a97ecf --- /dev/null +++ b/game/coalition.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from dcs import Point +from faker import Faker + +from game.commander import TheaterCommander +from game.income import Income +from game.navmesh import NavMesh +from game.profiling import logged_duration, MultiEventTracer +from game.threatzones import ThreatZones +from game.transfers import PendingTransfers +from gen.flights.ai_flight_planner import CoalitionMissionPlanner + +if TYPE_CHECKING: + from game import Game +from game.data.doctrine import Doctrine +from game.factions.faction import Faction +from game.procurement import AircraftProcurementRequest, ProcurementAi +from game.squadrons import AirWing +from game.theater.bullseye import Bullseye +from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder +from gen import AirTaskingOrder + + +class Coalition: + def __init__( + self, game: Game, faction: Faction, budget: float, player: bool + ) -> None: + self.game = game + self.player = player + self.faction = faction + self.budget = budget + self.ato = AirTaskingOrder() + self.transit_network = TransitNetwork() + self.procurement_requests: list[AircraftProcurementRequest] = [] + self.bullseye = Bullseye(Point(0, 0)) + self.faker = Faker(self.faction.locales) + self.air_wing = AirWing(game, self) + self.transfers = PendingTransfers(game, player) + + # Late initialized because the two coalitions in the game are mutually + # dependent, so must be both constructed before this property can be set. + self._opponent: Optional[Coalition] = None + + # Volatile properties that are not persisted to the save file since they can be + # recomputed on load. Keeping this data out of the save file makes save compat + # breaks less frequent. Each of these properties has a non-underscore-prefixed + # @property that should be used for non-Optional access. + # + # All of these are late-initialized (whether via on_load or called later), but + # will be non-None after the game has finished loading. + self._threat_zone: Optional[ThreatZones] = None + self._navmesh: Optional[NavMesh] = None + self.on_load() + + @property + def doctrine(self) -> Doctrine: + return self.faction.doctrine + + @property + def coalition_id(self) -> int: + if self.player: + return 2 + return 1 + + @property + def country_name(self) -> str: + return self.faction.country + + @property + def opponent(self) -> Coalition: + assert self._opponent is not None + return self._opponent + + @property + def threat_zone(self) -> ThreatZones: + assert self._threat_zone is not None + return self._threat_zone + + @property + def nav_mesh(self) -> NavMesh: + assert self._navmesh is not None + return self._navmesh + + def __getstate__(self) -> dict[str, Any]: + state = self.__dict__.copy() + # Avoid persisting any volatile types that can be deterministically + # recomputed on load for the sake of save compatibility. + del state["_threat_zone"] + del state["_navmesh"] + del state["faker"] + return state + + def __setstate__(self, state: dict[str, Any]) -> None: + self.__dict__.update(state) + # Regenerate any state that was not persisted. + self.on_load() + + def on_load(self) -> None: + self.faker = Faker(self.faction.locales) + + def set_opponent(self, opponent: Coalition) -> None: + if self._opponent is not None: + raise RuntimeError("Double-initialization of Coalition.opponent") + self._opponent = opponent + + def adjust_budget(self, amount: float) -> None: + self.budget += amount + + def compute_threat_zones(self) -> None: + self._threat_zone = ThreatZones.for_faction(self.game, self.player) + + def compute_nav_meshes(self) -> None: + self._navmesh = NavMesh.from_threat_zones( + self.opponent.threat_zone, self.game.theater + ) + + def update_transit_network(self) -> None: + self.transit_network = TransitNetworkBuilder( + self.game.theater, self.player + ).build() + + def set_bullseye(self, bullseye: Bullseye) -> None: + self.bullseye = bullseye + + def end_turn(self) -> None: + """Processes coalition-specific turn finalization. + + For more information on turn finalization in general, see the documentation for + `Game.finish_turn`. + """ + self.air_wing.replenish() + self.budget += Income(self.game, self.player).total + + # Need to recompute before transfers and deliveries to account for captures. + # This happens in in initialize_turn as well, because cheating doesn't advance a + # turn but can capture bases so we need to recompute there as well. + self.update_transit_network() + + # Must happen *before* unit deliveries are handled, or else new units will spawn + # one hop ahead. ControlPoint.process_turn handles unit deliveries. The + # coalition-specific turn-end happens before the theater-wide turn-end, so this + # is handled correctly. + self.transfers.perform_transfers() + + def initialize_turn(self) -> None: + """Processes coalition-specific turn initialization. + + For more information on turn initialization in general, see the documentation + for `Game.initialize_turn`. + """ + # Needs to happen *before* planning transfers so we don't cancel them. + self.ato.clear() + self.air_wing.reset() + self.refund_outstanding_orders() + self.procurement_requests.clear() + + with logged_duration("Transit network identification"): + self.update_transit_network() + with logged_duration("Procurement of airlift assets"): + self.transfers.order_airlift_assets() + with logged_duration("Transport planning"): + self.transfers.plan_transports() + + self.plan_missions() + self.plan_procurement() + + def refund_outstanding_orders(self) -> None: + # TODO: Split orders between air and ground units. + # This isn't quite right. If the player has ground purchases automated we should + # be refunding the ground units, and if they have air automated but not ground + # we should be refunding air units. + if self.player and not self.game.settings.automate_aircraft_reinforcements: + return + + for cp in self.game.theater.control_points_for(self.player): + cp.pending_unit_deliveries.refund_all(self) + + def plan_missions(self) -> None: + color = "Blue" if self.player else "Red" + with MultiEventTracer() as tracer: + mission_planner = CoalitionMissionPlanner(self.game, self.player) + with tracer.trace(f"{color} mission planning"): + with tracer.trace(f"{color} mission identification"): + commander = TheaterCommander(self.game, self.player) + commander.plan_missions(mission_planner, tracer) + with tracer.trace(f"{color} mission fulfillment"): + mission_planner.fulfill_missions() + + def plan_procurement(self) -> None: + # The first turn needs to buy a *lot* of aircraft to fill CAPs, so it gets much + # more of the budget that turn. Otherwise budget (after repairs) is split evenly + # between air and ground. For the default starting budget of 2000 this gives 600 + # to ground forces and 1400 to aircraft. After that the budget will be spent + # proportionally based on how much is already invested. + + if self.player: + manage_runways = self.game.settings.automate_runway_repair + manage_front_line = self.game.settings.automate_front_line_reinforcements + manage_aircraft = self.game.settings.automate_aircraft_reinforcements + else: + manage_runways = False + manage_front_line = False + manage_aircraft = False + + self.budget = ProcurementAi( + self.game, + self.player, + self.faction, + manage_runways, + manage_front_line, + manage_aircraft, + ).spend_budget(self.budget) diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index 34db8730..15e47ed0 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -14,9 +14,9 @@ from game.theater import ( Airfield, ) from game.theater.theatergroundobject import ( - NavalGroundObject, BuildingGroundObject, IadsGroundObject, + NavalGroundObject, ) from game.transfers import CargoShip, Convoy from game.utils import meters, nautical_miles @@ -163,13 +163,17 @@ class ObjectiveFinder: def convoys(self) -> Iterator[Convoy]: for front_line in self.front_lines(): - yield from self.game.transfers.convoys.travelling_to( + yield from self.game.coalition_for( + self.is_player + ).transfers.convoys.travelling_to( front_line.control_point_hostile_to(self.is_player) ) def cargo_ships(self) -> Iterator[CargoShip]: for front_line in self.front_lines(): - yield from self.game.transfers.cargo_ships.travelling_to( + yield from self.game.coalition_for( + self.is_player + ).transfers.cargo_ships.travelling_to( front_line.control_point_hostile_to(self.is_player) ) diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py index fcdc2273..10a55477 100644 --- a/game/commander/tasks/primitive/antiship.py +++ b/game/commander/tasks/primitive/antiship.py @@ -6,7 +6,7 @@ from game.commander.missionproposals import EscortType from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState from game.data.doctrine import Doctrine -from game.theater import NavalGroundObject +from game.theater.theatergroundobject import NavalGroundObject from gen.flights.flight import FlightType diff --git a/game/debriefing.py b/game/debriefing.py index e4e3bf0f..b2a155ad 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -18,7 +18,6 @@ from typing import ( Union, ) -from game import db from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.theater import Airfield, ControlPoint @@ -136,10 +135,8 @@ class Debriefing: self.game = game self.unit_map = unit_map - self.player_country = game.player_country - self.enemy_country = game.enemy_country - self.player_country_id = db.country_id_from_name(game.player_country) - self.enemy_country_id = db.country_id_from_name(game.enemy_country) + self.player_country = game.blue.country_name + self.enemy_country = game.red.country_name self.air_losses = self.dead_aircraft() self.ground_losses = self.dead_ground_units() diff --git a/game/event/event.py b/game/event/event.py index ad20e06c..2176220c 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -53,7 +53,7 @@ class Event: @property def is_player_attacking(self) -> bool: - return self.attacker_name == self.game.player_faction.name + return self.attacker_name == self.game.blue.faction.name @property def tasks(self) -> List[Type[Task]]: @@ -114,10 +114,10 @@ class Event: def complete_aircraft_transfers(self, debriefing: Debriefing) -> None: self._transfer_aircraft( - self.game.blue_ato, debriefing.air_losses, for_player=True + self.game.blue.ato, debriefing.air_losses, for_player=True ) self._transfer_aircraft( - self.game.red_ato, debriefing.air_losses, for_player=False + self.game.red.ato, debriefing.air_losses, for_player=False ) def commit_air_losses(self, debriefing: Debriefing) -> None: @@ -154,8 +154,8 @@ class Event: pilot.record.missions_flown += 1 def commit_pilot_experience(self) -> None: - self._commit_pilot_experience(self.game.blue_ato) - self._commit_pilot_experience(self.game.red_ato) + self._commit_pilot_experience(self.game.blue.ato) + self._commit_pilot_experience(self.game.red.ato) @staticmethod def commit_front_line_losses(debriefing: Debriefing) -> None: diff --git a/game/game.py b/game/game.py index fce069f8..9e1cb00c 100644 --- a/game/game.py +++ b/game/game.py @@ -1,13 +1,11 @@ import itertools import logging import math -import random -import sys +from collections import Iterator from datetime import date, datetime, timedelta from enum import Enum from typing import Any, List, Type, Union, cast -from dcs.action import Coalition from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike from dcs.vehicles import AirDefence @@ -19,28 +17,25 @@ from game.plugins import LuaPluginManager from gen import naming from gen.ato import AirTaskingOrder from gen.conflictgen import Conflict -from gen.flights.ai_flight_planner import CoalitionMissionPlanner from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType from gen.ground_forces.ai_ground_planner import GroundPlanner from . import persistency -from .commander import TheaterCommander +from .coalition import Coalition from .debriefing import Debriefing from .event.event import Event from .event.frontlineattack import FrontlineAttackEvent from .factions.faction import Faction -from .income import Income from .infos.information import Information from .navmesh import NavMesh -from .procurement import AircraftProcurementRequest, ProcurementAi -from .profiling import logged_duration, MultiEventTracer -from .settings import Settings, AutoAtoBehavior +from .procurement import AircraftProcurementRequest +from .profiling import logged_duration +from .settings import Settings from .squadrons import AirWing from .theater import ConflictTheater, ControlPoint from .theater.bullseye import Bullseye from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder from .threatzones import ThreatZones -from .transfers import PendingTransfers from .unitmap import UnitMap from .weather import Conditions, TimeOfDay @@ -98,10 +93,6 @@ class Game: self.settings = settings self.events: List[Event] = [] self.theater = theater - self.player_faction = player_faction - self.player_country = player_faction.country - self.enemy_faction = enemy_faction - self.enemy_country = enemy_faction.country # pass_turn() will be called when initialization is complete which will # increment this to turn 0 before it reaches the player. self.turn = -1 @@ -124,108 +115,70 @@ class Game: self.conditions = self.generate_conditions() - self.blue_transit_network = TransitNetwork() - self.red_transit_network = TransitNetwork() - - self.blue_procurement_requests: List[AircraftProcurementRequest] = [] - self.red_procurement_requests: List[AircraftProcurementRequest] = [] - - self.blue_ato = AirTaskingOrder() - self.red_ato = AirTaskingOrder() - - self.blue_bullseye = Bullseye(Point(0, 0)) - self.red_bullseye = Bullseye(Point(0, 0)) + self.sanitize_sides(player_faction, enemy_faction) + self.blue = Coalition(self, player_faction, player_budget, player=True) + self.red = Coalition(self, enemy_faction, enemy_budget, player=False) + self.blue.set_opponent(self.red) + self.red.set_opponent(self.blue) self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints) - self.transfers = PendingTransfers(self) - - self.sanitize_sides() - - self.blue_faker = Faker(self.player_faction.locales) - self.red_faker = Faker(self.enemy_faction.locales) - - self.blue_air_wing = AirWing(self, player=True) - self.red_air_wing = AirWing(self, player=False) - self.on_load(game_still_initializing=True) - def __getstate__(self) -> dict[str, Any]: - state = self.__dict__.copy() - # Avoid persisting any volatile types that can be deterministically - # recomputed on load for the sake of save compatibility. - del state["blue_threat_zone"] - del state["red_threat_zone"] - del state["blue_navmesh"] - del state["red_navmesh"] - del state["blue_faker"] - del state["red_faker"] - return state - def __setstate__(self, state: dict[str, Any]) -> None: self.__dict__.update(state) # Regenerate any state that was not persisted. self.on_load() + @property + def coalitions(self) -> Iterator[Coalition]: + yield self.blue + yield self.red + def ato_for(self, player: bool) -> AirTaskingOrder: - if player: - return self.blue_ato - return self.red_ato + return self.coalition_for(player).ato def procurement_requests_for( self, player: bool - ) -> List[AircraftProcurementRequest]: - if player: - return self.blue_procurement_requests - return self.red_procurement_requests + ) -> list[AircraftProcurementRequest]: + return self.coalition_for(player).procurement_requests def transit_network_for(self, player: bool) -> TransitNetwork: - if player: - return self.blue_transit_network - return self.red_transit_network + return self.coalition_for(player).transit_network def generate_conditions(self) -> Conditions: return Conditions.generate( self.theater, self.current_day, self.current_turn_time_of_day, self.settings ) - def sanitize_sides(self) -> None: + @staticmethod + def sanitize_sides(player_faction: Faction, enemy_faction: Faction) -> None: """ Make sure the opposing factions are using different countries :return: """ - if self.player_country == self.enemy_country: - if self.player_country == "USA": - self.enemy_country = "USAF Aggressors" - elif self.player_country == "Russia": - self.enemy_country = "USSR" + if player_faction.country == enemy_faction.country: + if player_faction.country == "USA": + enemy_faction.country = "USAF Aggressors" + elif player_faction.country == "Russia": + enemy_faction.country = "USSR" else: - self.enemy_country = "Russia" + enemy_faction.country = "Russia" def faction_for(self, player: bool) -> Faction: - if player: - return self.player_faction - return self.enemy_faction + return self.coalition_for(player).faction def faker_for(self, player: bool) -> Faker: - if player: - return self.blue_faker - return self.red_faker + return self.coalition_for(player).faker def air_wing_for(self, player: bool) -> AirWing: - if player: - return self.blue_air_wing - return self.red_air_wing + return self.coalition_for(player).air_wing def country_for(self, player: bool) -> str: - if player: - return self.player_country - return self.enemy_country + return self.coalition_for(player).country_name def bullseye_for(self, player: bool) -> Bullseye: - if player: - return self.blue_bullseye - return self.red_bullseye + return self.coalition_for(player).bullseye def _generate_player_event( self, event_class: Type[Event], player_cp: ControlPoint, enemy_cp: ControlPoint @@ -236,8 +189,8 @@ class Game: player_cp, enemy_cp, enemy_cp.position, - self.player_faction.name, - self.enemy_faction.name, + self.blue.faction.name, + self.red.faction.name, ) ) @@ -249,20 +202,13 @@ class Game: front_line.red_cp, ) - def adjust_budget(self, amount: float, player: bool) -> None: + def coalition_for(self, player: bool) -> Coalition: if player: - self.budget += amount - else: - self.enemy_budget += amount + return self.blue + return self.red - def process_player_income(self) -> None: - self.budget += Income(self, player=True).total - - def process_enemy_income(self) -> None: - # TODO: Clean up save compat. - if not hasattr(self, "enemy_budget"): - self.enemy_budget = 0 - self.enemy_budget += Income(self, player=False).total + def adjust_budget(self, amount: float, player: bool) -> None: + self.coalition_for(player).adjust_budget(amount) @staticmethod def initiate_event(event: Event) -> UnitMap: @@ -293,12 +239,6 @@ class Game: self.compute_conflicts_position() if not game_still_initializing: self.compute_threat_zones() - self.blue_faker = Faker(self.faction_for(player=True).locales) - self.red_faker = Faker(self.faction_for(player=False).locales) - - def reset_ato(self) -> None: - self.blue_ato.clear() - self.red_ato.clear() def finish_turn(self, skipped: bool = False) -> None: """Finalizes the current turn and advances to the next turn. @@ -333,23 +273,16 @@ class Game: ) self.turn += 1 - # Need to recompute before transfers and deliveries to account for captures. - # This happens in in initialize_turn as well, because cheating doesn't advance a - # turn but can capture bases so we need to recompute there as well. - self.compute_transit_networks() + # The coalition-specific turn finalization *must* happen before unit deliveries, + # since the coalition-specific finalization handles transit network updates and + # transfer processing. If in the other order, units may be delivered to captured + # bases, and freshly delivered units will spawn one leg through their journey. + self.blue.end_turn() + self.red.end_turn() - # Must happen *before* unit deliveries are handled, or else new units will spawn - # one hop ahead. ControlPoint.process_turn handles unit deliveries. - self.transfers.perform_transfers() - - # Needs to happen *before* planning transfers so we don't cancel them. - self.reset_ato() for control_point in self.theater.controlpoints: control_point.process_turn(self) - self.blue_air_wing.replenish() - self.red_air_wing.replenish() - if not skipped: for cp in self.theater.player_points(): cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY) @@ -360,9 +293,6 @@ class Game: self.conditions = self.generate_conditions() - self.process_enemy_income() - self.process_player_income() - def begin_turn_0(self) -> None: """Initialization for the first turn of the game.""" self.turn = 0 @@ -402,8 +332,8 @@ class Game: def set_bullseye(self) -> None: player_cp, enemy_cp = self.theater.closest_opposing_control_points() - self.blue_bullseye = Bullseye(enemy_cp.position) - self.red_bullseye = Bullseye(player_cp.position) + self.blue.bullseye = Bullseye(enemy_cp.position) + self.red.bullseye = Bullseye(player_cp.position) def initialize_turn(self, for_red: bool = True, for_blue: bool = True) -> None: """Performs turn initialization for the specified players. @@ -451,13 +381,20 @@ class Game: if turn_state in (TurnState.LOSS, TurnState.WIN): return self.process_win_loss(turn_state) + # Plan flights & combat for next turn + with logged_duration("Computing conflict positions"): + self.compute_conflicts_position() + with logged_duration("Threat zone computation"): + self.compute_threat_zones() + # Plan Coalition specific turn - if for_red: - self.initialize_turn_for(player=False) if for_blue: self.initialize_turn_for(player=True) + if for_red: + self.initialize_turn_for(player=False) # Plan GroundWar + self.ground_planners = {} for cp in self.theater.controlpoints: if cp.has_frontline: gplanner = GroundPlanner(cp, self) @@ -465,83 +402,10 @@ class Game: self.ground_planners[cp.id] = gplanner def initialize_turn_for(self, player: bool) -> None: - """Processes coalition-specific turn initialization. - - For more information on turn initialization in general, see the documentation - for `Game.initialize_turn`. - - Args: - player: True if the player coalition is being initialized. False for opfor - initialization. - """ - self.ato_for(player).clear() - self.air_wing_for(player).reset() - - self.aircraft_inventory.reset() - for cp in self.theater.controlpoints: + self.aircraft_inventory.reset(player) + for cp in self.theater.control_points_for(player): self.aircraft_inventory.set_from_control_point(cp) - # Refund all pending deliveries for opfor and if player - # has automate_aircraft_reinforcements - if (not player and not cp.captured) or ( - player - and cp.captured - and self.settings.automate_aircraft_reinforcements - ): - cp.pending_unit_deliveries.refund_all(self) - - # Plan flights & combat for next turn - with logged_duration("Computing conflict positions"): - self.compute_conflicts_position() - with logged_duration("Threat zone computation"): - self.compute_threat_zones() - with logged_duration("Transit network identification"): - self.compute_transit_networks() - self.ground_planners = {} - - self.procurement_requests_for(player).clear() - - with logged_duration("Procurement of airlift assets"): - self.transfers.order_airlift_assets() - with logged_duration("Transport planning"): - self.transfers.plan_transports() - - color = "Blue" if player else "Red" - with MultiEventTracer() as tracer: - mission_planner = CoalitionMissionPlanner(self, player) - with tracer.trace(f"{color} mission planning"): - with tracer.trace(f"{color} mission identification"): - commander = TheaterCommander(self, player) - commander.plan_missions(mission_planner, tracer) - with tracer.trace(f"{color} mission fulfillment"): - mission_planner.fulfill_missions() - - self.plan_procurement_for(player) - - def plan_procurement_for(self, for_player: bool) -> None: - # The first turn needs to buy a *lot* of aircraft to fill CAPs, so it - # gets much more of the budget that turn. Otherwise budget (after - # repairs) is split evenly between air and ground. For the default - # starting budget of 2000 this gives 600 to ground forces and 1400 to - # aircraft. After that the budget will be spend proportionally based on how much is already invested - - if for_player: - self.budget = ProcurementAi( - self, - for_player=True, - faction=self.player_faction, - manage_runways=self.settings.automate_runway_repair, - manage_front_line=self.settings.automate_front_line_reinforcements, - manage_aircraft=self.settings.automate_aircraft_reinforcements, - ).spend_budget(self.budget) - else: - self.enemy_budget = ProcurementAi( - self, - for_player=False, - faction=self.enemy_faction, - manage_runways=True, - manage_front_line=True, - manage_aircraft=True, - ).spend_budget(self.enemy_budget) + self.coalition_for(player).initialize_turn() def message(self, text: str) -> None: self.informations.append(Information(text, turn=self.turn)) @@ -568,32 +432,20 @@ class Game: self.current_group_id += 1 return self.current_group_id - def compute_transit_networks(self) -> None: - self.blue_transit_network = self.compute_transit_network_for(player=True) - self.red_transit_network = self.compute_transit_network_for(player=False) - def compute_transit_network_for(self, player: bool) -> TransitNetwork: return TransitNetworkBuilder(self.theater, player).build() def compute_threat_zones(self) -> None: - self.blue_threat_zone = ThreatZones.for_faction(self, player=True) - self.red_threat_zone = ThreatZones.for_faction(self, player=False) - self.blue_navmesh = NavMesh.from_threat_zones( - self.red_threat_zone, self.theater - ) - self.red_navmesh = NavMesh.from_threat_zones( - self.blue_threat_zone, self.theater - ) + self.blue.compute_threat_zones() + self.red.compute_threat_zones() + self.blue.compute_nav_meshes() + self.red.compute_nav_meshes() def threat_zone_for(self, player: bool) -> ThreatZones: - if player: - return self.blue_threat_zone - return self.red_threat_zone + return self.coalition_for(player).threat_zone def navmesh_for(self, player: bool) -> NavMesh: - if player: - return self.blue_navmesh - return self.red_navmesh + return self.coalition_for(player).nav_mesh def compute_conflicts_position(self) -> None: """ @@ -636,7 +488,7 @@ class Game: if cpoint is not None: zones.append(cpoint) - packages = itertools.chain(self.blue_ato.packages, self.red_ato.packages) + packages = itertools.chain(self.blue.ato.packages, self.red.ato.packages) for package in packages: if package.primary_task is FlightType.BARCAP: # BARCAPs will be planned at most locations on smaller theaters, @@ -682,25 +534,6 @@ class Game: """ return self.__culling_zones - # 1 = red, 2 = blue - def get_player_coalition_id(self) -> int: - return 2 - - def get_enemy_coalition_id(self) -> int: - return 1 - - def get_player_coalition(self) -> Coalition: - return Coalition.Blue - - def get_enemy_coalition(self) -> Coalition: - return Coalition.Red - - def get_player_color(self) -> str: - return "blue" - - def get_enemy_color(self) -> str: - return "red" - def process_win_loss(self, turn_state: TurnState) -> None: if turn_state is TurnState.WIN: self.message( diff --git a/game/inventory.py b/game/inventory.py index 4014c05c..77587cb2 100644 --- a/game/inventory.py +++ b/game/inventory.py @@ -2,9 +2,7 @@ from __future__ import annotations from collections import defaultdict -from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING, Type - -from dcs.unittype import FlyingType +from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING from game.dcs.aircrafttype import AircraftType from gen.flights.flight import Flight @@ -86,10 +84,11 @@ class GlobalAircraftInventory: cp: ControlPointAircraftInventory(cp) for cp in control_points } - def reset(self) -> None: - """Clears all control points and their inventories.""" + def reset(self, for_player: bool) -> None: + """Clears the inventory of every control point owned by the given coalition.""" for inventory in self.inventories.values(): - inventory.clear() + if inventory.control_point.captured == for_player: + inventory.clear() def set_from_control_point(self, control_point: ControlPoint) -> None: """Set the control point's aircraft inventory. diff --git a/game/operation/operation.py b/game/operation/operation.py index 56cfcf66..290076db 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import os from pathlib import Path -from typing import Iterable, List, Set, TYPE_CHECKING, cast +from typing import List, Set, TYPE_CHECKING, cast from dcs import Mission from dcs.action import DoScript, DoScriptFile @@ -81,10 +81,10 @@ class Operation: return Conflict( cls.game.theater, FrontLine(player_cp, enemy_cp), - cls.game.player_faction.name, - cls.game.enemy_faction.name, - cls.current_mission.country(cls.game.player_country), - cls.current_mission.country(cls.game.enemy_country), + cls.game.blue.faction.name, + cls.game.red.faction.name, + cls.current_mission.country(cls.game.blue.country_name), + cls.current_mission.country(cls.game.red.country_name), mid_point, ) @@ -95,14 +95,14 @@ class Operation: @classmethod def _setup_mission_coalitions(cls) -> None: cls.current_mission.coalition["blue"] = Coalition( - "blue", bullseye=cls.game.blue_bullseye.to_pydcs() + "blue", bullseye=cls.game.blue.bullseye.to_pydcs() ) cls.current_mission.coalition["red"] = Coalition( - "red", bullseye=cls.game.red_bullseye.to_pydcs() + "red", bullseye=cls.game.red.bullseye.to_pydcs() ) - p_country = cls.game.player_country - e_country = cls.game.enemy_country + p_country = cls.game.blue.country_name + e_country = cls.game.red.country_name cls.current_mission.coalition["blue"].add_country( country_dict[db.country_id_from_name(p_country)]() ) @@ -268,7 +268,7 @@ class Operation: and cls.game.settings.perf_destroyed_units ): cls.current_mission.static_group( - country=cls.current_mission.country(cls.game.player_country), + country=cls.current_mission.country(cls.game.blue.country_name), name="", _type=utype, hidden=True, @@ -358,18 +358,18 @@ class Operation: cls.airgen.clear_parking_slots() cls.airgen.generate_flights( - cls.current_mission.country(cls.game.player_country), - cls.game.blue_ato, + cls.current_mission.country(cls.game.blue.country_name), + cls.game.blue.ato, cls.groundobjectgen.runways, ) cls.airgen.generate_flights( - cls.current_mission.country(cls.game.enemy_country), - cls.game.red_ato, + cls.current_mission.country(cls.game.red.country_name), + cls.game.red.ato, cls.groundobjectgen.runways, ) cls.airgen.spawn_unused_aircraft( - cls.current_mission.country(cls.game.player_country), - cls.current_mission.country(cls.game.enemy_country), + cls.current_mission.country(cls.game.blue.country_name), + cls.current_mission.country(cls.game.red.country_name), ) @classmethod @@ -380,10 +380,10 @@ class Operation: player_cp = front_line.blue_cp enemy_cp = front_line.red_cp conflict = Conflict.frontline_cas_conflict( - cls.game.player_faction.name, - cls.game.enemy_faction.name, - cls.current_mission.country(cls.game.player_country), - cls.current_mission.country(cls.game.enemy_country), + cls.game.blue.faction.name, + cls.game.red.faction.name, + cls.current_mission.country(cls.game.blue.country_name), + cls.current_mission.country(cls.game.red.country_name), front_line, cls.game.theater, ) diff --git a/game/procurement.py b/game/procurement.py index 2e2c0e79..8820453c 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -72,7 +72,9 @@ class ProcurementAi: return 1 for cp in self.owned_points: - cp_ground_units = cp.allocated_ground_units(self.game.transfers) + cp_ground_units = cp.allocated_ground_units( + self.game.coalition_for(self.is_player).transfers + ) armor_investment += cp_ground_units.total_value cp_aircraft = cp.allocated_aircraft(self.game) aircraft_investment += cp_aircraft.total_value @@ -316,7 +318,9 @@ class ProcurementAi: continue purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR - allocated = cp.allocated_ground_units(self.game.transfers) + allocated = cp.allocated_ground_units( + self.game.coalition_for(self.is_player).transfers + ) if allocated.total >= purchase_target: # Control point is already sufficiently defended. continue @@ -343,7 +347,9 @@ class ProcurementAi: if not cp.can_recruit_ground_units(self.game): continue - allocated = cp.allocated_ground_units(self.game.transfers) + allocated = cp.allocated_ground_units( + self.game.coalition_for(self.is_player).transfers + ) if allocated.total >= self.game.settings.reserves_procurement_target: continue @@ -356,7 +362,9 @@ class ProcurementAi: def cost_ratio_of_ground_unit( self, control_point: ControlPoint, unit_class: GroundUnitClass ) -> float: - allocations = control_point.allocated_ground_units(self.game.transfers) + allocations = control_point.allocated_ground_units( + self.game.coalition_for(self.is_player).transfers + ) class_cost = 0 total_cost = 0 for unit_type, count in allocations.all.items(): diff --git a/game/squadrons.py b/game/squadrons.py index f3793916..3a23d4ea 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -20,10 +20,11 @@ import yaml from faker import Faker from game.dcs.aircrafttype import AircraftType -from game.settings import AutoAtoBehavior +from game.settings import AutoAtoBehavior, Settings if TYPE_CHECKING: from game import Game + from game.coalition import Coalition from gen.flights.flight import FlightType @@ -96,16 +97,13 @@ class Squadron: init=False, hash=False, compare=False ) - # We need a reference to the Game so that we can access the Faker without needing to - # persist it to the save game, or having to reconstruct it (it's not cheap) each - # time we create or load a squadron. - game: Game = field(hash=False, compare=False) - player: bool + coalition: Coalition = field(hash=False, compare=False) + settings: Settings = field(hash=False, compare=False) def __post_init__(self) -> None: if any(p.status is not PilotStatus.Active for p in self.pilot_pool): raise ValueError("Squadrons can only be created with active pilots.") - self._recruit_pilots(self.game.settings.squadron_pilot_limit) + self._recruit_pilots(self.settings.squadron_pilot_limit) self.auto_assignable_mission_types = set(self.mission_types) def __str__(self) -> str: @@ -113,9 +111,13 @@ class Squadron: return self.name return f'{self.name} "{self.nickname}"' + @property + def player(self) -> bool: + return self.coalition.player + @property def pilot_limits_enabled(self) -> bool: - return self.game.settings.enable_squadron_pilot_limits + return self.settings.enable_squadron_pilot_limits def claim_new_pilot_if_allowed(self) -> Optional[Pilot]: if self.pilot_limits_enabled: @@ -131,7 +133,7 @@ class Squadron: if not self.player: return self.available_pilots.pop() - preference = self.game.settings.auto_ato_behavior + preference = self.settings.auto_ato_behavior # No preference, so the first pilot is fine. if preference is AutoAtoBehavior.Default: @@ -184,7 +186,7 @@ class Squadron: return replenish_count = min( - self.game.settings.squadron_replenishment_rate, + self.settings.squadron_replenishment_rate, self._number_of_unfilled_pilot_slots, ) if replenish_count > 0: @@ -206,7 +208,7 @@ class Squadron: @property def faker(self) -> Faker: - return self.game.faker_for(self.player) + return self.coalition.faker def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]: return [p for p in self.current_roster if p.status == status] @@ -228,7 +230,7 @@ class Squadron: @property def _number_of_unfilled_pilot_slots(self) -> int: - return self.game.settings.squadron_pilot_limit - len(self.active_pilots) + return self.settings.squadron_pilot_limit - len(self.active_pilots) @property def number_of_available_pilots(self) -> int: @@ -252,7 +254,7 @@ class Squadron: return self.current_roster[index] @classmethod - def from_yaml(cls, path: Path, game: Game, player: bool) -> Squadron: + def from_yaml(cls, path: Path, game: Game, coalition: Coalition) -> Squadron: from gen.flights.ai_flight_planner_db import tasks_for_aircraft from gen.flights.flight import FlightType @@ -287,8 +289,8 @@ class Squadron: livery=data.get("livery"), mission_types=tuple(mission_types), pilot_pool=pilots, - game=game, - player=player, + coalition=coalition, + settings=game.settings, ) def __setstate__(self, state: dict[str, Any]) -> None: @@ -299,9 +301,9 @@ class Squadron: class SquadronLoader: - def __init__(self, game: Game, player: bool) -> None: + def __init__(self, game: Game, coalition: Coalition) -> None: self.game = game - self.player = player + self.coalition = coalition @staticmethod def squadron_directories() -> Iterator[Path]: @@ -312,8 +314,8 @@ class SquadronLoader: def load(self) -> dict[AircraftType, list[Squadron]]: squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list) - country = self.game.country_for(self.player) - faction = self.game.faction_for(self.player) + country = self.coalition.country_name + faction = self.coalition.faction any_country = country.startswith("Combined Joint Task Forces ") for directory in self.squadron_directories(): for path, squadron in self.load_squadrons_from(directory): @@ -347,7 +349,7 @@ class SquadronLoader: for squadron_path in directory.glob("*/*.yaml"): try: yield squadron_path, Squadron.from_yaml( - squadron_path, self.game, self.player + squadron_path, self.game, self.coalition ) except Exception as ex: raise RuntimeError( @@ -356,29 +358,28 @@ class SquadronLoader: class AirWing: - def __init__(self, game: Game, player: bool) -> None: + def __init__(self, game: Game, coalition: Coalition) -> None: from gen.flights.ai_flight_planner_db import tasks_for_aircraft self.game = game - self.player = player - self.squadrons = SquadronLoader(game, player).load() + self.squadrons = SquadronLoader(game, coalition).load() count = itertools.count(1) - for aircraft in game.faction_for(player).aircrafts: + for aircraft in coalition.faction.aircrafts: if aircraft in self.squadrons: continue self.squadrons[aircraft] = [ Squadron( name=f"Squadron {next(count):03}", nickname=self.random_nickname(), - country=game.country_for(player), + country=coalition.country_name, role="Flying Squadron", aircraft=aircraft, livery=None, mission_types=tuple(tasks_for_aircraft(aircraft)), pilot_pool=[], - game=game, - player=player, + coalition=coalition, + settings=game.settings, ) ] diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index e1333dfc..7fedc7fe 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -40,11 +40,7 @@ from gen.ground_forces.combat_stance import CombatStance from gen.runways import RunwayAssigner, RunwayData from .base import Base from .missiontarget import MissionTarget -from .theatergroundobject import ( - GenericCarrierGroundObject, - TheaterGroundObject, - NavalGroundObject, -) +from .theatergroundobject import GenericCarrierGroundObject, TheaterGroundObject from ..dcs.aircrafttype import AircraftType from ..dcs.groundunittype import GroundUnitType from ..utils import nautical_miles @@ -606,7 +602,7 @@ class ControlPoint(MissionTarget, ABC): # TODO: Should be Airbase specific. def capture(self, game: Game, for_player: bool) -> None: - self.pending_unit_deliveries.refund_all(game) + self.pending_unit_deliveries.refund_all(game.coalition_for(for_player)) self.retreat_ground_units(game) self.retreat_air_units(game) self.depopulate_uncapturable_tgos() @@ -623,11 +619,7 @@ class ControlPoint(MissionTarget, ABC): ... def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]: - if self.captured: - ato = game.blue_ato - else: - ato = game.red_ato - + ato = game.coalition_for(self.captured).ato transferring: defaultdict[AircraftType, int] = defaultdict(int) for package in ato.packages: for flight in package.flights: diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 4ec827ec..0bf85391 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -11,7 +11,7 @@ from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike from dcs.vehicles import AirDefence -from game import Game, db +from game import Game from game.factions.faction import Faction from game.scenery_group import SceneryGroup from game.theater import Carrier, Lha, PointWithHeading @@ -171,14 +171,11 @@ class ControlPointGroundObjectGenerator: @property def faction_name(self) -> str: - if self.control_point.captured: - return self.game.player_faction.name - else: - return self.game.enemy_faction.name + return self.faction.name @property def faction(self) -> Faction: - return db.FACTIONS[self.faction_name] + return self.game.coalition_for(self.control_point.captured).faction def generate(self) -> bool: self.control_point.connected_objectives = [] diff --git a/game/transfers.py b/game/transfers.py index a575964f..3bdc9b3c 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -316,7 +316,9 @@ class AirliftPlanner: capacity = flight_size * capacity_each if capacity < self.transfer.size: - transfer = self.game.transfers.split_transfer(self.transfer, capacity) + transfer = self.game.coalition_for( + self.for_player + ).transfers.split_transfer(self.transfer, capacity) else: transfer = self.transfer @@ -534,8 +536,9 @@ class CargoShipMap(TransportMap[CargoShip]): class PendingTransfers: - def __init__(self, game: Game) -> None: + def __init__(self, game: Game, player: bool) -> None: self.game = game + self.player = player self.convoys = ConvoyMap() self.cargo_ships = CargoShipMap() self.pending_transfers: List[TransferOrder] = [] @@ -609,7 +612,7 @@ class PendingTransfers: flight = transport.flight flight.package.remove_flight(flight) if not flight.package.flights: - self.game.ato_for(transport.player_owned).remove_package(flight.package) + self.game.ato_for(self.player).remove_package(flight.package) self.game.aircraft_inventory.return_from_flight(flight) flight.clear_roster() @@ -647,7 +650,7 @@ class PendingTransfers: self.arrange_transport(transfer) def order_airlift_assets(self) -> None: - for control_point in self.game.theater.controlpoints: + for control_point in self.game.theater.control_points_for(self.player): if self.game.air_wing_for(control_point.captured).can_auto_plan( FlightType.TRANSPORT ): @@ -682,7 +685,7 @@ class PendingTransfers: # aesthetic. gap += 1 - self.game.procurement_requests_for(player=control_point.captured).append( + self.game.procurement_requests_for(self.player).append( AircraftProcurementRequest( control_point, nautical_miles(200), FlightType.TRANSPORT, gap ) diff --git a/game/unitdelivery.py b/game/unitdelivery.py index ff7841c6..7dbfb0a0 100644 --- a/game/unitdelivery.py +++ b/game/unitdelivery.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Optional, TYPE_CHECKING, Any from game.theater import ControlPoint +from .coalition import Coalition from .dcs.groundunittype import GroundUnitType from .dcs.unittype import UnitType from .theater.transitnetwork import ( @@ -41,24 +42,22 @@ class PendingUnitDeliveries: for k, v in units.items(): self.units[k] -= v - def refund_all(self, game: Game) -> None: - self.refund(game, self.units) + def refund_all(self, coalition: Coalition) -> None: + self.refund(coalition, self.units) self.units = defaultdict(int) - def refund_ground_units(self, game: Game) -> None: + def refund_ground_units(self, coalition: Coalition) -> None: ground_units: dict[UnitType[Any], int] = { u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType) } - self.refund(game, ground_units) + self.refund(coalition, ground_units) for gu in ground_units.keys(): del self.units[gu] - def refund(self, game: Game, units: dict[UnitType[Any], int]) -> None: + def refund(self, coalition: Coalition, units: dict[UnitType[Any], int]) -> None: for unit_type, count in units.items(): logging.info(f"Refunding {count} {unit_type} at {self.destination.name}") - game.adjust_budget( - unit_type.price * count, player=self.destination.captured - ) + coalition.adjust_budget(unit_type.price * count) def pending_orders(self, unit_type: UnitType[Any]) -> int: pending_units = self.units.get(unit_type) @@ -71,19 +70,20 @@ class PendingUnitDeliveries: return self.pending_orders(unit_type) + current_units def process(self, game: Game) -> None: + coalition = game.coalition_for(self.destination.captured) ground_unit_source = self.find_ground_unit_source(game) if ground_unit_source is None: game.message( f"{self.destination.name} lost its source for ground unit " "reinforcements. Refunding purchase price." ) - self.refund_ground_units(game) + self.refund_ground_units(coalition) bought_units: dict[UnitType[Any], int] = {} units_needing_transfer: dict[GroundUnitType, int] = {} sold_units: dict[UnitType[Any], int] = {} for unit_type, count in self.units.items(): - coalition = "Ally" if self.destination.captured else "Enemy" + allegiance = "Ally" if self.destination.captured else "Enemy" d: dict[Any, int] if ( isinstance(unit_type, GroundUnitType) @@ -98,11 +98,11 @@ class PendingUnitDeliveries: if count >= 0: d[unit_type] = count game.message( - f"{coalition} reinforcements: {unit_type} x {count} at {source}" + f"{allegiance} reinforcements: {unit_type} x {count} at {source}" ) else: sold_units[unit_type] = -count - game.message(f"{coalition} sold: {unit_type} x {-count} at {source}") + game.message(f"{allegiance} sold: {unit_type} x {-count} at {source}") self.units = defaultdict(int) self.destination.base.commission_units(bought_units) @@ -111,16 +111,19 @@ class PendingUnitDeliveries: if units_needing_transfer: if ground_unit_source is None: raise RuntimeError( - f"ground unit source could not be found for {self.destination} but still tried to " - f"transfer units to there" + f"Ground unit source could not be found for {self.destination} but " + "still tried to transfer units to there" ) ground_unit_source.base.commission_units(units_needing_transfer) - self.create_transfer(game, ground_unit_source, units_needing_transfer) + self.create_transfer(coalition, ground_unit_source, units_needing_transfer) def create_transfer( - self, game: Game, source: ControlPoint, units: dict[GroundUnitType, int] + self, + coalition: Coalition, + source: ControlPoint, + units: dict[GroundUnitType, int], ) -> None: - game.transfers.new_transfer(TransferOrder(source, self.destination, units)) + coalition.transfers.new_transfer(TransferOrder(source, self.destination, units)) def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]: # This is running *after* the turn counter has been incremented, so this is the diff --git a/gen/aircraft.py b/gen/aircraft.py index c0caa0a0..392cd70c 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -22,7 +22,6 @@ from dcs.planes import ( C_101EB, F_14B, JF_17, - PlaneType, Su_33, Tu_22M3, ) @@ -262,8 +261,8 @@ class AircraftConflictGenerator: @cached_property def use_client(self) -> bool: """True if Client should be used instead of Player.""" - blue_clients = self.client_slots_in_ato(self.game.blue_ato) - red_clients = self.client_slots_in_ato(self.game.red_ato) + blue_clients = self.client_slots_in_ato(self.game.blue.ato) + red_clients = self.client_slots_in_ato(self.game.red.ato) return blue_clients + red_clients > 1 @staticmethod @@ -601,12 +600,11 @@ class AircraftConflictGenerator: if not isinstance(control_point, Airfield): continue + faction = self.game.coalition_for(control_point.captured).faction if control_point.captured: country = player_country - faction = self.game.player_faction else: country = enemy_country - faction = self.game.enemy_faction for aircraft, available in inventory.all_aircraft: try: @@ -699,11 +697,7 @@ class AircraftConflictGenerator: if flight.from_cp.cptype != ControlPointType.AIRBASE: return - if flight.from_cp.captured: - coalition = self.game.get_player_coalition_id() - else: - coalition = self.game.get_enemy_coalition_id() - + coalition = self.game.coalition_for(flight.departure.captured).coalition_id trigger.add_condition(CoalitionHasAirdrome(coalition, flight.from_cp.id)) def generate_planned_flight( diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 37fc30bb..409a0959 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -105,6 +105,8 @@ class AirSupportConflictGenerator: else self.conflict.red_cp ) + country = self.mission.country(self.game.blue.country_name) + if not self.game.settings.disable_legacy_tanker: fallback_tanker_number = 0 @@ -130,10 +132,8 @@ class AirSupportConflictGenerator: tanker_heading, TANKER_DISTANCE ) tanker_group = self.mission.refuel_flight( - country=self.mission.country(self.game.player_country), - name=namegen.next_tanker_name( - self.mission.country(self.game.player_country), tanker_unit_type - ), + country=country, + name=namegen.next_tanker_name(country, tanker_unit_type), airport=None, plane_type=unit_type, position=tanker_position, @@ -211,10 +211,8 @@ class AirSupportConflictGenerator: return awacs_flight = self.mission.awacs_flight( - country=self.mission.country(self.game.player_country), - name=namegen.next_awacs_name( - self.mission.country(self.game.player_country) - ), + country=country, + name=namegen.next_awacs_name(country), plane_type=unit_type, altitude=AWACS_ALT, airport=None, diff --git a/gen/armor.py b/gen/armor.py index 6db4f632..7e92169b 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -144,16 +144,16 @@ class GroundConflictGenerator: ) # Add JTAC - if self.game.player_faction.has_jtac: + if self.game.blue.faction.has_jtac: n = "JTAC" + str(self.conflict.blue_cp.id) + str(self.conflict.red_cp.id) code = 1688 - len(self.jtacs) - utype = self.game.player_faction.jtac_unit + utype = self.game.blue.faction.jtac_unit if utype is None: utype = AircraftType.named("MQ-9 Reaper") jtac = self.mission.flight_group( - country=self.mission.country(self.game.player_country), + country=self.mission.country(self.game.blue.country_name), name=n, aircraft_type=utype.dcs_unit_type, position=position[0], @@ -715,7 +715,7 @@ class GroundConflictGenerator: if is_player else int(heading_sum(heading, 90)) ) - country = self.game.player_country if is_player else self.game.enemy_country + country = self.game.coalition_for(is_player).country_name for group in groups: if group.role == CombatGroupRole.ARTILLERY: distance_from_frontline = ( diff --git a/gen/cargoshipgen.py b/gen/cargoshipgen.py index 9de370b9..ec7e6577 100644 --- a/gen/cargoshipgen.py +++ b/gen/cargoshipgen.py @@ -24,12 +24,13 @@ class CargoShipGenerator: def generate(self) -> None: # Reset the count to make generation deterministic. - for ship in self.game.transfers.cargo_ships: - self.generate_cargo_ship(ship) + for coalition in self.game.coalitions: + for ship in coalition.transfers.cargo_ships: + self.generate_cargo_ship(ship) def generate_cargo_ship(self, ship: CargoShip) -> ShipGroup: country = self.mission.country( - self.game.player_country if ship.player_owned else self.game.enemy_country + self.game.coalition_for(ship.player_owned).country_name ) waypoints = ship.route group = self.mission.ship_group( diff --git a/gen/convoygen.py b/gen/convoygen.py index 303c286f..b695d144 100644 --- a/gen/convoygen.py +++ b/gen/convoygen.py @@ -27,8 +27,9 @@ class ConvoyGenerator: def generate(self) -> None: # Reset the count to make generation deterministic. - for convoy in self.game.transfers.convoys: - self.generate_convoy(convoy) + for coalition in self.game.coalitions: + for convoy in coalition.transfers.convoys: + self.generate_convoy(convoy) def generate_convoy(self, convoy: Convoy) -> VehicleGroup: group = self._create_mixed_unit_group( @@ -53,9 +54,7 @@ class ConvoyGenerator: units: dict[GroundUnitType, int], for_player: bool, ) -> VehicleGroup: - country = self.mission.country( - self.game.player_country if for_player else self.game.enemy_country - ) + country = self.mission.country(self.game.coalition_for(for_player).country_name) unit_types = list(units.items()) main_unit_type, main_unit_count = unit_types[0] diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index cbe234b1..7c9e3e22 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -228,7 +228,7 @@ class CoalitionMissionPlanner: self.game = game self.is_player = is_player self.objective_finder = ObjectiveFinder(self.game, self.is_player) - self.ato = self.game.blue_ato if is_player else self.game.red_ato + self.ato = self.game.coalition_for(is_player).ato self.threat_zones = self.game.threat_zone_for(not self.is_player) self.procurement_requests = self.game.procurement_requests_for(self.is_player) self.faction: Faction = self.game.faction_for(self.is_player) diff --git a/gen/forcedoptionsgen.py b/gen/forcedoptionsgen.py index ea1c854b..e4025d48 100644 --- a/gen/forcedoptionsgen.py +++ b/gen/forcedoptionsgen.py @@ -38,8 +38,8 @@ class ForcedOptionsGenerator: self.mission.forced_options.labels = ForcedOptions.Labels.None_ def _set_unrestricted_satnav(self) -> None: - blue = self.game.player_faction - red = self.game.enemy_faction + blue = self.game.blue.faction + red = self.game.red.faction if blue.unrestricted_satnav or red.unrestricted_satnav: self.mission.forced_options.unrestricted_satnav = True diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index ad6d0262..c7b7ca53 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -25,7 +25,7 @@ from typing import ( from dcs import Mission, Point, unitgroup from dcs.action import SceneryDestructionZone from dcs.country import Country -from dcs.point import StaticPoint, MovingPoint +from dcs.point import StaticPoint from dcs.statics import Fortification, fortification_map, warehouse_map from dcs.task import ( ActivateBeaconCommand, @@ -36,8 +36,8 @@ from dcs.task import ( ) from dcs.triggers import TriggerStart, TriggerZone from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad -from dcs.unitgroup import Group, ShipGroup, StaticGroup, VehicleGroup -from dcs.unittype import StaticType, UnitType, ShipType, VehicleType +from dcs.unitgroup import ShipGroup, StaticGroup, VehicleGroup +from dcs.unittype import StaticType, ShipType, VehicleType from dcs.vehicles import vehicle_map from game import db @@ -587,13 +587,7 @@ class HelipadGenerator: self.tacan_registry = tacan_registry def generate(self) -> None: - - if self.cp.captured: - country_name = self.game.player_country - else: - country_name = self.game.enemy_country - country = self.m.country(country_name) - + country = self.m.country(self.game.coalition_for(self.cp.captured).country_name) for i, helipad in enumerate(self.cp.helipads): name = self.cp.name + "_helipad_" + str(i) logging.info("Generating helipad : " + name) @@ -636,12 +630,7 @@ class GroundObjectsGenerator: def generate(self) -> None: for cp in self.game.theater.controlpoints: - if cp.captured: - country_name = self.game.player_country - else: - country_name = self.game.enemy_country - country = self.m.country(country_name) - + country = self.m.country(self.game.coalition_for(cp.captured).country_name) HelipadGenerator( self.m, cp, self.game, self.radio_registry, self.tacan_registry ).generate() diff --git a/gen/visualgen.py b/gen/visualgen.py index 5d8ffead..83be4859 100644 --- a/gen/visualgen.py +++ b/gen/visualgen.py @@ -97,7 +97,7 @@ class VisualGenerator: break self.mission.static_group( - self.mission.country(self.game.enemy_country), + self.mission.country(self.game.red.country_name), "", _type=v, position=pos, diff --git a/qt_ui/models.py b/qt_ui/models.py index 4e5f46b3..ee842de5 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -12,11 +12,10 @@ from PySide2.QtCore import ( ) from PySide2.QtGui import QIcon -from game import db from game.game import Game from game.squadrons import Squadron, Pilot from game.theater.missiontarget import MissionTarget -from game.transfers import TransferOrder +from game.transfers import TransferOrder, PendingTransfers from gen.ato import AirTaskingOrder, Package from gen.flights.flight import Flight, FlightType from gen.flights.traveltime import TotEstimator @@ -281,9 +280,9 @@ class AtoModel(QAbstractListModel): self.package_models.clear() if self.game is not None: if player: - self.ato = self.game.blue_ato + self.ato = self.game.blue.ato else: - self.ato = self.game.red_ato + self.ato = self.game.red.ato else: self.ato = AirTaskingOrder() self.endResetModel() @@ -316,8 +315,12 @@ class TransferModel(QAbstractListModel): super().__init__() self.game_model = game_model + @property + def transfers(self) -> PendingTransfers: + return self.game_model.game.coalition_for(player=True).transfers + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - return self.game_model.game.transfers.pending_transfer_count + return self.transfers.pending_transfer_count def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: if not index.isValid(): @@ -345,7 +348,7 @@ class TransferModel(QAbstractListModel): """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.transfers.new_transfer(transfer) self.endInsertRows() def cancel_transfer_at_index(self, index: QModelIndex) -> None: @@ -354,15 +357,15 @@ class TransferModel(QAbstractListModel): def cancel_transfer(self, transfer: TransferOrder) -> None: """Cancels the planned unit transfer at the given index.""" - index = self.game_model.game.transfers.index_of_transfer(transfer) + index = self.transfers.index_of_transfer(transfer) self.beginRemoveRows(QModelIndex(), index, index) # TODO: Needs to regenerate base inventory tab. - self.game_model.game.transfers.cancel_transfer(transfer) + self.transfers.cancel_transfer(transfer) self.endRemoveRows() def transfer_at_index(self, index: QModelIndex) -> TransferOrder: """Returns the transfer located at the given index.""" - return self.game_model.game.transfers.transfer_at_index(index.row()) + return self.transfers.transfer_at_index(index.row()) class AirWingModel(QAbstractListModel): @@ -488,8 +491,8 @@ class GameModel: self.ato_model = AtoModel(self, AirTaskingOrder()) self.red_ato_model = AtoModel(self, AirTaskingOrder()) else: - self.ato_model = AtoModel(self, self.game.blue_ato) - self.red_ato_model = AtoModel(self, self.game.red_ato) + self.ato_model = AtoModel(self, self.game.blue.ato) + self.red_ato_model = AtoModel(self, self.game.red.ato) def ato_model_for(self, player: bool) -> AtoModel: if player: diff --git a/qt_ui/widgets/QFactionsInfos.py b/qt_ui/widgets/QFactionsInfos.py index 935f4af9..c0ca25cd 100644 --- a/qt_ui/widgets/QFactionsInfos.py +++ b/qt_ui/widgets/QFactionsInfos.py @@ -24,8 +24,8 @@ class QFactionsInfos(QGroupBox): def setGame(self, game: Game): if game is not None: - self.player_name.setText(game.player_faction.name) - self.enemy_name.setText(game.enemy_faction.name) + self.player_name.setText(game.blue.faction.name) + self.enemy_name.setText(game.red.faction.name) else: self.player_name.setText("") self.enemy_name.setText("") diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 5f295a6a..d94f2911 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -168,7 +168,7 @@ class QTopPanel(QFrame): package.time_over_target = estimator.earliest_tot() def ato_has_clients(self) -> bool: - for package in self.game.blue_ato.packages: + for package in self.game.blue.ato.packages: for flight in package.flights: if flight.client_count > 0: return True @@ -236,7 +236,7 @@ class QTopPanel(QFrame): def check_no_missing_pilots(self) -> bool: missing_pilots = [] - for package in self.game.blue_ato.packages: + for package in self.game.blue.ato.packages: for flight in package.flights: if flight.missing_pilots > 0: missing_pilots.append((package, flight)) @@ -282,8 +282,8 @@ class QTopPanel(QFrame): closest_cps[0], closest_cps[1], self.game.theater.controlpoints[0].position, - self.game.player_faction.name, - self.game.enemy_faction.name, + self.game.blue.faction.name, + self.game.red.faction.name, ) unit_map = self.game.initiate_event(game_event) diff --git a/qt_ui/widgets/combos/QAircraftTypeSelector.py b/qt_ui/widgets/combos/QAircraftTypeSelector.py index 80dfa5b0..12f913db 100644 --- a/qt_ui/widgets/combos/QAircraftTypeSelector.py +++ b/qt_ui/widgets/combos/QAircraftTypeSelector.py @@ -4,7 +4,6 @@ from typing import Iterable, Type from PySide2.QtWidgets import QComboBox from dcs.unittype import FlyingType -from game import db from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.flight import FlightType @@ -13,16 +12,12 @@ class QAircraftTypeSelector(QComboBox): """Combo box for selecting among the given aircraft types.""" def __init__( - self, - aircraft_types: Iterable[Type[FlyingType]], - country: str, - mission_type: FlightType, + self, aircraft_types: Iterable[Type[FlyingType]], mission_type: FlightType ) -> None: super().__init__() self.model().sort(0) self.setSizeAdjustPolicy(self.AdjustToContents) - self.country = country self.update_items(mission_type, aircraft_types) def update_items(self, mission_type: FlightType, aircraft_types): diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 3d56b5d8..c6c5e70b 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -336,8 +336,12 @@ class SupplyRouteJs(QObject): def find_transports(self) -> List[MultiGroupTransport]: if self.sea_route: - return self.find_in_transport_map(self.game.transfers.cargo_ships) - return self.find_in_transport_map(self.game.transfers.convoys) + return self.find_in_transport_map( + self.game.blue.transfers.cargo_ships + ) + self.find_in_transport_map(self.game.red.transfers.cargo_ships) + return self.find_in_transport_map( + self.game.blue.transfers.convoys + ) + self.find_in_transport_map(self.game.red.transfers.convoys) @Property(list, notify=activeTransportsChanged) def activeTransports(self) -> List[str]: @@ -672,8 +676,8 @@ class NavMeshJs(QObject): @classmethod def from_game(cls, game: Game) -> NavMeshJs: return NavMeshJs( - cls.to_polys(game.blue_navmesh, game.theater), - cls.to_polys(game.red_navmesh, game.theater), + cls.to_polys(game.blue.nav_mesh, game.theater), + cls.to_polys(game.red.nav_mesh, game.theater), ) @@ -870,8 +874,8 @@ class MapModel(QObject): def reset_atos(self) -> None: self._flights = self._flights_in_ato( - self.game.blue_ato, blue=True - ) + self._flights_in_ato(self.game.red_ato, blue=False) + self.game.blue.ato, blue=True + ) + self._flights_in_ato(self.game.red.ato, blue=False) self.flightsChanged.emit() @Property(list, notify=flightsChanged) diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index ac666e0e..df0cf81c 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -3,12 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Optional, Iterator -from PySide2.QtCore import ( - QItemSelectionModel, - QModelIndex, - Qt, - QSize, -) +from PySide2.QtCore import QItemSelectionModel, QModelIndex, QSize from PySide2.QtWidgets import ( QAbstractItemView, QCheckBox, @@ -183,7 +178,7 @@ class AirInventoryView(QWidget): self.table.setSortingEnabled(True) def iter_allocated_aircraft(self) -> Iterator[AircraftInventoryData]: - for package in self.game_model.game.blue_ato.packages: + for package in self.game_model.game.blue.ato.packages: for flight in package.flights: yield from AircraftInventoryData.from_flight(flight) diff --git a/qt_ui/windows/basemenu/DepartingConvoysMenu.py b/qt_ui/windows/basemenu/DepartingConvoysMenu.py index d858539e..c334f0bb 100644 --- a/qt_ui/windows/basemenu/DepartingConvoysMenu.py +++ b/qt_ui/windows/basemenu/DepartingConvoysMenu.py @@ -73,11 +73,15 @@ class DepartingConvoysList(QFrame): task_box_layout = QGridLayout() scroll_content.setLayout(task_box_layout) - for convoy in game_model.game.transfers.convoys.departing_from(cp): + for convoy in game_model.game.coalition_for( + cp.captured + ).transfers.convoys.departing_from(cp): group_info = DepartingConvoyInfo(convoy) task_box_layout.addWidget(group_info) - for cargo_ship in game_model.game.transfers.cargo_ships.departing_from(cp): + for cargo_ship in game_model.game.coalition_for( + cp.captured + ).transfers.cargo_ships.departing_from(cp): group_info = DepartingConvoyInfo(cargo_ship) task_box_layout.addWidget(group_info) diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index 5361350e..20dbf8f1 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -195,7 +195,9 @@ class QBaseMenu2(QDialog): ground_unit_limit = self.cp.frontline_unit_count_limit deployable_unit_info = "" - allocated = self.cp.allocated_ground_units(self.game_model.game.transfers) + allocated = self.cp.allocated_ground_units( + self.game_model.game.coalition_for(self.cp.captured).transfers + ) unit_overage = max( allocated.total_present - self.cp.frontline_unit_count_limit, 0 ) diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index c5edcdbc..e435fd75 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -45,7 +45,7 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): row = 0 unit_types: Set[AircraftType] = set() - for unit_type in self.game_model.game.player_faction.aircrafts: + for unit_type in self.game_model.game.blue.faction.aircrafts: if self.cp.is_carrier and not unit_type.carrier_capable: continue if self.cp.is_lha and not unit_type.lha_capable: diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py index 46b2cb53..0e629db8 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -1,7 +1,6 @@ import logging from typing import List, Optional -from PySide2 import QtCore from PySide2.QtGui import Qt from PySide2.QtWidgets import ( QComboBox, @@ -307,7 +306,7 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.buySamBox = QGroupBox("Buy SAM site :") self.buyArmorBox = QGroupBox("Buy defensive position :") - faction = self.game.player_faction + faction = self.game.blue.faction # Sams diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 7f5c6cc4..3c0a1e74 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -38,7 +38,7 @@ class QFlightCreator(QDialog): self.game = game self.package = package self.custom_name_text = None - self.country = self.game.player_country + self.country = self.game.blue.country_name self.setWindowTitle("Create flight") self.setWindowIcon(EVENT_ICONS["strike"]) @@ -52,7 +52,6 @@ class QFlightCreator(QDialog): self.aircraft_selector = QAircraftTypeSelector( self.game.aircraft_inventory.available_types_for_player, - self.game.player_country, self.task_selector.currentData(), ) self.aircraft_selector.setCurrentIndex(0) From 24f6aff8c89d6f74b976e681293393ce228c5e45 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 15:20:42 -0700 Subject: [PATCH 059/167] Reduce mission planning dependence on Game. --- game/coalition.py | 15 +- game/commander/objectivefinder.py | 2 +- .../tasks/compound/protectairspace.py | 2 +- game/commander/tasks/primitive/barcap.py | 15 +- game/commander/theaterstate.py | 15 +- game/theater/theatergroundobject.py | 12 +- game/transfers.py | 4 +- gen/flights/ai_flight_planner.py | 233 +++++++++--------- gen/flights/flightplan.py | 82 +++--- gen/flights/waypointbuilder.py | 16 +- qt_ui/windows/mission/QPackageDialog.py | 2 +- .../flight/settings/FlightAirfieldDisplay.py | 2 +- .../flight/waypoints/QFlightWaypointTab.py | 2 +- 13 files changed, 212 insertions(+), 190 deletions(-) diff --git a/game/coalition.py b/game/coalition.py index f7a97ecf..06432c97 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -11,7 +11,7 @@ from game.navmesh import NavMesh from game.profiling import logged_duration, MultiEventTracer from game.threatzones import ThreatZones from game.transfers import PendingTransfers -from gen.flights.ai_flight_planner import CoalitionMissionPlanner +from gen.flights.ai_flight_planner import CoalitionMissionPlanner, MissionScheduler if TYPE_CHECKING: from game import Game @@ -181,13 +181,20 @@ class Coalition: def plan_missions(self) -> None: color = "Blue" if self.player else "Red" with MultiEventTracer() as tracer: - mission_planner = CoalitionMissionPlanner(self.game, self.player) + mission_planner = CoalitionMissionPlanner( + self, + self.game.theater, + self.game.aircraft_inventory, + self.game.settings, + ) with tracer.trace(f"{color} mission planning"): with tracer.trace(f"{color} mission identification"): commander = TheaterCommander(self.game, self.player) commander.plan_missions(mission_planner, tracer) - with tracer.trace(f"{color} mission fulfillment"): - mission_planner.fulfill_missions() + with tracer.trace(f"{color} mission scheduling"): + MissionScheduler( + self, self.game.settings.desired_player_mission_duration + ).schedule_missions() def plan_procurement(self) -> None: # The first turn needs to buy a *lot* of aircraft to fill CAPs, so it gets much diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index 15e47ed0..cf5c6102 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -18,12 +18,12 @@ from game.theater.theatergroundobject import ( IadsGroundObject, NavalGroundObject, ) -from game.transfers import CargoShip, Convoy from game.utils import meters, nautical_miles from gen.flights.closestairfields import ObjectiveDistanceCache, ClosestAirfields if TYPE_CHECKING: from game import Game + from game.transfers import CargoShip, Convoy MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget) diff --git a/game/commander/tasks/compound/protectairspace.py b/game/commander/tasks/compound/protectairspace.py index 9e3c0d56..5a13e486 100644 --- a/game/commander/tasks/compound/protectairspace.py +++ b/game/commander/tasks/compound/protectairspace.py @@ -8,4 +8,4 @@ from game.htn import CompoundTask, Method class ProtectAirSpace(CompoundTask[TheaterState]): def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: for cp in state.vulnerable_control_points: - yield [PlanBarcap(cp)] + yield [PlanBarcap(cp, state.barcap_rounds)] diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py index 9707445c..8fd86fab 100644 --- a/game/commander/tasks/primitive/barcap.py +++ b/game/commander/tasks/primitive/barcap.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: @dataclass class PlanBarcap(TheaterCommanderTask): target: ControlPoint + rounds: int def preconditions_met(self, state: TheaterState) -> bool: if state.player and not state.ato_automation_enabled: @@ -29,19 +30,7 @@ class PlanBarcap(TheaterCommanderTask): def execute( self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer ) -> None: - # Plan enough rounds of CAP that the target has coverage over the expected - # mission duration. - mission_duration = int( - mission_planner.game.settings.desired_player_mission_duration.total_seconds() - ) - barcap_duration = int( - mission_planner.faction.doctrine.cap_duration.total_seconds() - ) - for _ in range( - 0, - mission_duration, - barcap_duration, - ): + for _ in range(self.rounds): mission_planner.plan_mission( ProposedMission( self.target, diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index f737a2aa..6da5f9f2 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -2,6 +2,7 @@ from __future__ import annotations import dataclasses import itertools +import math from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Union, Optional @@ -18,11 +19,11 @@ from game.theater.theatergroundobject import ( VehicleGroupGroundObject, ) from game.threatzones import ThreatZones -from game.transfers import Convoy, CargoShip from gen.ground_forces.combat_stance import CombatStance if TYPE_CHECKING: from game import Game + from game.transfers import Convoy, CargoShip @dataclass @@ -30,6 +31,7 @@ class TheaterState(WorldState["TheaterState"]): player: bool stance_automation_enabled: bool ato_automation_enabled: bool + barcap_rounds: int vulnerable_control_points: list[ControlPoint] active_front_lines: list[FrontLine] front_line_stances: dict[FrontLine, Optional[CombatStance]] @@ -86,6 +88,7 @@ class TheaterState(WorldState["TheaterState"]): player=self.player, stance_automation_enabled=self.stance_automation_enabled, ato_automation_enabled=self.ato_automation_enabled, + barcap_rounds=self.barcap_rounds, vulnerable_control_points=list(self.vulnerable_control_points), active_front_lines=list(self.active_front_lines), front_line_stances=dict(self.front_line_stances), @@ -120,10 +123,20 @@ class TheaterState(WorldState["TheaterState"]): auto_stance = game.settings.automate_front_line_stance auto_ato = game.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled ordered_capturable_points = finder.prioritized_unisolated_points() + + # Plan enough rounds of CAP that the target has coverage over the expected + # mission duration. + mission_duration = game.settings.desired_player_mission_duration.total_seconds() + barcap_duration = game.coalition_for( + player + ).doctrine.cap_duration.total_seconds() + barcap_rounds = math.ceil(mission_duration / barcap_duration) + return TheaterState( player=player, stance_automation_enabled=auto_stance, ato_automation_enabled=auto_ato, + barcap_rounds=barcap_rounds, vulnerable_control_points=list(finder.vulnerable_control_points()), active_front_lines=list(finder.front_lines()), front_line_stances={f: None for f in finder.front_lines()}, diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index fb6f015f..180dd352 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -4,7 +4,7 @@ import itertools import logging from abc import ABC from collections import Sequence -from typing import Iterator, List, TYPE_CHECKING, Union, Generic, TypeVar, Any +from typing import Iterator, List, TYPE_CHECKING, Union, Generic, TypeVar from dcs.mapping import Point from dcs.triggers import TriggerZone @@ -257,13 +257,17 @@ class BuildingGroundObject(TheaterGroundObject[VehicleGroup]): def kill(self) -> None: self._dead = True - def iter_building_group(self) -> Iterator[TheaterGroundObject[Any]]: + def iter_building_group(self) -> Iterator[BuildingGroundObject]: for tgo in self.control_point.ground_objects: - if tgo.obj_name == self.obj_name and not tgo.is_dead: + if ( + tgo.obj_name == self.obj_name + and not tgo.is_dead + and isinstance(tgo, BuildingGroundObject) + ): yield tgo @property - def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + def strike_targets(self) -> List[BuildingGroundObject]: return list(self.iter_building_group()) @property diff --git a/game/transfers.py b/game/transfers.py index 3bdc9b3c..7401b03d 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -340,7 +340,9 @@ class AirliftPlanner: transfer.transport = transport self.package.add_flight(flight) - planner = FlightPlanBuilder(self.game, self.package, self.for_player) + planner = FlightPlanBuilder( + self.package, self.game.coalition_for(self.for_player), self.game.theater + ) planner.populate_flight_plan(flight) self.game.aircraft_inventory.claim_for_flight(flight) return flight_size diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 7c9e3e22..e74ce0af 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -14,26 +14,26 @@ from typing import ( Tuple, ) -from game.commander import TheaterCommander from game.commander.missionproposals import ProposedFlight, ProposedMission, EscortType -from game.commander.objectivefinder import ObjectiveFinder from game.data.doctrine import Doctrine from game.dcs.aircrafttype import AircraftType -from game.factions.faction import Faction -from game.infos.information import Information from game.procurement import AircraftProcurementRequest -from game.profiling import logged_duration, MultiEventTracer +from game.profiling import MultiEventTracer +from game.settings import Settings from game.squadrons import AirWing, Squadron from game.theater import ( ControlPoint, MissionTarget, OffMapSpawn, + ConflictTheater, ) +from game.threatzones import ThreatZones from game.utils import nautical_miles -from gen.ato import Package +from gen.ato import Package, AirTaskingOrder from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.closestairfields import ( ClosestAirfields, + ObjectiveDistanceCache, ) from gen.flights.flight import ( Flight, @@ -44,7 +44,7 @@ from gen.flights.traveltime import TotEstimator # Avoid importing some types that cause circular imports unless type checking. if TYPE_CHECKING: - from game import Game + from game.coalition import Coalition from game.inventory import GlobalAircraftInventory @@ -201,6 +201,68 @@ class PackageBuilder: self.package.remove_flight(flight) +class MissionScheduler: + def __init__(self, coalition: Coalition, desired_mission_length: timedelta) -> None: + self.coalition = coalition + self.desired_mission_length = desired_mission_length + + def schedule_missions(self) -> None: + """Identifies and plans mission for the turn.""" + + def start_time_generator( + count: int, earliest: int, latest: int, margin: int + ) -> Iterator[timedelta]: + interval = (latest - earliest) // count + for time in range(earliest, latest, interval): + error = random.randint(-margin, margin) + yield timedelta(seconds=max(0, time + error)) + + dca_types = { + FlightType.BARCAP, + FlightType.TARCAP, + } + + previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta) + non_dca_packages = [ + p for p in self.coalition.ato.packages if p.primary_task not in dca_types + ] + + start_time = start_time_generator( + count=len(non_dca_packages), + earliest=5 * 60, + latest=int(self.desired_mission_length.total_seconds()), + margin=5 * 60, + ) + for package in self.coalition.ato.packages: + tot = TotEstimator(package).earliest_tot() + if package.primary_task in dca_types: + previous_end_time = previous_cap_end_time[package.target] + if tot > previous_end_time: + # Can't get there exactly on time, so get there ASAP. This + # will typically only happen for the first CAP at each + # target. + package.time_over_target = tot + else: + package.time_over_target = previous_end_time + + departure_time = package.mission_departure_time + # Should be impossible for CAPs + if departure_time is None: + logging.error(f"Could not determine mission end time for {package}") + continue + previous_cap_end_time[package.target] = departure_time + elif package.auto_asap: + package.set_tot_asap() + else: + # But other packages should be spread out a bit. Note that take + # times are delayed, but all aircraft will become active at + # mission start. This makes it more worthwhile to attack enemy + # airfields to hit grounded aircraft, since they're more likely + # to be present. Runway and air started aircraft will be + # delayed until their takeoff time by AirConflictGenerator. + package.time_over_target = next(start_time) + tot + + class CoalitionMissionPlanner: """Coalition flight planning AI. @@ -224,18 +286,46 @@ class CoalitionMissionPlanner: TODO: Stance and doctrine-specific planning behavior. """ - def __init__(self, game: Game, is_player: bool) -> None: - self.game = game - self.is_player = is_player - self.objective_finder = ObjectiveFinder(self.game, self.is_player) - self.ato = self.game.coalition_for(is_player).ato - self.threat_zones = self.game.threat_zone_for(not self.is_player) - self.procurement_requests = self.game.procurement_requests_for(self.is_player) - self.faction: Faction = self.game.faction_for(self.is_player) + def __init__( + self, + coalition: Coalition, + theater: ConflictTheater, + aircraft_inventory: GlobalAircraftInventory, + settings: Settings, + ) -> None: + self.coalition = coalition + self.theater = theater + self.aircraft_inventory = aircraft_inventory + self.player_missions_asap = settings.auto_ato_player_missions_asap + self.default_start_type = settings.default_start_type + + @property + def is_player(self) -> bool: + return self.coalition.player + + @property + def ato(self) -> AirTaskingOrder: + return self.coalition.ato + + @property + def air_wing(self) -> AirWing: + return self.coalition.air_wing @property def doctrine(self) -> Doctrine: - return self.faction.doctrine + return self.coalition.doctrine + + @property + def threat_zones(self) -> ThreatZones: + return self.coalition.opponent.threat_zone + + def add_procurement_request( + self, request: AircraftProcurementRequest, priority: bool + ) -> None: + if priority: + self.coalition.procurement_requests.insert(0, request) + else: + self.coalition.procurement_requests.append(request) def air_wing_can_plan(self, mission_type: FlightType) -> bool: """Returns True if it is possible for the air wing to plan this mission type. @@ -245,21 +335,7 @@ class CoalitionMissionPlanner: also possible for the player to exclude mission types from their squadron designs. """ - return self.game.air_wing_for(self.is_player).can_auto_plan(mission_type) - - def fulfill_missions(self) -> None: - """Identifies and plans mission for the turn.""" - player = "Blue" if self.is_player else "Red" - with logged_duration(f"{player} mission scheduling"): - self.stagger_missions() - - for cp in self.objective_finder.friendly_control_points(): - inventory = self.game.aircraft_inventory.for_control_point(cp) - for aircraft, available in inventory.all_aircraft: - self.message("Unused aircraft", f"{available} {aircraft} from {cp}") - - coalition_text = "player" if self.is_player else "opfor" - logging.debug(f"Planned {len(self.ato.packages)} {coalition_text} missions") + return self.air_wing.can_auto_plan(mission_type) def plan_flight( self, @@ -277,12 +353,9 @@ class CoalitionMissionPlanner: task_capability=flight.task, number=flight.num_aircraft, ) - if for_reserves: - # Reserves are planned for critical missions, so prioritize - # those orders over aircraft needed for non-critical missions. - self.procurement_requests.insert(0, purchase_order) - else: - self.procurement_requests.append(purchase_order) + # Reserves are planned for critical missions, so prioritize those orders + # over aircraft needed for non-critical missions. + self.add_procurement_request(purchase_order, priority=for_reserves) def scrub_mission_missing_aircraft( self, @@ -300,10 +373,9 @@ class CoalitionMissionPlanner: missing_types_str = ", ".join(sorted([t.name for t in missing_types])) builder.release_planned_aircraft() desc = "reserve aircraft" if reserves else "aircraft" - self.message( - "Insufficient aircraft", + logging.debug( f"Not enough {desc} in range for {mission.location.name} " - f"capable of: {missing_types_str}", + f"capable of: {missing_types_str}" ) def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]: @@ -325,12 +397,12 @@ class CoalitionMissionPlanner: """Allocates aircraft for a proposed mission and adds it to the ATO.""" builder = PackageBuilder( mission.location, - self.objective_finder.closest_airfields_to(mission.location), - self.game.aircraft_inventory, - self.game.air_wing_for(self.is_player), + ObjectiveDistanceCache.get_closest_airfields(mission.location), + self.aircraft_inventory, + self.air_wing, self.is_player, - self.game.country_for(self.is_player), - self.game.settings.default_start_type, + self.coalition.country_name, + self.default_start_type, mission.asap, ) @@ -374,7 +446,7 @@ class CoalitionMissionPlanner: # the other flights in the package. Escorts will not be able to # contribute to this. flight_plan_builder = FlightPlanBuilder( - self.game, builder.package, self.is_player + builder.package, self.coalition, self.theater ) for flight in builder.package.flights: with tracer.trace("Flight plan population"): @@ -410,75 +482,8 @@ class CoalitionMissionPlanner: with tracer.trace("Flight plan population"): flight_plan_builder.populate_flight_plan(flight) - if package.has_players and self.game.settings.auto_ato_player_missions_asap: + if package.has_players and self.player_missions_asap: package.auto_asap = True package.set_tot_asap() self.ato.add_package(package) - - def stagger_missions(self) -> None: - def start_time_generator( - count: int, earliest: int, latest: int, margin: int - ) -> Iterator[timedelta]: - interval = (latest - earliest) // count - for time in range(earliest, latest, interval): - error = random.randint(-margin, margin) - yield timedelta(seconds=max(0, time + error)) - - dca_types = { - FlightType.BARCAP, - FlightType.TARCAP, - } - - previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta) - non_dca_packages = [ - p for p in self.ato.packages if p.primary_task not in dca_types - ] - - start_time = start_time_generator( - count=len(non_dca_packages), - earliest=5 * 60, - latest=int( - self.game.settings.desired_player_mission_duration.total_seconds() - ), - margin=5 * 60, - ) - for package in self.ato.packages: - tot = TotEstimator(package).earliest_tot() - if package.primary_task in dca_types: - previous_end_time = previous_cap_end_time[package.target] - if tot > previous_end_time: - # Can't get there exactly on time, so get there ASAP. This - # will typically only happen for the first CAP at each - # target. - package.time_over_target = tot - else: - package.time_over_target = previous_end_time - - departure_time = package.mission_departure_time - # Should be impossible for CAPs - if departure_time is None: - logging.error(f"Could not determine mission end time for {package}") - continue - previous_cap_end_time[package.target] = departure_time - elif package.auto_asap: - package.set_tot_asap() - else: - # But other packages should be spread out a bit. Note that take - # times are delayed, but all aircraft will become active at - # mission start. This makes it more worthwhile to attack enemy - # airfields to hit grounded aircraft, since they're more likely - # to be present. Runway and air started aircraft will be - # delayed until their takeoff time by AirConflictGenerator. - package.time_over_target = next(start_time) + tot - - def message(self, title: str, text: str) -> None: - """Emits a planning message to the player. - - If the mission planner belongs to the players coalition, this emits a - message to the info panel. - """ - if self.is_player: - self.game.informations.append(Information(title, text, self.game.turn)) - else: - logging.info(f"{title}: {text}") diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 248846d6..a93e6f39 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -28,8 +28,14 @@ from game.theater import ( SamGroundObject, TheaterGroundObject, NavalControlPoint, + ConflictTheater, ) -from game.theater.theatergroundobject import EwrGroundObject, NavalGroundObject +from game.theater.theatergroundobject import ( + EwrGroundObject, + NavalGroundObject, + BuildingGroundObject, +) +from game.threatzones import ThreatZones from game.utils import Distance, Speed, feet, meters, nautical_miles, knots from .closestairfields import ObjectiveDistanceCache from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType @@ -38,8 +44,8 @@ from .waypointbuilder import StrikeTarget, WaypointBuilder from ..conflictgen import Conflict, FRONTLINE_LENGTH if TYPE_CHECKING: - from game import Game from gen.ato import Package + from game.coalition import Coalition from game.transfers import Convoy INGRESS_TYPES = { @@ -864,7 +870,9 @@ class CustomFlightPlan(FlightPlan): class FlightPlanBuilder: """Generates flight plans for flights.""" - def __init__(self, game: Game, package: Package, is_player: bool) -> None: + def __init__( + self, package: Package, coalition: Coalition, theater: ConflictTheater + ) -> None: # TODO: Plan similar altitudes for the in-country leg of the mission. # Waypoint altitudes for a given flight *shouldn't* differ too much # between the join and split points, so we don't need speeds for each @@ -872,11 +880,21 @@ class FlightPlanBuilder: # hold too well right now since nothing is stopping each waypoint from # jumping 20k feet each time, but that's a huge waste of energy we # should be avoiding anyway. - self.game = game self.package = package - self.is_player = is_player - self.doctrine: Doctrine = self.game.faction_for(self.is_player).doctrine - self.threat_zones = self.game.threat_zone_for(not self.is_player) + self.coalition = coalition + self.theater = theater + + @property + def is_player(self) -> bool: + return self.coalition.player + + @property + def doctrine(self) -> Doctrine: + return self.coalition.doctrine + + @property + def threat_zones(self) -> ThreatZones: + return self.coalition.opponent.threat_zone def populate_flight_plan( self, @@ -1022,7 +1040,7 @@ class FlightPlanBuilder: ) def preferred_join_point(self) -> Optional[Point]: - path = self.game.navmesh_for(self.is_player).shortest_path( + path = self.coalition.nav_mesh.shortest_path( self.package_airfield().position, self.package.target.position ) for point in reversed(path): @@ -1043,26 +1061,16 @@ class FlightPlanBuilder: raise InvalidObjectiveLocation(flight.flight_type, location) targets: List[StrikeTarget] = [] - if len(location.groups) > 0 and location.dcs_identifier == "AA": + if isinstance(location, BuildingGroundObject): + # A building "group" is implemented as multiple TGOs with the same name. + for building in location.strike_targets: + targets.append(StrikeTarget(building.category, building)) + else: # TODO: Replace with DEAD? # Strike missions on SEAD targets target units. for g in location.groups: for j, u in enumerate(g.units): targets.append(StrikeTarget(f"{u.type} #{j}", u)) - else: - # TODO: Does this actually happen? - # ConflictTheater is built with the belief that multiple ground - # objects have the same name. If that's the case, - # TheaterGroundObject needs some refactoring because it behaves very - # differently for SAM sites than it does for strike targets. - buildings = self.game.theater.find_ground_objects_by_obj_name( - location.obj_name - ) - for building in buildings: - if building.is_dead: - continue - - targets.append(StrikeTarget(building.category, building)) return self.strike_flightplan( flight, location, FlightWaypointType.INGRESS_STRIKE, targets @@ -1083,7 +1091,7 @@ class FlightPlanBuilder: else: patrol_alt = feet(25000) - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) orbit = builder.orbit(orbit_location, patrol_alt) return AwacsFlightPlan( @@ -1175,7 +1183,7 @@ class FlightPlanBuilder: ) ) - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) start, end = builder.race_track(start_pos, end_pos, patrol_alt) return BarCapFlightPlan( @@ -1211,7 +1219,7 @@ class FlightPlanBuilder: heading, -self.doctrine.sweep_distance.meters ) - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude) hold = builder.hold(self._hold_point(flight)) @@ -1251,7 +1259,7 @@ class FlightPlanBuilder: altitude = feet(1500) altitude_is_agl = True - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) pickup = None nav_to_pickup = [] @@ -1373,9 +1381,7 @@ class FlightPlanBuilder: self, origin: Point, front_line: FrontLine ) -> Tuple[Point, Point]: # Find targets waypoints - ingress, heading, distance = Conflict.frontline_vector( - front_line, self.game.theater - ) + ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater) center = ingress.point_from_heading(heading, distance / 2) orbit_center = center.point_from_heading( heading - 90, @@ -1414,7 +1420,7 @@ class FlightPlanBuilder: ) # Create points - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) if isinstance(location, FrontLine): orbit0p, orbit1p = self.racetrack_for_frontline( @@ -1545,7 +1551,7 @@ class FlightPlanBuilder: def generate_escort(self, flight: Flight) -> StrikeFlightPlan: assert self.package.waypoints is not None - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) ingress, target, egress = builder.escort( self.package.waypoints.ingress, self.package.target, @@ -1588,9 +1594,7 @@ class FlightPlanBuilder: if not isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) - ingress, heading, distance = Conflict.frontline_vector( - location, self.game.theater - ) + ingress, heading, distance = Conflict.frontline_vector(location, self.theater) center = ingress.point_from_heading(heading, distance / 2) egress = ingress.point_from_heading(heading, distance) @@ -1599,7 +1603,7 @@ class FlightPlanBuilder: if egress_distance < ingress_distance: ingress, egress = egress, ingress - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) return CasFlightPlan( package=self.package, @@ -1655,7 +1659,7 @@ class FlightPlanBuilder: orbit_heading - 90, racetrack_half_distance ) - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) tanker_type = flight.unit_type if tanker_type.patrol_altitude is not None: @@ -1776,7 +1780,7 @@ class FlightPlanBuilder: flight: The flight to generate the landing waypoint for. arrival: Arrival airfield or carrier. """ - builder = WaypointBuilder(flight, self.game, self.is_player) + builder = WaypointBuilder(flight, self.coalition) return builder.land(arrival) def strike_flightplan( @@ -1788,7 +1792,7 @@ class FlightPlanBuilder: lead_time: timedelta = timedelta(), ) -> StrikeFlightPlan: assert self.package.waypoints is not None - builder = WaypointBuilder(flight, self.game, self.is_player, targets) + builder = WaypointBuilder(flight, self.coalition, targets) target_waypoints: List[FlightWaypoint] = [] if targets is not None: diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index e911bebd..daaea056 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -15,10 +15,10 @@ from typing import ( from dcs.mapping import Point from dcs.unit import Unit -from dcs.unitgroup import Group, VehicleGroup, ShipGroup +from dcs.unitgroup import VehicleGroup, ShipGroup if TYPE_CHECKING: - from game import Game + from game.coalition import Coalition from game.transfers import MultiGroupTransport from game.theater import ( @@ -43,17 +43,15 @@ class WaypointBuilder: def __init__( self, flight: Flight, - game: Game, - player: bool, + coalition: Coalition, targets: Optional[List[StrikeTarget]] = None, ) -> None: self.flight = flight - self.conditions = game.conditions - self.doctrine = game.faction_for(player).doctrine - self.threat_zones = game.threat_zone_for(not player) - self.navmesh = game.navmesh_for(player) + self.doctrine = coalition.doctrine + self.threat_zones = coalition.opponent.threat_zone + self.navmesh = coalition.nav_mesh self.targets = targets - self._bullseye = game.bullseye_for(player) + self._bullseye = coalition.bullseye @property def is_helo(self) -> bool: diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 19634847..c86987ae 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -180,7 +180,7 @@ class QPackageDialog(QDialog): self.game.aircraft_inventory.claim_for_flight(flight) self.package_model.add_flight(flight) planner = FlightPlanBuilder( - self.game, self.package_model.package, is_player=True + self.package_model.package, self.game.blue, self.game.theater ) try: planner.populate_flight_plan(flight) diff --git a/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py b/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py index 282df1ce..2cca2425 100644 --- a/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py +++ b/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py @@ -100,6 +100,6 @@ class FlightAirfieldDisplay(QGroupBox): def update_flight_plan(self) -> None: planner = FlightPlanBuilder( - self.game, self.package_model.package, is_player=True + self.package_model.package, self.game.blue, self.game.theater ) planner.populate_flight_plan(self.flight) diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 440d3f9b..bc4a7d51 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -37,7 +37,7 @@ class QFlightWaypointTab(QFrame): self.game = game self.package = package self.flight = flight - self.planner = FlightPlanBuilder(self.game, package, is_player=True) + self.planner = FlightPlanBuilder(package, game.blue, game.theater) self.flight_waypoint_list: Optional[QFlightWaypointList] = None self.rtb_waypoint: Optional[QPushButton] = None From ccf6b6ef5f1369a967120500779cda9c656828dc Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 14:25:30 -0700 Subject: [PATCH 060/167] Check for available aircraft as task precondition. This makes it so that the mission planning effects are applied only if the package can be fulfilled. For example, breakthrough will be used only if all the BAI missions were fulfilled, not if they will *attempt* to be fulfilled. --- game/coalition.py | 16 +- game/commander/aircraftallocator.py | 76 +++ game/commander/missionscheduler.py | 76 +++ game/commander/packagebuilder.py | 98 ++++ game/commander/packagefulfiller.py | 213 ++++++++ game/commander/tasks/compound/capturebase.py | 2 +- game/commander/tasks/compound/defendbase.py | 4 +- .../tasks/compound/destroyenemygroundunits.py | 4 +- .../tasks/compound/protectairspace.py | 5 +- game/commander/tasks/frontlinestancetask.py | 12 +- game/commander/tasks/packageplanningtask.py | 40 +- game/commander/tasks/primitive/barcap.py | 39 +- game/commander/tasks/theatercommandertask.py | 8 +- game/commander/theatercommander.py | 9 +- game/commander/theaterstate.py | 60 ++- game/inventory.py | 24 +- gen/flights/ai_flight_planner.py | 489 ------------------ 17 files changed, 578 insertions(+), 597 deletions(-) create mode 100644 game/commander/aircraftallocator.py create mode 100644 game/commander/missionscheduler.py create mode 100644 game/commander/packagebuilder.py create mode 100644 game/commander/packagefulfiller.py delete mode 100644 gen/flights/ai_flight_planner.py diff --git a/game/coalition.py b/game/coalition.py index 06432c97..8b0ff812 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -6,12 +6,13 @@ from dcs import Point from faker import Faker from game.commander import TheaterCommander +from game.commander.missionscheduler import MissionScheduler from game.income import Income +from game.inventory import GlobalAircraftInventory from game.navmesh import NavMesh from game.profiling import logged_duration, MultiEventTracer from game.threatzones import ThreatZones from game.transfers import PendingTransfers -from gen.flights.ai_flight_planner import CoalitionMissionPlanner, MissionScheduler if TYPE_CHECKING: from game import Game @@ -84,6 +85,10 @@ class Coalition: assert self._navmesh is not None return self._navmesh + @property + def aircraft_inventory(self) -> GlobalAircraftInventory: + return self.game.aircraft_inventory + def __getstate__(self) -> dict[str, Any]: state = self.__dict__.copy() # Avoid persisting any volatile types that can be deterministically @@ -181,16 +186,9 @@ class Coalition: def plan_missions(self) -> None: color = "Blue" if self.player else "Red" with MultiEventTracer() as tracer: - mission_planner = CoalitionMissionPlanner( - self, - self.game.theater, - self.game.aircraft_inventory, - self.game.settings, - ) with tracer.trace(f"{color} mission planning"): with tracer.trace(f"{color} mission identification"): - commander = TheaterCommander(self.game, self.player) - commander.plan_missions(mission_planner, tracer) + TheaterCommander(self.game, self.player).plan_missions(tracer) with tracer.trace(f"{color} mission scheduling"): MissionScheduler( self, self.game.settings.desired_player_mission_duration diff --git a/game/commander/aircraftallocator.py b/game/commander/aircraftallocator.py new file mode 100644 index 00000000..16ea678a --- /dev/null +++ b/game/commander/aircraftallocator.py @@ -0,0 +1,76 @@ +from typing import Optional, Tuple + +from game.commander.missionproposals import ProposedFlight +from game.inventory import GlobalAircraftInventory +from game.squadrons import AirWing, Squadron +from game.theater import ControlPoint +from gen.flights.ai_flight_planner_db import aircraft_for_task +from gen.flights.closestairfields import ClosestAirfields +from gen.flights.flight import FlightType + + +class AircraftAllocator: + """Finds suitable aircraft for proposed missions.""" + + def __init__( + self, + air_wing: AirWing, + closest_airfields: ClosestAirfields, + global_inventory: GlobalAircraftInventory, + is_player: bool, + ) -> None: + self.air_wing = air_wing + self.closest_airfields = closest_airfields + self.global_inventory = global_inventory + self.is_player = is_player + + def find_squadron_for_flight( + self, flight: ProposedFlight + ) -> Optional[Tuple[ControlPoint, Squadron]]: + """Finds aircraft suitable for the given mission. + + Searches for aircraft capable of performing the given mission within the + maximum allowed range. If insufficient aircraft are available for the + mission, None is returned. + + Airfields are searched ordered nearest to farthest from the target and + searched twice. The first search looks for aircraft which prefer the + mission type, and the second search looks for any aircraft which are + capable of the mission type. For example, an F-14 from a nearby carrier + will be preferred for the CAP of an airfield that has only F-16s, but if + the carrier has only F/A-18s the F-16s will be used for CAP instead. + + Note that aircraft *will* be removed from the global inventory on + success. This is to ensure that the same aircraft are not matched twice + on subsequent calls. If the found aircraft are not used, the caller is + responsible for returning them to the inventory. + """ + return self.find_aircraft_for_task(flight, flight.task) + + def find_aircraft_for_task( + self, flight: ProposedFlight, task: FlightType + ) -> Optional[Tuple[ControlPoint, Squadron]]: + types = aircraft_for_task(task) + airfields_in_range = self.closest_airfields.operational_airfields_within( + flight.max_distance + ) + + for airfield in airfields_in_range: + if not airfield.is_friendly(self.is_player): + continue + inventory = self.global_inventory.for_control_point(airfield) + for aircraft in types: + if not airfield.can_operate(aircraft): + continue + if inventory.available(aircraft) < flight.num_aircraft: + continue + # Valid location with enough aircraft available. Find a squadron to fit + # the role. + squadrons = self.air_wing.auto_assignable_for_task_with_type( + aircraft, task + ) + for squadron in squadrons: + if squadron.can_provide_pilots(flight.num_aircraft): + inventory.remove_aircraft(aircraft, flight.num_aircraft) + return airfield, squadron + return None diff --git a/game/commander/missionscheduler.py b/game/commander/missionscheduler.py new file mode 100644 index 00000000..26889a97 --- /dev/null +++ b/game/commander/missionscheduler.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import logging +import random +from collections import defaultdict +from datetime import timedelta +from typing import Iterator, Dict, TYPE_CHECKING + +from game.theater import MissionTarget +from gen.flights.flight import FlightType +from gen.flights.traveltime import TotEstimator + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class MissionScheduler: + def __init__(self, coalition: Coalition, desired_mission_length: timedelta) -> None: + self.coalition = coalition + self.desired_mission_length = desired_mission_length + + def schedule_missions(self) -> None: + """Identifies and plans mission for the turn.""" + + def start_time_generator( + count: int, earliest: int, latest: int, margin: int + ) -> Iterator[timedelta]: + interval = (latest - earliest) // count + for time in range(earliest, latest, interval): + error = random.randint(-margin, margin) + yield timedelta(seconds=max(0, time + error)) + + dca_types = { + FlightType.BARCAP, + FlightType.TARCAP, + } + + previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta) + non_dca_packages = [ + p for p in self.coalition.ato.packages if p.primary_task not in dca_types + ] + + start_time = start_time_generator( + count=len(non_dca_packages), + earliest=5 * 60, + latest=int(self.desired_mission_length.total_seconds()), + margin=5 * 60, + ) + for package in self.coalition.ato.packages: + tot = TotEstimator(package).earliest_tot() + if package.primary_task in dca_types: + previous_end_time = previous_cap_end_time[package.target] + if tot > previous_end_time: + # Can't get there exactly on time, so get there ASAP. This + # will typically only happen for the first CAP at each + # target. + package.time_over_target = tot + else: + package.time_over_target = previous_end_time + + departure_time = package.mission_departure_time + # Should be impossible for CAPs + if departure_time is None: + logging.error(f"Could not determine mission end time for {package}") + continue + previous_cap_end_time[package.target] = departure_time + elif package.auto_asap: + package.set_tot_asap() + else: + # But other packages should be spread out a bit. Note that take + # times are delayed, but all aircraft will become active at + # mission start. This makes it more worthwhile to attack enemy + # airfields to hit grounded aircraft, since they're more likely + # to be present. Runway and air started aircraft will be + # delayed until their takeoff time by AirConflictGenerator. + package.time_over_target = next(start_time) + tot diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py new file mode 100644 index 00000000..490e0286 --- /dev/null +++ b/game/commander/packagebuilder.py @@ -0,0 +1,98 @@ +from typing import Optional + +from game.commander.missionproposals import ProposedFlight +from game.dcs.aircrafttype import AircraftType +from game.inventory import GlobalAircraftInventory +from game.squadrons import AirWing +from game.theater import MissionTarget, OffMapSpawn, ControlPoint +from game.utils import nautical_miles +from gen import Package +from game.commander.aircraftallocator import AircraftAllocator +from gen.flights.closestairfields import ClosestAirfields +from gen.flights.flight import Flight + + +class PackageBuilder: + """Builds a Package for the flights it receives.""" + + def __init__( + self, + location: MissionTarget, + closest_airfields: ClosestAirfields, + global_inventory: GlobalAircraftInventory, + air_wing: AirWing, + is_player: bool, + package_country: str, + start_type: str, + asap: bool, + ) -> None: + self.closest_airfields = closest_airfields + self.is_player = is_player + self.package_country = package_country + self.package = Package(location, auto_asap=asap) + self.allocator = AircraftAllocator( + air_wing, closest_airfields, global_inventory, is_player + ) + self.global_inventory = global_inventory + self.start_type = start_type + + def plan_flight(self, plan: ProposedFlight) -> bool: + """Allocates aircraft for the given flight and adds them to the package. + + If no suitable aircraft are available, False is returned. If the failed + flight was critical and the rest of the mission will be scrubbed, the + caller should return any previously planned flights to the inventory + using release_planned_aircraft. + """ + assignment = self.allocator.find_squadron_for_flight(plan) + if assignment is None: + return False + airfield, squadron = assignment + if isinstance(airfield, OffMapSpawn): + start_type = "In Flight" + else: + start_type = self.start_type + + flight = Flight( + self.package, + self.package_country, + squadron, + plan.num_aircraft, + plan.task, + start_type, + departure=airfield, + arrival=airfield, + divert=self.find_divert_field(squadron.aircraft, airfield), + ) + self.package.add_flight(flight) + return True + + def find_divert_field( + self, aircraft: AircraftType, arrival: ControlPoint + ) -> Optional[ControlPoint]: + divert_limit = nautical_miles(150) + for airfield in self.closest_airfields.operational_airfields_within( + divert_limit + ): + if airfield.captured != self.is_player: + continue + if airfield == arrival: + continue + if not airfield.can_operate(aircraft): + continue + if isinstance(airfield, OffMapSpawn): + continue + return airfield + return None + + def build(self) -> Package: + """Returns the built package.""" + return self.package + + def release_planned_aircraft(self) -> None: + """Returns any planned flights to the inventory.""" + flights = list(self.package.flights) + for flight in flights: + self.global_inventory.return_from_flight(flight) + flight.clear_roster() + self.package.remove_flight(flight) diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py new file mode 100644 index 00000000..be903548 --- /dev/null +++ b/game/commander/packagefulfiller.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional + +from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType +from game.data.doctrine import Doctrine +from game.inventory import GlobalAircraftInventory +from game.procurement import AircraftProcurementRequest +from game.profiling import MultiEventTracer +from game.settings import Settings +from game.squadrons import AirWing +from game.theater import ConflictTheater +from game.threatzones import ThreatZones +from gen import AirTaskingOrder, Package +from game.commander.packagebuilder import PackageBuilder +from gen.flights.closestairfields import ObjectiveDistanceCache +from gen.flights.flight import FlightType +from gen.flights.flightplan import FlightPlanBuilder + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class PackageFulfiller: + """Responsible for package aircraft allocation and flight plan layout.""" + + def __init__( + self, + coalition: Coalition, + theater: ConflictTheater, + aircraft_inventory: GlobalAircraftInventory, + settings: Settings, + ) -> None: + self.coalition = coalition + self.theater = theater + self.aircraft_inventory = aircraft_inventory + self.player_missions_asap = settings.auto_ato_player_missions_asap + self.default_start_type = settings.default_start_type + + @property + def is_player(self) -> bool: + return self.coalition.player + + @property + def ato(self) -> AirTaskingOrder: + return self.coalition.ato + + @property + def air_wing(self) -> AirWing: + return self.coalition.air_wing + + @property + def doctrine(self) -> Doctrine: + return self.coalition.doctrine + + @property + def threat_zones(self) -> ThreatZones: + return self.coalition.opponent.threat_zone + + def add_procurement_request(self, request: AircraftProcurementRequest) -> None: + self.coalition.procurement_requests.append(request) + + def air_wing_can_plan(self, mission_type: FlightType) -> bool: + """Returns True if it is possible for the air wing to plan this mission type. + + Not all mission types can be fulfilled by all air wings. Many factions do not + have AEW&C aircraft, so they will never be able to plan those missions. It's + also possible for the player to exclude mission types from their squadron + designs. + """ + return self.air_wing.can_auto_plan(mission_type) + + def plan_flight( + self, + mission: ProposedMission, + flight: ProposedFlight, + builder: PackageBuilder, + missing_types: Set[FlightType], + ) -> None: + if not builder.plan_flight(flight): + missing_types.add(flight.task) + purchase_order = AircraftProcurementRequest( + near=mission.location, + range=flight.max_distance, + task_capability=flight.task, + number=flight.num_aircraft, + ) + # Reserves are planned for critical missions, so prioritize those orders + # over aircraft needed for non-critical missions. + self.add_procurement_request(purchase_order) + + def scrub_mission_missing_aircraft( + self, + mission: ProposedMission, + builder: PackageBuilder, + missing_types: Set[FlightType], + not_attempted: Iterable[ProposedFlight], + ) -> None: + # Try to plan the rest of the mission just so we can count the missing + # types to buy. + for flight in not_attempted: + self.plan_flight(mission, flight, builder, missing_types) + + missing_types_str = ", ".join(sorted([t.name for t in missing_types])) + builder.release_planned_aircraft() + logging.debug( + f"Not enough aircraft in range for {mission.location.name} " + f"capable of: {missing_types_str}" + ) + + def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]: + threats = defaultdict(bool) + for flight in builder.package.flights: + if self.threat_zones.waypoints_threatened_by_aircraft( + flight.flight_plan.escorted_waypoints() + ): + threats[EscortType.AirToAir] = True + if self.threat_zones.waypoints_threatened_by_radar_sam( + list(flight.flight_plan.escorted_waypoints()) + ): + threats[EscortType.Sead] = True + return threats + + def plan_mission( + self, mission: ProposedMission, tracer: MultiEventTracer + ) -> Optional[Package]: + """Allocates aircraft for a proposed mission and adds it to the ATO.""" + builder = PackageBuilder( + mission.location, + ObjectiveDistanceCache.get_closest_airfields(mission.location), + self.aircraft_inventory, + self.air_wing, + self.is_player, + self.coalition.country_name, + self.default_start_type, + mission.asap, + ) + + # Attempt to plan all the main elements of the mission first. Escorts + # will be planned separately so we can prune escorts for packages that + # are not expected to encounter that type of threat. + missing_types: Set[FlightType] = set() + escorts = [] + for proposed_flight in mission.flights: + if not self.air_wing_can_plan(proposed_flight.task): + # This air wing can never plan this mission type because they do not + # have compatible aircraft or squadrons. Skip fulfillment so that we + # don't place the purchase request. + continue + if proposed_flight.escort_type is not None: + # Escorts are planned after the primary elements of the package. + # If the package does not need escorts they may be pruned. + escorts.append(proposed_flight) + continue + with tracer.trace("Flight planning"): + self.plan_flight(mission, proposed_flight, builder, missing_types) + + if missing_types: + self.scrub_mission_missing_aircraft( + mission, builder, missing_types, escorts + ) + return None + + if not builder.package.flights: + # The non-escort part of this mission is unplannable by this faction. Scrub + # the mission and do not attempt planning escorts because there's no reason + # to buy them because this mission will never be planned. + return None + + # Create flight plans for the main flights of the package so we can + # determine threats. This is done *after* creating all of the flights + # rather than as each flight is added because the flight plan for + # flights that will rendezvous with their package will be affected by + # the other flights in the package. Escorts will not be able to + # contribute to this. + flight_plan_builder = FlightPlanBuilder( + builder.package, self.coalition, self.theater + ) + for flight in builder.package.flights: + with tracer.trace("Flight plan population"): + flight_plan_builder.populate_flight_plan(flight) + + needed_escorts = self.check_needed_escorts(builder) + for escort in escorts: + # This list was generated from the not None set, so this should be + # impossible. + assert escort.escort_type is not None + if needed_escorts[escort.escort_type]: + with tracer.trace("Flight planning"): + self.plan_flight(mission, escort, builder, missing_types) + + # Check again for unavailable aircraft. If the escort was required and + # none were found, scrub the mission. + if missing_types: + self.scrub_mission_missing_aircraft( + mission, builder, missing_types, escorts + ) + return None + + package = builder.build() + # Add flight plans for escorts. + for flight in package.flights: + if not flight.flight_plan.waypoints: + with tracer.trace("Flight plan population"): + flight_plan_builder.populate_flight_plan(flight) + + if package.has_players and self.player_missions_asap: + package.auto_asap = True + package.set_tot_asap() + + return package diff --git a/game/commander/tasks/compound/capturebase.py b/game/commander/tasks/compound/capturebase.py index 747b7599..86d2b86e 100644 --- a/game/commander/tasks/compound/capturebase.py +++ b/game/commander/tasks/compound/capturebase.py @@ -15,5 +15,5 @@ class CaptureBase(CompoundTask[TheaterState]): front_line: FrontLine def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - yield [BreakthroughAttack(self.front_line, state.player)] + yield [BreakthroughAttack(self.front_line, state.context.coalition.player)] yield [DestroyEnemyGroundUnits(self.front_line)] diff --git a/game/commander/tasks/compound/defendbase.py b/game/commander/tasks/compound/defendbase.py index 69a008e5..e7071489 100644 --- a/game/commander/tasks/compound/defendbase.py +++ b/game/commander/tasks/compound/defendbase.py @@ -14,6 +14,6 @@ class DefendBase(CompoundTask[TheaterState]): front_line: FrontLine def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - yield [DefensiveStance(self.front_line, state.player)] - yield [RetreatStance(self.front_line, state.player)] + yield [DefensiveStance(self.front_line, state.context.coalition.player)] + yield [RetreatStance(self.front_line, state.context.coalition.player)] yield [PlanCas(self.front_line)] diff --git a/game/commander/tasks/compound/destroyenemygroundunits.py b/game/commander/tasks/compound/destroyenemygroundunits.py index cf83213f..327acecd 100644 --- a/game/commander/tasks/compound/destroyenemygroundunits.py +++ b/game/commander/tasks/compound/destroyenemygroundunits.py @@ -14,6 +14,6 @@ class DestroyEnemyGroundUnits(CompoundTask[TheaterState]): front_line: FrontLine def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - yield [EliminationAttack(self.front_line, state.player)] - yield [AggressiveAttack(self.front_line, state.player)] + yield [EliminationAttack(self.front_line, state.context.coalition.player)] + yield [AggressiveAttack(self.front_line, state.context.coalition.player)] yield [PlanCas(self.front_line)] diff --git a/game/commander/tasks/compound/protectairspace.py b/game/commander/tasks/compound/protectairspace.py index 5a13e486..67407010 100644 --- a/game/commander/tasks/compound/protectairspace.py +++ b/game/commander/tasks/compound/protectairspace.py @@ -7,5 +7,6 @@ from game.htn import CompoundTask, Method class ProtectAirSpace(CompoundTask[TheaterState]): def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - for cp in state.vulnerable_control_points: - yield [PlanBarcap(cp, state.barcap_rounds)] + for cp, needed in state.barcaps_needed.items(): + if needed > 0: + yield [PlanBarcap(cp)] diff --git a/game/commander/tasks/frontlinestancetask.py b/game/commander/tasks/frontlinestancetask.py index ae27f1a6..f8c3b8d1 100644 --- a/game/commander/tasks/frontlinestancetask.py +++ b/game/commander/tasks/frontlinestancetask.py @@ -6,12 +6,11 @@ from typing import TYPE_CHECKING from game.commander.tasks.theatercommandertask import TheaterCommanderTask from game.commander.theaterstate import TheaterState -from game.profiling import MultiEventTracer from game.theater import FrontLine from gen.ground_forces.combat_stance import CombatStance if TYPE_CHECKING: - from gen.flights.ai_flight_planner import CoalitionMissionPlanner + from game.coalition import Coalition class FrontLineStanceTask(TheaterCommanderTask, ABC): @@ -27,7 +26,10 @@ class FrontLineStanceTask(TheaterCommanderTask, ABC): @staticmethod def management_allowed(state: TheaterState) -> bool: - return not state.player or state.stance_automation_enabled + return ( + not state.context.coalition.player + or state.context.settings.automate_front_line_stance + ) def better_stance_already_set(self, state: TheaterState) -> bool: current_stance = state.front_line_stances[self.front_line] @@ -69,7 +71,5 @@ class FrontLineStanceTask(TheaterCommanderTask, ABC): def apply_effects(self, state: TheaterState) -> None: state.front_line_stances[self.front_line] = self.stance - def execute( - self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer - ) -> None: + def execute(self, coalition: Coalition) -> None: self.friendly_cp.stances[self.enemy_cp.id] = self.stance diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index 7196a0b9..fb50af23 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -8,18 +8,19 @@ from enum import unique, IntEnum, auto from typing import TYPE_CHECKING, Optional, Generic, TypeVar, Iterator, Union from game.commander.missionproposals import ProposedFlight, EscortType, ProposedMission +from game.commander.packagefulfiller import PackageFulfiller from game.commander.tasks.theatercommandertask import TheaterCommanderTask from game.commander.theaterstate import TheaterState from game.data.doctrine import Doctrine -from game.profiling import MultiEventTracer +from game.settings import AutoAtoBehavior from game.theater import MissionTarget from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject from game.utils import Distance, meters +from gen import Package from gen.flights.flight import FlightType if TYPE_CHECKING: - from gen.flights.ai_flight_planner import CoalitionMissionPlanner - + from game.coalition import Coalition MissionTargetT = TypeVar("MissionTargetT", bound=MissionTarget) @@ -36,18 +37,26 @@ class RangeType(IntEnum): class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): target: MissionTargetT flights: list[ProposedFlight] = field(init=False) + package: Optional[Package] = field(init=False, default=None) def __post_init__(self) -> None: self.flights = [] + self.package = Package(self.target) def preconditions_met(self, state: TheaterState) -> bool: - return not state.player or state.ato_automation_enabled + if ( + state.context.coalition.player + and state.context.settings.auto_ato_behavior is AutoAtoBehavior.Disabled + ): + return False + return self.fulfill_mission(state) - def execute( - self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer - ) -> None: - self.propose_flights(mission_planner.doctrine) - mission_planner.plan_mission(ProposedMission(self.target, self.flights), tracer) + def execute(self, coalition: Coalition) -> None: + if self.package is None: + raise RuntimeError("Attempted to execute failed package planning task") + for flight in self.package.flights: + coalition.aircraft_inventory.claim_for_flight(flight) + coalition.ato.add_package(self.package) @abstractmethod def propose_flights(self, doctrine: Doctrine) -> None: @@ -70,6 +79,19 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): def asap(self) -> bool: return False + def fulfill_mission(self, state: TheaterState) -> bool: + self.propose_flights(state.context.coalition.doctrine) + fulfiller = PackageFulfiller( + state.context.coalition, + state.context.theater, + state.available_aircraft, + state.context.settings, + ) + self.package = fulfiller.plan_mission( + ProposedMission(self.target, self.flights), state.context.tracer + ) + return self.package is not None + def propose_common_escorts(self, doctrine: Doctrine) -> None: self.propose_flight( FlightType.SEAD_ESCORT, diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py index 8fd86fab..8d3cf456 100644 --- a/game/commander/tasks/primitive/barcap.py +++ b/game/commander/tasks/primitive/barcap.py @@ -1,46 +1,23 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING -from game.commander.missionproposals import ProposedMission, ProposedFlight -from game.commander.tasks.theatercommandertask import TheaterCommanderTask +from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.profiling import MultiEventTracer +from game.data.doctrine import Doctrine from game.theater import ControlPoint from gen.flights.flight import FlightType -if TYPE_CHECKING: - from gen.flights.ai_flight_planner import CoalitionMissionPlanner - @dataclass -class PlanBarcap(TheaterCommanderTask): - target: ControlPoint - rounds: int - +class PlanBarcap(PackagePlanningTask[ControlPoint]): def preconditions_met(self, state: TheaterState) -> bool: - if state.player and not state.ato_automation_enabled: + if not super().preconditions_met(state): return False - return self.target in state.vulnerable_control_points + return state.barcaps_needed[self.target] > 0 def apply_effects(self, state: TheaterState) -> None: - state.vulnerable_control_points.remove(self.target) + state.barcaps_needed[self.target] -= 1 - def execute( - self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer - ) -> None: - for _ in range(self.rounds): - mission_planner.plan_mission( - ProposedMission( - self.target, - [ - ProposedFlight( - FlightType.BARCAP, - 2, - mission_planner.doctrine.mission_ranges.cap, - ), - ], - ), - tracer, - ) + def propose_flights(self, doctrine: Doctrine) -> None: + self.propose_flight(FlightType.BARCAP, 2, doctrine.mission_ranges.cap) diff --git a/game/commander/tasks/theatercommandertask.py b/game/commander/tasks/theatercommandertask.py index aefaeea5..5daa6b6c 100644 --- a/game/commander/tasks/theatercommandertask.py +++ b/game/commander/tasks/theatercommandertask.py @@ -5,16 +5,12 @@ from typing import TYPE_CHECKING from game.commander.theaterstate import TheaterState from game.htn import PrimitiveTask -from game.profiling import MultiEventTracer if TYPE_CHECKING: - from gen.flights.ai_flight_planner import CoalitionMissionPlanner + from game.coalition import Coalition -# TODO: Refactor so that we don't need to call up to the mission planner. class TheaterCommanderTask(PrimitiveTask[TheaterState]): @abstractmethod - def execute( - self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer - ) -> None: + def execute(self, coalition: Coalition) -> None: ... diff --git a/game/commander/theatercommander.py b/game/commander/theatercommander.py index 1e085410..3066ff54 100644 --- a/game/commander/theatercommander.py +++ b/game/commander/theatercommander.py @@ -64,7 +64,6 @@ from game.profiling import MultiEventTracer if TYPE_CHECKING: from game import Game - from gen.flights.ai_flight_planner import CoalitionMissionPlanner class TheaterCommander(Planner[TheaterState, TheaterCommanderTask]): @@ -77,15 +76,13 @@ class TheaterCommander(Planner[TheaterState, TheaterCommanderTask]): self.game = game self.player = player - def plan_missions( - self, mission_planner: CoalitionMissionPlanner, tracer: MultiEventTracer - ) -> None: - state = TheaterState.from_game(self.game, self.player) + def plan_missions(self, tracer: MultiEventTracer) -> None: + state = TheaterState.from_game(self.game, self.player, tracer) while True: result = self.plan(state) if result is None: # Planned all viable tasks this turn. return for task in result.tasks: - task.execute(mission_planner, tracer) + task.execute(self.game.coalition_for(self.player)) state = result.end_state diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index 6da5f9f2..cf41f9a5 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -8,10 +8,11 @@ from typing import TYPE_CHECKING, Any, Union, Optional from game.commander.garrisons import Garrisons from game.commander.objectivefinder import ObjectiveFinder -from game.data.doctrine import Doctrine from game.htn import WorldState -from game.settings import AutoAtoBehavior -from game.theater import ControlPoint, FrontLine, MissionTarget +from game.inventory import GlobalAircraftInventory +from game.profiling import MultiEventTracer +from game.settings import Settings +from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater from game.theater.theatergroundobject import ( TheaterGroundObject, NavalGroundObject, @@ -23,16 +24,22 @@ from gen.ground_forces.combat_stance import CombatStance if TYPE_CHECKING: from game import Game + from game.coalition import Coalition from game.transfers import Convoy, CargoShip +@dataclass(frozen=True) +class PersistentContext: + coalition: Coalition + theater: ConflictTheater + settings: Settings + tracer: MultiEventTracer + + @dataclass class TheaterState(WorldState["TheaterState"]): - player: bool - stance_automation_enabled: bool - ato_automation_enabled: bool - barcap_rounds: int - vulnerable_control_points: list[ControlPoint] + context: PersistentContext + barcaps_needed: dict[ControlPoint, int] active_front_lines: list[FrontLine] front_line_stances: dict[FrontLine, Optional[CombatStance]] vulnerable_front_lines: list[FrontLine] @@ -49,12 +56,12 @@ class TheaterState(WorldState["TheaterState"]): strike_targets: list[TheaterGroundObject[Any]] enemy_barcaps: list[ControlPoint] threat_zones: ThreatZones - opposing_doctrine: Doctrine + available_aircraft: GlobalAircraftInventory def _rebuild_threat_zones(self) -> None: """Recreates the theater's threat zones based on the current planned state.""" self.threat_zones = ThreatZones.for_threats( - self.opposing_doctrine, + self.context.coalition.opponent.doctrine, barcap_locations=self.enemy_barcaps, air_defenses=itertools.chain(self.enemy_air_defenses, self.enemy_ships), ) @@ -85,11 +92,8 @@ class TheaterState(WorldState["TheaterState"]): # Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly # expensive. return TheaterState( - player=self.player, - stance_automation_enabled=self.stance_automation_enabled, - ato_automation_enabled=self.ato_automation_enabled, - barcap_rounds=self.barcap_rounds, - vulnerable_control_points=list(self.vulnerable_control_points), + context=self.context, + barcaps_needed=dict(self.barcaps_needed), active_front_lines=list(self.active_front_lines), front_line_stances=dict(self.front_line_stances), vulnerable_front_lines=list(self.vulnerable_front_lines), @@ -106,7 +110,7 @@ class TheaterState(WorldState["TheaterState"]): strike_targets=list(self.strike_targets), enemy_barcaps=list(self.enemy_barcaps), threat_zones=self.threat_zones, - opposing_doctrine=self.opposing_doctrine, + available_aircraft=self.available_aircraft.clone(), # Persistent properties are not copied. These are a way for failed subtasks # to communicate requirements to other tasks. For example, the task to # attack enemy garrisons might fail because the target area has IADS @@ -118,26 +122,26 @@ class TheaterState(WorldState["TheaterState"]): ) @classmethod - def from_game(cls, game: Game, player: bool) -> TheaterState: + def from_game( + cls, game: Game, player: bool, tracer: MultiEventTracer + ) -> TheaterState: + coalition = game.coalition_for(player) finder = ObjectiveFinder(game, player) - auto_stance = game.settings.automate_front_line_stance - auto_ato = game.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled ordered_capturable_points = finder.prioritized_unisolated_points() + context = PersistentContext(coalition, game.theater, game.settings, tracer) + # Plan enough rounds of CAP that the target has coverage over the expected # mission duration. mission_duration = game.settings.desired_player_mission_duration.total_seconds() - barcap_duration = game.coalition_for( - player - ).doctrine.cap_duration.total_seconds() + barcap_duration = coalition.doctrine.cap_duration.total_seconds() barcap_rounds = math.ceil(mission_duration / barcap_duration) return TheaterState( - player=player, - stance_automation_enabled=auto_stance, - ato_automation_enabled=auto_ato, - barcap_rounds=barcap_rounds, - vulnerable_control_points=list(finder.vulnerable_control_points()), + context=context, + barcaps_needed={ + cp: barcap_rounds for cp in finder.vulnerable_control_points() + }, active_front_lines=list(finder.front_lines()), front_line_stances={f: None for f in finder.front_lines()}, vulnerable_front_lines=list(finder.front_lines()), @@ -156,5 +160,5 @@ class TheaterState(WorldState["TheaterState"]): strike_targets=list(finder.strike_targets()), enemy_barcaps=list(game.theater.control_points_for(not player)), threat_zones=game.threat_zone_for(not player), - opposing_doctrine=game.faction_for(not player).doctrine, + available_aircraft=game.aircraft_inventory.clone(), ) diff --git a/game/inventory.py b/game/inventory.py index 77587cb2..f7f0dbe1 100644 --- a/game/inventory.py +++ b/game/inventory.py @@ -1,8 +1,8 @@ """Inventory management APIs.""" from __future__ import annotations -from collections import defaultdict -from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING +from collections import defaultdict, Iterator, Iterable +from typing import TYPE_CHECKING from game.dcs.aircrafttype import AircraftType from gen.flights.flight import Flight @@ -16,7 +16,12 @@ class ControlPointAircraftInventory: def __init__(self, control_point: ControlPoint) -> None: self.control_point = control_point - self.inventory: Dict[AircraftType, int] = defaultdict(int) + self.inventory: dict[AircraftType, int] = defaultdict(int) + + def clone(self) -> ControlPointAircraftInventory: + new = ControlPointAircraftInventory(self.control_point) + new.inventory = self.inventory.copy() + return new def add_aircraft(self, aircraft: AircraftType, count: int) -> None: """Adds aircraft to the inventory. @@ -65,7 +70,7 @@ class ControlPointAircraftInventory: yield aircraft @property - def all_aircraft(self) -> Iterator[Tuple[AircraftType, int]]: + def all_aircraft(self) -> Iterator[tuple[AircraftType, int]]: """Iterates over all available aircraft types, including amounts.""" for aircraft, count in self.inventory.items(): if count > 0: @@ -80,10 +85,17 @@ class GlobalAircraftInventory: """Game-wide aircraft inventory.""" def __init__(self, control_points: Iterable[ControlPoint]) -> None: - self.inventories: Dict[ControlPoint, ControlPointAircraftInventory] = { + self.inventories: dict[ControlPoint, ControlPointAircraftInventory] = { cp: ControlPointAircraftInventory(cp) for cp in control_points } + def clone(self) -> GlobalAircraftInventory: + new = GlobalAircraftInventory([]) + new.inventories = { + cp: inventory.clone() for cp, inventory in self.inventories.items() + } + return new + def reset(self, for_player: bool) -> None: """Clears the inventory of every control point owned by the given coalition.""" for inventory in self.inventories.values(): @@ -109,7 +121,7 @@ class GlobalAircraftInventory: @property def available_types_for_player(self) -> Iterator[AircraftType]: """Iterates over all aircraft types available to the player.""" - seen: Set[AircraftType] = set() + seen: set[AircraftType] = set() for control_point, inventory in self.inventories.items(): if control_point.captured: for aircraft in inventory.types_available: diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py deleted file mode 100644 index e74ce0af..00000000 --- a/gen/flights/ai_flight_planner.py +++ /dev/null @@ -1,489 +0,0 @@ -from __future__ import annotations - -import logging -import random -from collections import defaultdict -from datetime import timedelta -from typing import ( - Dict, - Iterable, - Iterator, - Optional, - Set, - TYPE_CHECKING, - Tuple, -) - -from game.commander.missionproposals import ProposedFlight, ProposedMission, EscortType -from game.data.doctrine import Doctrine -from game.dcs.aircrafttype import AircraftType -from game.procurement import AircraftProcurementRequest -from game.profiling import MultiEventTracer -from game.settings import Settings -from game.squadrons import AirWing, Squadron -from game.theater import ( - ControlPoint, - MissionTarget, - OffMapSpawn, - ConflictTheater, -) -from game.threatzones import ThreatZones -from game.utils import nautical_miles -from gen.ato import Package, AirTaskingOrder -from gen.flights.ai_flight_planner_db import aircraft_for_task -from gen.flights.closestairfields import ( - ClosestAirfields, - ObjectiveDistanceCache, -) -from gen.flights.flight import ( - Flight, - FlightType, -) -from gen.flights.flightplan import FlightPlanBuilder -from gen.flights.traveltime import TotEstimator - -# Avoid importing some types that cause circular imports unless type checking. -if TYPE_CHECKING: - from game.coalition import Coalition - from game.inventory import GlobalAircraftInventory - - -class AircraftAllocator: - """Finds suitable aircraft for proposed missions.""" - - def __init__( - self, - air_wing: AirWing, - closest_airfields: ClosestAirfields, - global_inventory: GlobalAircraftInventory, - is_player: bool, - ) -> None: - self.air_wing = air_wing - self.closest_airfields = closest_airfields - self.global_inventory = global_inventory - self.is_player = is_player - - def find_squadron_for_flight( - self, flight: ProposedFlight - ) -> Optional[Tuple[ControlPoint, Squadron]]: - """Finds aircraft suitable for the given mission. - - Searches for aircraft capable of performing the given mission within the - maximum allowed range. If insufficient aircraft are available for the - mission, None is returned. - - Airfields are searched ordered nearest to farthest from the target and - searched twice. The first search looks for aircraft which prefer the - mission type, and the second search looks for any aircraft which are - capable of the mission type. For example, an F-14 from a nearby carrier - will be preferred for the CAP of an airfield that has only F-16s, but if - the carrier has only F/A-18s the F-16s will be used for CAP instead. - - Note that aircraft *will* be removed from the global inventory on - success. This is to ensure that the same aircraft are not matched twice - on subsequent calls. If the found aircraft are not used, the caller is - responsible for returning them to the inventory. - """ - return self.find_aircraft_for_task(flight, flight.task) - - def find_aircraft_for_task( - self, flight: ProposedFlight, task: FlightType - ) -> Optional[Tuple[ControlPoint, Squadron]]: - types = aircraft_for_task(task) - airfields_in_range = self.closest_airfields.operational_airfields_within( - flight.max_distance - ) - - for airfield in airfields_in_range: - if not airfield.is_friendly(self.is_player): - continue - inventory = self.global_inventory.for_control_point(airfield) - for aircraft in types: - if not airfield.can_operate(aircraft): - continue - if inventory.available(aircraft) < flight.num_aircraft: - continue - # Valid location with enough aircraft available. Find a squadron to fit - # the role. - squadrons = self.air_wing.auto_assignable_for_task_with_type( - aircraft, task - ) - for squadron in squadrons: - if squadron.can_provide_pilots(flight.num_aircraft): - inventory.remove_aircraft(aircraft, flight.num_aircraft) - return airfield, squadron - return None - - -class PackageBuilder: - """Builds a Package for the flights it receives.""" - - def __init__( - self, - location: MissionTarget, - closest_airfields: ClosestAirfields, - global_inventory: GlobalAircraftInventory, - air_wing: AirWing, - is_player: bool, - package_country: str, - start_type: str, - asap: bool, - ) -> None: - self.closest_airfields = closest_airfields - self.is_player = is_player - self.package_country = package_country - self.package = Package(location, auto_asap=asap) - self.allocator = AircraftAllocator( - air_wing, closest_airfields, global_inventory, is_player - ) - self.global_inventory = global_inventory - self.start_type = start_type - - def plan_flight(self, plan: ProposedFlight) -> bool: - """Allocates aircraft for the given flight and adds them to the package. - - If no suitable aircraft are available, False is returned. If the failed - flight was critical and the rest of the mission will be scrubbed, the - caller should return any previously planned flights to the inventory - using release_planned_aircraft. - """ - assignment = self.allocator.find_squadron_for_flight(plan) - if assignment is None: - return False - airfield, squadron = assignment - if isinstance(airfield, OffMapSpawn): - start_type = "In Flight" - else: - start_type = self.start_type - - flight = Flight( - self.package, - self.package_country, - squadron, - plan.num_aircraft, - plan.task, - start_type, - departure=airfield, - arrival=airfield, - divert=self.find_divert_field(squadron.aircraft, airfield), - ) - self.package.add_flight(flight) - return True - - def find_divert_field( - self, aircraft: AircraftType, arrival: ControlPoint - ) -> Optional[ControlPoint]: - divert_limit = nautical_miles(150) - for airfield in self.closest_airfields.operational_airfields_within( - divert_limit - ): - if airfield.captured != self.is_player: - continue - if airfield == arrival: - continue - if not airfield.can_operate(aircraft): - continue - if isinstance(airfield, OffMapSpawn): - continue - return airfield - return None - - def build(self) -> Package: - """Returns the built package.""" - return self.package - - def release_planned_aircraft(self) -> None: - """Returns any planned flights to the inventory.""" - flights = list(self.package.flights) - for flight in flights: - self.global_inventory.return_from_flight(flight) - flight.clear_roster() - self.package.remove_flight(flight) - - -class MissionScheduler: - def __init__(self, coalition: Coalition, desired_mission_length: timedelta) -> None: - self.coalition = coalition - self.desired_mission_length = desired_mission_length - - def schedule_missions(self) -> None: - """Identifies and plans mission for the turn.""" - - def start_time_generator( - count: int, earliest: int, latest: int, margin: int - ) -> Iterator[timedelta]: - interval = (latest - earliest) // count - for time in range(earliest, latest, interval): - error = random.randint(-margin, margin) - yield timedelta(seconds=max(0, time + error)) - - dca_types = { - FlightType.BARCAP, - FlightType.TARCAP, - } - - previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta) - non_dca_packages = [ - p for p in self.coalition.ato.packages if p.primary_task not in dca_types - ] - - start_time = start_time_generator( - count=len(non_dca_packages), - earliest=5 * 60, - latest=int(self.desired_mission_length.total_seconds()), - margin=5 * 60, - ) - for package in self.coalition.ato.packages: - tot = TotEstimator(package).earliest_tot() - if package.primary_task in dca_types: - previous_end_time = previous_cap_end_time[package.target] - if tot > previous_end_time: - # Can't get there exactly on time, so get there ASAP. This - # will typically only happen for the first CAP at each - # target. - package.time_over_target = tot - else: - package.time_over_target = previous_end_time - - departure_time = package.mission_departure_time - # Should be impossible for CAPs - if departure_time is None: - logging.error(f"Could not determine mission end time for {package}") - continue - previous_cap_end_time[package.target] = departure_time - elif package.auto_asap: - package.set_tot_asap() - else: - # But other packages should be spread out a bit. Note that take - # times are delayed, but all aircraft will become active at - # mission start. This makes it more worthwhile to attack enemy - # airfields to hit grounded aircraft, since they're more likely - # to be present. Runway and air started aircraft will be - # delayed until their takeoff time by AirConflictGenerator. - package.time_over_target = next(start_time) + tot - - -class CoalitionMissionPlanner: - """Coalition flight planning AI. - - This class is responsible for automatically planning missions for the - coalition at the start of the turn. - - The primary goal of the mission planner is to protect existing friendly - assets. Missions will be planned with the following priorities: - - 1. CAP for airfields/fleets in close proximity to the enemy to prevent heavy - losses of friendly aircraft. - 2. CAP for front line areas to protect ground and CAS units. - 3. DEAD to reduce necessity of SEAD for future missions. - 4. CAS to protect friendly ground units. - 5. Strike missions to reduce the enemy's resources. - - TODO: Anti-ship and airfield strikes to reduce enemy sortie rates. - TODO: BAI to prevent enemy forces from reaching the front line. - TODO: Should fleets always have a CAP? - - TODO: Stance and doctrine-specific planning behavior. - """ - - def __init__( - self, - coalition: Coalition, - theater: ConflictTheater, - aircraft_inventory: GlobalAircraftInventory, - settings: Settings, - ) -> None: - self.coalition = coalition - self.theater = theater - self.aircraft_inventory = aircraft_inventory - self.player_missions_asap = settings.auto_ato_player_missions_asap - self.default_start_type = settings.default_start_type - - @property - def is_player(self) -> bool: - return self.coalition.player - - @property - def ato(self) -> AirTaskingOrder: - return self.coalition.ato - - @property - def air_wing(self) -> AirWing: - return self.coalition.air_wing - - @property - def doctrine(self) -> Doctrine: - return self.coalition.doctrine - - @property - def threat_zones(self) -> ThreatZones: - return self.coalition.opponent.threat_zone - - def add_procurement_request( - self, request: AircraftProcurementRequest, priority: bool - ) -> None: - if priority: - self.coalition.procurement_requests.insert(0, request) - else: - self.coalition.procurement_requests.append(request) - - def air_wing_can_plan(self, mission_type: FlightType) -> bool: - """Returns True if it is possible for the air wing to plan this mission type. - - Not all mission types can be fulfilled by all air wings. Many factions do not - have AEW&C aircraft, so they will never be able to plan those missions. It's - also possible for the player to exclude mission types from their squadron - designs. - """ - return self.air_wing.can_auto_plan(mission_type) - - def plan_flight( - self, - mission: ProposedMission, - flight: ProposedFlight, - builder: PackageBuilder, - missing_types: Set[FlightType], - for_reserves: bool, - ) -> None: - if not builder.plan_flight(flight): - missing_types.add(flight.task) - purchase_order = AircraftProcurementRequest( - near=mission.location, - range=flight.max_distance, - task_capability=flight.task, - number=flight.num_aircraft, - ) - # Reserves are planned for critical missions, so prioritize those orders - # over aircraft needed for non-critical missions. - self.add_procurement_request(purchase_order, priority=for_reserves) - - def scrub_mission_missing_aircraft( - self, - mission: ProposedMission, - builder: PackageBuilder, - missing_types: Set[FlightType], - not_attempted: Iterable[ProposedFlight], - reserves: bool, - ) -> None: - # Try to plan the rest of the mission just so we can count the missing - # types to buy. - for flight in not_attempted: - self.plan_flight(mission, flight, builder, missing_types, reserves) - - missing_types_str = ", ".join(sorted([t.name for t in missing_types])) - builder.release_planned_aircraft() - desc = "reserve aircraft" if reserves else "aircraft" - logging.debug( - f"Not enough {desc} in range for {mission.location.name} " - f"capable of: {missing_types_str}" - ) - - def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]: - threats = defaultdict(bool) - for flight in builder.package.flights: - if self.threat_zones.waypoints_threatened_by_aircraft( - flight.flight_plan.escorted_waypoints() - ): - threats[EscortType.AirToAir] = True - if self.threat_zones.waypoints_threatened_by_radar_sam( - list(flight.flight_plan.escorted_waypoints()) - ): - threats[EscortType.Sead] = True - return threats - - def plan_mission( - self, mission: ProposedMission, tracer: MultiEventTracer, reserves: bool = False - ) -> None: - """Allocates aircraft for a proposed mission and adds it to the ATO.""" - builder = PackageBuilder( - mission.location, - ObjectiveDistanceCache.get_closest_airfields(mission.location), - self.aircraft_inventory, - self.air_wing, - self.is_player, - self.coalition.country_name, - self.default_start_type, - mission.asap, - ) - - # Attempt to plan all the main elements of the mission first. Escorts - # will be planned separately so we can prune escorts for packages that - # are not expected to encounter that type of threat. - missing_types: Set[FlightType] = set() - escorts = [] - for proposed_flight in mission.flights: - if not self.air_wing_can_plan(proposed_flight.task): - # This air wing can never plan this mission type because they do not - # have compatible aircraft or squadrons. Skip fulfillment so that we - # don't place the purchase request. - continue - if proposed_flight.escort_type is not None: - # Escorts are planned after the primary elements of the package. - # If the package does not need escorts they may be pruned. - escorts.append(proposed_flight) - continue - with tracer.trace("Flight planning"): - self.plan_flight( - mission, proposed_flight, builder, missing_types, reserves - ) - - if missing_types: - self.scrub_mission_missing_aircraft( - mission, builder, missing_types, escorts, reserves - ) - return - - if not builder.package.flights: - # The non-escort part of this mission is unplannable by this faction. Scrub - # the mission and do not attempt planning escorts because there's no reason - # to buy them because this mission will never be planned. - return - - # Create flight plans for the main flights of the package so we can - # determine threats. This is done *after* creating all of the flights - # rather than as each flight is added because the flight plan for - # flights that will rendezvous with their package will be affected by - # the other flights in the package. Escorts will not be able to - # contribute to this. - flight_plan_builder = FlightPlanBuilder( - builder.package, self.coalition, self.theater - ) - for flight in builder.package.flights: - with tracer.trace("Flight plan population"): - flight_plan_builder.populate_flight_plan(flight) - - needed_escorts = self.check_needed_escorts(builder) - for escort in escorts: - # This list was generated from the not None set, so this should be - # impossible. - assert escort.escort_type is not None - if needed_escorts[escort.escort_type]: - with tracer.trace("Flight planning"): - self.plan_flight(mission, escort, builder, missing_types, reserves) - - # Check again for unavailable aircraft. If the escort was required and - # none were found, scrub the mission. - if missing_types: - self.scrub_mission_missing_aircraft( - mission, builder, missing_types, escorts, reserves - ) - return - - if reserves: - # Mission is planned reserves which will not be used this turn. - # Return reserves to the inventory. - builder.release_planned_aircraft() - return - - package = builder.build() - # Add flight plans for escorts. - for flight in package.flights: - if not flight.flight_plan.waypoints: - with tracer.trace("Flight plan population"): - flight_plan_builder.populate_flight_plan(flight) - - if package.has_players and self.player_missions_asap: - package.auto_asap = True - package.set_tot_asap() - - self.ato.add_package(package) From 9568bc7ea69e6f38e1828347525a416d2e22a25f Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 17:04:49 -0700 Subject: [PATCH 061/167] Fix inversion of AI management for opfor. --- game/coalition.py | 6 +++--- .../compound/reduceenemyfrontlinecapacity.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 game/commander/tasks/compound/reduceenemyfrontlinecapacity.py diff --git a/game/coalition.py b/game/coalition.py index 8b0ff812..1922f3ce 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -206,9 +206,9 @@ class Coalition: manage_front_line = self.game.settings.automate_front_line_reinforcements manage_aircraft = self.game.settings.automate_aircraft_reinforcements else: - manage_runways = False - manage_front_line = False - manage_aircraft = False + manage_runways = True + manage_front_line = True + manage_aircraft = True self.budget = ProcurementAi( self.game, diff --git a/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py b/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py new file mode 100644 index 00000000..327acecd --- /dev/null +++ b/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py @@ -0,0 +1,19 @@ +from collections import Iterator +from dataclasses import dataclass + +from game.commander.tasks.primitive.aggressiveattack import AggressiveAttack +from game.commander.tasks.primitive.cas import PlanCas +from game.commander.tasks.primitive.eliminationattack import EliminationAttack +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method +from game.theater import FrontLine + + +@dataclass(frozen=True) +class DestroyEnemyGroundUnits(CompoundTask[TheaterState]): + front_line: FrontLine + + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [EliminationAttack(self.front_line, state.context.coalition.player)] + yield [AggressiveAttack(self.front_line, state.context.coalition.player)] + yield [PlanCas(self.front_line)] From 587034ad031fd786535ce53a22b5e1b4d8c4b771 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 17:06:25 -0700 Subject: [PATCH 062/167] Prioritize ammo depots when appropriate. The AI will now prioritize targeting ammo depots if the current deployable enemy forces outnumber the friendly cap by 50% or more. --- game/commander/tasks/compound/capturebase.py | 34 ++++++++++++- .../compound/reduceenemyfrontlinecapacity.py | 15 +++--- game/commander/theaterstate.py | 11 +++++ game/theater/controlpoint.py | 49 +++++++++++++------ game/theater/theatergroundobject.py | 4 ++ 5 files changed, 89 insertions(+), 24 deletions(-) diff --git a/game/commander/tasks/compound/capturebase.py b/game/commander/tasks/compound/capturebase.py index 86d2b86e..11936033 100644 --- a/game/commander/tasks/compound/capturebase.py +++ b/game/commander/tasks/compound/capturebase.py @@ -4,10 +4,13 @@ from dataclasses import dataclass from game.commander.tasks.compound.destroyenemygroundunits import ( DestroyEnemyGroundUnits, ) +from game.commander.tasks.compound.reduceenemyfrontlinecapacity import ( + ReduceEnemyFrontLineCapacity, +) from game.commander.tasks.primitive.breakthroughattack import BreakthroughAttack from game.commander.theaterstate import TheaterState from game.htn import CompoundTask, Method -from game.theater import FrontLine +from game.theater import FrontLine, ControlPoint @dataclass(frozen=True) @@ -17,3 +20,32 @@ class CaptureBase(CompoundTask[TheaterState]): def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: yield [BreakthroughAttack(self.front_line, state.context.coalition.player)] yield [DestroyEnemyGroundUnits(self.front_line)] + if self.worth_destroying_ammo_depots(state): + yield [ReduceEnemyFrontLineCapacity(self.enemy_cp(state))] + + def enemy_cp(self, state: TheaterState) -> ControlPoint: + return self.front_line.control_point_hostile_to(state.context.coalition.player) + + def units_deployable(self, state: TheaterState, player: bool) -> int: + cp = self.front_line.control_point_friendly_to(player) + ammo_depots = list(state.ammo_dumps_at(cp)) + return cp.deployable_front_line_units_with(len(ammo_depots)) + + def unit_cap(self, state: TheaterState, player: bool) -> int: + cp = self.front_line.control_point_friendly_to(player) + ammo_depots = list(state.ammo_dumps_at(cp)) + return cp.front_line_capacity_with(len(ammo_depots)) + + def enemy_has_ammo_dumps(self, state: TheaterState) -> bool: + return bool(state.ammo_dumps_at(self.enemy_cp(state))) + + def worth_destroying_ammo_depots(self, state: TheaterState) -> bool: + if not self.enemy_has_ammo_dumps(state): + return False + + friendly_cap = self.unit_cap(state, state.context.coalition.player) + enemy_deployable = self.units_deployable(state, state.context.coalition.player) + + # If the enemy can currently deploy 50% more units than we possibly could, it's + # worth killing an ammo depot. + return enemy_deployable / friendly_cap > 1.5 diff --git a/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py b/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py index 327acecd..1b8b0e7c 100644 --- a/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py +++ b/game/commander/tasks/compound/reduceenemyfrontlinecapacity.py @@ -1,19 +1,16 @@ from collections import Iterator from dataclasses import dataclass -from game.commander.tasks.primitive.aggressiveattack import AggressiveAttack -from game.commander.tasks.primitive.cas import PlanCas -from game.commander.tasks.primitive.eliminationattack import EliminationAttack +from game.commander.tasks.primitive.strike import PlanStrike from game.commander.theaterstate import TheaterState from game.htn import CompoundTask, Method -from game.theater import FrontLine +from game.theater import ControlPoint @dataclass(frozen=True) -class DestroyEnemyGroundUnits(CompoundTask[TheaterState]): - front_line: FrontLine +class ReduceEnemyFrontLineCapacity(CompoundTask[TheaterState]): + control_point: ControlPoint def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - yield [EliminationAttack(self.front_line, state.context.coalition.player)] - yield [AggressiveAttack(self.front_line, state.context.coalition.player)] - yield [PlanCas(self.front_line)] + for ammo_dump in state.ammo_dumps_at(self.control_point): + yield [PlanStrike(ammo_dump)] diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index cf41f9a5..e04a13a3 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses import itertools import math +from collections import Iterator from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Union, Optional @@ -18,6 +19,7 @@ from game.theater.theatergroundobject import ( NavalGroundObject, IadsGroundObject, VehicleGroupGroundObject, + BuildingGroundObject, ) from game.threatzones import ThreatZones from gen.ground_forces.combat_stance import CombatStance @@ -88,6 +90,15 @@ class TheaterState(WorldState["TheaterState"]): def eliminate_garrison(self, target: VehicleGroupGroundObject) -> None: self.enemy_garrisons[target.control_point].eliminate(target) + def ammo_dumps_at( + self, control_point: ControlPoint + ) -> Iterator[BuildingGroundObject]: + for target in self.strike_targets: + if target.control_point != control_point: + continue + if target.is_ammo_depot: + yield target + def clone(self) -> TheaterState: # Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly # expensive. diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 7fedc7fe..075f4f5e 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -40,7 +40,11 @@ from gen.ground_forces.combat_stance import CombatStance from gen.runways import RunwayAssigner, RunwayData from .base import Base from .missiontarget import MissionTarget -from .theatergroundobject import GenericCarrierGroundObject, TheaterGroundObject +from .theatergroundobject import ( + GenericCarrierGroundObject, + TheaterGroundObject, + BuildingGroundObject, +) from ..dcs.aircrafttype import AircraftType from ..dcs.groundunittype import GroundUnitType from ..utils import nautical_miles @@ -728,30 +732,47 @@ class ControlPoint(MissionTarget, ABC): @property def deployable_front_line_units(self) -> int: - return min(self.frontline_unit_count_limit, self.base.total_armor) + return self.deployable_front_line_units_with(self.active_ammo_depots_count) + + def deployable_front_line_units_with(self, ammo_depot_count: int) -> int: + return min( + self.front_line_capacity_with(ammo_depot_count), self.base.total_armor + ) + + @classmethod + def front_line_capacity_with(cls, ammo_depot_count: int) -> int: + return ( + FREE_FRONTLINE_UNIT_SUPPLY + + ammo_depot_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION + ) @property def frontline_unit_count_limit(self) -> int: - return ( - FREE_FRONTLINE_UNIT_SUPPLY - + self.active_ammo_depots_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION - ) + return self.front_line_capacity_with(self.active_ammo_depots_count) + + @property + def all_ammo_depots(self) -> Iterator[BuildingGroundObject]: + for tgo in self.connected_objectives: + if not tgo.is_ammo_depot: + continue + assert isinstance(tgo, BuildingGroundObject) + yield tgo + + @property + def active_ammo_depots(self) -> Iterator[BuildingGroundObject]: + for tgo in self.all_ammo_depots: + if not tgo.is_dead: + yield tgo @property def active_ammo_depots_count(self) -> int: """Return the number of available ammo depots""" - return len( - [ - obj - for obj in self.connected_objectives - if obj.category == "ammo" and not obj.is_dead - ] - ) + return len(list(self.active_ammo_depots)) @property def total_ammo_depots_count(self) -> int: """Return the number of ammo depots, including dead ones""" - return len([obj for obj in self.connected_objectives if obj.category == "ammo"]) + return len(list(self.all_ammo_depots)) @property def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]: diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index 180dd352..f063a1ea 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -181,6 +181,10 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): def threat_range(self, group: GroupT, radar_only: bool = False) -> Distance: return self._max_range_of_type(group, "threat_range") + @property + def is_ammo_depot(self) -> bool: + return self.category == "ammo" + @property def is_factory(self) -> bool: return self.category == "factory" From 5f8be5fa910a1191ea4da7b400b172bc5ae183d3 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 17:08:37 -0700 Subject: [PATCH 063/167] Fix type checker issue. --- game/commander/theaterstate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index e04a13a3..4450c95b 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -97,6 +97,7 @@ class TheaterState(WorldState["TheaterState"]): if target.control_point != control_point: continue if target.is_ammo_depot: + assert isinstance(target, BuildingGroundObject) yield target def clone(self) -> TheaterState: From a1910f49a811daad94357a26c4337a98d25c89fa Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 17:49:10 -0700 Subject: [PATCH 064/167] Color navmesh zones based on threat. --- qt_ui/widgets/map/mapmodel.py | 32 ++++++++++++++++++++++++++++---- resources/ui/map/map.js | 7 +++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index c6c5e70b..02eadd9f 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -12,7 +12,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPo from game import Game from game.dcs.groundunittype import GroundUnitType -from game.navmesh import NavMesh +from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( ConflictTheater, @@ -646,11 +646,35 @@ class ThreatZoneContainerJs(QObject): return self._red +class NavMeshPolyJs(QObject): + polyChanged = Signal() + threatenedChanged = Signal() + + def __init__(self, poly: LeafletPoly, threatened: bool) -> None: + super().__init__() + self._poly = poly + self._threatened = threatened + + @Property(list, notify=polyChanged) + def poly(self) -> LeafletPoly: + return self._poly + + @Property(bool, notify=threatenedChanged) + def threatened(self) -> bool: + return self._threatened + + @classmethod + def from_navmesh(cls, poly: NavMeshPoly, theater: ConflictTheater) -> NavMeshPolyJs: + return NavMeshPolyJs( + shapely_poly_to_leaflet_points(poly.poly, theater), poly.threatened + ) + + class NavMeshJs(QObject): blueChanged = Signal() redChanged = Signal() - def __init__(self, blue: list[LeafletPoly], red: list[LeafletPoly]) -> None: + def __init__(self, blue: list[NavMeshPolyJs], red: list[NavMeshPolyJs]) -> None: super().__init__() self._blue = blue self._red = red @@ -667,10 +691,10 @@ class NavMeshJs(QObject): return self._red @staticmethod - def to_polys(navmesh: NavMesh, theater: ConflictTheater) -> list[LeafletPoly]: + def to_polys(navmesh: NavMesh, theater: ConflictTheater) -> list[NavMeshPolyJs]: polys = [] for poly in navmesh.polys: - polys.append(shapely_poly_to_leaflet_points(poly.poly, theater)) + polys.append(NavMeshPolyJs.from_navmesh(poly, theater)) return polys @classmethod diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 141fe2ef..6cae9ae2 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -904,10 +904,13 @@ function drawThreatZones() { function drawNavmesh(zones, layer) { for (const zone of zones) { - L.polyline(zone, { + L.polyline(zone.poly, { color: "#000000", weight: 1, - fill: false, + fillColor: zone.threatened ? "#ff0000" : "#00ff00", + fill: true, + fillOpacity: 0.1, + noClip: true, interactive: false, }).addTo(layer); } From f7bbe0fa9493a80e4a0025daa4c0c28b9f353c14 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 18:11:32 -0700 Subject: [PATCH 065/167] Improve flight plan layout for untheatened IPs. Try all the nav points between the origin and the target rather than just the first non-threatened point. This prevents us from using the fallback behavior for any target that's sufficiently far from the package airfield. --- gen/flights/flightplan.py | 61 ++++++++++++++------------------------- 1 file changed, 22 insertions(+), 39 deletions(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index a93e6f39..0bcdd28c 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -959,30 +959,14 @@ class FlightPlanBuilder: raise PlanningError(f"{task} flight plan generation not implemented") def regenerate_package_waypoints(self) -> None: - # The simple case is where the target is greater than the ingress - # distance into the threat zone and the target is not near the departure - # airfield. In this case, we can plan the shortest route from the - # departure airfield to the target, use the last non-threatened point as - # the join point, and plan the IP inside the threatened area. - # - # When the target is near the edge of the threat zone the IP may need to - # be placed outside the zone. - # - # +--------------+ +---------------+ - # | | | | - # | | IP---+-T | - # | | | | - # | | | | - # +--------------+ +---------------+ - # - # Here we want to place the IP first and route the flight to the IP - # rather than routing to the target and placing the IP based on the join - # point. + # The simple case is where the target is not near the departure airfield. In + # this case, we can plan the shortest route from the departure airfield to the + # target, use the nearest non-threatened point *that's farther from the target + # than the ingress point to avoid backtracking) as the join point. # # The other case that we need to handle is when the target is close to - # the origin airfield. In this case we also need to set up the IP first, - # but depending on the placement of the IP we may need to place the join - # point in a retreating position. + # the origin airfield. In this case we currently fall back to the old planning + # behavior. # # A messy (and very unlikely) case that we can't do much about: # @@ -996,21 +980,18 @@ class FlightPlanBuilder: target = self.package.target.position - join_point = self.preferred_join_point() - if join_point is None: - # The whole path from the origin airfield to the target is - # threatened. Need to retreat out of the threat area. - join_point = self.retreat_point(self.package_airfield().position) + for join_point in self.preferred_join_points(): + join_distance = meters(join_point.distance_to_point(target)) + if join_distance > self.doctrine.ingress_egress_distance: + break + else: + # The entire path to the target is threatened. Use the fallback behavior for + # now. + self.legacy_package_waypoints_impl() + return attack_heading = join_point.heading_between_point(target) ingress_point = self._ingress_point(attack_heading) - join_distance = meters(join_point.distance_to_point(target)) - ingress_distance = meters(ingress_point.distance_to_point(target)) - if join_distance < ingress_distance: - # The second case described above. The ingress point is farther from - # the target than the join point. Use the fallback behavior for now. - self.legacy_package_waypoints_impl() - return # The first case described above. The ingress and join points are placed # reasonably relative to each other. @@ -1031,7 +1012,7 @@ class FlightPlanBuilder: ingress_point = self._ingress_point(self._target_heading_to_package_airfield()) egress_point = self._egress_point(self._target_heading_to_package_airfield()) join_point = self._rendezvous_point(ingress_point) - split_point = self._rendezvous_point(egress_point) + split_point = self._rendezvous_point(self.package.target.position) self.package.waypoints = PackageWaypoints( join_point, ingress_point, @@ -1039,14 +1020,16 @@ class FlightPlanBuilder: split_point, ) - def preferred_join_point(self) -> Optional[Point]: + def preferred_join_points(self) -> Iterator[Point]: path = self.coalition.nav_mesh.shortest_path( self.package_airfield().position, self.package.target.position ) - for point in reversed(path): + # Use non-threatened points along the path to the target (excluding the target + # itself) as the join point. We may need to try more than one in the event that + # the close non-threatened points are closer than the ingress point itself. + for point in reversed(path[:-1]): if not self.threat_zones.threatened(point): - return point - return None + yield point def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. From dfcd372d2d0dfd40cb384aaf67b8786599c31504 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 18:13:20 -0700 Subject: [PATCH 066/167] Remove egress points from most flight plans. These don't have any function. Remove them and remove the angled attack heading from the IP. --- game/data/doctrine.py | 21 +++++++++++------- gen/ato.py | 1 - gen/flights/flightplan.py | 40 ++++++---------------------------- gen/flights/waypointbuilder.py | 11 +++------- 4 files changed, 23 insertions(+), 50 deletions(-) diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 3c648259..5fe9617f 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,7 +1,9 @@ from dataclasses import dataclass, field from datetime import timedelta +from typing import Any from game.data.groundunitclass import GroundUnitClass +from game.savecompat import has_save_compat_for from game.utils import Distance, feet, nautical_miles @@ -38,9 +40,8 @@ class Doctrine: push_distance: Distance join_distance: Distance split_distance: Distance - ingress_egress_distance: Distance + ingress_distance: Distance ingress_altitude: Distance - egress_altitude: Distance min_patrol_altitude: Distance max_patrol_altitude: Distance @@ -75,6 +76,13 @@ class Doctrine: mission_ranges: MissionPlannerMaxRanges = field(default=MissionPlannerMaxRanges()) + @has_save_compat_for(5) + def __setstate__(self, state: dict[str, Any]) -> None: + if "ingress_distance" not in state: + state["ingress_distance"] = state["ingress_egress_distance"] + del state["ingress_egress_distance"] + self.__dict__.update(state) + MODERN_DOCTRINE = Doctrine( cap=True, @@ -87,9 +95,8 @@ MODERN_DOCTRINE = Doctrine( push_distance=nautical_miles(20), join_distance=nautical_miles(20), split_distance=nautical_miles(20), - ingress_egress_distance=nautical_miles(45), + ingress_distance=nautical_miles(45), ingress_altitude=feet(20000), - egress_altitude=feet(20000), min_patrol_altitude=feet(15000), max_patrol_altitude=feet(33000), pattern_altitude=feet(5000), @@ -125,9 +132,8 @@ COLDWAR_DOCTRINE = Doctrine( push_distance=nautical_miles(10), join_distance=nautical_miles(10), split_distance=nautical_miles(10), - ingress_egress_distance=nautical_miles(30), + ingress_distance=nautical_miles(30), ingress_altitude=feet(18000), - egress_altitude=feet(18000), min_patrol_altitude=feet(10000), max_patrol_altitude=feet(24000), pattern_altitude=feet(5000), @@ -163,9 +169,8 @@ WWII_DOCTRINE = Doctrine( join_distance=nautical_miles(5), split_distance=nautical_miles(5), rendezvous_altitude=feet(10000), - ingress_egress_distance=nautical_miles(7), + ingress_distance=nautical_miles(7), ingress_altitude=feet(8000), - egress_altitude=feet(8000), min_patrol_altitude=feet(4000), max_patrol_altitude=feet(15000), pattern_altitude=feet(5000), diff --git a/gen/ato.py b/gen/ato.py index da6fbf1c..944cf316 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -40,7 +40,6 @@ class Task: class PackageWaypoints: join: Point ingress: Point - egress: Point split: Point diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 0bcdd28c..92a7eb31 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -542,7 +542,6 @@ class StrikeFlightPlan(FormationFlightPlan): join: FlightWaypoint ingress: FlightWaypoint targets: List[FlightWaypoint] - egress: FlightWaypoint split: FlightWaypoint nav_from: List[FlightWaypoint] land: FlightWaypoint @@ -557,7 +556,6 @@ class StrikeFlightPlan(FormationFlightPlan): yield self.join yield self.ingress yield from self.targets - yield self.egress yield self.split yield from self.nav_from yield self.land @@ -569,7 +567,6 @@ class StrikeFlightPlan(FormationFlightPlan): def package_speed_waypoints(self) -> Set[FlightWaypoint]: return { self.ingress, - self.egress, self.split, } | set(self.targets) @@ -633,8 +630,8 @@ class StrikeFlightPlan(FormationFlightPlan): @property def split_time(self) -> timedelta: - travel_time = self.travel_time_between_waypoints(self.egress, self.split) - return self.egress_time + travel_time + travel_time = self.travel_time_between_waypoints(self.ingress, self.split) + return self.ingress_time + travel_time @property def ingress_time(self) -> timedelta: @@ -644,19 +641,9 @@ class StrikeFlightPlan(FormationFlightPlan): ) return tot - travel_time - @property - def egress_time(self) -> timedelta: - tot = self.tot - travel_time = self.travel_time_between_waypoints( - self.target_area_waypoint, self.egress - ) - return tot + travel_time - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: if waypoint == self.ingress: return self.ingress_time - elif waypoint == self.egress: - return self.egress_time elif waypoint in self.targets: return self.tot return super().tot_for_waypoint(waypoint) @@ -982,7 +969,7 @@ class FlightPlanBuilder: for join_point in self.preferred_join_points(): join_distance = meters(join_point.distance_to_point(target)) - if join_distance > self.doctrine.ingress_egress_distance: + if join_distance > self.doctrine.ingress_distance: break else: # The entire path to the target is threatened. Use the fallback behavior for @@ -995,11 +982,9 @@ class FlightPlanBuilder: # The first case described above. The ingress and join points are placed # reasonably relative to each other. - egress_point = self._egress_point(attack_heading) self.package.waypoints = PackageWaypoints( WaypointBuilder.perturb(join_point), ingress_point, - egress_point, WaypointBuilder.perturb(join_point), ) @@ -1010,13 +995,11 @@ class FlightPlanBuilder: from gen.ato import PackageWaypoints ingress_point = self._ingress_point(self._target_heading_to_package_airfield()) - egress_point = self._egress_point(self._target_heading_to_package_airfield()) join_point = self._rendezvous_point(ingress_point) split_point = self._rendezvous_point(self.package.target.position) self.package.waypoints = PackageWaypoints( join_point, ingress_point, - egress_point, split_point, ) @@ -1535,10 +1518,8 @@ class FlightPlanBuilder: assert self.package.waypoints is not None builder = WaypointBuilder(flight, self.coalition) - ingress, target, egress = builder.escort( - self.package.waypoints.ingress, - self.package.target, - self.package.waypoints.egress, + ingress, target = builder.escort( + self.package.waypoints.ingress, self.package.target ) hold = builder.hold(self._hold_point(flight)) join = builder.join(self.package.waypoints.join) @@ -1556,7 +1537,6 @@ class FlightPlanBuilder: join=join, ingress=ingress, targets=[target], - egress=egress, split=split, nav_from=builder.nav_path( split.position, flight.arrival.position, self.doctrine.ingress_altitude @@ -1804,7 +1784,6 @@ class FlightPlanBuilder: ingress_type, self.package.waypoints.ingress, location ), targets=target_waypoints, - egress=builder.egress(self.package.waypoints.egress, location), split=split, nav_from=builder.nav_path( split.position, flight.arrival.position, self.doctrine.ingress_altitude @@ -1848,7 +1827,7 @@ class FlightPlanBuilder: """Returns the position of the rendezvous point. Args: - attack_transition: The ingress or egress point for this rendezvous. + attack_transition: The ingress or target point for this rendezvous. """ if self._rendezvous_should_retreat(attack_transition): return self._retreating_rendezvous_point(attack_transition) @@ -1856,12 +1835,7 @@ class FlightPlanBuilder: def _ingress_point(self, heading: float) -> Point: return self.package.target.position.point_from_heading( - heading - 180 + 15, self.doctrine.ingress_egress_distance.meters - ) - - def _egress_point(self, heading: float) -> Point: - return self.package.target.position.point_from_heading( - heading - 180 - 15, self.doctrine.ingress_egress_distance.meters + heading - 180, self.doctrine.ingress_distance.meters ) def _target_heading_to_package_airfield(self) -> float: diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index daaea056..05ca1d93 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -427,21 +427,18 @@ class WaypointBuilder: self, ingress: Point, target: MissionTarget, - egress: Point, - ) -> Tuple[FlightWaypoint, FlightWaypoint, FlightWaypoint]: + ) -> Tuple[FlightWaypoint, FlightWaypoint]: """Creates the waypoints needed to escort the package. Args: ingress: The package ingress point. target: The mission target. - egress: The package egress point. """ # This would preferably be no points at all, and instead the Escort task # would begin on the join point and end on the split point, however the # escort task does not appear to work properly (see the longer # description in gen.aircraft.JoinPointBuilder), so instead we give - # the escort flights a flight plan including the ingress point, target - # area, and egress point. + # the escort flights a flight plan including the ingress point and target area. ingress_wp = self.ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target) waypoint = FlightWaypoint( @@ -455,9 +452,7 @@ class WaypointBuilder: waypoint.name = "TARGET" waypoint.description = "Escort the package" waypoint.pretty_name = "Target area" - - egress_wp = self.egress(egress, target) - return ingress_wp, waypoint, egress_wp + return ingress_wp, waypoint @staticmethod def pickup(control_point: ControlPoint) -> FlightWaypoint: From 8e59c99666e2cd08aff6ef748af2cea8682ebf48 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 18:43:02 -0700 Subject: [PATCH 067/167] Improve hold point placement for nearby joins. --- gen/flights/flightplan.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 92a7eb31..5a39c4a8 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -1689,11 +1689,10 @@ class FlightPlanBuilder: origin = flight.departure.position target = self.package.target.position join = self.package.waypoints.join - origin_to_target = origin.distance_to_point(target) - join_to_target = join.distance_to_point(target) - if origin_to_target < join_to_target: - # If the origin airfield is closer to the target than the join - # point, plan the hold point such that it retreats from the origin + origin_to_join = origin.distance_to_point(join) + if meters(origin_to_join) < self.doctrine.push_distance: + # If the origin airfield is closer to the join point, than the minimum push + # distance. Plan the hold point such that it retreats from the origin # airfield. return join.point_from_heading( target.heading_between_point(origin), self.doctrine.push_distance.meters From c1d3c93dbb516c01420dd5c46d62a087cd9b2b81 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 18:56:06 -0700 Subject: [PATCH 068/167] Speed up turn processing. Run the expensive precondition check (package fulfillment) last. --- game/commander/packagefulfiller.py | 3 ++- game/commander/tasks/primitive/antiship.py | 6 +++--- game/commander/tasks/primitive/antishipping.py | 6 +++--- game/commander/tasks/primitive/bai.py | 6 +++--- game/commander/tasks/primitive/barcap.py | 4 ++-- game/commander/tasks/primitive/cas.py | 4 ++-- game/commander/tasks/primitive/convoyinterdiction.py | 6 +++--- game/commander/tasks/primitive/dead.py | 6 +++--- game/commander/tasks/primitive/oca.py | 6 +++--- game/commander/tasks/primitive/strike.py | 6 +++--- 10 files changed, 27 insertions(+), 26 deletions(-) diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index be903548..d4d8352b 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -105,8 +105,9 @@ class PackageFulfiller: missing_types_str = ", ".join(sorted([t.name for t in missing_types])) builder.release_planned_aircraft() + color = "Blue" if self.is_player else "Red" logging.debug( - f"Not enough aircraft in range for {mission.location.name} " + f"{color}: not enough aircraft in range for {mission.location.name} " f"capable of: {missing_types_str}" ) diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py index 10a55477..3f85c74c 100644 --- a/game/commander/tasks/primitive/antiship.py +++ b/game/commander/tasks/primitive/antiship.py @@ -13,11 +13,11 @@ from gen.flights.flight import FlightType @dataclass class PlanAntiShip(PackagePlanningTask[NavalGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: - if not super().preconditions_met(state): - return False if self.target not in state.threatening_air_defenses: return False - return self.target_area_preconditions_met(state, ignore_iads=True) + if not self.target_area_preconditions_met(state, ignore_iads=True): + return False + return super().preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.eliminate_ship(self.target) diff --git a/game/commander/tasks/primitive/antishipping.py b/game/commander/tasks/primitive/antishipping.py index 64be2cb9..303a9af1 100644 --- a/game/commander/tasks/primitive/antishipping.py +++ b/game/commander/tasks/primitive/antishipping.py @@ -12,11 +12,11 @@ from gen.flights.flight import FlightType @dataclass class PlanAntiShipping(PackagePlanningTask[CargoShip]): def preconditions_met(self, state: TheaterState) -> bool: - if not super().preconditions_met(state): - return False if self.target not in state.enemy_shipping: return False - return self.target_area_preconditions_met(state) + if not self.target_area_preconditions_met(state): + return False + return super().preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.enemy_shipping.remove(self.target) diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py index 352aa0b4..f9d61818 100644 --- a/game/commander/tasks/primitive/bai.py +++ b/game/commander/tasks/primitive/bai.py @@ -12,11 +12,11 @@ from gen.flights.flight import FlightType @dataclass class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: - if not super().preconditions_met(state): - return False if not state.has_garrison(self.target): return False - return self.target_area_preconditions_met(state) + if not self.target_area_preconditions_met(state): + return False + return super().preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.eliminate_garrison(self.target) diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py index 8d3cf456..77302adf 100644 --- a/game/commander/tasks/primitive/barcap.py +++ b/game/commander/tasks/primitive/barcap.py @@ -12,9 +12,9 @@ from gen.flights.flight import FlightType @dataclass class PlanBarcap(PackagePlanningTask[ControlPoint]): def preconditions_met(self, state: TheaterState) -> bool: - if not super().preconditions_met(state): + if not state.barcaps_needed[self.target]: return False - return state.barcaps_needed[self.target] > 0 + return super().preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.barcaps_needed[self.target] -= 1 diff --git a/game/commander/tasks/primitive/cas.py b/game/commander/tasks/primitive/cas.py index 14255c2e..7a9997ff 100644 --- a/game/commander/tasks/primitive/cas.py +++ b/game/commander/tasks/primitive/cas.py @@ -12,9 +12,9 @@ from gen.flights.flight import FlightType @dataclass class PlanCas(PackagePlanningTask[FrontLine]): def preconditions_met(self, state: TheaterState) -> bool: - if not super().preconditions_met(state): + if self.target not in state.vulnerable_front_lines: return False - return self.target in state.vulnerable_front_lines + return super().preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.vulnerable_front_lines.remove(self.target) diff --git a/game/commander/tasks/primitive/convoyinterdiction.py b/game/commander/tasks/primitive/convoyinterdiction.py index 9026057d..11ed4ee4 100644 --- a/game/commander/tasks/primitive/convoyinterdiction.py +++ b/game/commander/tasks/primitive/convoyinterdiction.py @@ -12,11 +12,11 @@ from gen.flights.flight import FlightType @dataclass class PlanConvoyInterdiction(PackagePlanningTask[Convoy]): def preconditions_met(self, state: TheaterState) -> bool: - if not super().preconditions_met(state): - return False if self.target not in state.enemy_convoys: return False - return self.target_area_preconditions_met(state) + if not self.target_area_preconditions_met(state): + return False + return super().preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.enemy_convoys.remove(self.target) diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py index 77ca80cb..3861908c 100644 --- a/game/commander/tasks/primitive/dead.py +++ b/game/commander/tasks/primitive/dead.py @@ -13,14 +13,14 @@ from gen.flights.flight import FlightType @dataclass class PlanDead(PackagePlanningTask[IadsGroundObject]): def preconditions_met(self, state: TheaterState) -> bool: - if not super().preconditions_met(state): - return False if ( self.target not in state.threatening_air_defenses and self.target not in state.detecting_air_defenses ): return False - return self.target_area_preconditions_met(state, ignore_iads=True) + if not self.target_area_preconditions_met(state, ignore_iads=True): + return False + return super().preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.eliminate_air_defense(self.target) diff --git a/game/commander/tasks/primitive/oca.py b/game/commander/tasks/primitive/oca.py index f3d43b18..4c995f75 100644 --- a/game/commander/tasks/primitive/oca.py +++ b/game/commander/tasks/primitive/oca.py @@ -14,11 +14,11 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]): aircraft_cold_start: bool def preconditions_met(self, state: TheaterState) -> bool: - if not super().preconditions_met(state): - return False if self.target not in state.oca_targets: return False - return self.target_area_preconditions_met(state) + if not self.target_area_preconditions_met(state): + return False + return super().preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.oca_targets.remove(self.target) diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py index 7090eb86..ce322dad 100644 --- a/game/commander/tasks/primitive/strike.py +++ b/game/commander/tasks/primitive/strike.py @@ -13,11 +13,11 @@ from gen.flights.flight import FlightType @dataclass class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]): def preconditions_met(self, state: TheaterState) -> bool: - if not super().preconditions_met(state): - return False if self.target not in state.strike_targets: return False - return self.target_area_preconditions_met(state) + if not self.target_area_preconditions_met(state): + return False + return super().preconditions_met(state) def apply_effects(self, state: TheaterState) -> None: state.strike_targets.remove(self.target) From 415b8c6317e74969b0f3a5a490056017fc9ae046 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 19:59:22 -0700 Subject: [PATCH 069/167] Allow the split point to differ from the join. This places the split point based on the best path from the IP to home, rather than the best path from home to the target. The outcome is that the planner might choose an alternate route out of a threatened area based on the safest escape from the IP, which is where the aircraft should be when it releases its weapons, rather than at the target. That's of course not always perfect since the IP distance is not based on the carried weapon, but it's a better choice when it matters more (when carrying standoff weapons attacking a more dangerous target). --- gen/flights/flightplan.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 5a39c4a8..9e9fd21f 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -985,7 +985,9 @@ class FlightPlanBuilder: self.package.waypoints = PackageWaypoints( WaypointBuilder.perturb(join_point), ingress_point, - WaypointBuilder.perturb(join_point), + WaypointBuilder.perturb( + self.preferred_split_point(ingress_point, join_point) + ), ) def retreat_point(self, origin: Point) -> Point: @@ -1003,17 +1005,29 @@ class FlightPlanBuilder: split_point, ) - def preferred_join_points(self) -> Iterator[Point]: - path = self.coalition.nav_mesh.shortest_path( - self.package_airfield().position, self.package.target.position - ) - # Use non-threatened points along the path to the target (excluding the target - # itself) as the join point. We may need to try more than one in the event that - # the close non-threatened points are closer than the ingress point itself. - for point in reversed(path[:-1]): + def safe_points_between(self, a: Point, b: Point) -> Iterator[Point]: + for point in self.coalition.nav_mesh.shortest_path(a, b)[1:]: if not self.threat_zones.threatened(point): yield point + def preferred_join_points(self) -> Iterator[Point]: + # Use non-threatened points along the path to the target as the join point. We + # may need to try more than one in the event that the close non-threatened + # points are closer than the ingress point itself. + return self.safe_points_between( + self.package.target.position, self.package_airfield().position + ) + + def preferred_split_point(self, ingress_point: Point, join_point: Point) -> Point: + # Use non-threatened points along the path to the target as the join point. We + # may need to try more than one in the event that the close non-threatened + # points are closer than the ingress point itself. + for point in self.safe_points_between( + ingress_point, self.package_airfield().position + ): + return point + return join_point + def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. From 076df7cf6676e2a26e3cfe25593bd18fbc1a5f6b Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 20:07:42 -0700 Subject: [PATCH 070/167] Fix placement of fallback split points. Using the target placed the split in the threatened area. --- gen/flights/flightplan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 9e9fd21f..14e37757 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -998,7 +998,7 @@ class FlightPlanBuilder: ingress_point = self._ingress_point(self._target_heading_to_package_airfield()) join_point = self._rendezvous_point(ingress_point) - split_point = self._rendezvous_point(self.package.target.position) + split_point = self._rendezvous_point(ingress_point) self.package.waypoints = PackageWaypoints( join_point, ingress_point, From 7c3e08050fe326e7cfd19f66ab785dff37a99a77 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 20:10:29 -0700 Subject: [PATCH 071/167] Perturb fallback join/split points. Otherwise they're placed exactly on top of each other, which makes the map harder to read. --- gen/flights/flightplan.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 14e37757..ef642b8e 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -998,11 +998,10 @@ class FlightPlanBuilder: ingress_point = self._ingress_point(self._target_heading_to_package_airfield()) join_point = self._rendezvous_point(ingress_point) - split_point = self._rendezvous_point(ingress_point) self.package.waypoints = PackageWaypoints( - join_point, + WaypointBuilder.perturb(join_point), ingress_point, - split_point, + WaypointBuilder.perturb(join_point), ) def safe_points_between(self, a: Point, b: Point) -> Iterator[Point]: From a7d49b986d44f936f32c28ff9426c8cf2947a50d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 20:11:09 -0700 Subject: [PATCH 072/167] Exclude both path endpoints for joins/splits. Without this the points would often be placed exactly on top of the origin airfield, when in that case we actually should use the fallback behavior. --- gen/flights/flightplan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index ef642b8e..0927b968 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -1005,7 +1005,7 @@ class FlightPlanBuilder: ) def safe_points_between(self, a: Point, b: Point) -> Iterator[Point]: - for point in self.coalition.nav_mesh.shortest_path(a, b)[1:]: + for point in self.coalition.nav_mesh.shortest_path(a, b)[1:-1]: if not self.threat_zones.threatened(point): yield point From 9bbcee645eb3c581ff26a41075c2f3fd5a3a7b5f Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 20:18:30 -0700 Subject: [PATCH 073/167] Cleanup and document some of Doctrine. --- game/data/doctrine.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 5fe9617f..4f944833 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -36,11 +36,20 @@ class Doctrine: antiship: bool rendezvous_altitude: Distance + + #: The minimum distance between the departure airfield and the hold point. hold_distance: Distance + + #: The minimum distance between the hold point and the join point. push_distance: Distance + + #: The distance between the join point and the ingress point. Only used for the + #: fallback flight plan layout (when the departure airfield is near a threat zone). join_distance: Distance - split_distance: Distance + + #: The distance between the ingress point (beginning of the attack) and target. ingress_distance: Distance + ingress_altitude: Distance min_patrol_altitude: Distance @@ -94,7 +103,6 @@ MODERN_DOCTRINE = Doctrine( hold_distance=nautical_miles(15), push_distance=nautical_miles(20), join_distance=nautical_miles(20), - split_distance=nautical_miles(20), ingress_distance=nautical_miles(45), ingress_altitude=feet(20000), min_patrol_altitude=feet(15000), @@ -131,7 +139,6 @@ COLDWAR_DOCTRINE = Doctrine( hold_distance=nautical_miles(10), push_distance=nautical_miles(10), join_distance=nautical_miles(10), - split_distance=nautical_miles(10), ingress_distance=nautical_miles(30), ingress_altitude=feet(18000), min_patrol_altitude=feet(10000), @@ -167,7 +174,6 @@ WWII_DOCTRINE = Doctrine( hold_distance=nautical_miles(5), push_distance=nautical_miles(5), join_distance=nautical_miles(5), - split_distance=nautical_miles(5), rendezvous_altitude=feet(10000), ingress_distance=nautical_miles(7), ingress_altitude=feet(8000), From 9177588220477e4544c9bfec0d2927a0dcd01a86 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 13 Jul 2021 20:49:31 -0700 Subject: [PATCH 074/167] Don't target ammo depots at inactive front lines. --- game/commander/tasks/compound/attackbuildings.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/game/commander/tasks/compound/attackbuildings.py b/game/commander/tasks/compound/attackbuildings.py index fe80dbf0..21e9a652 100644 --- a/game/commander/tasks/compound/attackbuildings.py +++ b/game/commander/tasks/compound/attackbuildings.py @@ -7,5 +7,9 @@ from game.htn import CompoundTask, Method class AttackBuildings(CompoundTask[TheaterState]): def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - for garrison in state.strike_targets: - yield [PlanStrike(garrison)] + for building in state.strike_targets: + # Ammo depots are targeted based on the needs of the front line by + # ReduceEnemyFrontLineCapacity. No reason to target them before that front + # line is active. + if not building.is_ammo_depot: + yield [PlanStrike(building)] From 7648716199649c205d32584cb2560b07894b2285 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 14 Jul 2021 00:57:57 -0700 Subject: [PATCH 075/167] Make weapon groups explicit and moddable. The only parts of the old weapon data that worked well (didn't commonly result in empty pylons) did this implicitly, so make the grouping explicit. This also moves the data out of Python and into the resources, which both makes the data moddable and isolates us from a huge amount of effort and a save compat break whenever ED changes weapon names. I didn't auto migrate the old data since the old groups were not explict and there's no way to infer the grouping. Besides, since most of the weapons were *not* grouped, the old data did more harm than good in my experience. I've handled the AIM-120 and AIM-7 for now, but will get at least all the fox 3 missiles before we ship. --- changelog.md | 1 + game/data/weapons.py | 1257 +++-------------- gen/flights/loadouts.py | 4 +- qt_ui/main.py | 15 +- .../mission/flight/payload/QPylonEditor.py | 2 +- .../weapons/a2a-missiles/AIM-120B-2X.yaml | 5 + resources/weapons/a2a-missiles/AIM-120B.yaml | 7 + .../weapons/a2a-missiles/AIM-120C-2X.yaml | 5 + resources/weapons/a2a-missiles/AIM-120C.yaml | 7 + resources/weapons/a2a-missiles/AIM-7E.yaml | 5 + resources/weapons/a2a-missiles/AIM-7F.yaml | 8 + resources/weapons/a2a-missiles/AIM-7M.yaml | 8 + resources/weapons/a2a-missiles/AIM-7MH.yaml | 8 + resources/weapons/pods/atflir.yaml | 4 + resources/weapons/pods/litening.yaml | 5 + 15 files changed, 234 insertions(+), 1107 deletions(-) create mode 100644 resources/weapons/a2a-missiles/AIM-120B-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-120B.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-120C-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-120C.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-7E.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-7F.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-7M.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-7MH.yaml create mode 100644 resources/weapons/pods/atflir.yaml create mode 100644 resources/weapons/pods/litening.yaml diff --git a/changelog.md b/changelog.md index ce1855ac..9be996fd 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ Saves from 3.x are not compatible with 5.0. ## Features/Improvements +* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. diff --git a/game/data/weapons.py b/game/data/weapons.py index 50042a1b..5d0b0dd1 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -3,74 +3,197 @@ from __future__ import annotations import datetime import inspect import logging -from collections import defaultdict from dataclasses import dataclass, field -from typing import Dict, Iterator, Optional, Set, Tuple, cast, Any +from functools import cached_property +from pathlib import Path +from typing import Iterator, Optional, Any, ClassVar +import yaml from dcs.unitgroup import FlyingGroup -from dcs.weapons_data import Weapons, weapon_ids +from dcs.weapons_data import weapon_ids from game.dcs.aircrafttype import AircraftType PydcsWeapon = Any -PydcsWeaponAssignment = Tuple[int, PydcsWeapon] +PydcsWeaponAssignment = tuple[int, PydcsWeapon] @dataclass(frozen=True) class Weapon: - """Wraps a pydcs weapon dict in a hashable type.""" + """Wrapper for DCS weapons.""" - cls_id: str - name: str = field(compare=False) - weight: int = field(compare=False) + #: The CLSID used by DCS. + clsid: str + + #: The group this weapon belongs to. + weapon_group: WeaponGroup = field(compare=False) + + _by_clsid: ClassVar[dict[str, Weapon]] = {} + _loaded: ClassVar[bool] = False + + def __str__(self) -> str: + return self.name + + @cached_property + def pydcs_data(self) -> PydcsWeapon: + if self.clsid == "": + # Special case for a "weapon" that isn't exposed by pydcs. + return { + "clsid": self.clsid, + "name": "Clean", + "weight": 0, + } + return weapon_ids[self.clsid] + + @property + def name(self) -> str: + return self.pydcs_data["name"] + + def __setstate__(self, state: dict[str, Any]) -> None: + # Update any existing models with new data on load. + updated = Weapon.with_clsid(state["clsid"]) + state.update(updated.__dict__) + self.__dict__.update(state) + + @classmethod + def register(cls, weapon: Weapon) -> None: + if weapon.clsid in cls._by_clsid: + duplicate = cls._by_clsid[weapon.clsid] + raise ValueError( + "Weapon CLSID used in more than one weapon type: " + f"{duplicate.name} and {weapon.name}" + ) + cls._by_clsid[weapon.clsid] = weapon + + @classmethod + def with_clsid(cls, clsid: str) -> Weapon: + if not cls._loaded: + cls._load_all() + return cls._by_clsid[clsid] + + @classmethod + def _load_all(cls) -> None: + WeaponGroup.load_all() + cls._loaded = True def available_on(self, date: datetime.date) -> bool: - introduction_year = WEAPON_INTRODUCTION_YEARS.get(self) + introduction_year = self.weapon_group.introduction_year if introduction_year is None: - logging.warning( - f"No introduction year for {self}, assuming always available" - ) return True return date >= datetime.date(introduction_year, 1, 1) - @property - def as_pydcs(self) -> PydcsWeapon: - return { - "clsid": self.cls_id, - "name": self.name, - "weight": self.weight, - } - @property def fallbacks(self) -> Iterator[Weapon]: yield self - fallback = WEAPON_FALLBACK_MAP[self] - if fallback is not None: - yield from fallback.fallbacks + fallback: Optional[WeaponGroup] = self.weapon_group + while fallback is not None: + yield from fallback.weapons + fallback = fallback.fallback - @classmethod - def from_pydcs(cls, weapon_data: PydcsWeapon) -> Weapon: - return cls( - cast(str, weapon_data["clsid"]), - cast(str, weapon_data["name"]), - cast(int, weapon_data["weight"]), - ) - @classmethod - def from_clsid(cls, clsid: str) -> Optional[Weapon]: - data = weapon_ids.get(clsid) - if clsid == "": - # Special case for a "weapon" that isn't exposed by pydcs. - return Weapon(clsid, "Clean", 0) - if data is None: +@dataclass(frozen=True) +class WeaponGroup: + """Group of "identical" weapons loaded from resources/weapons. + + DCS has multiple unique "weapons" for each type of weapon. There are four distinct + class IDs for the AIM-7M, some unique to certain aircraft. We group them in the + resources to make year/fallback data easier to track. + """ + + #: The name of the weapon group in the resource file. + name: str = field(compare=False) + + #: The year of introduction. + introduction_year: Optional[int] = field(compare=False) + + #: The name of the fallback weapon group. + fallback_name: Optional[str] = field(compare=False) + + #: The specific weapons that belong to this weapon group. + weapons: list[Weapon] = field(init=False, default_factory=list) + + _by_name: ClassVar[dict[str, WeaponGroup]] = {} + _loaded: ClassVar[bool] = False + + def __str__(self) -> str: + return self.name + + @property + def fallback(self) -> Optional[WeaponGroup]: + if self.fallback_name is None: return None - return cls.from_pydcs(data) + return WeaponGroup.named(self.fallback_name) + + def __setstate__(self, state: dict[str, Any]) -> None: + # Update any existing models with new data on load. + updated = WeaponGroup.named(state["name"]) + state.update(updated.__dict__) + self.__dict__.update(state) + + @classmethod + def register(cls, group: WeaponGroup) -> None: + if group.name in cls._by_name: + duplicate = cls._by_name[group.name] + raise ValueError( + "Weapon group name used in more than one weapon type: " + f"{duplicate.name} and {group.name}" + ) + cls._by_name[group.name] = group + + @classmethod + def named(cls, name: str) -> WeaponGroup: + if not cls._loaded: + cls.load_all() + return cls._by_name[name] + + @classmethod + def _each_weapon_group(cls) -> Iterator[WeaponGroup]: + for group_file_path in Path("resources/weapons").glob("**/*.yaml"): + with group_file_path.open(encoding="utf8") as group_file: + data = yaml.safe_load(group_file) + name = data["name"] + year = data.get("year") + fallback_name = data.get("fallback") + group = WeaponGroup(name, year, fallback_name) + for clsid in data["clsids"]: + weapon = Weapon(clsid, group) + Weapon.register(weapon) + group.weapons.append(weapon) + yield group + + @classmethod + def register_clean_pylon(cls) -> None: + group = WeaponGroup("Clean pylon", introduction_year=None, fallback_name=None) + cls.register(group) + weapon = Weapon("", group) + Weapon.register(weapon) + group.weapons.append(weapon) + + @classmethod + def register_unknown_weapons(cls, seen_clsids: set[str]) -> None: + unknown_weapons = set(weapon_ids.keys()) - seen_clsids + group = WeaponGroup("Unknown", introduction_year=None, fallback_name=None) + cls.register(group) + for clsid in unknown_weapons: + weapon = Weapon(clsid, group) + Weapon.register(weapon) + group.weapons.append(weapon) + + @classmethod + def load_all(cls) -> None: + seen_clsids: set[str] = set() + for group in cls._each_weapon_group(): + cls.register(group) + seen_clsids.update(w.clsid for w in group.weapons) + cls.register_clean_pylon() + cls.register_unknown_weapons(seen_clsids) + cls._loaded = True @dataclass(frozen=True) class Pylon: number: int - allowed: Set[Weapon] + allowed: set[Weapon] def can_equip(self, weapon: Weapon) -> bool: # TODO: Fix pydcs to support the "weapon". @@ -81,7 +204,7 @@ class Pylon: # A similar hack exists in QPylonEditor to forcibly add "Clean" to the list of # valid configurations for that pylon if a loadout has been seen with that # configuration. - return weapon in self.allowed or weapon.cls_id == "" + return weapon in self.allowed or weapon.clsid == "" def equip(self, group: FlyingGroup[Any], weapon: Weapon) -> None: if not self.can_equip(weapon): @@ -89,7 +212,7 @@ class Pylon: group.load_pylon(self.make_pydcs_assignment(weapon), self.number) def make_pydcs_assignment(self, weapon: Weapon) -> PydcsWeaponAssignment: - return self.number, weapon.as_pydcs + return self.number, weapon.pydcs_data def available_on(self, date: datetime.date) -> Iterator[Weapon]: for weapon in self.allowed: @@ -116,7 +239,7 @@ class Pylon: pylon_number, weapon = value if pylon_number != number: continue - allowed.add(Weapon.from_pydcs(weapon)) + allowed.add(Weapon.with_clsid(weapon["clsid"])) return cls(number, allowed) @@ -124,1053 +247,3 @@ class Pylon: def iter_pylons(cls, aircraft: AircraftType) -> Iterator[Pylon]: for pylon in sorted(list(aircraft.dcs_unit_type.pylons)): yield cls.for_aircraft(aircraft, pylon) - - -_WEAPON_FALLBACKS = [ - # ADM-141 TALD - (Weapons.ADM_141A, None), - (Weapons.ADM_141A_, None), - (Weapons.ADM_141A_TALD, None), - (Weapons.ADM_141B_TALD, None), - # AGM-114K Hellfire - (Weapons.AGM114x2_OH_58, Weapons.M260_HYDRA), # assuming OH-58 and not MQ-9 - (Weapons.AGM_114K, None), # Only for RQ-1 - (Weapons.AGM_114K___4, Weapons.LAU_61_pod___19_x_2_75_Hydra__UnGd_Rkts_M151__HE), - # AGM-119 Penguin - (Weapons.AGM_119B_Penguin_ASM, Weapons.Mk_82), - # AGM-122 Sidearm - (Weapons.AGM_122_Sidearm, Weapons.GBU_12), # outer pylons harrier - ( - Weapons.AGM_122_Sidearm_, - Weapons.LAU_117_with_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_, - ), # internal pylons harrier - # AGM-154 JSOW - ( - Weapons.AGM_154A___JSOW_CEB__CBU_type_, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), - ( - Weapons.BRU_55_with_2_x_AGM_154A___JSOW_CEB__CBU_type_, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), - ( - Weapons.BRU_57_with_2_x_AGM_154A___JSOW_CEB__CBU_type_, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), # doesn't exist on any aircraft yet - (Weapons.AGM_154B___JSOW_Anti_Armour, Weapons.CBU_105___10_x_SFW__CBU_with_WCMD), - ( - Weapons.AGM_154C___JSOW_Unitary_BROACH, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), - ( - Weapons.BRU_55_with_2_x_AGM_154C___JSOW_Unitary_BROACH, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), - # AGM-45 Shrike - (Weapons.AGM_45A_Shrike_ARM, None), - (Weapons.LAU_118a_with_AGM_45B_Shrike_ARM__Imp_, Weapons.AGM_45A_Shrike_ARM), - (Weapons.AGM_45B_Shrike_ARM__Imp_, Weapons.AGM_45A_Shrike_ARM), - # AGM-62 Walleye - (Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, Weapons.Mk_84), - # AGM-65 Maverick - ( - Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), # Walleye is the predecessor to the maverick - (Weapons.LAU_117_with_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_, None), - (Weapons.LAU_117_AGM_65F, Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_), - (Weapons.LAU_117_AGM_65G, Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_), - (Weapons.LAU_117_AGM_65H, Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_), - ( - Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_, - Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_, - ), - (Weapons.LAU_117_AGM_65L, None), - (Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_, None), - (Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, None), - (Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_, None), - (Weapons.LAU_88_AGM_65D_ONE, None), - ( - Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_, - ), - ( - Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd__, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, - ), - ( - Weapons.LAU_88_with_3_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_, - Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_, - ), - (Weapons.LAU_88_AGM_65H, Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_), - ( - Weapons.LAU_88_AGM_65H_2_L, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, - ), - ( - Weapons.LAU_88_AGM_65H_2_R, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, - ), - (Weapons.LAU_88_AGM_65H_3, Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_), - ( - Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM_, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_, - ), - ( - Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM__, - Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__, - ), - ( - Weapons.LAU_88_with_3_x_AGM_65K___Maverick_K__CCD_Imp_ASM_, - Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_, - ), - # AGM-84 Harpoon - (Weapons.AGM_84A_Harpoon_ASM, Weapons.Mk_82), - (Weapons._8_x_AGM_84A_Harpoon_ASM, Weapons._27_x_Mk_82___500lb_GP_Bombs_LD), - ( - Weapons.AGM_84D_Harpoon_AShM, - Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, - ), - ( - Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile_, - Weapons.LAU_117_AGM_65F, - ), - ( - Weapons.AGM_84H_SLAM_ER__Expanded_Response_, - Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile_, - ), - # AGM-86 ALCM - (Weapons.AGM_86C_ALCM, Weapons._27_x_Mk_82___500lb_GP_Bombs_LD), - (Weapons._8_x_AGM_86C_ALCM, Weapons._27_x_Mk_82___500lb_GP_Bombs_LD), - ( - Weapons._6_x_AGM_86C_ALCM_on_MER, - Weapons.MER12_with_12_x_Mk_82___500lb_GP_Bombs_LD, - ), - # AGM-88 HARM - ( - Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile, - Weapons.LAU_88_AGM_65D_ONE, - ), - ( - Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile_, - Weapons.LAU_88_AGM_65D_ONE, - ), - # AIM-120 AMRAAM - (Weapons.AIM_120B_AMRAAM___Active_Rdr_AAM, Weapons.AIM_7MH), - ( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM, - Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar, - ), - ( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM_, - Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar, - ), - ( - Weapons.LAU_115_2_LAU_127_AIM_120B, - Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar, - ), - ( - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, - Weapons.AIM_120B_AMRAAM___Active_Rdr_AAM, - ), - ( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120C_5_AMRAAM___Active_Rdr_AAM, - Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM, - ), - ( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120C_5_AMRAAM___Active_Rdr_AAM_, - Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM_, - ), - (Weapons.LAU_115_2_LAU_127_AIM_120C, Weapons.LAU_115_2_LAU_127_AIM_120B), - # AIM-54 Phoenix - (Weapons.AIM_54A_Mk47, None), - (Weapons.AIM_54A_Mk47_, None), - (Weapons.AIM_54A_Mk47__, None), - (Weapons.AIM_54A_Mk60, Weapons.AIM_54A_Mk47), - (Weapons.AIM_54A_Mk60_, Weapons.AIM_54A_Mk47_), - (Weapons.AIM_54A_Mk60__, Weapons.AIM_54A_Mk47__), - (Weapons.AIM_54C_Mk47, Weapons.AIM_54A_Mk60), - (Weapons.AIM_54C_Mk47_, Weapons.AIM_54A_Mk60_), - (Weapons.AIM_54C_Mk47__, Weapons.AIM_54A_Mk60__), - # AIM-7 Sparrow - (Weapons.AIM_7E_Sparrow_Semi_Active_Radar, None), - ( - Weapons.AIM_7F_Sparrow_Semi_Active_Radar, - Weapons.AIM_7E_Sparrow_Semi_Active_Radar, - ), - (Weapons.AIM_7F_, None), - (Weapons.AIM_7M, Weapons.AIM_7F_Sparrow_Semi_Active_Radar), - (Weapons.AIM_7M_, Weapons.AIM_7F_), - (Weapons.AIM_7MH, Weapons.AIM_7M), - (Weapons.AIM_7MH_, Weapons.AIM_7M_), - (Weapons.LAU_115C_with_AIM_7F_Sparrow_Semi_Active_Radar, None), - ( - Weapons.LAU_115_with_AIM_7M_Sparrow_Semi_Active_Radar, - Weapons.LAU_115C_with_AIM_7F_Sparrow_Semi_Active_Radar, - ), - ( - Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar, - Weapons.LAU_115_with_AIM_7M_Sparrow_Semi_Active_Radar, - ), - (Weapons.LAU_115C_with_AIM_7E_Sparrow_Semi_Active_Radar, None), - # AIM-9 Sidewinder - (Weapons.AIM_9M_Sidewinder_IR_AAM, Weapons.AIM_9P5_Sidewinder_IR_AAM), - (Weapons.AIM_9P5_Sidewinder_IR_AAM, Weapons.AIM_9P_Sidewinder_IR_AAM), - (Weapons.AIM_9P_Sidewinder_IR_AAM, Weapons.AIM_9L_Sidewinder_IR_AAM), - (Weapons.AIM_9X_Sidewinder_IR_AAM, Weapons.AIM_9P_Sidewinder_IR_AAM), - (Weapons.LAU_105_1_AIM_9L_L, None), - (Weapons.LAU_105_1_AIM_9L_R, None), - (Weapons.LAU_105_1_AIM_9M_L, Weapons.LAU_105_1_AIM_9L_L), - (Weapons.LAU_105_1_AIM_9M_R, Weapons.LAU_105_1_AIM_9L_R), - (Weapons.LAU_105_2_AIM_9L, None), - (Weapons.LAU_105_2_AIM_9P5, Weapons.LAU_105_with_2_x_AIM_9P_Sidewinder_IR_AAM), - (Weapons.LAU_105_with_2_x_AIM_9M_Sidewinder_IR_AAM, Weapons.LAU_105_2_AIM_9L), - ( - Weapons.LAU_105_with_2_x_AIM_9P_Sidewinder_IR_AAM, - Weapons.LAU_105_with_2_x_AIM_9M_Sidewinder_IR_AAM, - ), - (Weapons.LAU_115_2_LAU_127_AIM_9L, None), - (Weapons.LAU_115_2_LAU_127_AIM_9M, Weapons.LAU_115_2_LAU_127_AIM_9L), - (Weapons.LAU_115_2_LAU_127_AIM_9X, Weapons.LAU_115_2_LAU_127_AIM_9M), - (Weapons.LAU_115_LAU_127_AIM_9L, None), - (Weapons.LAU_115_LAU_127_AIM_9M, Weapons.LAU_115_LAU_127_AIM_9L), - (Weapons.LAU_115_LAU_127_AIM_9X, Weapons.LAU_115_LAU_127_AIM_9M), - (Weapons.LAU_127_AIM_9L, None), - (Weapons.LAU_127_AIM_9M, Weapons.LAU_127_AIM_9L), - (Weapons.LAU_127_AIM_9X, Weapons.LAU_127_AIM_9M), - (Weapons.LAU_138_AIM_9L, None), - (Weapons.LAU_138_AIM_9M, Weapons.LAU_138_AIM_9L), - (Weapons.LAU_7_AIM_9L, None), - (Weapons.LAU_7_AIM_9M, Weapons.LAU_7_AIM_9L), - ( - Weapons.LAU_7_with_AIM_9M_Sidewinder_IR_AAM, - Weapons.LAU_7_with_AIM_9P5_Sidewinder_IR_AAM, - ), - ( - Weapons.LAU_7_with_AIM_9P5_Sidewinder_IR_AAM, - Weapons.LAU_7_with_AIM_9P_Sidewinder_IR_AAM, - ), - (Weapons.LAU_7_with_AIM_9P_Sidewinder_IR_AAM, Weapons.LAU_7_AIM_9L), - ( - Weapons.LAU_7_with_AIM_9X_Sidewinder_IR_AAM, - Weapons.LAU_7_with_AIM_9M_Sidewinder_IR_AAM, - ), - ( - Weapons.LAU_7_with_2_x_AIM_9M_Sidewinder_IR_AAM, - Weapons.LAU_7_with_2_x_AIM_9P5_Sidewinder_IR_AAM, - ), - ( - Weapons.LAU_7_with_2_x_AIM_9P5_Sidewinder_IR_AAM, - Weapons.LAU_7_with_2_x_AIM_9P_Sidewinder_IR_AAM, - ), - ( - Weapons.LAU_7_with_2_x_AIM_9P_Sidewinder_IR_AAM, - Weapons.LAU_7_with_2_x_AIM_9L_Sidewinder_IR_AAM, - ), - # ALQ ECM Pods - (Weapons.ALQ_131___ECM_Pod, None), - (Weapons.ALQ_184, Weapons.ALQ_131___ECM_Pod), - (Weapons.AN_ALQ_164_DECM_Pod, None), - # TGP Pods - (Weapons.AN_AAQ_28_LITENING___Targeting_Pod_, None), - (Weapons.AN_AAQ_28_LITENING___Targeting_Pod, Weapons.Lantirn_F_16), - (Weapons.AN_ASQ_228_ATFLIR___Targeting_Pod, None), - (Weapons.AN_ASQ_173_Laser_Spot_Tracker_Strike_CAMera__LST_SCAM_, None), - (Weapons.AWW_13_DATALINK_POD, None), - (Weapons.LANTIRN_Targeting_Pod, None), - (Weapons.Lantirn_F_16, None), - (Weapons.Lantirn_Target_Pod, None), - (Weapons.Pavetack_F_111, None), - # BLU-107 - (Weapons.BLU_107___440lb_Anti_Runway_Penetrator_Bomb, None), - ( - Weapons.MER6_with_6_x_BLU_107___440lb_Anti_Runway_Penetrator_Bombs, - Weapons.MER6_with_6_x_Mk_82___500lb_GP_Bombs_LD, - ), - # GBU-10 LGB - (Weapons.DIS_GBU_10, Weapons.Mk_84), - (Weapons.GBU_10, Weapons.Mk_84), - (Weapons.BRU_42_with_2_x_GBU_10___2000lb_Laser_Guided_Bombs, Weapons.Mk_84), - (Weapons.DIS_GBU_10, Weapons.Mk_84), - # GBU-12 LGB - (Weapons.AUF2_GBU_12_x_2, None), - ( - Weapons.BRU_33_with_2_x_GBU_12___500lb_Laser_Guided_Bomb, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - (Weapons.BRU_42_3_GBU_12, Weapons._3_Mk_82), - (Weapons.DIS_GBU_12, Weapons.Mk_82), - ( - Weapons.DIS_GBU_12_DUAL_GDJ_II19_L, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.DIS_GBU_12_DUAL_GDJ_II19_R, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - (Weapons.GBU_12, Weapons.Mk_82), - ( - Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb_, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD_, - ), - (Weapons._2_GBU_12, Weapons._2_Mk_82), - (Weapons._2_GBU_12_, Weapons._2_Mk_82_), - # GBU-16 LGB - (Weapons.BRU_33_with_2_x_GBU_16___1000lb_Laser_Guided_Bomb, None), - (Weapons.DIS_GBU_16, Weapons.Mk_83), - (Weapons.GBU_16, Weapons.Mk_83), - (Weapons.BRU_42_with_3_x_GBU_16___1000lb_Laser_Guided_Bombs, None), - # GBU-24 LGB - (Weapons.GBU_24, Weapons.GBU_10), - ( - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb, - Weapons.GBU_16___1000lb_Laser_Guided_Bomb, - ), - ( - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb_, - Weapons.GBU_10___2000lb_Laser_Guided_Bomb, - ), - # GBU-27 LGB - ( - Weapons.GBU_27___2000lb_Laser_Guided_Penetrator_Bomb, - Weapons.GBU_16___1000lb_Laser_Guided_Bomb, - ), - # GBU-28 LGB - (Weapons.GBU_28___5000lb_Laser_Guided_Penetrator_Bomb, None), - # GBU-31 JDAM - (Weapons.GBU_31V3B_8, Weapons.B_1B_Mk_84_8), - (Weapons.GBU_31_8, Weapons.B_1B_Mk_84_8), - ( - Weapons.GBU_31_V_1_B___JDAM__2000lb_GPS_Guided_Bomb, - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb, - ), - ( - Weapons.GBU_31_V_2_B___JDAM__2000lb_GPS_Guided_Bomb, - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb, - ), - ( - Weapons.GBU_31_V_3_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb, - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb, - ), - ( - Weapons.GBU_31_V_4_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb, - Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb, - ), - # GBU-32 JDAM - (Weapons.GBU_32_V_2_B___JDAM__1000lb_GPS_Guided_Bomb, Weapons.GBU_16), - # GBU-32 JDAM - ( - Weapons.BRU_55_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.BRU_57_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb, - None, - ), # Doesn't exist - (Weapons.GBU_38___JDAM__500lb_GPS_Guided_Bomb, Weapons.Mk_82), - (Weapons.GBU_38_16, Weapons.MK_82_28), - (Weapons._2_GBU_38, Weapons._2_Mk_82), - (Weapons._2_GBU_38_, Weapons._2_Mk_82_), - (Weapons._3_GBU_38, Weapons._3_Mk_82), - # GBU-54 LJDAM - ( - Weapons.GBU_54B___LJDAM__500lb_Laser__GPS_Guided_Bomb_LD, - Weapons.GBU_38___JDAM__500lb_GPS_Guided_Bomb, - ), - (Weapons._2_GBU_54_V_1_B, Weapons._2_GBU_38), - (Weapons._2_GBU_54_V_1_B_, Weapons._2_GBU_38_), - (Weapons._3_GBU_54_V_1_B, Weapons._3_GBU_38), - # CBU-52 - (Weapons.CBU_52B___220_x_HE_Frag_bomblets, None), - # CBU-87 CEM - (Weapons.CBU_87___202_x_CEM_Cluster_Bomb, Weapons.Mk_82), - ( - Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb_, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.TER_9A_with_3_x_CBU_87___202_x_CEM_Cluster_Bomb, - Weapons.TER_9A_with_3_x_Mk_82___500lb_GP_Bomb_LD, - ), - # CBU-97 - (Weapons.CBU_97___10_x_SFW_Cluster_Bomb, Weapons.Mk_82), - ( - Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD, - ), - ( - Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb_, - Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD_, - ), - ( - Weapons.TER_9A_with_3_x_CBU_97___10_x_SFW_Cluster_Bomb, - Weapons.TER_9A_with_3_x_Mk_82___500lb_GP_Bomb_LD, - ), - # CBU-99 (It's a bomb made in 1968, I'm not bothering right now with backups) - # CBU-103 - ( - Weapons.CBU_103___202_x_CEM__CBU_with_WCMD, - Weapons.CBU_87___202_x_CEM_Cluster_Bomb, - ), - # CBU-105 - (Weapons.CBU_105___10_x_SFW__CBU_with_WCMD, Weapons.CBU_97___10_x_SFW_Cluster_Bomb), - ( - Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS, - Weapons.LAU_131_pod___7_x_2_75_Hydra__UnGd_Rkts_M151__HE, - ), - ( - Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS, - Weapons.LAU_131_pod___7_x_2_75_Hydra__UnGd_Rkts_M151__HE, - ), - ( - Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS, - Weapons.BRU_42_with_3_x_LAU_68_pods___21_x_2_75_Hydra__UnGd_Rkts_M151__HE, - ), - ( - Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS, - Weapons.BRU_42_with_3_x_LAU_68_pods___21_x_2_75_Hydra__UnGd_Rkts_M151__HE, - ), - # Russia - # KAB-1500 - (Weapons.KAB_1500Kr___1500kg_TV_Guided_Bomb, None), - ( - Weapons.KAB_1500LG_Pr___1500kg_Laser_Guided_Penetrator_Bomb, - Weapons.KAB_1500Kr___1500kg_TV_Guided_Bomb, - ), - ( - Weapons.KAB_1500L___1500kg_Laser_Guided_Bomb, - Weapons.KAB_1500LG_Pr___1500kg_Laser_Guided_Penetrator_Bomb, - ), - # KAB-500 - (Weapons.KAB_500Kr___500kg_TV_Guided_Bomb, Weapons.FAB_500_M_62___500kg_GP_Bomb_LD), - ( - Weapons.KAB_500LG___500kg_Laser_Guided_Bomb, - Weapons.KAB_500Kr___500kg_TV_Guided_Bomb, - ), - ( - Weapons.KAB_500S___500kg_GPS_Guided_Bomb, - Weapons.KAB_500LG___500kg_Laser_Guided_Bomb, - ), - # KH Series - (Weapons.Kh_22__AS_4_Kitchen____1000kg__AShM__IN__Act_Pas_Rdr, None), - (Weapons.Kh_23L_Grom__AS_7_Kerry____286kg__ASM__Laser_Guided, None), - (Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser, None), - (Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser_, None), - (Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser__, None), - (Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, None), - ( - Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr, - Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, - ), - (Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__10km__RC_Guided, None), - (Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided, None), - (Weapons.Kh_28__AS_9_Kyle____720kg__ARM__Pas_Rdr, None), - ( - Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser, - Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser, - ), - ( - Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser_, - Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser_, - ), - ( - Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser__, - Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser__, - ), - ( - Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided, - Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__10km__RC_Guided, - ), - ( - Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided_, - Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided, - ), - ( - Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided_, - Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided, - ), - (Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr, None), - (Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr_, None), - (Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr__, None), - ( - Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr, - Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, - ), - ( - Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr_, - Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, - ), - ( - Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr__, - Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr, - ), - ( - Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr, - Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr, - ), - ( - Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr_, - Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr_, - ), - (Weapons._6_x_Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr, None), - (Weapons.Kh_41__SS_N_22_Sunburn____4500kg__AShM__IN__Act_Rdr, None), - ( - Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr, - Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr, - ), - ( - Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr_, - Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr_, - ), - ( - Weapons.Kh_59M__AS_18_Kazoo____930kg__ASM__IN, - Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr, - ), - (Weapons.Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC, None), - (Weapons._6_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC, None), - (Weapons._8_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC, None), - (Weapons.Kh_66_Grom__21__APU_68, None), - # ECM - (Weapons.L175V_Khibiny_ECM_pod, None), - # R-13 - (Weapons.R_13M, None), - (Weapons.R_13M1, Weapons.R_13M), - # R-24 - (Weapons.R_24R__AA_7_Apex_SA____Semi_Act_Rdr, None), - (Weapons.R_24T__AA_7_Apex_IR____Infra_Red, None), - # R-27 - ( - Weapons.R_27T__AA_10_Alamo_B____Infra_Red, - Weapons.R_24T__AA_7_Apex_IR____Infra_Red, - ), - ( - Weapons.R_27R__AA_10_Alamo_A____Semi_Act_Rdr, - Weapons.R_24R__AA_7_Apex_SA____Semi_Act_Rdr, - ), - ( - Weapons.R_27ER__AA_10_Alamo_C____Semi_Act_Extended_Range, - Weapons.R_27R__AA_10_Alamo_A____Semi_Act_Rdr, - ), - ( - Weapons.R_27ET__AA_10_Alamo_D____IR_Extended_Range, - Weapons.R_27T__AA_10_Alamo_B____Infra_Red, - ), - # R-33 - (Weapons.R_33__AA_9_Amos____Semi_Act_Rdr, None), - # R-3 - (Weapons.R_3S, Weapons.R_13M), - (Weapons.R_3R, Weapons.R_3S), - # R-40 - (Weapons.R_40R__AA_6_Acrid____Semi_Act_Rdr, None), - (Weapons.R_40T__AA_6_Acrid____Infra_Red, None), - # R-55 - (Weapons.R_55, None), - (Weapons.RS2US, None), - # R-60 - (Weapons.R_60, Weapons.R_13M1), - (Weapons.R_60_x_2, Weapons.R_13M1), - (Weapons.R_60_x_2_, Weapons.R_13M1), - (Weapons.R_60M, Weapons.R_60), - (Weapons.APU_60_1M_with_R_60M__AA_8_Aphid____Infra_Red, Weapons.R_60), - (Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red, Weapons.R_60M), - (Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red_, Weapons.R_60M), - (Weapons.R_60M_x_2, Weapons.R_60M), - (Weapons.R_60M_x_2_, Weapons.R_60M), - # R-73 - (Weapons.R_73__AA_11_Archer____Infra_Red, Weapons.R_60M), - (Weapons.R_73__AA_11_Archer____Infra_Red_, None), - # R-77 - ( - Weapons.R_77__AA_12_Adder____Active_Rdr, - Weapons.R_27ER__AA_10_Alamo_C____Semi_Act_Extended_Range, - ), - (Weapons.R_77__AA_12_Adder____Active_Rdr_, None), - # UK - # ALARM - (Weapons.ALARM, None), - # France - # BLG-66 Belouga - (Weapons.AUF2_BLG_66_AC_x_2, Weapons.AUF2_MK_82_x_2), - (Weapons.BLG_66_AC_Belouga, Weapons.Mk_82), - (Weapons.BLG_66_Belouga___290kg_CBU__151_Frag_Pen_bomblets, Weapons.Mk_82), - # HOT-3 - (Weapons.HOT3, None), - (Weapons.HOT3_, None), - # Magic 2 - (Weapons.Matra_Magic_II, None), - (Weapons.R_550_Magic_2, None), - # Super 530D - (Weapons.Matra_Super_530D, Weapons.Matra_Magic_II), - (Weapons.Super_530D, None), -] - -WEAPON_FALLBACK_MAP: Dict[Weapon, Optional[Weapon]] = defaultdict( - lambda: cast(Optional[Weapon], None), - ( - (Weapon.from_pydcs(a), b if b is None else Weapon.from_pydcs(b)) - for a, b in _WEAPON_FALLBACKS - ), -) - - -WEAPON_INTRODUCTION_YEARS = { - # USA - # ADM-141 TALD - Weapon.from_pydcs(Weapons.ADM_141A): 1987, - Weapon.from_pydcs(Weapons.ADM_141A_): 1987, - Weapon.from_pydcs(Weapons.ADM_141A_TALD): 1987, - Weapon.from_pydcs(Weapons.ADM_141B_TALD): 1987, - # AGM-114K Hellfire - Weapon.from_pydcs(Weapons.AGM114x2_OH_58): 1993, - Weapon.from_pydcs(Weapons.AGM_114K): 1993, - Weapon.from_pydcs(Weapons.AGM_114K___4): 1993, - # AGM-119 Penguin - Weapon.from_pydcs(Weapons.AGM_119B_Penguin_ASM): 1972, - # AGM-122 Sidearm - Weapon.from_pydcs(Weapons.AGM_122_Sidearm___light_ARM): 1986, - Weapon.from_pydcs(Weapons.AGM_122_Sidearm): 1986, - Weapon.from_pydcs(Weapons.AGM_122_Sidearm_): 1986, - # AGM-154 JSOW - Weapon.from_pydcs(Weapons.AGM_154A___JSOW_CEB__CBU_type_): 1998, - Weapon.from_pydcs(Weapons.BRU_55_with_2_x_AGM_154A___JSOW_CEB__CBU_type_): 1998, - Weapon.from_pydcs(Weapons.BRU_57_with_2_x_AGM_154A___JSOW_CEB__CBU_type_): 1998, - Weapon.from_pydcs(Weapons.AGM_154B___JSOW_Anti_Armour): 2005, - Weapon.from_pydcs(Weapons.AGM_154C___JSOW_Unitary_BROACH): 2005, - Weapon.from_pydcs(Weapons._4_x_AGM_154C___JSOW_Unitary_BROACH): 2005, - Weapon.from_pydcs(Weapons.BRU_55_with_2_x_AGM_154C___JSOW_Unitary_BROACH): 2005, - # AGM-45 Shrike - Weapon.from_pydcs(Weapons.AGM_45A_Shrike_ARM): 1965, - Weapon.from_pydcs(Weapons.AGM_45B_Shrike_ARM__Imp_): 1970, - Weapon.from_pydcs(Weapons.LAU_118a_with_AGM_45B_Shrike_ARM__Imp_): 1970, - # AGM-62 Walleye - Weapon.from_pydcs(Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_): 1972, - # AGM-65 Maverick - Weapon.from_pydcs(Weapons.LAU_88_AGM_65D_ONE): 1983, - Weapon.from_pydcs(Weapons.AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_): 1985, - Weapon.from_pydcs(Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_): 2007, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65A): 1972, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65B): 1972, - Weapon.from_pydcs(Weapons.LAU_117_with_AGM_65D___Maverick_D__IIR_ASM_): 1986, - Weapon.from_pydcs( - Weapons.LAU_117_with_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_ - ): 1990, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65F): 1991, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65G): 1989, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65H): 2002, - Weapon.from_pydcs(Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_): 2002, - Weapon.from_pydcs(Weapons.LAU_117_AGM_65L): 1985, - Weapon.from_pydcs(Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM_): 1983, - Weapon.from_pydcs(Weapons.LAU_88_with_2_x_AGM_65D___Maverick_D__IIR_ASM__): 1983, - Weapon.from_pydcs(Weapons.LAU_88_with_3_x_AGM_65D___Maverick_D__IIR_ASM_): 1983, - Weapon.from_pydcs(Weapons.LAU_88_AGM_65D_ONE): 1983, - Weapon.from_pydcs( - Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_ - ): 1985, - Weapon.from_pydcs( - Weapons.LAU_88_with_2_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd__ - ): 1985, - Weapon.from_pydcs( - Weapons.LAU_88_with_3_x_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_ - ): 1985, - Weapon.from_pydcs(Weapons.LAU_88_AGM_65H): 2007, - Weapon.from_pydcs(Weapons.LAU_88_AGM_65H_2_L): 2007, - Weapon.from_pydcs(Weapons.LAU_88_AGM_65H_2_R): 2007, - Weapon.from_pydcs(Weapons.LAU_88_AGM_65H_3): 2007, - Weapon.from_pydcs(Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM_): 2007, - Weapon.from_pydcs( - Weapons.LAU_88_with_2_x_AGM_65K___Maverick_K__CCD_Imp_ASM__ - ): 2007, - Weapon.from_pydcs(Weapons.LAU_88_with_3_x_AGM_65K___Maverick_K__CCD_Imp_ASM_): 2007, - # AGM-84 Harpoon - Weapon.from_pydcs(Weapons.AGM_84): 1979, - Weapon.from_pydcs(Weapons.AGM_84A_Harpoon_ASM): 1979, - Weapon.from_pydcs(Weapons._8_x_AGM_84A_Harpoon_ASM): 1979, - Weapon.from_pydcs(Weapons.AGM_84D_Harpoon_AShM): 1979, - Weapon.from_pydcs( - Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile_ - ): 1990, - Weapon.from_pydcs( - Weapons.AGM_84E_Harpoon_SLAM__Stand_Off_Land_Attack_Missile__ - ): 1990, - Weapon.from_pydcs(Weapons.AGM_84H_SLAM_ER__Expanded_Response_): 1998, - # AGM-86 ALCM - Weapon.from_pydcs(Weapons.AGM_86C_ALCM): 1986, - Weapon.from_pydcs(Weapons._20_x_AGM_86C_ALCM): 1986, - Weapon.from_pydcs(Weapons._8_x_AGM_86C_ALCM): 1986, - Weapon.from_pydcs(Weapons._6_x_AGM_86C_ALCM_on_MER): 1986, - # AGM-88 HARM - Weapon.from_pydcs(Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile): 1983, - Weapon.from_pydcs(Weapons.AGM_88C_HARM___High_Speed_Anti_Radiation_Missile_): 1983, - # for future reference: 1983 is the A model IOC. B model in 1986 and C model in 1994. - # AIM-120 AMRAAM - Weapon.from_pydcs(Weapons.AIM_120B_AMRAAM___Active_Rdr_AAM): 1994, - Weapon.from_pydcs(Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM): 1996, - Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_120B): 1994, - Weapon.from_pydcs( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120B_AMRAAM___Active_Rdr_AAM - ): 1994, - Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_120C): 1996, - Weapon.from_pydcs( - Weapons.LAU_115_with_1_x_LAU_127_AIM_120C_5_AMRAAM___Active_Rdr_AAM - ): 1996, - # AIM-54 Phoenix - Weapon.from_pydcs(Weapons.AIM_54A_Mk47): 1974, - Weapon.from_pydcs(Weapons.AIM_54A_Mk47_): 1974, - Weapon.from_pydcs(Weapons.AIM_54A_Mk47__): 1974, - Weapon.from_pydcs(Weapons.AIM_54A_Mk60): 1974, - Weapon.from_pydcs(Weapons.AIM_54A_Mk60_): 1974, - Weapon.from_pydcs(Weapons.AIM_54A_Mk60__): 1974, - Weapon.from_pydcs(Weapons.AIM_54C_Mk47_Phoenix_IN__Semi_Active_Radar): 1974, - Weapon.from_pydcs(Weapons.AIM_54C_Mk47): 1974, - Weapon.from_pydcs(Weapons.AIM_54C_Mk47_): 1974, - Weapon.from_pydcs(Weapons.AIM_54C_Mk47__): 1974, - # AIM-7 Sparrow - Weapon.from_pydcs(Weapons.AIM_7E_Sparrow_Semi_Active_Radar): 1963, - Weapon.from_pydcs(Weapons.AIM_7F_Sparrow_Semi_Active_Radar): 1976, - Weapon.from_pydcs(Weapons.AIM_7F_): 1976, - Weapon.from_pydcs(Weapons.AIM_7F): 1976, - Weapon.from_pydcs(Weapons.AIM_7M): 1982, - Weapon.from_pydcs(Weapons.AIM_7M_): 1982, - Weapon.from_pydcs(Weapons.LAU_115_with_AIM_7M_Sparrow_Semi_Active_Radar): 1982, - Weapon.from_pydcs(Weapons.AIM_7MH): 1987, - Weapon.from_pydcs(Weapons.AIM_7MH_): 1987, - Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar): 1987, - Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7E_Sparrow_Semi_Active_Radar): 1963, - Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7F_Sparrow_Semi_Active_Radar): 1976, - Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar): 1987, - # AIM-9 Sidewinder - Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9B_Sidewinder_IR_AAM): 1956, - Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9B_Sidewinder_IR_AAM): 1956, - Weapon.from_pydcs(Weapons.AIM_9L_Sidewinder_IR_AAM): 1977, - Weapon.from_pydcs(Weapons.AIM_9M_Sidewinder_IR_AAM): 1982, - Weapon.from_pydcs(Weapons.AIM_9P5_Sidewinder_IR_AAM): 1980, - Weapon.from_pydcs(Weapons.AIM_9P_Sidewinder_IR_AAM): 1978, - Weapon.from_pydcs(Weapons.AIM_9X_Sidewinder_IR_AAM): 2003, - Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9L_L): 1977, - Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9L_R): 1977, - Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9M_L): 1982, - Weapon.from_pydcs(Weapons.LAU_105_1_AIM_9M_R): 1982, - Weapon.from_pydcs(Weapons.LAU_105_2_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_105_2_AIM_9P5): 1980, - Weapon.from_pydcs(Weapons.LAU_105_with_2_x_AIM_9M_Sidewinder_IR_AAM): 1982, - Weapon.from_pydcs(Weapons.LAU_105_with_2_x_AIM_9P_Sidewinder_IR_AAM): 1978, - Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_9M): 1982, - Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_9X): 2003, - Weapon.from_pydcs(Weapons.LAU_115_LAU_127_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_115_LAU_127_AIM_9M): 1982, - Weapon.from_pydcs(Weapons.LAU_115_LAU_127_AIM_9X): 2003, - Weapon.from_pydcs(Weapons.LAU_127_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_127_AIM_9M): 1982, - Weapon.from_pydcs(Weapons.LAU_127_AIM_9X): 2003, - Weapon.from_pydcs(Weapons.LAU_138_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_138_AIM_9M): 1982, - Weapon.from_pydcs(Weapons.LAU_7_AIM_9L): 1977, - Weapon.from_pydcs(Weapons.LAU_7_AIM_9M): 1982, - Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9M_Sidewinder_IR_AAM): 1982, - Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9P5_Sidewinder_IR_AAM): 1980, - Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9P_Sidewinder_IR_AAM): 1978, - Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9X_Sidewinder_IR_AAM): 2003, - Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9L_Sidewinder_IR_AAM): 1977, - Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9M_Sidewinder_IR_AAM): 1982, - Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9P5_Sidewinder_IR_AAM): 1980, - Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9P_Sidewinder_IR_AAM): 1978, - # ALQ ECM Pods - Weapon.from_pydcs(Weapons.ALQ_131___ECM_Pod): 1970, - Weapon.from_pydcs(Weapons.ALQ_184): 1989, - Weapon.from_pydcs(Weapons.AN_ALQ_164_DECM_Pod): 1984, - # TGP Pods - Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod): 1999, - Weapon.from_pydcs(Weapons.AN_AAQ_28_LITENING___Targeting_Pod_): 1999, - Weapon.from_pydcs(Weapons.AN_ASQ_228_ATFLIR___Targeting_Pod): 2003, - Weapon.from_pydcs( - Weapons.AN_ASQ_173_Laser_Spot_Tracker_Strike_CAMera__LST_SCAM_ - ): 1993, - Weapon.from_pydcs(Weapons.AWW_13_DATALINK_POD): 1967, - Weapon.from_pydcs(Weapons.LANTIRN_Targeting_Pod): 1990, - Weapon.from_pydcs(Weapons.Lantirn_F_16): 1990, - Weapon.from_pydcs(Weapons.Lantirn_Target_Pod): 1990, - Weapon.from_pydcs(Weapons.Pavetack_F_111): 1982, - # BLU-107 - Weapon.from_pydcs(Weapons.BLU_107___440lb_Anti_Runway_Penetrator_Bomb): 1983, - Weapon.from_pydcs( - Weapons.MER6_with_6_x_BLU_107___440lb_Anti_Runway_Penetrator_Bombs - ): 1983, - # GBU-10 LGB - Weapon.from_pydcs(Weapons.DIS_GBU_10): 1976, - Weapon.from_pydcs(Weapons.GBU_10): 1976, - Weapon.from_pydcs(Weapons.BRU_42_with_2_x_GBU_10___2000lb_Laser_Guided_Bombs): 1976, - Weapon.from_pydcs(Weapons.GBU_10___2000lb_Laser_Guided_Bomb): 1976, - # GBU-12 LGB - Weapon.from_pydcs(Weapons.AUF2_GBU_12_x_2): 1976, - Weapon.from_pydcs(Weapons.BRU_33_with_2_x_GBU_12___500lb_Laser_Guided_Bomb): 1976, - Weapon.from_pydcs(Weapons.BRU_42_3_GBU_12): 1976, - Weapon.from_pydcs(Weapons.DIS_GBU_12): 1976, - Weapon.from_pydcs(Weapons.DIS_GBU_12_DUAL_GDJ_II19_L): 1976, - Weapon.from_pydcs(Weapons.DIS_GBU_12_DUAL_GDJ_II19_R): 1976, - Weapon.from_pydcs(Weapons.GBU_12): 1976, - Weapon.from_pydcs(Weapons.GBU_12): 1976, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb): 1976, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_GBU_12___500lb_Laser_Guided_Bomb_): 1976, - Weapon.from_pydcs(Weapons._2_GBU_12): 1976, - Weapon.from_pydcs(Weapons._2_GBU_12_): 1976, - Weapon.from_pydcs(Weapons._3_GBU_12): 1976, - # GBU-16 LGB - Weapon.from_pydcs(Weapons.BRU_33_with_2_x_GBU_16___1000lb_Laser_Guided_Bomb): 1976, - Weapon.from_pydcs(Weapons.DIS_GBU_16): 1976, - Weapon.from_pydcs(Weapons.GBU_16): 1976, - Weapon.from_pydcs(Weapons.GBU_16___1000lb_Laser_Guided_Bomb): 1976, - Weapon.from_pydcs(Weapons._2_GBU_16): 1976, - Weapon.from_pydcs(Weapons._2_GBU_16_): 1976, - Weapon.from_pydcs(Weapons._3_GBU_16): 1976, - Weapon.from_pydcs(Weapons.BRU_42_with_3_x_GBU_16___1000lb_Laser_Guided_Bombs): 1976, - # GBU-24 LGB - Weapon.from_pydcs(Weapons.GBU_24): 1986, - Weapon.from_pydcs(Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb): 1986, - Weapon.from_pydcs(Weapons.GBU_24_Paveway_III___2000lb_Laser_Guided_Bomb_): 1986, - # GBU-27 LGB - Weapon.from_pydcs(Weapons.GBU_27___2000lb_Laser_Guided_Penetrator_Bomb): 1991, - Weapon.from_pydcs( - Weapons.BRU_42_with_2_x_GBU_27___2000lb_Laser_Guided_Penetrator_Bombs - ): 1991, - # GBU-28 - Weapon.from_pydcs(Weapons.GBU_28___5000lb_Laser_Guided_Penetrator_Bomb): 1991, - # GBU-31 JDAM - Weapon.from_pydcs(Weapons.GBU_31V3B_8): 2001, - Weapon.from_pydcs(Weapons.GBU_31_8): 2001, - Weapon.from_pydcs(Weapons.GBU_31_V_1_B___JDAM__2000lb_GPS_Guided_Bomb): 2001, - Weapon.from_pydcs(Weapons.GBU_31_V_2_B___JDAM__2000lb_GPS_Guided_Bomb): 2001, - Weapon.from_pydcs( - Weapons.GBU_31_V_3_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb - ): 2001, - Weapon.from_pydcs( - Weapons.GBU_31_V_4_B___JDAM__2000lb_GPS_Guided_Penetrator_Bomb - ): 2001, - # GBU-32 JDAM - Weapon.from_pydcs(Weapons.GBU_32_V_2_B___JDAM__1000lb_GPS_Guided_Bomb): 2002, - # GBU-38 JDAM - Weapon.from_pydcs( - Weapons.BRU_55_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb - ): 2005, - Weapon.from_pydcs( - Weapons.BRU_57_with_2_x_GBU_38___JDAM__500lb_GPS_Guided_Bomb - ): 2005, - Weapon.from_pydcs(Weapons.GBU_38___JDAM__500lb_GPS_Guided_Bomb): 2005, - Weapon.from_pydcs(Weapons.GBU_38_16): 2005, - Weapon.from_pydcs(Weapons._2_GBU_38): 2005, - Weapon.from_pydcs(Weapons._2_GBU_38_): 2005, - Weapon.from_pydcs(Weapons._3_GBU_38): 2005, - # GBU-54 LJDAM - Weapon.from_pydcs(Weapons.GBU_54B___LJDAM__500lb_Laser__GPS_Guided_Bomb_LD): 2008, - Weapon.from_pydcs(Weapons._2_GBU_54_V_1_B): 2008, - Weapon.from_pydcs(Weapons._2_GBU_54_V_1_B_): 2008, - Weapon.from_pydcs(Weapons._3_GBU_54_V_1_B): 2008, - # CBU-52 - Weapon.from_pydcs(Weapons.CBU_52B___220_x_HE_Frag_bomblets): 1970, - # CBU-87 CEM - Weapon.from_pydcs(Weapons.CBU_87___202_x_CEM_Cluster_Bomb): 1986, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb): 1986, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb_): 1986, - Weapon.from_pydcs(Weapons.TER_9A_with_3_x_CBU_87___202_x_CEM_Cluster_Bomb): 1986, - # CBU-97 - Weapon.from_pydcs(Weapons.CBU_97___10_x_SFW_Cluster_Bomb): 1992, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb): 1992, - Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb_): 1992, - Weapon.from_pydcs(Weapons.TER_9A_with_3_x_CBU_97___10_x_SFW_Cluster_Bomb): 1992, - # CBU-99 - Weapon.from_pydcs( - Weapons.BRU_33_with_2_x_CBU_99___490lbs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs( - Weapons.BRU_33_with_2_x_CBU_99___490lbs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs( - Weapons.BRU_33_with_2_x_CBU_99___490lbs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs(Weapons.DIS_MK_20): 1968, - Weapon.from_pydcs(Weapons.DIS_MK_20_DUAL_GDJ_II19_L): 1968, - Weapon.from_pydcs(Weapons.DIS_MK_20_DUAL_GDJ_II19_R): 1968, - Weapon.from_pydcs( - Weapons.HSAB_with_9_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs(Weapons.MAK79_2_MK_20): 1968, - Weapon.from_pydcs(Weapons.MAK79_2_MK_20_): 1968, - Weapon.from_pydcs(Weapons.MAK79_MK_20): 1968, - Weapon.from_pydcs(Weapons.MAK79_MK_20_): 1968, - Weapon.from_pydcs( - Weapons.MER6_with_6_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs(Weapons.Mk_20): 1968, - Weapon.from_pydcs(Weapons.Mk_20_Rockeye___490lbs_CBU__247_x_HEAT_Bomblets): 1968, - Weapon.from_pydcs(Weapons.Mk_20_18): 1968, - Weapon.from_pydcs( - Weapons._6_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets - ): 1968, - Weapon.from_pydcs(Weapons._2_MK_20): 1968, - Weapon.from_pydcs(Weapons._2_MK_20_): 1968, - Weapon.from_pydcs(Weapons._2_MK_20__): 1968, - Weapon.from_pydcs(Weapons._2_MK_20___): 1968, - Weapon.from_pydcs(Weapons._2_MK_20____): 1968, - Weapon.from_pydcs(Weapons._2_MK_20_____): 1968, - Weapon.from_pydcs(Weapons._2_Mk_20_Rockeye): 1968, - Weapon.from_pydcs(Weapons._2_Mk_20_Rockeye_): 1968, - Weapon.from_pydcs( - Weapons.MER2_with_2_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets - ): 1968, - # CBU-103 - Weapon.from_pydcs(Weapons.BRU_57_with_2_x_CBU_103___202_x_CEM__CBU_with_WCMD): 2000, - Weapon.from_pydcs(Weapons.CBU_103___202_x_CEM__CBU_with_WCMD): 2000, - # CBU-105 - Weapon.from_pydcs(Weapons.BRU_57_with_2_x_CBU_105___10_x_SFW__CBU_with_WCMD): 2000, - Weapon.from_pydcs(Weapons.CBU_105___10_x_SFW__CBU_with_WCMD): 2000, - # APKWS - Weapon.from_pydcs( - Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS - ): 2016, - Weapon.from_pydcs( - Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS - ): 2016, - Weapon.from_pydcs( - Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS - ): 2016, - Weapon.from_pydcs( - Weapons.BRU_42_with_3_x_LAU_131_pods___7_x_2_75_Hydra__Laser_Guided_Rkts_M282__MPP_APKWS - ): 2016, - # Russia - # KAB-1500 - Weapon.from_pydcs(Weapons.KAB_1500Kr___1500kg_TV_Guided_Bomb): 1985, - Weapon.from_pydcs(Weapons.KAB_1500L___1500kg_Laser_Guided_Bomb): 1995, - Weapon.from_pydcs( - Weapons.KAB_1500LG_Pr___1500kg_Laser_Guided_Penetrator_Bomb - ): 1990, - # KAB-500 - Weapon.from_pydcs(Weapons.KAB_500Kr___500kg_TV_Guided_Bomb): 1980, - Weapon.from_pydcs(Weapons.KAB_500LG___500kg_Laser_Guided_Bomb): 1995, - Weapon.from_pydcs(Weapons.KAB_500S___500kg_GPS_Guided_Bomb): 2000, - # Kh Series - Weapon.from_pydcs( - Weapons.Kh_22__AS_4_Kitchen____1000kg__AShM__IN__Act_Pas_Rdr - ): 1962, - Weapon.from_pydcs( - Weapons.Kh_23L_Grom__AS_7_Kerry____286kg__ASM__Laser_Guided - ): 1975, - Weapon.from_pydcs(Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser): 1975, - Weapon.from_pydcs( - Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser_ - ): 1975, - Weapon.from_pydcs( - Weapons.Kh_25ML__AS_10_Karen____300kg__ASM__Semi_Act_Laser__ - ): 1975, - Weapon.from_pydcs(Weapons.Kh_25MP__AS_12_Kegler____320kg__ARM__Pas_Rdr): 1975, - Weapon.from_pydcs( - Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr - ): 1980, - Weapon.from_pydcs( - Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr_ - ): 1980, - Weapon.from_pydcs( - Weapons.Kh_25MPU__Updated_AS_12_Kegler____320kg__ARM__IN__Pas_Rdr__ - ): 1980, - Weapon.from_pydcs( - Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__10km__RC_Guided - ): 1975, - Weapon.from_pydcs(Weapons.Kh_25MR__AS_10_Karen____300kg__ASM__RC_Guided): 1975, - Weapon.from_pydcs(Weapons.Kh_28__AS_9_Kyle____720kg__ARM__Pas_Rdr): 1973, - Weapon.from_pydcs(Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser): 1980, - Weapon.from_pydcs(Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser_): 1980, - Weapon.from_pydcs( - Weapons.Kh_29L__AS_14_Kedge____657kg__ASM__Semi_Act_Laser__ - ): 1980, - Weapon.from_pydcs(Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided): 1980, - Weapon.from_pydcs(Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided_): 1980, - Weapon.from_pydcs(Weapons.Kh_29T__AS_14_Kedge____670kg__ASM__TV_Guided__): 1980, - Weapon.from_pydcs(Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr): 1980, - Weapon.from_pydcs(Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr_): 1980, - Weapon.from_pydcs( - Weapons.Kh_31A__AS_17_Krypton____610kg__AShM__IN__Act_Rdr__ - ): 1980, - Weapon.from_pydcs(Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr): 1980, - Weapon.from_pydcs(Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr_): 1980, - Weapon.from_pydcs(Weapons.Kh_31P__AS_17_Krypton____600kg__ARM__IN__Pas_Rdr__): 1980, - Weapon.from_pydcs(Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr): 2003, - Weapon.from_pydcs(Weapons.Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr_): 2003, - Weapon.from_pydcs( - Weapons._6_x_Kh_35__AS_20_Kayak____520kg__AShM__IN__Act_Rdr - ): 2003, - Weapon.from_pydcs( - Weapons.Kh_41__SS_N_22_Sunburn____4500kg__AShM__IN__Act_Rdr - ): 1984, - Weapon.from_pydcs(Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr): 1985, - Weapon.from_pydcs(Weapons.Kh_58U__AS_11_Kilter____640kg__ARM__IN__Pas_Rdr_): 1985, - Weapon.from_pydcs(Weapons.Kh_59M__AS_18_Kazoo____930kg__ASM__IN): 1990, - Weapon.from_pydcs(Weapons.Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC): 1992, - Weapon.from_pydcs(Weapons._6_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC): 1992, - Weapon.from_pydcs(Weapons._8_x_Kh_65__AS_15B_Kent____1250kg__ASM__IN__MCC): 1992, - Weapon.from_pydcs(Weapons.Kh_66_Grom__21__APU_68): 1968, - # ECM - Weapon.from_pydcs(Weapons.L175V_Khibiny_ECM_pod): 1982, - # R-13 - Weapon.from_pydcs(Weapons.R_13M): 1961, - Weapon.from_pydcs(Weapons.R_13M1): 1965, - # R-24 - Weapon.from_pydcs(Weapons.R_24R__AA_7_Apex_SA____Semi_Act_Rdr): 1981, - Weapon.from_pydcs(Weapons.R_24T__AA_7_Apex_IR____Infra_Red): 1981, - # R-27 - Weapon.from_pydcs(Weapons.R_27ER__AA_10_Alamo_C____Semi_Act_Extended_Range): 1983, - Weapon.from_pydcs(Weapons.R_27ET__AA_10_Alamo_D____IR_Extended_Range): 1986, - Weapon.from_pydcs(Weapons.R_27R__AA_10_Alamo_A____Semi_Act_Rdr): 1983, - Weapon.from_pydcs(Weapons.R_27T__AA_10_Alamo_B____Infra_Red): 1983, - # R-33 - Weapon.from_pydcs(Weapons.R_33__AA_9_Amos____Semi_Act_Rdr): 1981, - # R-3 - Weapon.from_pydcs(Weapons.R_3R): 1966, - Weapon.from_pydcs(Weapons.R_3S): 1962, - # R-40 - Weapon.from_pydcs(Weapons.R_40R__AA_6_Acrid____Semi_Act_Rdr): 1976, - Weapon.from_pydcs(Weapons.R_40T__AA_6_Acrid____Infra_Red): 1976, - # R-55 - Weapon.from_pydcs(Weapons.R_55): 1957, - Weapon.from_pydcs(Weapons.RS2US): 1957, - # R-60 - Weapon.from_pydcs(Weapons.R_60): 1973, - Weapon.from_pydcs(Weapons.R_60_x_2): 1973, - Weapon.from_pydcs(Weapons.R_60_x_2_): 1973, - Weapon.from_pydcs(Weapons.APU_60_1M_with_R_60M__AA_8_Aphid____Infra_Red): 1982, - Weapon.from_pydcs(Weapons.R_60M): 1982, - Weapon.from_pydcs(Weapons.R_60M__AA_8_Aphid____Infra_Red): 1982, - Weapon.from_pydcs(Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red): 1982, - Weapon.from_pydcs(Weapons.APU_60_2M_with_2_x_R_60M__AA_8_Aphid____Infra_Red_): 1982, - Weapon.from_pydcs(Weapons.R_60M_x_2): 1982, - Weapon.from_pydcs(Weapons.R_60M_x_2_): 1982, - # R-73 - Weapon.from_pydcs(Weapons.R_73__AA_11_Archer____Infra_Red): 1984, - Weapon.from_pydcs(Weapons.R_73__AA_11_Archer____Infra_Red_): 1984, - # R-77 - Weapon.from_pydcs(Weapons.R_77__AA_12_Adder____Active_Rdr): 2002, - Weapon.from_pydcs(Weapons.R_77__AA_12_Adder____Active_Rdr_): 2002, - # UK - # ALARM - Weapon.from_pydcs(Weapons.ALARM): 1990, - # France - # BLG-66 Belouga - Weapon.from_pydcs(Weapons.AUF2_BLG_66_AC_x_2): 1979, - Weapon.from_pydcs(Weapons.BLG_66_AC_Belouga): 1979, - Weapon.from_pydcs(Weapons.BLG_66_Belouga___290kg_CBU__151_Frag_Pen_bomblets): 1979, - # HOT-3 - Weapon.from_pydcs(Weapons.HOT3): 1998, - Weapon.from_pydcs(Weapons.HOT3_): 1998, - # Magic 2 - Weapon.from_pydcs(Weapons.Matra_Magic_II): 1986, - Weapon.from_pydcs(Weapons.R_550_Magic_2): 1986, - # Super 530D - Weapon.from_pydcs(Weapons.Matra_Super_530D): 1988, - Weapon.from_pydcs(Weapons.Super_530D): 1988, -} diff --git a/gen/flights/loadouts.py b/gen/flights/loadouts.py index 8b34f9ef..826cc01a 100644 --- a/gen/flights/loadouts.py +++ b/gen/flights/loadouts.py @@ -68,7 +68,7 @@ class Loadout: pylons = payload["pylons"] yield Loadout( name, - {p["num"]: Weapon.from_clsid(p["CLSID"]) for p in pylons.values()}, + {p["num"]: Weapon.with_clsid(p["CLSID"]) for p in pylons.values()}, date=None, ) @@ -132,7 +132,7 @@ class Loadout: if payload is not None: return Loadout( name, - {i: Weapon.from_clsid(d["clsid"]) for i, d in payload}, + {i: Weapon.with_clsid(d["clsid"]) for i, d in payload}, date=None, ) diff --git a/qt_ui/main.py b/qt_ui/main.py index d1aacdff..43cf6e53 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -11,14 +11,9 @@ from PySide2.QtCore import Qt from PySide2.QtGui import QPixmap from PySide2.QtWidgets import QApplication, QSplashScreen from dcs.payloads import PayloadDirectories -from dcs.weapons_data import weapon_ids from game import Game, VERSION, persistency -from game.data.weapons import ( - WEAPON_FALLBACK_MAP, - WEAPON_INTRODUCTION_YEARS, - Weapon, -) +from game.data.weapons import WeaponGroup from game.db import FACTIONS from game.profiling import logged_duration from game.settings import Settings @@ -239,12 +234,8 @@ def create_game( def lint_weapon_data() -> None: - for clsid in weapon_ids: - weapon = Weapon.from_clsid(clsid) - if weapon not in WEAPON_INTRODUCTION_YEARS: - logging.warning(f"{weapon} has no introduction date") - if weapon not in WEAPON_FALLBACK_MAP: - logging.warning(f"{weapon} has no fallback") + for weapon in WeaponGroup.named("Unknown").weapons: + logging.warning(f"No weapon data for {weapon}: {weapon.clsid}") def main(): diff --git a/qt_ui/windows/mission/flight/payload/QPylonEditor.py b/qt_ui/windows/mission/flight/payload/QPylonEditor.py index 3cb22c19..e6eeaa24 100644 --- a/qt_ui/windows/mission/flight/payload/QPylonEditor.py +++ b/qt_ui/windows/mission/flight/payload/QPylonEditor.py @@ -56,7 +56,7 @@ class QPylonEditor(QComboBox): # # A similar hack exists in Pylon to support forcibly equipping this even when # it's not known to be compatible. - if weapon.cls_id == "": + if weapon.clsid == "": if not self.has_added_clean_item: self.addItem("Clean", weapon) self.has_added_clean_item = True diff --git a/resources/weapons/a2a-missiles/AIM-120B-2X.yaml b/resources/weapons/a2a-missiles/AIM-120B-2X.yaml new file mode 100644 index 00000000..562fcb0f --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-120B-2X.yaml @@ -0,0 +1,5 @@ +name: 2xAIM-120B +year: 1994 +fallback: AIM-7MH +clsids: + - "LAU-115_2*LAU-127_AIM-120B" diff --git a/resources/weapons/a2a-missiles/AIM-120B.yaml b/resources/weapons/a2a-missiles/AIM-120B.yaml new file mode 100644 index 00000000..01301534 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-120B.yaml @@ -0,0 +1,7 @@ +name: AIM-120B +year: 1994 +fallback: AIM-7MH +clsids: + - "{C8E06185-7CD6-4C90-959F-044679E90751}" + - "{LAU-115 - AIM-120B}" + - "{LAU-115 - AIM-120B_R}" diff --git a/resources/weapons/a2a-missiles/AIM-120C-2X.yaml b/resources/weapons/a2a-missiles/AIM-120C-2X.yaml new file mode 100644 index 00000000..95d4a089 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-120C-2X.yaml @@ -0,0 +1,5 @@ +name: 2xAIM-120C +year: 1996 +fallback: 2xAIM-120B +clsids: + - "LAU-115_2*LAU-127_AIM-120C" diff --git a/resources/weapons/a2a-missiles/AIM-120C.yaml b/resources/weapons/a2a-missiles/AIM-120C.yaml new file mode 100644 index 00000000..2098e2b8 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-120C.yaml @@ -0,0 +1,7 @@ +name: AIM-120C +year: 1996 +fallback: AIM-120B +clsids: + - "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}" + - "{LAU-115 - AIM-120C}" + - "{LAU-115 - AIM-120C_R}" diff --git a/resources/weapons/a2a-missiles/AIM-7E.yaml b/resources/weapons/a2a-missiles/AIM-7E.yaml new file mode 100644 index 00000000..16e60733 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-7E.yaml @@ -0,0 +1,5 @@ +name: AIM-7E +year: 1963 +clsids: + - "{AIM-7E}" + - "{LAU-115 - AIM-7E}" diff --git a/resources/weapons/a2a-missiles/AIM-7F.yaml b/resources/weapons/a2a-missiles/AIM-7F.yaml new file mode 100644 index 00000000..d66fd3b8 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-7F.yaml @@ -0,0 +1,8 @@ +name: AIM-7F +year: 1976 +fallback: AIM-7E +clsids: + - "{SHOULDER AIM-7F}" + - "{BELLY AIM-7F}" + - "{AIM-7F}" + - "{LAU-115 - AIM-7F}" diff --git a/resources/weapons/a2a-missiles/AIM-7M.yaml b/resources/weapons/a2a-missiles/AIM-7M.yaml new file mode 100644 index 00000000..9128c8ed --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-7M.yaml @@ -0,0 +1,8 @@ +name: AIM-7M +year: 1982 +fallback: AIM-7F +clsids: + - "{SHOULDER AIM-7M}" + - "{BELLY AIM-7M}" + - "{8D399DDA-FF81-4F14-904D-099B34FE7918}" + - "{LAU-115 - AIM-7M}" diff --git a/resources/weapons/a2a-missiles/AIM-7MH.yaml b/resources/weapons/a2a-missiles/AIM-7MH.yaml new file mode 100644 index 00000000..e2d15b34 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-7MH.yaml @@ -0,0 +1,8 @@ +name: AIM-7MH +year: 1987 +fallback: AIM-7M +clsids: + - "{SHOULDER AIM-7MH}" + - "{BELLY AIM-7MH}" + - "{AIM-7H}" + - "{LAU-115 - AIM-7H}" diff --git a/resources/weapons/pods/atflir.yaml b/resources/weapons/pods/atflir.yaml new file mode 100644 index 00000000..64ef6833 --- /dev/null +++ b/resources/weapons/pods/atflir.yaml @@ -0,0 +1,4 @@ +name: AN/ASQ-228 ATFLIR +year: 2003 +clsids: + - "{AN_ASQ_228}" diff --git a/resources/weapons/pods/litening.yaml b/resources/weapons/pods/litening.yaml new file mode 100644 index 00000000..0ea9db08 --- /dev/null +++ b/resources/weapons/pods/litening.yaml @@ -0,0 +1,5 @@ +name: AN/AAQ-28 LITENING +year: 1999 +clsids: + - "{A111396E-D3E8-4b9c-8AC9-2432489304D5}" + - "{AAQ-28_LEFT}" From b1b60f428663acaa73d042c472ad78efe9d153bf Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 14 Jul 2021 01:27:00 -0700 Subject: [PATCH 076/167] Add JSOW A and C weapon data and fallbacks. Both types use JSOW -> Walleye -> Mk 84. The JSOW A should maybe fall back to some CBU instead, but I think the standoff capability is more important to preserve than the warhead type. --- resources/weapons/bombs/Mk-84.yaml | 6 ++++++ resources/weapons/standoff/AGM-154A-2X.yaml | 6 ++++++ resources/weapons/standoff/AGM-154A.yaml | 5 +++++ resources/weapons/standoff/AGM-154C-2X.yaml | 5 +++++ resources/weapons/standoff/AGM-154C.yaml | 5 +++++ resources/weapons/standoff/AGM-62.yaml | 5 +++++ 6 files changed, 32 insertions(+) create mode 100644 resources/weapons/bombs/Mk-84.yaml create mode 100644 resources/weapons/standoff/AGM-154A-2X.yaml create mode 100644 resources/weapons/standoff/AGM-154A.yaml create mode 100644 resources/weapons/standoff/AGM-154C-2X.yaml create mode 100644 resources/weapons/standoff/AGM-154C.yaml create mode 100644 resources/weapons/standoff/AGM-62.yaml diff --git a/resources/weapons/bombs/Mk-84.yaml b/resources/weapons/bombs/Mk-84.yaml new file mode 100644 index 00000000..aa246026 --- /dev/null +++ b/resources/weapons/bombs/Mk-84.yaml @@ -0,0 +1,6 @@ +name: Mk 84 +clsids: + - "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}" + - "{BRU-32 MK-84}" + - "{Mk_84P}" + - "{Mk_84T}" diff --git a/resources/weapons/standoff/AGM-154A-2X.yaml b/resources/weapons/standoff/AGM-154A-2X.yaml new file mode 100644 index 00000000..efa5e150 --- /dev/null +++ b/resources/weapons/standoff/AGM-154A-2X.yaml @@ -0,0 +1,6 @@ +name: 2xAGM-154A JSOW +year: 1998 +fallback: AGM-62 Walleye II +clsids: + - "{BRU55_2*AGM-154A}" + - "{BRU57_2*AGM-154A}" diff --git a/resources/weapons/standoff/AGM-154A.yaml b/resources/weapons/standoff/AGM-154A.yaml new file mode 100644 index 00000000..40bcc727 --- /dev/null +++ b/resources/weapons/standoff/AGM-154A.yaml @@ -0,0 +1,5 @@ +name: AGM-154A JSOW +year: 1998 +fallback: AGM-62 Walleye II +clsids: + - "{AGM-154A}" diff --git a/resources/weapons/standoff/AGM-154C-2X.yaml b/resources/weapons/standoff/AGM-154C-2X.yaml new file mode 100644 index 00000000..0959a9d3 --- /dev/null +++ b/resources/weapons/standoff/AGM-154C-2X.yaml @@ -0,0 +1,5 @@ +name: 2xAGM-154C JSOW +year: 2005 +fallback: AGM-62 Walleye II +clsids: + - "{BRU55_2*AGM-154C}" diff --git a/resources/weapons/standoff/AGM-154C.yaml b/resources/weapons/standoff/AGM-154C.yaml new file mode 100644 index 00000000..4e7f4770 --- /dev/null +++ b/resources/weapons/standoff/AGM-154C.yaml @@ -0,0 +1,5 @@ +name: AGM-154C JSOW +year: 2005 +fallback: AGM-62 Walleye II +clsids: + - "{9BCC2A2B-5708-4860-B1F1-053A18442067}" diff --git a/resources/weapons/standoff/AGM-62.yaml b/resources/weapons/standoff/AGM-62.yaml new file mode 100644 index 00000000..d1f72a12 --- /dev/null +++ b/resources/weapons/standoff/AGM-62.yaml @@ -0,0 +1,5 @@ +name: AGM-62 Walleye II +year: 1972 +fallback: Mk 84 +clsids: + - "{C40A1E3A-DD05-40D9-85A4-217729E37FAE}" From 72c181a39966399f31e1b3cbaad6ece0ba1d5548 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 14 Jul 2021 17:33:01 -0700 Subject: [PATCH 077/167] Fix budget mismatch in the UI. Much of the UI was using the old budget which wasn't removed from Game like it should have been when Coaltion was introduced. The UI displayed (and in some cases pulled from) the starting budget rather than the real budget. --- game/game.py | 2 -- qt_ui/widgets/QBudgetBox.py | 5 +++-- qt_ui/windows/basemenu/QBaseMenu2.py | 12 +++++++----- qt_ui/windows/basemenu/QRecruitBehaviour.py | 4 ++-- qt_ui/windows/finances/QFinancesMenu.py | 5 +---- .../windows/groundobject/QGroundObjectMenu.py | 18 +++++++++--------- qt_ui/windows/settings/QSettingsWindow.py | 2 +- 7 files changed, 23 insertions(+), 25 deletions(-) diff --git a/game/game.py b/game/game.py index 9e1cb00c..6ce7b178 100644 --- a/game/game.py +++ b/game/game.py @@ -107,8 +107,6 @@ class Game: self.__culling_zones: List[Point] = [] self.__destroyed_units: list[dict[str, Union[float, str]]] = [] self.savepath = "" - self.budget = player_budget - self.enemy_budget = enemy_budget self.current_unit_id = 0 self.current_group_id = 0 self.name_generator = naming.namegen diff --git a/qt_ui/widgets/QBudgetBox.py b/qt_ui/widgets/QBudgetBox.py index e44713a8..30713c92 100644 --- a/qt_ui/widgets/QBudgetBox.py +++ b/qt_ui/widgets/QBudgetBox.py @@ -1,6 +1,7 @@ from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QPushButton import qt_ui.uiconstants as CONST +from game import Game from game.income import Income from qt_ui.windows.finances.QFinancesMenu import QFinancesMenu @@ -10,7 +11,7 @@ class QBudgetBox(QGroupBox): UI Component to display current budget and player's money """ - def __init__(self, game): + def __init__(self, game: Game): super(QBudgetBox, self).__init__("Budget") self.game = game @@ -40,7 +41,7 @@ class QBudgetBox(QGroupBox): return self.game = game - self.setBudget(self.game.budget, Income(self.game, player=True).total) + self.setBudget(self.game.blue.budget, Income(self.game, player=True).total) self.finances.setEnabled(True) def openFinances(self): diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index 20dbf8f1..d10e5bc7 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -108,7 +108,7 @@ class QBaseMenu2(QDialog): capture_button.clicked.connect(self.cheat_capture) self.budget_display = QLabel( - QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.budget) + QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.blue.budget) ) self.budget_display.setAlignment(Qt.AlignRight | Qt.AlignBottom) self.budget_display.setProperty("style", "budget-label") @@ -139,7 +139,7 @@ class QBaseMenu2(QDialog): @property def can_afford_runway_repair(self) -> bool: - return self.game_model.game.budget >= db.RUNWAY_REPAIR_COST + return self.game_model.game.blue.budget >= db.RUNWAY_REPAIR_COST def begin_runway_repair(self) -> None: if not self.can_afford_runway_repair: @@ -147,7 +147,7 @@ class QBaseMenu2(QDialog): self, "Cannot repair runway", f"Runway repair costs ${db.RUNWAY_REPAIR_COST}M but you have " - f"only ${self.game_model.game.budget}M available.", + f"only ${self.game_model.game.blue.budget}M available.", QMessageBox.Ok, ) return @@ -161,7 +161,7 @@ class QBaseMenu2(QDialog): return self.cp.begin_runway_repair() - self.game_model.game.budget -= db.RUNWAY_REPAIR_COST + self.game_model.game.blue.budget -= db.RUNWAY_REPAIR_COST self.update_repair_button() self.update_intel_summary() GameUpdateSignal.get_instance().updateGame(self.game_model.game) @@ -257,4 +257,6 @@ class QBaseMenu2(QDialog): 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)) + self.budget_display.setText( + QRecruitBehaviour.BUDGET_FORMAT.format(game.blue.budget) + ) diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py index 5eb7534a..b3ab3d8f 100644 --- a/qt_ui/windows/basemenu/QRecruitBehaviour.py +++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py @@ -103,11 +103,11 @@ class QRecruitBehaviour: @property def budget(self) -> float: - return self.game_model.game.budget + return self.game_model.game.blue.budget @budget.setter def budget(self, value: int) -> None: - self.game_model.game.budget = value + self.game_model.game.blue.budget = value def add_purchase_row( self, diff --git a/qt_ui/windows/finances/QFinancesMenu.py b/qt_ui/windows/finances/QFinancesMenu.py index 4ef8b281..c1eec23e 100644 --- a/qt_ui/windows/finances/QFinancesMenu.py +++ b/qt_ui/windows/finances/QFinancesMenu.py @@ -57,10 +57,7 @@ class FinancesLayout(QGridLayout): middle=f"Income multiplier: {income.multiplier:.1f}", right=f"{income.total}M", ) - if player: - budget = game.budget - else: - budget = game.enemy_budget + budget = game.coalition_for(player).budget self.add_row(middle="Balance", right=f"{budget}M") self.setRowStretch(next(self.row), 1) diff --git a/qt_ui/windows/groundobject/QGroundObjectMenu.py b/qt_ui/windows/groundobject/QGroundObjectMenu.py index 0e629db8..5622682f 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -237,8 +237,8 @@ class QGroundObjectMenu(QDialog): self.total_value = total_value def repair_unit(self, group, unit, price): - if self.game.budget > price: - self.game.budget -= price + if self.game.blue.budget > price: + self.game.blue.budget -= price group.units_losts = [u for u in group.units_losts if u.id != unit.id] group.units.append(unit) GameUpdateSignal.get_instance().updateGame(self.game) @@ -256,7 +256,7 @@ class QGroundObjectMenu(QDialog): def sell_all(self): self.update_total_value() - self.game.budget = self.game.budget + self.total_value + self.game.blue.budget = self.game.blue.budget + self.total_value self.ground_object.groups = [] # Replan if the tgo was a target of the redfor @@ -433,12 +433,12 @@ class QBuyGroupForGroundObjectDialog(QDialog): logging.info("Buying Armor ") utype = self.buyArmorCombo.itemData(self.buyArmorCombo.currentIndex()) price = utype.price * self.amount.value() - self.current_group_value - if price > self.game.budget: + if price > self.game.blue.budget: self.error_money() self.close() return else: - self.game.budget -= price + self.game.blue.budget -= price # Generate Armor group = generate_armor_group_of_type_and_size( @@ -454,11 +454,11 @@ class QBuyGroupForGroundObjectDialog(QDialog): def buySam(self): sam_generator = self.samCombo.itemData(self.samCombo.currentIndex()) price = sam_generator.price - self.current_group_value - if price > self.game.budget: + if price > self.game.blue.budget: self.error_money() return else: - self.game.budget -= price + self.game.blue.budget -= price self.ground_object.groups = list(sam_generator.groups) @@ -470,11 +470,11 @@ class QBuyGroupForGroundObjectDialog(QDialog): def buy_ewr(self): ewr_generator = self.ewr_selector.itemData(self.ewr_selector.currentIndex()) price = ewr_generator.price - self.current_group_value - if price > self.game.budget: + if price > self.game.blue.budget: self.error_money() return else: - self.game.budget -= price + self.game.blue.budget -= price self.ground_object.groups = [ewr_generator.vg] diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 5aba6a7d..188963d7 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -873,7 +873,7 @@ class QSettingsWindow(QDialog): def cheatMoney(self, amount): logging.info("CHEATING FOR AMOUNT : " + str(amount) + "M") - self.game.budget += amount + self.game.blue.budget += amount if amount > 0: self.game.informations.append( Information( From 56b17dfbcf090069768690ab75adf80317956231 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 14 Jul 2021 18:34:33 -0700 Subject: [PATCH 078/167] Correct behavior for multi-task HTN methods. Add tasks to the left of the deque, not the right. Not symptomatic yet since we don't actually have any multi-task methods currently. --- game/htn.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/game/htn.py b/game/htn.py index 9399175a..49699892 100644 --- a/game/htn.py +++ b/game/htn.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections import Iterator, deque +from collections import Iterator, deque, Sequence from dataclasses import dataclass from typing import Any, Generic, Optional, TypeVar @@ -18,7 +18,7 @@ class Task(Generic[WorldStateT]): pass -Method = list[Task[WorldStateT]] +Method = Sequence[Task[WorldStateT]] class PrimitiveTask(Task[WorldStateT], Generic[WorldStateT], ABC): @@ -104,18 +104,20 @@ class Planner(Generic[WorldStateT, PrimitiveTaskT]): methods = planning_state.methods try: method = next(methods) + # Push the current node back onto the stack so that we resume + # handling this task when we pop back to this state. + resume_tasks: deque[Task[WorldStateT]] = deque([task]) + resume_tasks.extend(planning_state.tasks_to_process) history.push( PlanningState( planning_state.state.clone(), - # Push the current node back onto the stack so that we - # resume handling this task when we pop back to this state. - planning_state.tasks_to_process + deque([task]), + resume_tasks, planning_state.plan, methods, ) ) planning_state.methods = None - planning_state.tasks_to_process.extend(method) + planning_state.tasks_to_process.extendleft(reversed(method)) except StopIteration: try: planning_state = history.pop() From d25befabdd6e7670a239f6f98060eab07e092753 Mon Sep 17 00:00:00 2001 From: Magnus Wolffelt Date: Thu, 15 Jul 2021 22:34:09 +0200 Subject: [PATCH 079/167] Randomize mission temperature and pressure. --- game/weather.py | 66 +++++++++++++++++++++++++++++++++++++++++-- gen/environmentgen.py | 8 +++++- requirements.txt | 2 +- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/game/weather.py b/game/weather.py index 97c58f97..fae1d5a0 100644 --- a/game/weather.py +++ b/game/weather.py @@ -24,6 +24,14 @@ class TimeOfDay(Enum): Night = "night" +@dataclass(frozen=True) +class AtmosphericConditions: + #: Pressure at sea level in inches of mercury. + qnh_inches_mercury: float + #: Temperature at sea level in Celcius. + temperature_celsius: float + + @dataclass(frozen=True) class WindConditions: at_0m: Wind @@ -64,10 +72,16 @@ class Fog: class Weather: def __init__(self) -> None: + # Future improvement: Use theater, day and time of day + # to get a more realistic conditions + self.atmospheric = self.generate_atmospheric() self.clouds = self.generate_clouds() self.fog = self.generate_fog() self.wind = self.generate_wind() + def generate_atmospheric(self) -> AtmosphericConditions: + raise NotImplementedError + def generate_clouds(self) -> Optional[Clouds]: raise NotImplementedError @@ -105,8 +119,35 @@ class Weather: def random_cloud_thickness() -> int: return random.randint(100, 400) + @staticmethod + def random_pressure(average_pressure: float) -> float: + # "Safe" constants based roughly on ME and viper altimeter. + # Units are inches of mercury. + SAFE_MIN = 28.4 + SAFE_MAX = 30.9 + # Use normalvariate to get normal distribution, more realistic than uniform + pressure = random.normalvariate(average_pressure, 0.2) + return max(SAFE_MIN, min(SAFE_MAX, pressure)) + + @staticmethod + def random_temperature(average_temperature: float) -> float: + # "Safe" constants based roughly on ME. + # Temperatures are in Celcius. + SAFE_MIN = -12 + SAFE_MAX = 49 + # Use normalvariate to get normal distribution, more realistic than uniform + temperature = random.normalvariate(average_temperature, 4) + temperature = round(temperature) + return max(SAFE_MIN, min(SAFE_MAX, temperature)) + class ClearSkies(Weather): + def generate_atmospheric(self) -> AtmosphericConditions: + return AtmosphericConditions( + qnh_inches_mercury=self.random_pressure(29.96), + temperature_celsius=self.random_temperature(22), + ) + def generate_clouds(self) -> Optional[Clouds]: return None @@ -118,6 +159,12 @@ class ClearSkies(Weather): class Cloudy(Weather): + def generate_atmospheric(self) -> AtmosphericConditions: + return AtmosphericConditions( + qnh_inches_mercury=self.random_pressure(29.90), + temperature_celsius=self.random_temperature(20), + ) + def generate_clouds(self) -> Optional[Clouds]: return Clouds.random_preset(rain=False) @@ -130,6 +177,12 @@ class Cloudy(Weather): class Raining(Weather): + def generate_atmospheric(self) -> AtmosphericConditions: + return AtmosphericConditions( + qnh_inches_mercury=self.random_pressure(29.70), + temperature_celsius=self.random_temperature(16), + ) + def generate_clouds(self) -> Optional[Clouds]: return Clouds.random_preset(rain=True) @@ -142,6 +195,12 @@ class Raining(Weather): class Thunderstorm(Weather): + def generate_atmospheric(self) -> AtmosphericConditions: + return AtmosphericConditions( + qnh_inches_mercury=self.random_pressure(29.60), + temperature_celsius=self.random_temperature(15), + ) + def generate_clouds(self) -> Optional[Clouds]: return Clouds( base=self.random_cloud_base(), @@ -168,11 +227,12 @@ class Conditions: time_of_day: TimeOfDay, settings: Settings, ) -> Conditions: + _start_time = cls.generate_start_time( + theater, day, time_of_day, settings.night_disabled + ) return cls( time_of_day=time_of_day, - start_time=cls.generate_start_time( - theater, day, time_of_day, settings.night_disabled - ), + start_time=_start_time, weather=cls.generate_weather(), ) diff --git a/gen/environmentgen.py b/gen/environmentgen.py index cd4d09ff..a431abe9 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -2,7 +2,7 @@ from typing import Optional from dcs.mission import Mission -from game.weather import Clouds, Fog, Conditions, WindConditions +from game.weather import Clouds, Fog, Conditions, WindConditions, AtmosphericConditions class EnvironmentGenerator: @@ -10,6 +10,11 @@ class EnvironmentGenerator: self.mission = mission self.conditions = conditions + def set_atmospheric(self, atmospheric: AtmosphericConditions) -> None: + inch_to_mm = 25.400002776728 + self.mission.weather.qnh = atmospheric.qnh_inches_mercury * inch_to_mm + self.mission.weather.season_temperature = atmospheric.temperature_celsius + def set_clouds(self, clouds: Optional[Clouds]) -> None: if clouds is None: return @@ -32,6 +37,7 @@ class EnvironmentGenerator: def generate(self) -> None: self.mission.start_time = self.conditions.start_time + self.set_atmospheric(self.conditions.weather.atmospheric) self.set_clouds(self.conditions.weather.clouds) self.set_fog(self.conditions.weather.fog) self.set_wind(self.conditions.weather.wind) diff --git a/requirements.txt b/requirements.txt index fe4a96f7..54073289 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ pathspec==0.8.1 pefile==2019.4.18 Pillow==8.2.0 pre-commit==2.10.1 --e git://github.com/pydcs/dcs@1fa15385d2f8300a125155c8ac307d5c37e70152#egg=pydcs +-e git://github.com/pydcs/dcs@2baba37e32bc55fed59ef977c43dad275c9821eb#egg=pydcs pyinstaller==4.3 pyinstaller-hooks-contrib==2021.1 pyparsing==2.4.7 From 62036a273e372bcfda5ca15140b91c1a0d0d6d00 Mon Sep 17 00:00:00 2001 From: RndName Date: Tue, 13 Jul 2021 20:55:52 +0200 Subject: [PATCH 080/167] allow user to set empty dcs install dir This will allow expert users to disable the automatic MissionScripting.lua replacement. There are many warnings and errors which have to be ignored to achieve this because DCS Liberation will not work with unmodified MissionScripting.lua --- qt_ui/liberation_install.py | 8 +++-- qt_ui/main.py | 16 ++++++++++ .../QLiberationFirstStartWindow.py | 6 ++++ .../preferences/QLiberationPreferences.py | 32 ++++++++++++++++--- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/qt_ui/liberation_install.py b/qt_ui/liberation_install.py index 0bbcb0b0..341cd45a 100644 --- a/qt_ui/liberation_install.py +++ b/qt_ui/liberation_install.py @@ -112,7 +112,7 @@ def replace_mission_scripting_file(): ) liberation_scripting_path = "./resources/scripts/MissionScripting.lua" backup_scripting_path = "./resources/scripts/MissionScripting.original.lua" - if os.path.isfile(mission_scripting_path): + if install_dir != "" and os.path.isfile(mission_scripting_path): with open(mission_scripting_path, "r") as ms: current_file_content = ms.read() with open(liberation_scripting_path, "r") as libe_ms: @@ -133,5 +133,9 @@ def restore_original_mission_scripting(): ) backup_scripting_path = "./resources/scripts/MissionScripting.original.lua" - if os.path.isfile(backup_scripting_path) and os.path.isfile(mission_scripting_path): + if ( + install_dir != "" + and os.path.isfile(backup_scripting_path) + and os.path.isfile(mission_scripting_path) + ): copyfile(backup_scripting_path, mission_scripting_path) diff --git a/qt_ui/main.py b/qt_ui/main.py index 43cf6e53..26c5cb48 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -95,6 +95,22 @@ def run_ui(game: Optional[Game]) -> None: uiconstants.load_aircraft_banners() uiconstants.load_vehicle_banners() + # Show warning if no DCS Installation directory was set + if liberation_install.get_dcs_install_directory() == "": + QtWidgets.QMessageBox.warning( + splash, + "No DCS installation directory.", + "The DCS Installation directory is not set correctly. " + "This will prevent DCS Liberation to work properly as the MissionScripting " + "file will not be modified." + "

To solve this problem, you can set the Installation directory " + "within the preferences menu. You can also manually edit or replace the " + "following file:" + "

<dcs_installation_directory>/Scripts/MissionScripting.lua" + "

The easiest way to do it is to replace the original file with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua)." + "

You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub.

", + QtWidgets.QMessageBox.StandardButton.Ok, + ) # Replace DCS Mission scripting file to allow DCS Liberation to work try: liberation_install.replace_mission_scripting_file() diff --git a/qt_ui/windows/preferences/QLiberationFirstStartWindow.py b/qt_ui/windows/preferences/QLiberationFirstStartWindow.py index 4a300f35..78b898a1 100644 --- a/qt_ui/windows/preferences/QLiberationFirstStartWindow.py +++ b/qt_ui/windows/preferences/QLiberationFirstStartWindow.py @@ -58,6 +58,12 @@ class QLiberationFirstStartWindow(QDialog):

As you click on the button below, the file will be replaced in your DCS installation directory.

+
+

If you leave the DCS Installation Directory empty, DCS Liberation can not automatically replace the MissionScripting.lua and will therefore not work correctly! + In this case, you need to edit the file yourself. The easiest way to do it is to replace the original file with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua). +

You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub.

+ +

Thank you for reading ! diff --git a/qt_ui/windows/preferences/QLiberationPreferences.py b/qt_ui/windows/preferences/QLiberationPreferences.py index 0d41b298..fbfa6770 100644 --- a/qt_ui/windows/preferences/QLiberationPreferences.py +++ b/qt_ui/windows/preferences/QLiberationPreferences.py @@ -22,6 +22,7 @@ class QLiberationPreferences(QFrame): super(QLiberationPreferences, self).__init__() self.saved_game_dir = "" self.dcs_install_dir = "" + self.install_dir_ignore_warning = False self.dcs_install_dir = liberation_install.get_dcs_install_directory() self.saved_game_dir = liberation_install.get_saved_game_dir() @@ -102,17 +103,38 @@ class QLiberationPreferences(QFrame): error_dialog.exec_() return False - if not os.path.isdir(self.dcs_install_dir): + if self.install_dir_ignore_warning and self.dcs_install_dir == "": + warning_dialog = QMessageBox.warning( + self, + "The DCS Installation directory was not set", + "You set an empty DCS Installation directory! " + "

Without this directory, DCS Liberation can not replace the MissionScripting.lua for you and will not work properly. " + "In this case, you need to edit the MissionScripting.lua yourself. The easiest way to do it is to replace the original file (<dcs_installation_directory>/Scripts/MissionScripting.lua) with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua)." + "

You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub.

" + "

Are you sure that you want to leave the installation directory empty?" + "

This is only recommended for expert users!", + QMessageBox.StandardButton.Yes, + QMessageBox.StandardButton.No, + ) + if warning_dialog == QMessageBox.No: + return False + elif not os.path.isdir(self.dcs_install_dir): error_dialog = QMessageBox.critical( self, "Wrong DCS installation directory.", - self.dcs_install_dir + " is not a valid directory", + self.dcs_install_dir + + " is not a valid directory. DCS Liberation requires the installation directory to replace the MissionScripting.lua" + "

If you ignore this Error, DCS Liberation can not work properly and needs your attention. " + "In this case, you need to edit the MissionScripting.lua yourself. The easiest way to do it is to replace the original file (<dcs_installation_directory>/Scripts/MissionScripting.lua) with the file in dcs-liberation distribution (<dcs_liberation_installation>/resources/scripts/MissionScripting.lua)." + "

You can find more information on how to manually change this file in the Liberation Wiki (Page: Dedicated Server Guide) on GitHub.

" + "

This is only recommended for expert users!", + QMessageBox.StandardButton.Ignore, QMessageBox.StandardButton.Ok, ) - error_dialog.exec_() + if error_dialog == QMessageBox.Ignore: + self.install_dir_ignore_warning = True return False - - if not os.path.isdir( + elif not os.path.isdir( os.path.join(self.dcs_install_dir, "Scripts") ) and os.path.isfile(os.path.join(self.dcs_install_dir, "bin", "DCS.exe")): error_dialog = QMessageBox.critical( From 2b696144e31a03c3842a1e3d880313db2bc76bec Mon Sep 17 00:00:00 2001 From: Magnus Wolffelt Date: Fri, 16 Jul 2021 00:34:58 +0200 Subject: [PATCH 081/167] Add QNH and temperature to the kneeboard. --- gen/environmentgen.py | 4 ++-- gen/kneeboard.py | 17 +++++++++++++++++ gen/units.py | 12 +++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/gen/environmentgen.py b/gen/environmentgen.py index a431abe9..2bc9da84 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -3,6 +3,7 @@ from typing import Optional from dcs.mission import Mission from game.weather import Clouds, Fog, Conditions, WindConditions, AtmosphericConditions +from .units import inches_hg_to_mm_hg class EnvironmentGenerator: @@ -11,8 +12,7 @@ class EnvironmentGenerator: self.conditions = conditions def set_atmospheric(self, atmospheric: AtmosphericConditions) -> None: - inch_to_mm = 25.400002776728 - self.mission.weather.qnh = atmospheric.qnh_inches_mercury * inch_to_mm + self.mission.weather.qnh = inches_hg_to_mm_hg(atmospheric.qnh_inches_mercury) self.mission.weather.season_temperature = atmospheric.temperature_celsius def set_clouds(self, clouds: Optional[Clouds]) -> None: diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 110dff55..35aac4e3 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -39,6 +39,7 @@ from game.db import unit_type_from_name from game.dcs.aircrafttype import AircraftType from game.theater import ConflictTheater, TheaterGroundObject, LatLon from game.theater.bullseye import Bullseye +from game.weather import Weather from game.utils import meters from .aircraft import FlightData from .airsupportgen import AwacsInfo, TankerInfo @@ -46,6 +47,7 @@ from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator from .flights.flight import FlightWaypoint, FlightWaypointType, FlightType from .radios import RadioFrequency from .runways import RunwayData +from .units import inches_hg_to_mm_hg, inches_hg_to_hpa if TYPE_CHECKING: from game import Game @@ -265,12 +267,14 @@ class BriefingPage(KneeboardPage): flight: FlightData, bullseye: Bullseye, theater: ConflictTheater, + weather: Weather, start_time: datetime.datetime, dark_kneeboard: bool, ) -> None: self.flight = flight self.bullseye = bullseye self.theater = theater + self.weather = weather self.start_time = start_time self.dark_kneeboard = dark_kneeboard @@ -304,6 +308,18 @@ class BriefingPage(KneeboardPage): writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}") + qnh_in_hg = "{:.2f}".format(self.weather.atmospheric.qnh_inches_mercury) + qnh_mm_hg = "{:.1f}".format( + inches_hg_to_mm_hg(self.weather.atmospheric.qnh_inches_mercury) + ) + qnh_hpa = "{:.1f}".format( + inches_hg_to_hpa(self.weather.atmospheric.qnh_inches_mercury) + ) + writer.text( + f"Temperature: {round(self.weather.atmospheric.temperature_celsius)} °C at sea level" + ) + writer.text(f"QNH: {qnh_in_hg} inHg / {qnh_mm_hg} mmHg / {qnh_hpa} hPa") + writer.table( [ [ @@ -634,6 +650,7 @@ class KneeboardGenerator(MissionInfoGenerator): flight, self.game.bullseye_for(flight.friendly), self.game.theater, + self.game.conditions.weather, self.mission.start_time, self.dark_kneeboard, ), diff --git a/gen/units.py b/gen/units.py index cfd16ab8..9aec8348 100644 --- a/gen/units.py +++ b/gen/units.py @@ -2,5 +2,15 @@ def meters_to_feet(meters: float) -> float: - """Convers meters to feet.""" + """Converts meters to feet.""" return meters * 3.28084 + + +def inches_hg_to_mm_hg(inches_hg: float) -> float: + """Converts inches mercury to millimeters mercury.""" + return inches_hg * 25.400002776728 + + +def inches_hg_to_hpa(inches_hg: float) -> float: + """Converts inches mercury to hectopascal.""" + return inches_hg * 33.86389 From b46d44c3a509350a35c7106ff2b2202d91c970bf Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Thu, 15 Jul 2021 16:58:14 -0700 Subject: [PATCH 082/167] Change CAP and SEAD Loadouts Replaced the TGP with SPJ pod for these roles. --- resources/customized_payloads/JF-17.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/customized_payloads/JF-17.lua b/resources/customized_payloads/JF-17.lua index 8e135655..b4c0e4b5 100644 --- a/resources/customized_payloads/JF-17.lua +++ b/resources/customized_payloads/JF-17.lua @@ -77,7 +77,7 @@ local unitPayloads = { ["num"] = 3, }, [3] = { - ["CLSID"] = "DIS_WMD7", + ["CLSID"] = "DIS_SPJ_POD", ["num"] = 4, }, [4] = { @@ -107,7 +107,7 @@ local unitPayloads = { ["name"] = "SEAD", ["pylons"] = { [1] = { - ["CLSID"] = "DIS_WMD7", + ["CLSID"] = "DIS_SPJ_POD", ["num"] = 4, }, [2] = { From e03d710d5366ff56b0eba895d991864deeaa8511 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 14 Jul 2021 21:59:46 -0700 Subject: [PATCH 083/167] [1/3] Rework IP placement. Test cases: 1. Target is not threatened. The IP should be placed on a direct heading from the origin to the target at the max ingress distance, or very near the origin airfield if the airfield is closer to the target than the IP distance. 2. Unthreatened home zone, max IP between origin and target, safe locations available for IP. The IP should be placed in LAR at the closest point to home. 3. Unthreatened home zone, origin within LAR, safe locations available for IP. The IP should be placed near the origin airfield to prevent backtracking more than needed. 4. Unthreatened home zone, origin entirely nearer the target than LAR, safe locations available for IP. The IP should be placed in LAR as close as possible to the origin. 5. Threatened home zone, safe locations available for IP. The IP should be placed in LAR as close as possible to the origin. 6. No safe IP. The IP should be placed in LAR at the point nearest the threat boundary. --- game/data/doctrine.py | 37 ++++++-- game/flightplan/__init__.py | 1 + game/flightplan/ipzonegeometry.py | 114 ++++++++++++++++++++++++ gen/flights/flightplan.py | 71 +++++---------- qt_ui/widgets/map/mapmodel.py | 94 +++++++++++++++++++- resources/ui/map/map.js | 139 +++++++++++++++++++++--------- 6 files changed, 354 insertions(+), 102 deletions(-) create mode 100644 game/flightplan/__init__.py create mode 100644 game/flightplan/ipzonegeometry.py diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 4f944833..21402501 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -47,8 +47,13 @@ class Doctrine: #: fallback flight plan layout (when the departure airfield is near a threat zone). join_distance: Distance - #: The distance between the ingress point (beginning of the attack) and target. - ingress_distance: Distance + #: The maximum distance between the ingress point (beginning of the attack) and + #: target. + max_ingress_distance: Distance + + #: The minimum distance between the ingress point (beginning of the attack) and + #: target. + min_ingress_distance: Distance ingress_altitude: Distance @@ -87,9 +92,22 @@ class Doctrine: @has_save_compat_for(5) def __setstate__(self, state: dict[str, Any]) -> None: - if "ingress_distance" not in state: - state["ingress_distance"] = state["ingress_egress_distance"] - del state["ingress_egress_distance"] + if "max_ingress_distance" not in state: + try: + state["max_ingress_distance"] = state["ingress_distance"] + del state["ingress_distance"] + except KeyError: + state["max_ingress_distance"] = state["ingress_egress_distance"] + del state["ingress_egress_distance"] + + max_ip: Distance = state["max_ingress_distance"] + if "min_ingress_distance" not in state: + if max_ip < nautical_miles(10): + min_ip = nautical_miles(5) + else: + min_ip = nautical_miles(10) + state["min_ingress_distance"] = min_ip + self.__dict__.update(state) @@ -103,7 +121,8 @@ MODERN_DOCTRINE = Doctrine( hold_distance=nautical_miles(15), push_distance=nautical_miles(20), join_distance=nautical_miles(20), - ingress_distance=nautical_miles(45), + max_ingress_distance=nautical_miles(45), + min_ingress_distance=nautical_miles(10), ingress_altitude=feet(20000), min_patrol_altitude=feet(15000), max_patrol_altitude=feet(33000), @@ -139,7 +158,8 @@ COLDWAR_DOCTRINE = Doctrine( hold_distance=nautical_miles(10), push_distance=nautical_miles(10), join_distance=nautical_miles(10), - ingress_distance=nautical_miles(30), + max_ingress_distance=nautical_miles(30), + min_ingress_distance=nautical_miles(10), ingress_altitude=feet(18000), min_patrol_altitude=feet(10000), max_patrol_altitude=feet(24000), @@ -175,7 +195,8 @@ WWII_DOCTRINE = Doctrine( push_distance=nautical_miles(5), join_distance=nautical_miles(5), rendezvous_altitude=feet(10000), - ingress_distance=nautical_miles(7), + max_ingress_distance=nautical_miles(7), + min_ingress_distance=nautical_miles(5), ingress_altitude=feet(8000), min_patrol_altitude=feet(4000), max_patrol_altitude=feet(15000), diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py new file mode 100644 index 00000000..51dce4a3 --- /dev/null +++ b/game/flightplan/__init__.py @@ -0,0 +1 @@ +from .ipzonegeometry import IpZoneGeometry diff --git a/game/flightplan/ipzonegeometry.py b/game/flightplan/ipzonegeometry.py new file mode 100644 index 00000000..92e647c2 --- /dev/null +++ b/game/flightplan/ipzonegeometry.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shapely.ops +from dcs import Point +from shapely.geometry import Point as ShapelyPoint + +from game.utils import nautical_miles, meters + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class IpZoneGeometry: + """Defines the zones used for finding optimal IP placement. + + The zones themselves are stored in the class rather than just the resulting IP so + that the zones can be drawn in the map for debugging purposes. + """ + + def __init__( + self, + target: Point, + home: Point, + coalition: Coalition, + ) -> None: + self.threat_zone = coalition.opponent.threat_zone.all + self.home = ShapelyPoint(home.x, home.y) + + max_ip_distance = coalition.doctrine.max_ingress_distance + min_ip_distance = coalition.doctrine.min_ingress_distance + + # The minimum distance between the home location and the IP. + min_distance_from_home = nautical_miles(5) + + # The distance that is expected to be needed between the beginning of the attack + # and weapon release. This buffers the threat zone to give a 5nm window between + # the edge of the "safe" zone and the actual threat so that "safe" IPs are less + # likely to end up with the attacker entering a threatened area. + attack_distance_buffer = nautical_miles(5) + + home_threatened = coalition.opponent.threat_zone.threatened(home) + + shapely_target = ShapelyPoint(target.x, target.y) + home_to_target_distance = meters(home.distance_to_point(target)) + + self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference( + self.home.buffer(min_distance_from_home.meters) + ) + + # If the home zone is not threatened and home is within LAR, constrain the max + # range to the home-to-target distance to prevent excessive backtracking. + # + # If the home zone *is* threatened, we need to back out of the zone to + # rendezvous anyway. + if not home_threatened and ( + min_ip_distance < home_to_target_distance < max_ip_distance + ): + max_ip_distance = home_to_target_distance + max_ip_bubble = shapely_target.buffer(max_ip_distance.meters) + min_ip_bubble = shapely_target.buffer(min_ip_distance.meters) + self.ip_bubble = max_ip_bubble.difference(min_ip_bubble) + + # The intersection of the home bubble and IP bubble will be all the points that + # are within the valid IP range that are not farther from home than the target + # is. However, if the origin airfield is threatened but there are safe + # placements for the IP, we should not constrain to the home zone. In this case + # we'll either end up with a safe zone outside the home zone and pick the + # closest point in to to home (minimizing backtracking), or we'll have no safe + # IP anywhere within range of the target, and we'll later pick the IP nearest + # the edge of the threat zone. + if home_threatened: + self.permissible_zone = self.ip_bubble + else: + self.permissible_zone = self.ip_bubble.intersection(self.home_bubble) + + if self.permissible_zone.is_empty: + # If home is closer to the target than the min range, there will not be an + # IP solution that's close enough to home, in which case we need to ignore + # the home bubble. + self.permissible_zone = self.ip_bubble + + self.safe_zone = self.permissible_zone.difference( + self.threat_zone.buffer(attack_distance_buffer.meters) + ) + + def _unsafe_ip(self) -> ShapelyPoint: + unthreatened_home_zone = self.home_bubble.difference(self.threat_zone) + if unthreatened_home_zone.is_empty: + # Nowhere in our home zone is safe. The package will need to exit the + # threatened area to hold and rendezvous. Pick the IP closest to the + # edge of the threat zone. + return shapely.ops.nearest_points( + self.permissible_zone, self.threat_zone.boundary + )[0] + + # No safe point in the IP zone, but the home zone is safe. Pick the max- + # distance IP that's closest to the untreatened home zone. + return shapely.ops.nearest_points( + self.permissible_zone, unthreatened_home_zone + )[0] + + def _safe_ip(self) -> ShapelyPoint: + # We have a zone of possible IPs that are safe, close enough, and in range. Pick + # the IP in the zone that's closest to the target. + return shapely.ops.nearest_points(self.safe_zone, self.home)[0] + + def find_best_ip(self) -> Point: + if self.safe_zone.is_empty: + ip = self._unsafe_ip() + else: + ip = self._safe_ip() + return Point(ip.x, ip.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 0927b968..13295d01 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,6 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine +from game.flightplan import IpZoneGeometry from game.theater import ( Airfield, ControlPoint, @@ -946,57 +947,35 @@ class FlightPlanBuilder: raise PlanningError(f"{task} flight plan generation not implemented") def regenerate_package_waypoints(self) -> None: - # The simple case is where the target is not near the departure airfield. In - # this case, we can plan the shortest route from the departure airfield to the - # target, use the nearest non-threatened point *that's farther from the target - # than the ingress point to avoid backtracking) as the join point. - # - # The other case that we need to handle is when the target is close to - # the origin airfield. In this case we currently fall back to the old planning - # behavior. - # - # A messy (and very unlikely) case that we can't do much about: - # - # +--------------+ +---------------+ - # | | | | - # | IP-+---+-T | - # | | | | - # | | | | - # +--------------+ +---------------+ from gen.ato import PackageWaypoints - target = self.package.target.position + # Start by picking the best IP for the attack. + ingress_point = IpZoneGeometry( + self.package.target.position, + self.package_airfield().position, + self.coalition, + ).find_best_ip() - for join_point in self.preferred_join_points(): - join_distance = meters(join_point.distance_to_point(target)) - if join_distance > self.doctrine.ingress_distance: - break - else: + # Pick the join point based on the best route to the IP. + join_point = self.preferred_join_point(ingress_point) + if join_point is None: # The entire path to the target is threatened. Use the fallback behavior for # now. - self.legacy_package_waypoints_impl() + self.legacy_package_waypoints_impl(ingress_point) return - attack_heading = join_point.heading_between_point(target) - ingress_point = self._ingress_point(attack_heading) - - # The first case described above. The ingress and join points are placed - # reasonably relative to each other. + # And the split point based on the best route from the IP. Since that's no + # different than the best route *to* the IP, this is the same as the join point. + # TODO: Estimate attack completion point based on the IP and split from there? self.package.waypoints = PackageWaypoints( WaypointBuilder.perturb(join_point), ingress_point, - WaypointBuilder.perturb( - self.preferred_split_point(ingress_point, join_point) - ), + WaypointBuilder.perturb(join_point), ) - def retreat_point(self, origin: Point) -> Point: - return self.threat_zones.closest_boundary(origin) - - def legacy_package_waypoints_impl(self) -> None: + def legacy_package_waypoints_impl(self, ingress_point: Point) -> None: from gen.ato import PackageWaypoints - ingress_point = self._ingress_point(self._target_heading_to_package_airfield()) join_point = self._rendezvous_point(ingress_point) self.package.waypoints = PackageWaypoints( WaypointBuilder.perturb(join_point), @@ -1009,23 +988,15 @@ class FlightPlanBuilder: if not self.threat_zones.threatened(point): yield point - def preferred_join_points(self) -> Iterator[Point]: + def preferred_join_point(self, ingress_point: Point) -> Optional[Point]: # Use non-threatened points along the path to the target as the join point. We # may need to try more than one in the event that the close non-threatened # points are closer than the ingress point itself. - return self.safe_points_between( - self.package.target.position, self.package_airfield().position - ) - - def preferred_split_point(self, ingress_point: Point, join_point: Point) -> Point: - # Use non-threatened points along the path to the target as the join point. We - # may need to try more than one in the event that the close non-threatened - # points are closer than the ingress point itself. - for point in self.safe_points_between( + for join_point in self.safe_points_between( ingress_point, self.package_airfield().position ): - return point - return join_point + return join_point + return None def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. @@ -1847,7 +1818,7 @@ class FlightPlanBuilder: def _ingress_point(self, heading: float) -> Point: return self.package.target.position.point_from_heading( - heading - 180, self.doctrine.ingress_distance.meters + heading - 180, self.doctrine.max_ingress_distance.meters ) def _target_heading_to_package_airfield(self) -> float: diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 02eadd9f..c2e6706a 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -27,7 +27,12 @@ from game.transfers import MultiGroupTransport, TransportMap from game.utils import meters, nautical_miles from gen.ato import AirTaskingOrder from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType -from gen.flights.flightplan import FlightPlan, PatrollingFlightPlan, CasFlightPlan +from gen.flights.flightplan import ( + FlightPlan, + PatrollingFlightPlan, + CasFlightPlan, +) +from game.flightplan.ipzonegeometry import IpZoneGeometry from qt_ui.dialogs import Dialog from qt_ui.models import GameModel, AtoModel from qt_ui.windows.GameUpdateSignal import GameUpdateSignal @@ -39,6 +44,10 @@ LeafletPoly = list[LeafletLatLon] MAX_SHIP_DISTANCE = nautical_miles(80) +# Set to True to enable computing expensive debugging information. At the time of +# writing this only controls computing the waypoint placement zones. +ENABLE_EXPENSIVE_DEBUG_TOOLS = False + # **EVERY PROPERTY NEEDS A NOTIFY SIGNAL** # # https://bugreports.qt.io/browse/PYSIDE-1426 @@ -512,6 +521,19 @@ class FlightJs(QObject): selectedChanged = Signal() commitBoundaryChanged = Signal() + originChanged = Signal() + + @Property(list, notify=originChanged) + def origin(self) -> LeafletLatLon: + return self._waypoints[0].position + + targetChanged = Signal() + + @Property(list, notify=targetChanged) + def target(self) -> LeafletLatLon: + ll = self.theater.point_to_ll(self.flight.package.target.position) + return [ll.latitude, ll.longitude] + def __init__( self, flight: Flight, @@ -769,6 +791,56 @@ class UnculledZone(QObject): ) +class IpZonesJs(QObject): + homeBubbleChanged = Signal() + ipBubbleChanged = Signal() + permissibleZoneChanged = Signal() + safeZoneChanged = Signal() + + def __init__( + self, + home_bubble: list[LeafletPoly], + ip_bubble: list[LeafletPoly], + permissible_zone: list[LeafletPoly], + safe_zone: list[LeafletPoly], + ) -> None: + super().__init__() + self._home_bubble = home_bubble + self._ip_bubble = ip_bubble + self._permissible_zone = permissible_zone + self._safe_zone = safe_zone + + @Property(list, notify=homeBubbleChanged) + def homeBubble(self) -> list[LeafletPoly]: + return self._home_bubble + + @Property(list, notify=ipBubbleChanged) + def ipBubble(self) -> list[LeafletPoly]: + return self._ip_bubble + + @Property(list, notify=permissibleZoneChanged) + def permissibleZone(self) -> list[LeafletPoly]: + return self._permissible_zone + + @Property(list, notify=permissibleZoneChanged) + def safeZone(self) -> list[LeafletPoly]: + return self._safe_zone + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: + target = flight.package.target + home = flight.departure + if not ENABLE_EXPENSIVE_DEBUG_TOOLS or target == home: + return IpZonesJs([], [], [], []) + geometry = IpZoneGeometry(target.position, home.position, game.blue) + return IpZonesJs( + shapely_to_leaflet_polys(geometry.home_bubble, game.theater), + shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), + shapely_to_leaflet_polys(geometry.permissible_zone, game.theater), + shapely_to_leaflet_polys(geometry.safe_zone, game.theater), + ) + + class MapModel(QObject): cleared = Signal() @@ -782,6 +854,7 @@ class MapModel(QObject): navmeshesChanged = Signal() mapZonesChanged = Signal() unculledZonesChanged = Signal() + ipZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -798,6 +871,7 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] + self._ip_zones = IpZonesJs([], [], [], []) self._selected_flight_index: Optional[Tuple[int, int]] = None GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) @@ -821,6 +895,7 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] + self._ip_zones = IpZonesJs([], [], [], []) self.cleared.emit() def set_package_selection(self, index: int) -> None: @@ -896,11 +971,24 @@ class MapModel(QObject): ) return flights + def _get_selected_flight(self) -> Optional[Flight]: + for p_idx, package in enumerate(self.game.blue.ato.packages): + for f_idx, flight in enumerate(package.flights): + if (p_idx, f_idx) == self._selected_flight_index: + return flight + return None + def reset_atos(self) -> None: self._flights = self._flights_in_ato( self.game.blue.ato, blue=True ) + self._flights_in_ato(self.game.red.ato, blue=False) self.flightsChanged.emit() + selected_flight = self._get_selected_flight() + if selected_flight is not None: + self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) + else: + self._ip_zones = IpZonesJs([], [], [], []) + self.ipZonesChanged.emit() @Property(list, notify=flightsChanged) def flights(self) -> List[FlightJs]: @@ -1029,6 +1117,10 @@ class MapModel(QObject): def unculledZones(self) -> list[UnculledZone]: return self._unculled_zones + @Property(IpZonesJs, notify=ipZonesChanged) + def ipZones(self) -> IpZonesJs: + return self._ip_zones + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 6cae9ae2..e56a9fc0 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -1,3 +1,7 @@ +// Won't actually enable anything unless the same property is set in +// mapmodel.py. +const ENABLE_EXPENSIVE_DEBUG_TOOLS = false; + const Colors = Object.freeze({ Blue: "#0084ff", Red: "#c85050", @@ -124,26 +128,26 @@ const map = L.map("map", { L.control.scale({ maxWidth: 200 }).addTo(map); const rulerOptions = { - position: 'topleft', + position: "topleft", circleMarker: { color: Colors.Highlight, - radius: 2 + radius: 2, }, lineStyle: { color: Colors.Highlight, - dashArray: '1,6' + dashArray: "1,6", }, lengthUnit: { display: "nm", decimal: "2", factor: 0.539956803, - label: "Distance:" + label: "Distance:", }, angleUnit: { - display: '°', + display: "°", decimal: 0, - label: "Bearing:" - } + label: "Bearing:", + }, }; L.control.ruler(rulerOptions).addTo(map); @@ -194,6 +198,48 @@ const exclusionZones = L.layerGroup(); const seaZones = L.layerGroup(); const unculledZones = L.layerGroup(); +const homeBubble = L.layerGroup(); +const ipBubble = L.layerGroup(); +const permissibleZone = L.layerGroup(); +const safeZone = L.layerGroup(); + +const debugControlGroups = { + "Blue Threat Zones": { + Hide: L.layerGroup().addTo(map), + Full: blueFullThreatZones, + Aircraft: blueAircraftThreatZones, + "Air Defenses": blueAirDefenseThreatZones, + "Radar SAMs": blueRadarSamThreatZones, + }, + "Red Threat Zones": { + Hide: L.layerGroup().addTo(map), + Full: redFullThreatZones, + Aircraft: redAircraftThreatZones, + "Air Defenses": redAirDefenseThreatZones, + "Radar SAMs": redRadarSamThreatZones, + }, + Navmeshes: { + Hide: L.layerGroup().addTo(map), + Blue: blueNavmesh, + Red: redNavmesh, + }, + "Map Zones": { + "Inclusion zones": inclusionZones, + "Exclusion zones": exclusionZones, + "Sea zones": seaZones, + "Culling exclusion zones": unculledZones, + }, +}; + +if (ENABLE_EXPENSIVE_DEBUG_TOOLS) { + debugControlGroups["IP Zones"] = { + "Home bubble": homeBubble, + "IP bubble": ipBubble, + "Permissible zone": permissibleZone, + "Safe zone": safeZone, + }; +} + // Main map controls. These are the ones that we expect users to interact with. // These are always open, which unfortunately means that the scroll bar will not // appear if the menu doesn't fit. This fits in the smallest window size we @@ -239,41 +285,11 @@ L.control // Debug map controls. Hover over to open. Not something most users will want or // need to interact with. L.control - .groupedLayers( - null, - { - "Blue Threat Zones": { - Hide: L.layerGroup().addTo(map), - Full: blueFullThreatZones, - Aircraft: blueAircraftThreatZones, - "Air Defenses": blueAirDefenseThreatZones, - "Radar SAMs": blueRadarSamThreatZones, - }, - "Red Threat Zones": { - Hide: L.layerGroup().addTo(map), - Full: redFullThreatZones, - Aircraft: redAircraftThreatZones, - "Air Defenses": redAirDefenseThreatZones, - "Radar SAMs": redRadarSamThreatZones, - }, - Navmeshes: { - Hide: L.layerGroup().addTo(map), - Blue: blueNavmesh, - Red: redNavmesh, - }, - "Map Zones": { - "Inclusion zones": inclusionZones, - "Exclusion zones": exclusionZones, - "Sea zones": seaZones, - "Culling exclusion zones": unculledZones, - }, - }, - { - position: "topleft", - exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"], - groupCheckboxes: true, - } - ) + .groupedLayers(null, debugControlGroups, { + position: "topleft", + exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"], + groupCheckboxes: true, + }) .addTo(map); let game; @@ -291,6 +307,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.navmeshesChanged.connect(drawNavmeshes); game.mapZonesChanged.connect(drawMapZones); game.unculledZonesChanged.connect(drawUnculledZones); + game.ipZonesChanged.connect(drawIpZones); }); function recenterMap(center) { @@ -570,7 +587,11 @@ class TheaterGroundObject { } L.marker(this.tgo.position, { icon: this.icon() }) - .bindTooltip(`${this.tgo.name} (${this.tgo.controlPointName})
${this.tgo.units.join("
")}`) + .bindTooltip( + `${this.tgo.name} (${ + this.tgo.controlPointName + })
${this.tgo.units.join("
")}` + ) .on("click", () => this.tgo.showInfoDialog()) .on("contextmenu", () => this.tgo.showPackageDialog()) .addTo(this.layer()); @@ -970,6 +991,37 @@ function drawUnculledZones() { } } +function drawIpZones() { + homeBubble.clearLayers(); + ipBubble.clearLayers(); + permissibleZone.clearLayers(); + safeZone.clearLayers(); + + L.polygon(game.ipZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(homeBubble); + + L.polygon(game.ipZones.ipBubble, { + color: "#bb89ff", + fillOpacity: 0.1, + interactive: false, + }).addTo(ipBubble); + + L.polygon(game.ipZones.permissibleZone, { + color: "#ffffff", + fillOpacity: 0.1, + interactive: false, + }).addTo(permissibleZone); + + L.polygon(game.ipZones.safeZone, { + color: Colors.Green, + fillOpacity: 0.1, + interactive: false, + }).addTo(safeZone); +} + function drawInitialMap() { recenterMap(game.mapCenter); drawControlPoints(); @@ -981,6 +1033,7 @@ function drawInitialMap() { drawNavmeshes(); drawMapZones(); drawUnculledZones(); + drawIpZones(); } function clearAllLayers() { From d444d716f53ab10daa92ccd122bd422779312888 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Jul 2021 18:49:07 -0700 Subject: [PATCH 084/167] [2/3] Improve join point placement. --- game/flightplan/__init__.py | 1 + game/flightplan/joinzonegeometry.py | 84 +++++++++++++++++++++++ gen/flights/flightplan.py | 20 +++--- qt_ui/widgets/map/mapmodel.py | 103 +++++++++++++++++++++++++--- resources/ui/map/map.js | 70 ++++++++++++++----- 5 files changed, 243 insertions(+), 35 deletions(-) create mode 100644 game/flightplan/joinzonegeometry.py diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py index 51dce4a3..42c4e3e9 100644 --- a/game/flightplan/__init__.py +++ b/game/flightplan/__init__.py @@ -1 +1,2 @@ from .ipzonegeometry import IpZoneGeometry +from .joinzonegeometry import JoinZoneGeometry diff --git a/game/flightplan/joinzonegeometry.py b/game/flightplan/joinzonegeometry.py new file mode 100644 index 00000000..39c7d640 --- /dev/null +++ b/game/flightplan/joinzonegeometry.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shapely.ops +from dcs import Point +from shapely.geometry import Point as ShapelyPoint, Polygon + +from game.theater import ConflictTheater +from game.utils import nautical_miles + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class JoinZoneGeometry: + """Defines the zones used for finding optimal join point placement. + + The zones themselves are stored in the class rather than just the resulting join + point so that the zones can be drawn in the map for debugging purposes. + """ + + def __init__( + self, + target: Point, + home: Point, + ip: Point, + coalition: Coalition, + theater: ConflictTheater, + ) -> None: + # Normal join placement is based on the path from home to the IP. If no path is + # found it means that the target is on a direct path. In that case we instead + # want to enforce that the join point is: + # + # * Not closer to the target than the IP. + # * Not too close to the home airfield. + # * Not threatened. + # * A minimum distance from the IP. + # * Not too sharp a turn at the ingress point. + self.ip = ShapelyPoint(ip.x, ip.y) + self.threat_zone = coalition.opponent.threat_zone.all + self.home = ShapelyPoint(home.x, home.y) + + self.ip_bubble = self.ip.buffer(coalition.doctrine.join_distance.meters) + + ip_distance = ip.distance_to_point(target) + self.target_bubble = ShapelyPoint(target.x, target.y).buffer(ip_distance) + + # The minimum distance between the home location and the IP. + min_distance_from_home = nautical_miles(5) + + self.home_bubble = self.home.buffer(min_distance_from_home.meters) + + self.excluded_zone = shapely.ops.unary_union( + [self.home_bubble, self.ip_bubble, self.target_bubble, self.threat_zone] + ) + + ip_heading = target.heading_between_point(ip) + + # Arbitrarily large since this is later constrained by the map boundary, and + # we'll be picking a location close to the IP anyway. Just used to avoid real + # distance calculations to project to the map edge. + large_distance = nautical_miles(400).meters + turn_limit = 40 + ip_limit_ccw = ip.point_from_heading(ip_heading - turn_limit, large_distance) + ip_limit_cw = ip.point_from_heading(ip_heading + turn_limit, large_distance) + + ip_direction_limit_wedge = Polygon( + [ + (ip.x, ip.y), + (ip_limit_ccw.x, ip_limit_ccw.y), + (ip_limit_cw.x, ip_limit_cw.y), + ] + ) + + self.permissible_line = ( + coalition.nav_mesh.map_bounds(theater) + .intersection(ip_direction_limit_wedge) + .intersection(self.excluded_zone.boundary) + ) + + def find_best_join_point(self) -> Point: + join, _ = shapely.ops.nearest_points(self.permissible_line, self.home) + return Point(join.x, join.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 13295d01..5c1c0820 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,7 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine -from game.flightplan import IpZoneGeometry +from game.flightplan import IpZoneGeometry, JoinZoneGeometry from game.theater import ( Airfield, ControlPoint, @@ -949,20 +949,22 @@ class FlightPlanBuilder: def regenerate_package_waypoints(self) -> None: from gen.ato import PackageWaypoints + package_airfield = self.package_airfield() + # Start by picking the best IP for the attack. ingress_point = IpZoneGeometry( self.package.target.position, - self.package_airfield().position, + package_airfield.position, self.coalition, ).find_best_ip() - # Pick the join point based on the best route to the IP. - join_point = self.preferred_join_point(ingress_point) - if join_point is None: - # The entire path to the target is threatened. Use the fallback behavior for - # now. - self.legacy_package_waypoints_impl(ingress_point) - return + join_point = JoinZoneGeometry( + self.package.target.position, + package_airfield.position, + ingress_point, + self.coalition, + self.theater, + ).find_best_join_point() # And the split point based on the best route from the IP. Since that's no # different than the best route *to* the IP, this is the same as the join point. diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index c2e6706a..09bc6022 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -12,6 +12,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPo from game import Game from game.dcs.groundunittype import GroundUnitType +from game.flightplan import JoinZoneGeometry from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( @@ -82,6 +83,12 @@ def shapely_to_leaflet_polys( return [shapely_poly_to_leaflet_points(poly, theater) for poly in polys] +def shapely_line_to_leaflet_points( + line: LineString, theater: ConflictTheater +) -> list[LeafletLatLon]: + return [theater.point_to_ll(Point(x, y)).as_list() for x, y in line.coords] + + class ControlPointJs(QObject): nameChanged = Signal() blueChanged = Signal() @@ -822,16 +829,20 @@ class IpZonesJs(QObject): def permissibleZone(self) -> list[LeafletPoly]: return self._permissible_zone - @Property(list, notify=permissibleZoneChanged) + @Property(list, notify=safeZoneChanged) def safeZone(self) -> list[LeafletPoly]: return self._safe_zone + @classmethod + def empty(cls) -> IpZonesJs: + return IpZonesJs([], [], [], []) + @classmethod def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: + if not ENABLE_EXPENSIVE_DEBUG_TOOLS: + return IpZonesJs.empty() target = flight.package.target home = flight.departure - if not ENABLE_EXPENSIVE_DEBUG_TOOLS or target == home: - return IpZonesJs([], [], [], []) geometry = IpZoneGeometry(target.position, home.position, game.blue) return IpZonesJs( shapely_to_leaflet_polys(geometry.home_bubble, game.theater), @@ -841,6 +852,73 @@ class IpZonesJs(QObject): ) +class JoinZonesJs(QObject): + homeBubbleChanged = Signal() + targetBubbleChanged = Signal() + ipBubbleChanged = Signal() + excludedZoneChanged = Signal() + permissibleLineChanged = Signal() + + def __init__( + self, + home_bubble: list[LeafletPoly], + target_bubble: list[LeafletPoly], + ip_bubble: list[LeafletPoly], + excluded_zone: list[LeafletPoly], + permissible_line: list[LeafletLatLon], + ) -> None: + super().__init__() + self._home_bubble = home_bubble + self._target_bubble = target_bubble + self._ip_bubble = ip_bubble + self._excluded_zone = excluded_zone + self._permissible_line = permissible_line + + @Property(list, notify=homeBubbleChanged) + def homeBubble(self) -> list[LeafletPoly]: + return self._home_bubble + + @Property(list, notify=targetBubbleChanged) + def targetBubble(self) -> list[LeafletPoly]: + return self._target_bubble + + @Property(list, notify=ipBubbleChanged) + def ipBubble(self) -> list[LeafletPoly]: + return self._ip_bubble + + @Property(list, notify=excludedZoneChanged) + def excludedZone(self) -> list[LeafletPoly]: + return self._excluded_zone + + @Property(list, notify=permissibleLineChanged) + def permissibleLine(self) -> list[LeafletLatLon]: + return self._permissible_line + + @classmethod + def empty(cls) -> JoinZonesJs: + return JoinZonesJs([], [], [], [], []) + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> JoinZonesJs: + if not ENABLE_EXPENSIVE_DEBUG_TOOLS: + return JoinZonesJs.empty() + target = flight.package.target + home = flight.departure + if flight.package.waypoints is None: + return JoinZonesJs.empty() + ip = flight.package.waypoints.ingress + geometry = JoinZoneGeometry( + target.position, home.position, ip, game.blue, game.theater + ) + return JoinZonesJs( + shapely_to_leaflet_polys(geometry.home_bubble, game.theater), + shapely_to_leaflet_polys(geometry.target_bubble, game.theater), + shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), + shapely_to_leaflet_polys(geometry.excluded_zone, game.theater), + shapely_line_to_leaflet_points(geometry.permissible_line, game.theater), + ) + + class MapModel(QObject): cleared = Signal() @@ -855,6 +933,7 @@ class MapModel(QObject): mapZonesChanged = Signal() unculledZonesChanged = Signal() ipZonesChanged = Signal() + joinZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -871,7 +950,8 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] - self._ip_zones = IpZonesJs([], [], [], []) + self._ip_zones = IpZonesJs.empty() + self._join_zones = JoinZonesJs.empty() self._selected_flight_index: Optional[Tuple[int, int]] = None GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) @@ -895,7 +975,7 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] - self._ip_zones = IpZonesJs([], [], [], []) + self._ip_zones = IpZonesJs.empty() self.cleared.emit() def set_package_selection(self, index: int) -> None: @@ -984,11 +1064,14 @@ class MapModel(QObject): ) + self._flights_in_ato(self.game.red.ato, blue=False) self.flightsChanged.emit() selected_flight = self._get_selected_flight() - if selected_flight is not None: - self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) + if selected_flight is None: + self._ip_zones = IpZonesJs.empty() + self._join_zones = JoinZonesJs.empty() else: - self._ip_zones = IpZonesJs([], [], [], []) + self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) + self._join_zones = JoinZonesJs.for_flight(selected_flight, self.game) self.ipZonesChanged.emit() + self.joinZonesChanged.emit() @Property(list, notify=flightsChanged) def flights(self) -> List[FlightJs]: @@ -1121,6 +1204,10 @@ class MapModel(QObject): def ipZones(self) -> IpZonesJs: return self._ip_zones + @Property(JoinZonesJs, notify=joinZonesChanged) + def joinZones(self) -> JoinZonesJs: + return self._join_zones + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index e56a9fc0..87e4ca7e 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -198,10 +198,8 @@ const exclusionZones = L.layerGroup(); const seaZones = L.layerGroup(); const unculledZones = L.layerGroup(); -const homeBubble = L.layerGroup(); -const ipBubble = L.layerGroup(); -const permissibleZone = L.layerGroup(); -const safeZone = L.layerGroup(); +const ipZones = L.layerGroup(); +const joinZones = L.layerGroup().addTo(map); const debugControlGroups = { "Blue Threat Zones": { @@ -232,11 +230,9 @@ const debugControlGroups = { }; if (ENABLE_EXPENSIVE_DEBUG_TOOLS) { - debugControlGroups["IP Zones"] = { - "Home bubble": homeBubble, - "IP bubble": ipBubble, - "Permissible zone": permissibleZone, - "Safe zone": safeZone, + debugControlGroups["Waypoint Zones"] = { + "IP Zones": ipZones, + "Join Zones": joinZones, }; } @@ -287,7 +283,12 @@ L.control L.control .groupedLayers(null, debugControlGroups, { position: "topleft", - exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"], + exclusiveGroups: [ + "Blue Threat Zones", + "Red Threat Zones", + "Navmeshes", + "Waypoint Zones", + ], groupCheckboxes: true, }) .addTo(map); @@ -308,6 +309,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.mapZonesChanged.connect(drawMapZones); game.unculledZonesChanged.connect(drawUnculledZones); game.ipZonesChanged.connect(drawIpZones); + game.joinZonesChanged.connect(drawJoinZones); }); function recenterMap(center) { @@ -992,34 +994,65 @@ function drawUnculledZones() { } function drawIpZones() { - homeBubble.clearLayers(); - ipBubble.clearLayers(); - permissibleZone.clearLayers(); - safeZone.clearLayers(); + ipZones.clearLayers(); L.polygon(game.ipZones.homeBubble, { color: Colors.Highlight, fillOpacity: 0.1, interactive: false, - }).addTo(homeBubble); + }).addTo(ipZones); L.polygon(game.ipZones.ipBubble, { color: "#bb89ff", fillOpacity: 0.1, interactive: false, - }).addTo(ipBubble); + }).addTo(ipZones); L.polygon(game.ipZones.permissibleZone, { color: "#ffffff", fillOpacity: 0.1, interactive: false, - }).addTo(permissibleZone); + }).addTo(ipZones); L.polygon(game.ipZones.safeZone, { color: Colors.Green, fillOpacity: 0.1, interactive: false, - }).addTo(safeZone); + }).addTo(ipZones); +} + +function drawJoinZones() { + joinZones.clearLayers(); + + L.polygon(game.joinZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(joinZones); + + L.polygon(game.joinZones.targetBubble, { + color: "#bb89ff", + fillOpacity: 0.1, + interactive: false, + }).addTo(joinZones); + + L.polygon(game.joinZones.ipBubble, { + color: "#ffffff", + fillOpacity: 0.1, + interactive: false, + }).addTo(joinZones); + + L.polygon(game.joinZones.excludedZone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(joinZones); + + L.polyline(game.joinZones.permissibleLine, { + color: Colors.Green, + interactive: false, + }).addTo(joinZones); } function drawInitialMap() { @@ -1034,6 +1067,7 @@ function drawInitialMap() { drawMapZones(); drawUnculledZones(); drawIpZones(); + drawJoinZones(); } function clearAllLayers() { From 82cca0a602a8316f709be88c6f5f2a8084873ad4 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Jul 2021 22:18:53 -0700 Subject: [PATCH 085/167] [3/3] Rework hold points. --- changelog.md | 1 + game/flightplan/__init__.py | 1 + game/flightplan/holdzonegeometry.py | 108 +++++++++++++++++ game/flightplan/ipzonegeometry.py | 12 +- game/flightplan/joinzonegeometry.py | 29 +++-- gen/flights/flightplan.py | 126 +------------------- qt_ui/widgets/map/mapmodel.py | 179 +++++++++++++++++++++------- resources/ui/map/map.js | 80 ++++++++++--- 8 files changed, 346 insertions(+), 190 deletions(-) create mode 100644 game/flightplan/holdzonegeometry.py diff --git a/changelog.md b/changelog.md index 9be996fd..7722fcd7 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. +* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. ## Fixes diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py index 42c4e3e9..17a92708 100644 --- a/game/flightplan/__init__.py +++ b/game/flightplan/__init__.py @@ -1,2 +1,3 @@ +from .holdzonegeometry import HoldZoneGeometry from .ipzonegeometry import IpZoneGeometry from .joinzonegeometry import JoinZoneGeometry diff --git a/game/flightplan/holdzonegeometry.py b/game/flightplan/holdzonegeometry.py new file mode 100644 index 00000000..b382e11a --- /dev/null +++ b/game/flightplan/holdzonegeometry.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shapely.ops +from dcs import Point +from shapely.geometry import Point as ShapelyPoint, Polygon, MultiPolygon + +from game.theater import ConflictTheater +from game.utils import nautical_miles + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class HoldZoneGeometry: + """Defines the zones used for finding optimal hold point placement. + + The zones themselves are stored in the class rather than just the resulting hold + point so that the zones can be drawn in the map for debugging purposes. + """ + + def __init__( + self, + target: Point, + home: Point, + ip: Point, + join: Point, + coalition: Coalition, + theater: ConflictTheater, + ) -> None: + # Hold points are placed one of two ways. Either approach guarantees: + # + # * Safe hold point. + # * Minimum distance to the join point. + # * Not closer to the target than the join point. + # + # 1. As near the join point as possible with a specific distance from the + # departure airfield. This prevents loitering directly above the airfield but + # also keeps the hold point close to the departure airfield. + # + # 2. Alternatively, if the entire home zone is excluded by the above criteria, + # as neat the departure airfield as possible within a minimum distance from + # the join point, with a restricted turn angle at the join point. This + # handles the case where we need to backtrack from the departure airfield and + # the join point to place the hold point, but the turn angle limit restricts + # the maximum distance of the backtrack while maintaining the direction of + # the flight plan. + self.threat_zone = coalition.opponent.threat_zone.all + self.home = ShapelyPoint(home.x, home.y) + + self.join = ShapelyPoint(join.x, join.y) + + self.join_bubble = self.join.buffer(coalition.doctrine.push_distance.meters) + + join_to_target_distance = join.distance_to_point(target) + self.target_bubble = ShapelyPoint(target.x, target.y).buffer( + join_to_target_distance + ) + + self.home_bubble = self.home.buffer(coalition.doctrine.hold_distance.meters) + + excluded_zones = shapely.ops.unary_union( + [self.join_bubble, self.target_bubble, self.threat_zone] + ) + if not isinstance(excluded_zones, MultiPolygon): + excluded_zones = MultiPolygon([excluded_zones]) + self.excluded_zones = excluded_zones + + join_heading = ip.heading_between_point(join) + + # Arbitrarily large since this is later constrained by the map boundary, and + # we'll be picking a location close to the IP anyway. Just used to avoid real + # distance calculations to project to the map edge. + large_distance = nautical_miles(400).meters + turn_limit = 40 + join_limit_ccw = join.point_from_heading( + join_heading - turn_limit, large_distance + ) + join_limit_cw = join.point_from_heading( + join_heading + turn_limit, large_distance + ) + + join_direction_limit_wedge = Polygon( + [ + (join.x, join.y), + (join_limit_ccw.x, join_limit_ccw.y), + (join_limit_cw.x, join_limit_cw.y), + ] + ) + + permissible_zones = ( + coalition.nav_mesh.map_bounds(theater) + .intersection(join_direction_limit_wedge) + .difference(self.excluded_zones) + .difference(self.home_bubble) + ) + if not isinstance(permissible_zones, MultiPolygon): + permissible_zones = MultiPolygon([permissible_zones]) + self.permissible_zones = permissible_zones + self.preferred_lines = self.home_bubble.boundary.difference(self.excluded_zones) + + def find_best_hold_point(self) -> Point: + if self.preferred_lines.is_empty: + hold, _ = shapely.ops.nearest_points(self.permissible_zones, self.home) + else: + hold, _ = shapely.ops.nearest_points(self.preferred_lines, self.join) + return Point(hold.x, hold.y) diff --git a/game/flightplan/ipzonegeometry.py b/game/flightplan/ipzonegeometry.py index 92e647c2..a909cf03 100644 --- a/game/flightplan/ipzonegeometry.py +++ b/game/flightplan/ipzonegeometry.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING import shapely.ops from dcs import Point -from shapely.geometry import Point as ShapelyPoint +from shapely.geometry import Point as ShapelyPoint, MultiPolygon from game.utils import nautical_miles, meters @@ -81,10 +81,14 @@ class IpZoneGeometry: # the home bubble. self.permissible_zone = self.ip_bubble - self.safe_zone = self.permissible_zone.difference( + safe_zones = self.permissible_zone.difference( self.threat_zone.buffer(attack_distance_buffer.meters) ) + if not isinstance(safe_zones, MultiPolygon): + safe_zones = MultiPolygon([safe_zones]) + self.safe_zones = safe_zones + def _unsafe_ip(self) -> ShapelyPoint: unthreatened_home_zone = self.home_bubble.difference(self.threat_zone) if unthreatened_home_zone.is_empty: @@ -104,10 +108,10 @@ class IpZoneGeometry: def _safe_ip(self) -> ShapelyPoint: # We have a zone of possible IPs that are safe, close enough, and in range. Pick # the IP in the zone that's closest to the target. - return shapely.ops.nearest_points(self.safe_zone, self.home)[0] + return shapely.ops.nearest_points(self.safe_zones, self.home)[0] def find_best_ip(self) -> Point: - if self.safe_zone.is_empty: + if self.safe_zones.is_empty: ip = self._unsafe_ip() else: ip = self._safe_ip() diff --git a/game/flightplan/joinzonegeometry.py b/game/flightplan/joinzonegeometry.py index 39c7d640..48cff780 100644 --- a/game/flightplan/joinzonegeometry.py +++ b/game/flightplan/joinzonegeometry.py @@ -4,7 +4,12 @@ from typing import TYPE_CHECKING import shapely.ops from dcs import Point -from shapely.geometry import Point as ShapelyPoint, Polygon +from shapely.geometry import ( + Point as ShapelyPoint, + Polygon, + MultiPolygon, + MultiLineString, +) from game.theater import ConflictTheater from game.utils import nautical_miles @@ -51,10 +56,14 @@ class JoinZoneGeometry: self.home_bubble = self.home.buffer(min_distance_from_home.meters) - self.excluded_zone = shapely.ops.unary_union( - [self.home_bubble, self.ip_bubble, self.target_bubble, self.threat_zone] + excluded_zones = shapely.ops.unary_union( + [self.ip_bubble, self.target_bubble, self.threat_zone] ) + if not isinstance(excluded_zones, MultiPolygon): + excluded_zones = MultiPolygon([excluded_zones]) + self.excluded_zones = excluded_zones + ip_heading = target.heading_between_point(ip) # Arbitrarily large since this is later constrained by the map boundary, and @@ -73,12 +82,14 @@ class JoinZoneGeometry: ] ) - self.permissible_line = ( - coalition.nav_mesh.map_bounds(theater) - .intersection(ip_direction_limit_wedge) - .intersection(self.excluded_zone.boundary) - ) + permissible_lines = ip_direction_limit_wedge.intersection( + self.excluded_zones.boundary + ).difference(self.home_bubble) + + if not isinstance(permissible_lines, MultiLineString): + permissible_lines = MultiLineString([permissible_lines]) + self.permissible_lines = permissible_lines def find_best_join_point(self) -> Point: - join, _ = shapely.ops.nearest_points(self.permissible_line, self.home) + join, _ = shapely.ops.nearest_points(self.permissible_lines, self.home) return Point(join.x, join.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 5c1c0820..4f28e4cf 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,7 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine -from game.flightplan import IpZoneGeometry, JoinZoneGeometry +from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry from game.theater import ( Airfield, ControlPoint, @@ -975,31 +975,6 @@ class FlightPlanBuilder: WaypointBuilder.perturb(join_point), ) - def legacy_package_waypoints_impl(self, ingress_point: Point) -> None: - from gen.ato import PackageWaypoints - - join_point = self._rendezvous_point(ingress_point) - self.package.waypoints = PackageWaypoints( - WaypointBuilder.perturb(join_point), - ingress_point, - WaypointBuilder.perturb(join_point), - ) - - def safe_points_between(self, a: Point, b: Point) -> Iterator[Point]: - for point in self.coalition.nav_mesh.shortest_path(a, b)[1:-1]: - if not self.threat_zones.threatened(point): - yield point - - def preferred_join_point(self, ingress_point: Point) -> Optional[Point]: - # Use non-threatened points along the path to the target as the join point. We - # may need to try more than one in the event that the close non-threatened - # points are closer than the ingress point itself. - for join_point in self.safe_points_between( - ingress_point, self.package_airfield().position - ): - return join_point - return None - def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. @@ -1675,48 +1650,10 @@ class FlightPlanBuilder: origin = flight.departure.position target = self.package.target.position join = self.package.waypoints.join - origin_to_join = origin.distance_to_point(join) - if meters(origin_to_join) < self.doctrine.push_distance: - # If the origin airfield is closer to the join point, than the minimum push - # distance. Plan the hold point such that it retreats from the origin - # airfield. - return join.point_from_heading( - target.heading_between_point(origin), self.doctrine.push_distance.meters - ) - - heading_to_join = origin.heading_between_point(join) - hold_point = origin.point_from_heading( - heading_to_join, self.doctrine.push_distance.meters - ) - hold_distance = meters(hold_point.distance_to_point(join)) - if hold_distance >= self.doctrine.push_distance: - # Hold point is between the origin airfield and the join point and - # spaced sufficiently. - return hold_point - - # The hold point is between the origin airfield and the join point, but - # the distance between the hold point and the join point is too short. - # Bend the hold point out to extend the distance while maintaining the - # minimum distance from the origin airfield to keep the AI flying - # properly. - origin_to_join = origin.distance_to_point(join) - cos_theta = ( - self.doctrine.hold_distance.meters ** 2 - + origin_to_join ** 2 - - self.doctrine.join_distance.meters ** 2 - ) / (2 * self.doctrine.hold_distance.meters * origin_to_join) - try: - theta = math.acos(cos_theta) - except ValueError: - # No solution that maintains hold and join distances. Extend the - # hold point away from the target. - return origin.point_from_heading( - target.heading_between_point(origin), self.doctrine.hold_distance.meters - ) - - return origin.point_from_heading( - heading_to_join - theta, self.doctrine.hold_distance.meters - ) + ip = self.package.waypoints.ingress + return HoldZoneGeometry( + target, origin, ip, join, self.coalition, self.theater + ).find_best_hold_point() # TODO: Make a model for the waypoint builder and use that in the UI. def generate_rtb_waypoint( @@ -1779,59 +1716,6 @@ class FlightPlanBuilder: lead_time=lead_time, ) - def _retreating_rendezvous_point(self, attack_transition: Point) -> Point: - """Creates a rendezvous point that retreats from the origin airfield.""" - return attack_transition.point_from_heading( - self.package.target.position.heading_between_point( - self.package_airfield().position - ), - self.doctrine.join_distance.meters, - ) - - def _advancing_rendezvous_point(self, attack_transition: Point) -> Point: - """Creates a rendezvous point that advances toward the target.""" - heading = self._heading_to_package_airfield(attack_transition) - return attack_transition.point_from_heading( - heading, -self.doctrine.join_distance.meters - ) - - def _rendezvous_should_retreat(self, attack_transition: Point) -> bool: - transition_target_distance = attack_transition.distance_to_point( - self.package.target.position - ) - origin_target_distance = self._distance_to_package_airfield( - self.package.target.position - ) - - # If the origin point is closer to the target than the ingress point, - # the rendezvous point should be positioned in a position that retreats - # from the origin airfield. - return origin_target_distance < transition_target_distance - - def _rendezvous_point(self, attack_transition: Point) -> Point: - """Returns the position of the rendezvous point. - - Args: - attack_transition: The ingress or target point for this rendezvous. - """ - if self._rendezvous_should_retreat(attack_transition): - return self._retreating_rendezvous_point(attack_transition) - return self._advancing_rendezvous_point(attack_transition) - - def _ingress_point(self, heading: float) -> Point: - return self.package.target.position.point_from_heading( - heading - 180, self.doctrine.max_ingress_distance.meters - ) - - def _target_heading_to_package_airfield(self) -> float: - return self._heading_to_package_airfield(self.package.target.position) - - def _heading_to_package_airfield(self, point: Point) -> float: - return self.package_airfield().position.heading_between_point(point) - - def _distance_to_package_airfield(self, point: Point) -> float: - return self.package_airfield().position.distance_to_point(point) - def package_airfield(self) -> ControlPoint: # We'll always have a package, but if this is being planned via the UI # it could be the first flight in the package. diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 09bc6022..e8a75298 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -8,11 +8,17 @@ from PySide2.QtCore import Property, QObject, Signal, Slot from dcs import Point from dcs.unit import Unit from dcs.vehicles import vehicle_map -from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPolygon +from shapely.geometry import ( + LineString, + Point as ShapelyPoint, + Polygon, + MultiPolygon, + MultiLineString, +) from game import Game from game.dcs.groundunittype import GroundUnitType -from game.flightplan import JoinZoneGeometry +from game.flightplan import JoinZoneGeometry, HoldZoneGeometry from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( @@ -89,6 +95,12 @@ def shapely_line_to_leaflet_points( return [theater.point_to_ll(Point(x, y)).as_list() for x, y in line.coords] +def shapely_lines_to_leaflet_points( + lines: MultiLineString, theater: ConflictTheater +) -> list[list[LeafletLatLon]]: + return [shapely_line_to_leaflet_points(l, theater) for l in lines.geoms] + + class ControlPointJs(QObject): nameChanged = Signal() blueChanged = Signal() @@ -802,36 +814,36 @@ class IpZonesJs(QObject): homeBubbleChanged = Signal() ipBubbleChanged = Signal() permissibleZoneChanged = Signal() - safeZoneChanged = Signal() + safeZonesChanged = Signal() def __init__( self, - home_bubble: list[LeafletPoly], - ip_bubble: list[LeafletPoly], - permissible_zone: list[LeafletPoly], - safe_zone: list[LeafletPoly], + home_bubble: LeafletPoly, + ip_bubble: LeafletPoly, + permissible_zone: LeafletPoly, + safe_zones: list[LeafletPoly], ) -> None: super().__init__() self._home_bubble = home_bubble self._ip_bubble = ip_bubble self._permissible_zone = permissible_zone - self._safe_zone = safe_zone + self._safe_zones = safe_zones @Property(list, notify=homeBubbleChanged) - def homeBubble(self) -> list[LeafletPoly]: + def homeBubble(self) -> LeafletPoly: return self._home_bubble @Property(list, notify=ipBubbleChanged) - def ipBubble(self) -> list[LeafletPoly]: + def ipBubble(self) -> LeafletPoly: return self._ip_bubble @Property(list, notify=permissibleZoneChanged) - def permissibleZone(self) -> list[LeafletPoly]: + def permissibleZone(self) -> LeafletPoly: return self._permissible_zone - @Property(list, notify=safeZoneChanged) - def safeZone(self) -> list[LeafletPoly]: - return self._safe_zone + @Property(list, notify=safeZonesChanged) + def safeZones(self) -> list[LeafletPoly]: + return self._safe_zones @classmethod def empty(cls) -> IpZonesJs: @@ -845,10 +857,10 @@ class IpZonesJs(QObject): home = flight.departure geometry = IpZoneGeometry(target.position, home.position, game.blue) return IpZonesJs( - shapely_to_leaflet_polys(geometry.home_bubble, game.theater), - shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), - shapely_to_leaflet_polys(geometry.permissible_zone, game.theater), - shapely_to_leaflet_polys(geometry.safe_zone, game.theater), + shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.permissible_zone, game.theater), + shapely_to_leaflet_polys(geometry.safe_zones, game.theater), ) @@ -856,43 +868,43 @@ class JoinZonesJs(QObject): homeBubbleChanged = Signal() targetBubbleChanged = Signal() ipBubbleChanged = Signal() - excludedZoneChanged = Signal() - permissibleLineChanged = Signal() + excludedZonesChanged = Signal() + permissibleLinesChanged = Signal() def __init__( self, - home_bubble: list[LeafletPoly], - target_bubble: list[LeafletPoly], - ip_bubble: list[LeafletPoly], - excluded_zone: list[LeafletPoly], - permissible_line: list[LeafletLatLon], + home_bubble: LeafletPoly, + target_bubble: LeafletPoly, + ip_bubble: LeafletPoly, + excluded_zones: list[LeafletPoly], + permissible_lines: list[list[LeafletLatLon]], ) -> None: super().__init__() self._home_bubble = home_bubble self._target_bubble = target_bubble self._ip_bubble = ip_bubble - self._excluded_zone = excluded_zone - self._permissible_line = permissible_line + self._excluded_zones = excluded_zones + self._permissible_lines = permissible_lines @Property(list, notify=homeBubbleChanged) - def homeBubble(self) -> list[LeafletPoly]: + def homeBubble(self) -> LeafletPoly: return self._home_bubble @Property(list, notify=targetBubbleChanged) - def targetBubble(self) -> list[LeafletPoly]: + def targetBubble(self) -> LeafletPoly: return self._target_bubble @Property(list, notify=ipBubbleChanged) - def ipBubble(self) -> list[LeafletPoly]: + def ipBubble(self) -> LeafletPoly: return self._ip_bubble - @Property(list, notify=excludedZoneChanged) - def excludedZone(self) -> list[LeafletPoly]: - return self._excluded_zone + @Property(list, notify=excludedZonesChanged) + def excludedZones(self) -> list[LeafletPoly]: + return self._excluded_zones - @Property(list, notify=permissibleLineChanged) - def permissibleLine(self) -> list[LeafletLatLon]: - return self._permissible_line + @Property(list, notify=permissibleLinesChanged) + def permissibleLines(self) -> list[list[LeafletLatLon]]: + return self._permissible_lines @classmethod def empty(cls) -> JoinZonesJs: @@ -911,11 +923,87 @@ class JoinZonesJs(QObject): target.position, home.position, ip, game.blue, game.theater ) return JoinZonesJs( - shapely_to_leaflet_polys(geometry.home_bubble, game.theater), - shapely_to_leaflet_polys(geometry.target_bubble, game.theater), - shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), - shapely_to_leaflet_polys(geometry.excluded_zone, game.theater), - shapely_line_to_leaflet_points(geometry.permissible_line, game.theater), + shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater), + shapely_to_leaflet_polys(geometry.excluded_zones, game.theater), + shapely_lines_to_leaflet_points(geometry.permissible_lines, game.theater), + ) + + +class HoldZonesJs(QObject): + homeBubbleChanged = Signal() + targetBubbleChanged = Signal() + joinBubbleChanged = Signal() + excludedZonesChanged = Signal() + permissibleZonesChanged = Signal() + permissibleLinesChanged = Signal() + + def __init__( + self, + home_bubble: LeafletPoly, + target_bubble: LeafletPoly, + join_bubble: LeafletPoly, + excluded_zones: list[LeafletPoly], + permissible_zones: list[LeafletPoly], + permissible_lines: list[list[LeafletLatLon]], + ) -> None: + super().__init__() + self._home_bubble = home_bubble + self._target_bubble = target_bubble + self._join_bubble = join_bubble + self._excluded_zones = excluded_zones + self._permissible_zones = permissible_zones + self._permissible_lines = permissible_lines + + @Property(list, notify=homeBubbleChanged) + def homeBubble(self) -> LeafletPoly: + return self._home_bubble + + @Property(list, notify=targetBubbleChanged) + def targetBubble(self) -> LeafletPoly: + return self._target_bubble + + @Property(list, notify=joinBubbleChanged) + def joinBubble(self) -> LeafletPoly: + return self._join_bubble + + @Property(list, notify=excludedZonesChanged) + def excludedZones(self) -> list[LeafletPoly]: + return self._excluded_zones + + @Property(list, notify=permissibleZonesChanged) + def permissibleZones(self) -> list[LeafletPoly]: + return self._permissible_zones + + @Property(list, notify=permissibleLinesChanged) + def permissibleLines(self) -> list[list[LeafletLatLon]]: + return self._permissible_lines + + @classmethod + def empty(cls) -> HoldZonesJs: + return HoldZonesJs([], [], [], [], [], []) + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> HoldZonesJs: + if not ENABLE_EXPENSIVE_DEBUG_TOOLS: + return JoinZonesJs.empty() + target = flight.package.target + home = flight.departure + if flight.package.waypoints is None: + return HoldZonesJs.empty() + ip = flight.package.waypoints.ingress + join = flight.package.waypoints.join + geometry = HoldZoneGeometry( + target.position, home.position, ip, join, game.blue, game.theater + ) + return HoldZonesJs( + shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.join_bubble, game.theater), + shapely_to_leaflet_polys(geometry.excluded_zones, game.theater), + shapely_to_leaflet_polys(geometry.permissible_zones, game.theater), + [], # shapely_to_leaflet_polys(geometry.permissible_lines, game.theater), ) @@ -934,6 +1022,7 @@ class MapModel(QObject): unculledZonesChanged = Signal() ipZonesChanged = Signal() joinZonesChanged = Signal() + holdZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -952,6 +1041,7 @@ class MapModel(QObject): self._unculled_zones = [] self._ip_zones = IpZonesJs.empty() self._join_zones = JoinZonesJs.empty() + self._hold_zones = HoldZonesJs.empty() self._selected_flight_index: Optional[Tuple[int, int]] = None GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) @@ -1067,11 +1157,14 @@ class MapModel(QObject): if selected_flight is None: self._ip_zones = IpZonesJs.empty() self._join_zones = JoinZonesJs.empty() + self._hold_zones = HoldZonesJs.empty() else: self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) self._join_zones = JoinZonesJs.for_flight(selected_flight, self.game) + self._hold_zones = HoldZonesJs.for_flight(selected_flight, self.game) self.ipZonesChanged.emit() self.joinZonesChanged.emit() + self.holdZonesChanged.emit() @Property(list, notify=flightsChanged) def flights(self) -> List[FlightJs]: @@ -1208,6 +1301,10 @@ class MapModel(QObject): def joinZones(self) -> JoinZonesJs: return self._join_zones + @Property(HoldZonesJs, notify=holdZonesChanged) + def holdZones(self) -> HoldZonesJs: + return self._hold_zones + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 87e4ca7e..7ac1dac2 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -198,8 +198,10 @@ const exclusionZones = L.layerGroup(); const seaZones = L.layerGroup(); const unculledZones = L.layerGroup(); +const noWaypointZones = L.layerGroup(); const ipZones = L.layerGroup(); -const joinZones = L.layerGroup().addTo(map); +const joinZones = L.layerGroup(); +const holdZones = L.layerGroup().addTo(map); const debugControlGroups = { "Blue Threat Zones": { @@ -231,8 +233,10 @@ const debugControlGroups = { if (ENABLE_EXPENSIVE_DEBUG_TOOLS) { debugControlGroups["Waypoint Zones"] = { + None: noWaypointZones, "IP Zones": ipZones, "Join Zones": joinZones, + "Hold Zones": holdZones, }; } @@ -310,6 +314,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.unculledZonesChanged.connect(drawUnculledZones); game.ipZonesChanged.connect(drawIpZones); game.joinZonesChanged.connect(drawJoinZones); + game.holdZonesChanged.connect(drawHoldZones); }); function recenterMap(center) { @@ -1014,11 +1019,13 @@ function drawIpZones() { interactive: false, }).addTo(ipZones); - L.polygon(game.ipZones.safeZone, { - color: Colors.Green, - fillOpacity: 0.1, - interactive: false, - }).addTo(ipZones); + for (const zone of game.ipZones.safeZones) { + L.polygon(zone, { + color: Colors.Green, + fillOpacity: 0.1, + interactive: false, + }).addTo(ipZones); + } } function drawJoinZones() { @@ -1042,17 +1049,59 @@ function drawJoinZones() { interactive: false, }).addTo(joinZones); - L.polygon(game.joinZones.excludedZone, { - color: "#ffa500", - fillOpacity: 0.2, - stroke: false, - interactive: false, - }).addTo(joinZones); + for (const zone of game.joinZones.excludedZones) { + L.polygon(zone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(joinZones); + } - L.polyline(game.joinZones.permissibleLine, { - color: Colors.Green, + for (const line of game.joinZones.permissibleLines) { + L.polyline(line, { + color: Colors.Green, + interactive: false, + }).addTo(joinZones); + } +} + +function drawHoldZones() { + holdZones.clearLayers(); + + L.polygon(game.holdZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, interactive: false, - }).addTo(joinZones); + }).addTo(holdZones); + + L.polygon(game.holdZones.targetBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(holdZones); + + L.polygon(game.holdZones.joinBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(holdZones); + + for (const zone of game.holdZones.excludedZones) { + L.polygon(zone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(holdZones); + } + + for (const zone of game.holdZones.permissibleZones) { + L.polygon(zone, { + color: Colors.Green, + interactive: false, + }).addTo(holdZones); + } } function drawInitialMap() { @@ -1068,6 +1117,7 @@ function drawInitialMap() { drawUnculledZones(); drawIpZones(); drawJoinZones(); + drawHoldZones(); } function clearAllLayers() { From ee77516716f0a8ef43e0339e44ba3c553dd345c1 Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Fri, 16 Jul 2021 00:17:20 -0700 Subject: [PATCH 086/167] Replace TGP with SPJ for JF-17 CAP/SEAD. Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1422. --- resources/customized_payloads/JF-17.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/customized_payloads/JF-17.lua b/resources/customized_payloads/JF-17.lua index 8e135655..b4c0e4b5 100644 --- a/resources/customized_payloads/JF-17.lua +++ b/resources/customized_payloads/JF-17.lua @@ -77,7 +77,7 @@ local unitPayloads = { ["num"] = 3, }, [3] = { - ["CLSID"] = "DIS_WMD7", + ["CLSID"] = "DIS_SPJ_POD", ["num"] = 4, }, [4] = { @@ -107,7 +107,7 @@ local unitPayloads = { ["name"] = "SEAD", ["pylons"] = { [1] = { - ["CLSID"] = "DIS_WMD7", + ["CLSID"] = "DIS_SPJ_POD", ["num"] = 4, }, [2] = { From 1b640f40dca364f25c2b5ce0582eb50a85bc5fd4 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 00:26:42 -0700 Subject: [PATCH 087/167] Fix map issues when debugging tools are disabled. --- resources/ui/map/map.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 7ac1dac2..831701a9 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -1001,6 +1001,10 @@ function drawUnculledZones() { function drawIpZones() { ipZones.clearLayers(); + if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { + return; + } + L.polygon(game.ipZones.homeBubble, { color: Colors.Highlight, fillOpacity: 0.1, @@ -1031,6 +1035,10 @@ function drawIpZones() { function drawJoinZones() { joinZones.clearLayers(); + if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { + return; + } + L.polygon(game.joinZones.homeBubble, { color: Colors.Highlight, fillOpacity: 0.1, @@ -1069,6 +1077,10 @@ function drawJoinZones() { function drawHoldZones() { holdZones.clearLayers(); + if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { + return; + } + L.polygon(game.holdZones.homeBubble, { color: Colors.Highlight, fillOpacity: 0.1, From e5c0fc92ecb8d8911d3d4a515f3d7cd8dce82e4e Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 01:06:31 -0700 Subject: [PATCH 088/167] Don't reload weapon data if already loaded. --- game/data/weapons.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/game/data/weapons.py b/game/data/weapons.py index 5d0b0dd1..6f2889ec 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -181,6 +181,8 @@ class WeaponGroup: @classmethod def load_all(cls) -> None: + if cls._loaded: + return seen_clsids: set[str] = set() for group in cls._each_weapon_group(): cls.register(group) From 04a346678c566008de1ce872aa1a0091b0be358f Mon Sep 17 00:00:00 2001 From: Magnus Wolffelt Date: Fri, 16 Jul 2021 23:08:14 +0200 Subject: [PATCH 089/167] Add situational temperature and pressure variation. Now varies by: * Season * Theater * Weather * Time of day --- game/theater/conflicttheater.py | 85 ++++++++++++++++++++++ game/utils.py | 12 ++++ game/weather.py | 124 ++++++++++++++++++++++++-------- 3 files changed, 192 insertions(+), 29 deletions(-) diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index e0f4d69a..43cd2c9d 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -497,6 +497,17 @@ class ReferencePoint: image_coordinates: Point +@dataclass(frozen=True) +class SeasonalConditions: + # Units are inHg and degrees Celsius + # Future improvement: add clouds/precipitation + summer_avg_pressure: float + winter_avg_pressure: float + summer_avg_temperature: float + winter_avg_temperature: float + temperature_day_night_difference: float + + class ConflictTheater: terrain: Terrain @@ -719,6 +730,10 @@ class ConflictTheater: MizCampaignLoader(directory / miz, t).populate_theater() return t + @property + def seasonal_conditions(self) -> SeasonalConditions: + raise NotImplementedError + @property def projection_parameters(self) -> TransverseMercator: raise NotImplementedError @@ -748,6 +763,16 @@ class CaucasusTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=30.02, # TODO: More science + winter_avg_pressure=29.72, # TODO: More science + summer_avg_temperature=22.5, + winter_avg_temperature=3.0, + temperature_day_night_difference=6.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .caucasus import PARAMETERS @@ -770,6 +795,16 @@ class PersianGulfTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=29.98, # TODO: More science + winter_avg_pressure=29.80, # TODO: More science + summer_avg_temperature=32.5, + winter_avg_temperature=15.0, + temperature_day_night_difference=2.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .persiangulf import PARAMETERS @@ -792,6 +827,16 @@ class NevadaTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=30.02, # TODO: More science + winter_avg_pressure=29.72, # TODO: More science + summer_avg_temperature=31.5, + winter_avg_temperature=5.0, + temperature_day_night_difference=6.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .nevada import PARAMETERS @@ -814,6 +859,16 @@ class NormandyTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=30.02, # TODO: More science + winter_avg_pressure=29.72, # TODO: More science + summer_avg_temperature=20.0, + winter_avg_temperature=0.0, + temperature_day_night_difference=5.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .normandy import PARAMETERS @@ -836,6 +891,16 @@ class TheChannelTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=30.02, # TODO: More science + winter_avg_pressure=29.72, # TODO: More science + summer_avg_temperature=20.0, + winter_avg_temperature=0.0, + temperature_day_night_difference=5.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .thechannel import PARAMETERS @@ -858,6 +923,16 @@ class SyriaTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=29.98, # TODO: More science + winter_avg_pressure=29.86, # TODO: More science + summer_avg_temperature=28.5, + winter_avg_temperature=10.0, + temperature_day_night_difference=8.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .syria import PARAMETERS @@ -877,6 +952,16 @@ class MarianaIslandsTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=30.02, # TODO: More science + winter_avg_pressure=29.82, # TODO: More science + summer_avg_temperature=28.0, + winter_avg_temperature=27.0, + temperature_day_night_difference=1.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .marianaislands import PARAMETERS diff --git a/game/utils.py b/game/utils.py index 2370c56f..291e098b 100644 --- a/game/utils.py +++ b/game/utils.py @@ -189,3 +189,15 @@ def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]: a, b = itertools.tee(iterable) next(b, None) return zip(a, b) + + +def interpolate(value1: float, value2: float, factor: float, clamp: bool) -> float: + """Inerpolate between two values, factor 0-1""" + interpolated = value1 + (value2 - value1) * factor + + if clamp: + bigger_value = max(value1, value2) + smaller_value = min(value1, value2) + return min(bigger_value, max(smaller_value, interpolated)) + else: + return interpolated diff --git a/game/weather.py b/game/weather.py index fae1d5a0..ae31fa7f 100644 --- a/game/weather.py +++ b/game/weather.py @@ -11,10 +11,11 @@ from dcs.cloud_presets import Clouds as PydcsClouds from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind from game.settings import Settings -from game.utils import Distance, meters +from game.utils import Distance, meters, interpolate if TYPE_CHECKING: from game.theater import ConflictTheater + from game.theater.conflicttheater import SeasonalConditions class TimeOfDay(Enum): @@ -71,15 +72,56 @@ class Fog: class Weather: - def __init__(self) -> None: + def __init__( + self, + seasonal_conditions: SeasonalConditions, + day: datetime.date, + time_of_day: TimeOfDay, + ) -> None: # Future improvement: Use theater, day and time of day # to get a more realistic conditions - self.atmospheric = self.generate_atmospheric() + self.atmospheric = self.generate_atmospheric( + seasonal_conditions, day, time_of_day + ) self.clouds = self.generate_clouds() self.fog = self.generate_fog() self.wind = self.generate_wind() - def generate_atmospheric(self) -> AtmosphericConditions: + def generate_atmospheric( + self, + seasonal_conditions: SeasonalConditions, + day: datetime.date, + time_of_day: TimeOfDay, + ) -> AtmosphericConditions: + pressure = self.interpolate_summer_winter( + seasonal_conditions.summer_avg_pressure, + seasonal_conditions.winter_avg_pressure, + day, + ) + temperature = self.interpolate_summer_winter( + seasonal_conditions.summer_avg_temperature, + seasonal_conditions.winter_avg_temperature, + day, + ) + + if time_of_day == TimeOfDay.Day: + temperature += seasonal_conditions.temperature_day_night_difference / 2 + if time_of_day == TimeOfDay.Night: + temperature -= seasonal_conditions.temperature_day_night_difference / 2 + pressure += self.pressure_adjustment + temperature += self.temperature_adjustment + conditions = AtmosphericConditions( + qnh_inches_mercury=self.random_pressure(pressure), + temperature_celsius=self.random_temperature(temperature), + ) + return conditions + + @property + def pressure_adjustment(self) -> float: + raise NotImplementedError + + @property + def temperature_adjustment(self) -> float: raise NotImplementedError def generate_clouds(self) -> Optional[Clouds]: @@ -126,7 +168,7 @@ class Weather: SAFE_MIN = 28.4 SAFE_MAX = 30.9 # Use normalvariate to get normal distribution, more realistic than uniform - pressure = random.normalvariate(average_pressure, 0.2) + pressure = random.normalvariate(average_pressure, 0.1) return max(SAFE_MIN, min(SAFE_MAX, pressure)) @staticmethod @@ -136,17 +178,29 @@ class Weather: SAFE_MIN = -12 SAFE_MAX = 49 # Use normalvariate to get normal distribution, more realistic than uniform - temperature = random.normalvariate(average_temperature, 4) + temperature = random.normalvariate(average_temperature, 2) temperature = round(temperature) return max(SAFE_MIN, min(SAFE_MAX, temperature)) + @staticmethod + def interpolate_summer_winter( + summer_value: float, winter_value: float, day: datetime.date + ) -> float: + day_of_year = day.timetuple().tm_yday + day_of_year_peak_summer = 183 + distance_from_peak_summer = abs(-day_of_year_peak_summer + day_of_year) + winter_factor = distance_from_peak_summer / day_of_year_peak_summer + return interpolate(summer_value, winter_value, winter_factor, clamp=True) + class ClearSkies(Weather): - def generate_atmospheric(self) -> AtmosphericConditions: - return AtmosphericConditions( - qnh_inches_mercury=self.random_pressure(29.96), - temperature_celsius=self.random_temperature(22), - ) + @property + def pressure_adjustment(self) -> float: + return 0.22 + + @property + def temperature_adjustment(self) -> float: + return 3.0 def generate_clouds(self) -> Optional[Clouds]: return None @@ -159,11 +213,13 @@ class ClearSkies(Weather): class Cloudy(Weather): - def generate_atmospheric(self) -> AtmosphericConditions: - return AtmosphericConditions( - qnh_inches_mercury=self.random_pressure(29.90), - temperature_celsius=self.random_temperature(20), - ) + @property + def pressure_adjustment(self) -> float: + return 0.0 + + @property + def temperature_adjustment(self) -> float: + return 0.0 def generate_clouds(self) -> Optional[Clouds]: return Clouds.random_preset(rain=False) @@ -177,11 +233,13 @@ class Cloudy(Weather): class Raining(Weather): - def generate_atmospheric(self) -> AtmosphericConditions: - return AtmosphericConditions( - qnh_inches_mercury=self.random_pressure(29.70), - temperature_celsius=self.random_temperature(16), - ) + @property + def pressure_adjustment(self) -> float: + return -0.22 + + @property + def temperature_adjustment(self) -> float: + return -3.0 def generate_clouds(self) -> Optional[Clouds]: return Clouds.random_preset(rain=True) @@ -195,11 +253,13 @@ class Raining(Weather): class Thunderstorm(Weather): - def generate_atmospheric(self) -> AtmosphericConditions: - return AtmosphericConditions( - qnh_inches_mercury=self.random_pressure(29.60), - temperature_celsius=self.random_temperature(15), - ) + @property + def pressure_adjustment(self) -> float: + return 0.1 + + @property + def temperature_adjustment(self) -> float: + return -3.0 def generate_clouds(self) -> Optional[Clouds]: return Clouds( @@ -233,7 +293,7 @@ class Conditions: return cls( time_of_day=time_of_day, start_time=_start_time, - weather=cls.generate_weather(), + weather=cls.generate_weather(theater.seasonal_conditions, day, time_of_day), ) @classmethod @@ -259,7 +319,13 @@ class Conditions: return datetime.datetime.combine(day, time) @classmethod - def generate_weather(cls) -> Weather: + def generate_weather( + cls, + seasonal_conditions: SeasonalConditions, + day: datetime.date, + time_of_day: TimeOfDay, + ) -> Weather: + # Future improvement: use seasonal weights for theaters chances = { Thunderstorm: 1, Raining: 20, @@ -269,4 +335,4 @@ class Conditions: weather_type = random.choices( list(chances.keys()), weights=list(chances.values()) )[0] - return weather_type() + return weather_type(seasonal_conditions, day, time_of_day) From 771c74ee7590d698e7538f836e9a7d57c3c01480 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 15:07:14 -0700 Subject: [PATCH 090/167] Fill out weapon data for AIM-9s. --- resources/weapons/a2a-missiles/AIM-120B-2X.yaml | 3 ++- resources/weapons/a2a-missiles/AIM-7E.yaml | 1 + resources/weapons/a2a-missiles/AIM-9L-2X.yaml | 8 ++++++++ resources/weapons/a2a-missiles/AIM-9L.yaml | 11 +++++++++++ resources/weapons/a2a-missiles/AIM-9M-2X.yaml | 7 +++++++ resources/weapons/a2a-missiles/AIM-9M.yaml | 13 +++++++++++++ resources/weapons/a2a-missiles/AIM-9P-2X.yaml | 6 ++++++ resources/weapons/a2a-missiles/AIM-9P.yaml | 6 ++++++ resources/weapons/a2a-missiles/AIM-9P5-2X.yaml | 6 ++++++ resources/weapons/a2a-missiles/AIM-9P5.yaml | 6 ++++++ resources/weapons/a2a-missiles/AIM-9X-2X.yaml | 5 +++++ resources/weapons/a2a-missiles/AIM-9X.yaml | 9 +++++++++ 12 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 resources/weapons/a2a-missiles/AIM-9L-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9L.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9M-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9M.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9P-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9P.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9P5-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9P5.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9X-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9X.yaml diff --git a/resources/weapons/a2a-missiles/AIM-120B-2X.yaml b/resources/weapons/a2a-missiles/AIM-120B-2X.yaml index 562fcb0f..736cb20a 100644 --- a/resources/weapons/a2a-missiles/AIM-120B-2X.yaml +++ b/resources/weapons/a2a-missiles/AIM-120B-2X.yaml @@ -1,5 +1,6 @@ name: 2xAIM-120B year: 1994 -fallback: AIM-7MH +# If we've run out of doubles, start over with the singles. +fallback: AIM-120C clsids: - "LAU-115_2*LAU-127_AIM-120B" diff --git a/resources/weapons/a2a-missiles/AIM-7E.yaml b/resources/weapons/a2a-missiles/AIM-7E.yaml index 16e60733..f4231011 100644 --- a/resources/weapons/a2a-missiles/AIM-7E.yaml +++ b/resources/weapons/a2a-missiles/AIM-7E.yaml @@ -1,5 +1,6 @@ name: AIM-7E year: 1963 +fallback: AIM-9X clsids: - "{AIM-7E}" - "{LAU-115 - AIM-7E}" diff --git a/resources/weapons/a2a-missiles/AIM-9L-2X.yaml b/resources/weapons/a2a-missiles/AIM-9L-2X.yaml new file mode 100644 index 00000000..30734034 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9L-2X.yaml @@ -0,0 +1,8 @@ +name: 2xAIM-9L +year: 1977 +# If we've run out of doubles, start over with the singles. +fallback: AIM-9X +clsids: + - "LAU-105_2*AIM-9L" + - "LAU-115_2*LAU-127_AIM-9L" + - "{F4-2-AIM9L}" diff --git a/resources/weapons/a2a-missiles/AIM-9L.yaml b/resources/weapons/a2a-missiles/AIM-9L.yaml new file mode 100644 index 00000000..b06c6fe9 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9L.yaml @@ -0,0 +1,11 @@ +name: AIM-9L +year: 1977 +clsids: + - "{AIM-9L}" + - "LAU-105_1*AIM-9L_L" + - "LAU-105_1*AIM-9L_R" + - "LAU-115_LAU-127_AIM-9L" + - "LAU-115_LAU-127_AIM-9L_R" + - "LAU-127_AIM-9L" + - "{LAU-138 wtip - AIM-9L}" + - "{LAU-7 - AIM-9L}" diff --git a/resources/weapons/a2a-missiles/AIM-9M-2X.yaml b/resources/weapons/a2a-missiles/AIM-9M-2X.yaml new file mode 100644 index 00000000..d985f95a --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9M-2X.yaml @@ -0,0 +1,7 @@ +name: 2xAIM-9M +year: 1982 +fallback: 2xAIM-9P5 +clsids: + - "{DB434044-F5D0-4F1F-9BA9-B73027E18DD3}" + - "LAU-115_2*LAU-127_AIM-9M" + - "{9DDF5297-94B9-42FC-A45E-6E316121CD85}" diff --git a/resources/weapons/a2a-missiles/AIM-9M.yaml b/resources/weapons/a2a-missiles/AIM-9M.yaml new file mode 100644 index 00000000..ee82d32f --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9M.yaml @@ -0,0 +1,13 @@ +name: AIM-9M +year: 1982 +fallback: AIM-9P5 +clsids: + - "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}" + - "LAU-105_1*AIM-9M_L" + - "LAU-105_1*AIM-9M_R" + - "LAU-115_LAU-127_AIM-9M" + - "LAU-115_LAU-127_AIM-9M_R" + - "LAU-127_AIM-9M" + - "{LAU-138 wtip - AIM-9M}" + - "{LAU-7 - AIM-9M}" + - "{AIM-9M-ON-ADAPTER}" diff --git a/resources/weapons/a2a-missiles/AIM-9P-2X.yaml b/resources/weapons/a2a-missiles/AIM-9P-2X.yaml new file mode 100644 index 00000000..5625d97e --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9P-2X.yaml @@ -0,0 +1,6 @@ +name: 2xAIM-9P +year: 1978 +fallback: 2xAIM-9L +clsids: + - "{3C0745ED-8B0B-42eb-B907-5BD5C1717447}" + - "{773675AB-7C29-422f-AFD8-32844A7B7F17}" diff --git a/resources/weapons/a2a-missiles/AIM-9P.yaml b/resources/weapons/a2a-missiles/AIM-9P.yaml new file mode 100644 index 00000000..52a1b092 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9P.yaml @@ -0,0 +1,6 @@ +name: AIM-9P +year: 1978 +fallback: AIM-9L +clsids: + - "{9BFD8C90-F7AE-4e90-833B-BFD0CED0E536}" + - "{AIM-9P-ON-ADAPTER}" diff --git a/resources/weapons/a2a-missiles/AIM-9P5-2X.yaml b/resources/weapons/a2a-missiles/AIM-9P5-2X.yaml new file mode 100644 index 00000000..9b35d4b3 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9P5-2X.yaml @@ -0,0 +1,6 @@ +name: 2xAIM-9P5 +year: 1980 +fallback: 2xAIM-9P +clsids: + - "LAU-105_2*AIM-9P5" + - "{F4-2-AIM9P5}" diff --git a/resources/weapons/a2a-missiles/AIM-9P5.yaml b/resources/weapons/a2a-missiles/AIM-9P5.yaml new file mode 100644 index 00000000..0da82ebf --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9P5.yaml @@ -0,0 +1,6 @@ +name: AIM-9P5 +year: 1980 +fallback: AIM-9P +clsids: + - "{AIM-9P5}" + - "{AIM-9P5-ON-ADAPTER}" diff --git a/resources/weapons/a2a-missiles/AIM-9X-2X.yaml b/resources/weapons/a2a-missiles/AIM-9X-2X.yaml new file mode 100644 index 00000000..3496f279 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9X-2X.yaml @@ -0,0 +1,5 @@ +name: 2xAIM-9X +year: 2003 +fallback: 2xAIM-9M +clsids: + - "LAU-115_2*LAU-127_AIM-9X" diff --git a/resources/weapons/a2a-missiles/AIM-9X.yaml b/resources/weapons/a2a-missiles/AIM-9X.yaml new file mode 100644 index 00000000..bec1ddc0 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9X.yaml @@ -0,0 +1,9 @@ +name: AIM-9X +year: 2003 +fallback: AIM-9M +clsids: + - "{5CE2FF2A-645A-4197-B48D-8720AC69394F}" + - "LAU-115_LAU-127_AIM-9X" + - "LAU-115_LAU-127_AIM-9X_R" + - "LAU-127_AIM-9X" + - "{AIM-9X-ON-ADAPTER}" From bb46d00f22794446cc8708c1cfebf19419743584 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 15:22:11 -0700 Subject: [PATCH 091/167] Add weapon data for R-77, R-27, and R-24. --- resources/weapons/a2a-missiles/R-24R.yaml | 4 ++++ resources/weapons/a2a-missiles/R-24T.yaml | 4 ++++ resources/weapons/a2a-missiles/R-27ER.yaml | 5 +++++ resources/weapons/a2a-missiles/R-27ET.yaml | 5 +++++ resources/weapons/a2a-missiles/R-27R.yaml | 5 +++++ resources/weapons/a2a-missiles/R-27T.yaml | 5 +++++ resources/weapons/a2a-missiles/R-77.yaml | 6 ++++++ 7 files changed, 34 insertions(+) create mode 100644 resources/weapons/a2a-missiles/R-24R.yaml create mode 100644 resources/weapons/a2a-missiles/R-24T.yaml create mode 100644 resources/weapons/a2a-missiles/R-27ER.yaml create mode 100644 resources/weapons/a2a-missiles/R-27ET.yaml create mode 100644 resources/weapons/a2a-missiles/R-27R.yaml create mode 100644 resources/weapons/a2a-missiles/R-27T.yaml create mode 100644 resources/weapons/a2a-missiles/R-77.yaml diff --git a/resources/weapons/a2a-missiles/R-24R.yaml b/resources/weapons/a2a-missiles/R-24R.yaml new file mode 100644 index 00000000..0865bfe2 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-24R.yaml @@ -0,0 +1,4 @@ +name: R-24R +year: 1981 +clsids: + - "{CCF898C9-5BC7-49A4-9D1E-C3ED3D5166A1}" diff --git a/resources/weapons/a2a-missiles/R-24T.yaml b/resources/weapons/a2a-missiles/R-24T.yaml new file mode 100644 index 00000000..f5f64531 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-24T.yaml @@ -0,0 +1,4 @@ +name: R-24T +year: 1981 +clsids: + - "{6980735A-44CC-4BB9-A1B5-591532F1DC69}" diff --git a/resources/weapons/a2a-missiles/R-27ER.yaml b/resources/weapons/a2a-missiles/R-27ER.yaml new file mode 100644 index 00000000..f3f56749 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-27ER.yaml @@ -0,0 +1,5 @@ +name: R-27ER +year: 1983 +fallback: R-27R +clsids: + - "{E8069896-8435-4B90-95C0-01A03AE6E400}" diff --git a/resources/weapons/a2a-missiles/R-27ET.yaml b/resources/weapons/a2a-missiles/R-27ET.yaml new file mode 100644 index 00000000..c304bb76 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-27ET.yaml @@ -0,0 +1,5 @@ +name: R-27ET +year: 1986 +fallback: R-27T +clsids: + - "{B79C379A-9E87-4E50-A1EE-7F7E29C2E87A}" diff --git a/resources/weapons/a2a-missiles/R-27R.yaml b/resources/weapons/a2a-missiles/R-27R.yaml new file mode 100644 index 00000000..3f9edc94 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-27R.yaml @@ -0,0 +1,5 @@ +name: R-27R +year: 1983 +fallback: R-24R +clsids: + - "{9B25D316-0434-4954-868F-D51DB1A38DF0}" diff --git a/resources/weapons/a2a-missiles/R-27T.yaml b/resources/weapons/a2a-missiles/R-27T.yaml new file mode 100644 index 00000000..c9232ef6 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-27T.yaml @@ -0,0 +1,5 @@ +name: R-27T +year: 1983 +fallback: R-24T +clsids: + - "{88DAC840-9F75-4531-8689-B46E64E42E53}" diff --git a/resources/weapons/a2a-missiles/R-77.yaml b/resources/weapons/a2a-missiles/R-77.yaml new file mode 100644 index 00000000..13eb3074 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-77.yaml @@ -0,0 +1,6 @@ +name: R-77 +year: 2002 +fallback: R-27ER +clsids: + - "{B4C01D60-A8A3-4237-BD72-CA7655BC0FE9}" + - "{B4C01D60-A8A3-4237-BD72-CA7655BC0FEC}" From aa3d644f97478af6b6027439f311a6256d79bb17 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 15:27:32 -0700 Subject: [PATCH 092/167] Prevent empty cheek stations for the Hornet. This is a bit of a hack that makes the TGPs fall back to AIM-120s. It works okay because this only applies to a few cases: The A-10 gets an empty pylon. That's fine. Maybe later we can add multiple fallback paths and depth-first-search through them so that that pylon could carry bombs instead. The Viper has no replacemnt for that station. The jammer goes on the other fuselage station, the HTS isn't a replacement, and we don't have LANTIRN for the Viper. No weapons can be fit to those stations. What this helps is the Hornet, where any Gulf War scenario ends up with an empty cheek station because we don't have the NITE HAWK to fall back to. In this case we can instead fall back through the air-to-air missiles to fill the station. --- resources/weapons/pods/atflir.yaml | 3 +++ resources/weapons/pods/litening.yaml | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/resources/weapons/pods/atflir.yaml b/resources/weapons/pods/atflir.yaml index 64ef6833..3733a299 100644 --- a/resources/weapons/pods/atflir.yaml +++ b/resources/weapons/pods/atflir.yaml @@ -1,4 +1,7 @@ name: AN/ASQ-228 ATFLIR year: 2003 +# A bit of a hack, but fixes the common case where the Hornet cheek station is +# empty because no TGP is available. +fallback: AIM-120C clsids: - "{AN_ASQ_228}" diff --git a/resources/weapons/pods/litening.yaml b/resources/weapons/pods/litening.yaml index 0ea9db08..e6fd5141 100644 --- a/resources/weapons/pods/litening.yaml +++ b/resources/weapons/pods/litening.yaml @@ -1,5 +1,14 @@ name: AN/AAQ-28 LITENING year: 1999 +# A bit of a hack, but fixes the common case where the Hornet cheek station is +# empty because no TGP is available. For the Viper this will have no effect +# because missiles can't be put on that station, but for the Viper an empty +# pylon is the correct replacement for a TGP anyway (the jammer goes on the +# other fuselage station, HTS isn't a good replacement, and we don't have +# LANTIRN for the Viper). +# +# For the A-10 an empty pylon is also fine. +fallback: AIM-120C clsids: - "{A111396E-D3E8-4b9c-8AC9-2432489304D5}" - "{AAQ-28_LEFT}" From b733e6855b543134dab8ffb037ecdb9829649503 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 15:48:12 -0700 Subject: [PATCH 093/167] Add SLAM/SLAM-ER weapon data. --- resources/weapons/standoff/AGM-84E.yaml | 6 ++++++ resources/weapons/standoff/AGM-84H.yaml | 5 +++++ 2 files changed, 11 insertions(+) create mode 100644 resources/weapons/standoff/AGM-84E.yaml create mode 100644 resources/weapons/standoff/AGM-84H.yaml diff --git a/resources/weapons/standoff/AGM-84E.yaml b/resources/weapons/standoff/AGM-84E.yaml new file mode 100644 index 00000000..f1041fb9 --- /dev/null +++ b/resources/weapons/standoff/AGM-84E.yaml @@ -0,0 +1,6 @@ +name: AGM-84E SLAM +year: 1990 +fallback: AGM-62 Walleye II +clsids: + - "{AF42E6DF-9A60-46D8-A9A0-1708B241AADB}" + - "{AGM_84E}" diff --git a/resources/weapons/standoff/AGM-84H.yaml b/resources/weapons/standoff/AGM-84H.yaml new file mode 100644 index 00000000..38d1bf02 --- /dev/null +++ b/resources/weapons/standoff/AGM-84H.yaml @@ -0,0 +1,5 @@ +name: AGM-84H SLAM-ER +year: 2000 +fallback: AGM-84E SLAM +clsids: + - "{AGM_84H}" From 11c2d4ab2591e7264ad144ce9df3fd5cd848d82b Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 16:23:22 -0700 Subject: [PATCH 094/167] Add JDAMs and their fallbacks. Hornet should be compatible with 1990 campaigns now. Air-to-ground weapon restrictions are less interesting for AI aircraft so I haven't covered *all* the variants here (the >2 variants of each carried by the B1 and such). --- resources/weapons/bombs/GBU-10-2X.yaml | 4 ++++ resources/weapons/bombs/GBU-10.yaml | 6 ++++++ resources/weapons/bombs/GBU-12-2X.yaml | 13 +++++++++++++ resources/weapons/bombs/GBU-12.yaml | 6 ++++++ resources/weapons/bombs/GBU-16-2X.yaml | 6 ++++++ resources/weapons/bombs/GBU-16.yaml | 6 ++++++ resources/weapons/bombs/GBU-24.yaml | 7 +++++++ resources/weapons/bombs/GBU-31V1B.yaml | 5 +++++ resources/weapons/bombs/GBU-31V2B.yaml | 5 +++++ resources/weapons/bombs/GBU-31V3B.yaml | 5 +++++ resources/weapons/bombs/GBU-31V4B.yaml | 5 +++++ resources/weapons/bombs/GBU-32V2B.yaml | 5 +++++ resources/weapons/bombs/GBU-38-2X.yaml | 8 ++++++++ resources/weapons/bombs/GBU-38.yaml | 5 +++++ 14 files changed, 86 insertions(+) create mode 100644 resources/weapons/bombs/GBU-10-2X.yaml create mode 100644 resources/weapons/bombs/GBU-10.yaml create mode 100644 resources/weapons/bombs/GBU-12-2X.yaml create mode 100644 resources/weapons/bombs/GBU-12.yaml create mode 100644 resources/weapons/bombs/GBU-16-2X.yaml create mode 100644 resources/weapons/bombs/GBU-16.yaml create mode 100644 resources/weapons/bombs/GBU-24.yaml create mode 100644 resources/weapons/bombs/GBU-31V1B.yaml create mode 100644 resources/weapons/bombs/GBU-31V2B.yaml create mode 100644 resources/weapons/bombs/GBU-31V3B.yaml create mode 100644 resources/weapons/bombs/GBU-31V4B.yaml create mode 100644 resources/weapons/bombs/GBU-32V2B.yaml create mode 100644 resources/weapons/bombs/GBU-38-2X.yaml create mode 100644 resources/weapons/bombs/GBU-38.yaml diff --git a/resources/weapons/bombs/GBU-10-2X.yaml b/resources/weapons/bombs/GBU-10-2X.yaml new file mode 100644 index 00000000..0f261926 --- /dev/null +++ b/resources/weapons/bombs/GBU-10-2X.yaml @@ -0,0 +1,4 @@ +name: 2xGBU-10 +year: 1976 +clsids: + - "{62BE78B1-9258-48AE-B882-279534C0D278}" diff --git a/resources/weapons/bombs/GBU-10.yaml b/resources/weapons/bombs/GBU-10.yaml new file mode 100644 index 00000000..36e30965 --- /dev/null +++ b/resources/weapons/bombs/GBU-10.yaml @@ -0,0 +1,6 @@ +name: GBU-10 +year: 1976 +clsids: + - "DIS_GBU_10" + - "{BRU-32 GBU-10}" + - "{51F9AAE5-964F-4D21-83FB-502E3BFE5F8A}" diff --git a/resources/weapons/bombs/GBU-12-2X.yaml b/resources/weapons/bombs/GBU-12-2X.yaml new file mode 100644 index 00000000..282667c7 --- /dev/null +++ b/resources/weapons/bombs/GBU-12-2X.yaml @@ -0,0 +1,13 @@ +name: 2xGBU-12 +year: 1976 +clsids: + - "{M2KC_RAFAUT_GBU12}" + - "{BRU33_2X_GBU-12}" + - "DIS_GBU_12_DUAL_GDJ_II19_L" + - "DIS_GBU_12_DUAL_GDJ_II19_R" + - "{TER_9A_2L*GBU-12}" + - "{TER_9A_2R*GBU-12}" + - "{89D000B0-0360-461A-AD83-FB727E2ABA98}" + - "{BRU-42_2xGBU-12_right}" + - "{BRU-42_2*GBU-12_LEFT}" + - "{BRU-42_2*GBU-12_RIGHT}" diff --git a/resources/weapons/bombs/GBU-12.yaml b/resources/weapons/bombs/GBU-12.yaml new file mode 100644 index 00000000..3e9500b3 --- /dev/null +++ b/resources/weapons/bombs/GBU-12.yaml @@ -0,0 +1,6 @@ +name: GBU-12 +year: 1976 +clsids: + - "DIS_GBU_12" + - "{BRU-32 GBU-12}" + - "{DB769D48-67D7-42ED-A2BE-108D566C8B1E}" diff --git a/resources/weapons/bombs/GBU-16-2X.yaml b/resources/weapons/bombs/GBU-16-2X.yaml new file mode 100644 index 00000000..22a48d70 --- /dev/null +++ b/resources/weapons/bombs/GBU-16-2X.yaml @@ -0,0 +1,6 @@ +name: 2xGBU-16 +year: 1976 +clsids: + - "{BRU33_2X_GBU-16}" + - "{BRU-42_2*GBU-16_LEFT}" + - "{BRU-42_2*GBU-16_RIGHT}" diff --git a/resources/weapons/bombs/GBU-16.yaml b/resources/weapons/bombs/GBU-16.yaml new file mode 100644 index 00000000..c31f360e --- /dev/null +++ b/resources/weapons/bombs/GBU-16.yaml @@ -0,0 +1,6 @@ +name: GBU-16 +year: 1976 +clsids: + - "DIS_GBU_16" + - "{BRU-32 GBU-16}" + - "{0D33DDAE-524F-4A4E-B5B8-621754FE3ADE}" diff --git a/resources/weapons/bombs/GBU-24.yaml b/resources/weapons/bombs/GBU-24.yaml new file mode 100644 index 00000000..b9c8fd14 --- /dev/null +++ b/resources/weapons/bombs/GBU-24.yaml @@ -0,0 +1,7 @@ +name: GBU-24 +year: 1986 +fallback: GBU-10 +clsids: + - "{BRU-32 GBU-24}" + - "{34759BBC-AF1E-4AEE-A581-498FF7A6EBCE}" + - "{GBU-24}" diff --git a/resources/weapons/bombs/GBU-31V1B.yaml b/resources/weapons/bombs/GBU-31V1B.yaml new file mode 100644 index 00000000..b08b3f34 --- /dev/null +++ b/resources/weapons/bombs/GBU-31V1B.yaml @@ -0,0 +1,5 @@ +name: GBU-31(V)1/B +year: 2001 +fallback: GBU-24 +clsids: + - "{GBU-31}" diff --git a/resources/weapons/bombs/GBU-31V2B.yaml b/resources/weapons/bombs/GBU-31V2B.yaml new file mode 100644 index 00000000..a8a55030 --- /dev/null +++ b/resources/weapons/bombs/GBU-31V2B.yaml @@ -0,0 +1,5 @@ +name: GBU-31(V)2/B +year: 2001 +fallback: GBU-24 +clsids: + - "{GBU_31_V_2B}" diff --git a/resources/weapons/bombs/GBU-31V3B.yaml b/resources/weapons/bombs/GBU-31V3B.yaml new file mode 100644 index 00000000..0f4e0843 --- /dev/null +++ b/resources/weapons/bombs/GBU-31V3B.yaml @@ -0,0 +1,5 @@ +name: GBU-31(V)3/B +year: 2001 +fallback: GBU-24 +clsids: + - "{GBU-31V3B}" diff --git a/resources/weapons/bombs/GBU-31V4B.yaml b/resources/weapons/bombs/GBU-31V4B.yaml new file mode 100644 index 00000000..04b6298a --- /dev/null +++ b/resources/weapons/bombs/GBU-31V4B.yaml @@ -0,0 +1,5 @@ +name: GBU-31(V)4/B +year: 2001 +fallback: GBU-24 +clsids: + - "{GBU_31_V_4B}" diff --git a/resources/weapons/bombs/GBU-32V2B.yaml b/resources/weapons/bombs/GBU-32V2B.yaml new file mode 100644 index 00000000..0f65cf5e --- /dev/null +++ b/resources/weapons/bombs/GBU-32V2B.yaml @@ -0,0 +1,5 @@ +name: GBU-32(V)2/B +year: 2002 +fallback: GBU-16 +clsids: + - "{GBU_32_V_2B}" diff --git a/resources/weapons/bombs/GBU-38-2X.yaml b/resources/weapons/bombs/GBU-38-2X.yaml new file mode 100644 index 00000000..48f0ac39 --- /dev/null +++ b/resources/weapons/bombs/GBU-38-2X.yaml @@ -0,0 +1,8 @@ +name: 2xGBU-38 +year: 2002 +fallback: 2xGBU-12 +clsids: + - "{BRU55_2*GBU-38}" + - "{BRU57_2*GBU-38}" + - "{BRU-42_2*GBU-38_LEFT}" + - "{BRU-42_2*GBU-38_RIGHT}" diff --git a/resources/weapons/bombs/GBU-38.yaml b/resources/weapons/bombs/GBU-38.yaml new file mode 100644 index 00000000..b02f2332 --- /dev/null +++ b/resources/weapons/bombs/GBU-38.yaml @@ -0,0 +1,5 @@ +name: GBU-38 +year: 2002 +fallback: GBU-12 +clsids: + - "{GBU-38}" From 8e977f994fd8ff57a67293552deb01f4825472b9 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 17:43:37 -0700 Subject: [PATCH 095/167] Remove LGBs from degraded loadouts without TGPs. This only takes effect for default loadouts. Custom loadouts set from the UI will allow LGBs. In the default case there will not be buddy-lase coordination so we should take iron bombs instead. Also adds single/double Mk 83 and Mk 82 weapon data to accomodate this. --- changelog.md | 1 + game/data/weapons.py | 35 ++++++++++-- gen/flights/loadouts.py | 73 ++++++++++++++++++++------ resources/weapons/bombs/GBU-10-2X.yaml | 2 + resources/weapons/bombs/GBU-10.yaml | 2 + resources/weapons/bombs/GBU-12-2X.yaml | 2 + resources/weapons/bombs/GBU-12.yaml | 2 + resources/weapons/bombs/GBU-16-2X.yaml | 2 + resources/weapons/bombs/GBU-16.yaml | 2 + resources/weapons/bombs/GBU-24.yaml | 1 + resources/weapons/bombs/Mk-82-2X.yaml | 18 +++++++ resources/weapons/bombs/Mk-82.yaml | 11 ++++ resources/weapons/bombs/Mk-83-2X.yaml | 7 +++ resources/weapons/bombs/Mk-83.yaml | 16 ++++++ resources/weapons/pods/atflir.yaml | 1 + resources/weapons/pods/lantirn.yaml | 7 +++ resources/weapons/pods/litening.yaml | 1 + 17 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 resources/weapons/bombs/Mk-82-2X.yaml create mode 100644 resources/weapons/bombs/Mk-82.yaml create mode 100644 resources/weapons/bombs/Mk-83-2X.yaml create mode 100644 resources/weapons/bombs/Mk-83.yaml create mode 100644 resources/weapons/pods/lantirn.yaml diff --git a/changelog.md b/changelog.md index 7722fcd7..ba02af20 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Saves from 3.x are not compatible with 5.0. ## Features/Improvements * **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. +* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. diff --git a/game/data/weapons.py b/game/data/weapons.py index 6f2889ec..8e7c86c9 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -4,6 +4,7 @@ import datetime import inspect import logging from dataclasses import dataclass, field +from enum import unique, Enum from functools import cached_property from pathlib import Path from typing import Iterator, Optional, Any, ClassVar @@ -61,7 +62,7 @@ class Weapon: duplicate = cls._by_clsid[weapon.clsid] raise ValueError( "Weapon CLSID used in more than one weapon type: " - f"{duplicate.name} and {weapon.name}" + f"{duplicate.name} and {weapon.name}: {weapon.clsid}" ) cls._by_clsid[weapon.clsid] = weapon @@ -91,6 +92,13 @@ class Weapon: fallback = fallback.fallback +@unique +class WeaponType(Enum): + LGB = "LGB" + TGP = "TGP" + UNKNOWN = "unknown" + + @dataclass(frozen=True) class WeaponGroup: """Group of "identical" weapons loaded from resources/weapons. @@ -101,7 +109,10 @@ class WeaponGroup: """ #: The name of the weapon group in the resource file. - name: str = field(compare=False) + name: str + + #: The type of the weapon group. + type: WeaponType = field(compare=False) #: The year of introduction. introduction_year: Optional[int] = field(compare=False) @@ -152,9 +163,13 @@ class WeaponGroup: with group_file_path.open(encoding="utf8") as group_file: data = yaml.safe_load(group_file) name = data["name"] + try: + weapon_type = WeaponType(data["type"]) + except KeyError: + weapon_type = WeaponType.UNKNOWN year = data.get("year") fallback_name = data.get("fallback") - group = WeaponGroup(name, year, fallback_name) + group = WeaponGroup(name, weapon_type, year, fallback_name) for clsid in data["clsids"]: weapon = Weapon(clsid, group) Weapon.register(weapon) @@ -163,7 +178,12 @@ class WeaponGroup: @classmethod def register_clean_pylon(cls) -> None: - group = WeaponGroup("Clean pylon", introduction_year=None, fallback_name=None) + group = WeaponGroup( + "Clean pylon", + type=WeaponType.UNKNOWN, + introduction_year=None, + fallback_name=None, + ) cls.register(group) weapon = Weapon("", group) Weapon.register(weapon) @@ -172,7 +192,12 @@ class WeaponGroup: @classmethod def register_unknown_weapons(cls, seen_clsids: set[str]) -> None: unknown_weapons = set(weapon_ids.keys()) - seen_clsids - group = WeaponGroup("Unknown", introduction_year=None, fallback_name=None) + group = WeaponGroup( + "Unknown", + type=WeaponType.UNKNOWN, + introduction_year=None, + fallback_name=None, + ) cls.register(group) for clsid in unknown_weapons: weapon = Weapon(clsid, group) diff --git a/gen/flights/loadouts.py b/gen/flights/loadouts.py index 826cc01a..0e3dd4d6 100644 --- a/gen/flights/loadouts.py +++ b/gen/flights/loadouts.py @@ -1,9 +1,10 @@ from __future__ import annotations import datetime -from typing import Optional, List, Iterator, TYPE_CHECKING, Mapping +from collections import Iterable +from typing import Optional, Iterator, TYPE_CHECKING, Mapping -from game.data.weapons import Weapon, Pylon +from game.data.weapons import Weapon, Pylon, WeaponType from game.dcs.aircrafttype import AircraftType if TYPE_CHECKING: @@ -30,9 +31,28 @@ class Loadout: def derive_custom(self, name: str) -> Loadout: return Loadout(name, self.pylons, self.date, is_custom=True) + @staticmethod + def _fallback_for( + weapon: Weapon, + pylon: Pylon, + date: datetime.date, + skip_types: Optional[Iterable[WeaponType]] = None, + ) -> Optional[Weapon]: + if skip_types is None: + skip_types = set() + for fallback in weapon.fallbacks: + if not pylon.can_equip(fallback): + continue + if not fallback.available_on(date): + continue + if fallback.weapon_group.type in skip_types: + continue + return fallback + return None + def degrade_for_date(self, unit_type: AircraftType, date: datetime.date) -> Loadout: if self.date is not None and self.date <= date: - return Loadout(self.name, self.pylons, self.date) + return Loadout(self.name, self.pylons, self.date, self.is_custom) new_pylons = dict(self.pylons) for pylon_number, weapon in self.pylons.items(): @@ -41,16 +61,41 @@ class Loadout: continue if not weapon.available_on(date): pylon = Pylon.for_aircraft(unit_type, pylon_number) - for fallback in weapon.fallbacks: - if not pylon.can_equip(fallback): - continue - if not fallback.available_on(date): - continue - new_pylons[pylon_number] = fallback - break - else: + fallback = self._fallback_for(weapon, pylon, date) + if fallback is None: del new_pylons[pylon_number] - return Loadout(f"{self.name} ({date.year})", new_pylons, date) + else: + new_pylons[pylon_number] = fallback + loadout = Loadout(self.name, new_pylons, date, self.is_custom) + # If this is not a custom loadout, we should replace any LGBs with iron bombs if + # the loadout lost its TGP. + # + # If the loadout was chosen explicitly by the user, assume they know what + # they're doing. They may be coordinating buddy-lase. + if not loadout.is_custom: + loadout.replace_lgbs_if_no_tgp(unit_type, date) + return loadout + + def replace_lgbs_if_no_tgp( + self, unit_type: AircraftType, date: datetime.date + ) -> None: + for weapon in self.pylons.values(): + if weapon is not None and weapon.weapon_group.type is WeaponType.TGP: + # Have a TGP. Nothing to do. + return + + new_pylons = dict(self.pylons) + for pylon_number, weapon in self.pylons.items(): + if weapon is not None and weapon.weapon_group.type is WeaponType.LGB: + pylon = Pylon.for_aircraft(unit_type, pylon_number) + fallback = self._fallback_for( + weapon, pylon, date, skip_types={WeaponType.LGB} + ) + if fallback is None: + del new_pylons[pylon_number] + else: + new_pylons[pylon_number] = fallback + self.pylons = new_pylons @classmethod def iter_for(cls, flight: Flight) -> Iterator[Loadout]: @@ -72,10 +117,6 @@ class Loadout: date=None, ) - @classmethod - def all_for(cls, flight: Flight) -> List[Loadout]: - return list(cls.iter_for(flight)) - @classmethod def default_loadout_names_for(cls, flight: Flight) -> Iterator[str]: from gen.flights.flight import FlightType diff --git a/resources/weapons/bombs/GBU-10-2X.yaml b/resources/weapons/bombs/GBU-10-2X.yaml index 0f261926..c0c51345 100644 --- a/resources/weapons/bombs/GBU-10-2X.yaml +++ b/resources/weapons/bombs/GBU-10-2X.yaml @@ -1,4 +1,6 @@ name: 2xGBU-10 +type: LGB year: 1976 +fallback: 2xMk 84 clsids: - "{62BE78B1-9258-48AE-B882-279534C0D278}" diff --git a/resources/weapons/bombs/GBU-10.yaml b/resources/weapons/bombs/GBU-10.yaml index 36e30965..4b7306d1 100644 --- a/resources/weapons/bombs/GBU-10.yaml +++ b/resources/weapons/bombs/GBU-10.yaml @@ -1,5 +1,7 @@ name: GBU-10 +type: LGB year: 1976 +fallback: Mk 84 clsids: - "DIS_GBU_10" - "{BRU-32 GBU-10}" diff --git a/resources/weapons/bombs/GBU-12-2X.yaml b/resources/weapons/bombs/GBU-12-2X.yaml index 282667c7..2cd83f89 100644 --- a/resources/weapons/bombs/GBU-12-2X.yaml +++ b/resources/weapons/bombs/GBU-12-2X.yaml @@ -1,5 +1,7 @@ name: 2xGBU-12 +type: LGB year: 1976 +fallback: 2xMk 82 clsids: - "{M2KC_RAFAUT_GBU12}" - "{BRU33_2X_GBU-12}" diff --git a/resources/weapons/bombs/GBU-12.yaml b/resources/weapons/bombs/GBU-12.yaml index 3e9500b3..85705c79 100644 --- a/resources/weapons/bombs/GBU-12.yaml +++ b/resources/weapons/bombs/GBU-12.yaml @@ -1,5 +1,7 @@ name: GBU-12 +type: LGB year: 1976 +fallback: Mk 82 clsids: - "DIS_GBU_12" - "{BRU-32 GBU-12}" diff --git a/resources/weapons/bombs/GBU-16-2X.yaml b/resources/weapons/bombs/GBU-16-2X.yaml index 22a48d70..19afd987 100644 --- a/resources/weapons/bombs/GBU-16-2X.yaml +++ b/resources/weapons/bombs/GBU-16-2X.yaml @@ -1,5 +1,7 @@ name: 2xGBU-16 +type: LGB year: 1976 +fallback: 2xMk 83 clsids: - "{BRU33_2X_GBU-16}" - "{BRU-42_2*GBU-16_LEFT}" diff --git a/resources/weapons/bombs/GBU-16.yaml b/resources/weapons/bombs/GBU-16.yaml index c31f360e..c966af60 100644 --- a/resources/weapons/bombs/GBU-16.yaml +++ b/resources/weapons/bombs/GBU-16.yaml @@ -1,5 +1,7 @@ name: GBU-16 +type: LGB year: 1976 +fallback: Mk 83 clsids: - "DIS_GBU_16" - "{BRU-32 GBU-16}" diff --git a/resources/weapons/bombs/GBU-24.yaml b/resources/weapons/bombs/GBU-24.yaml index b9c8fd14..6258584f 100644 --- a/resources/weapons/bombs/GBU-24.yaml +++ b/resources/weapons/bombs/GBU-24.yaml @@ -1,4 +1,5 @@ name: GBU-24 +type: LGB year: 1986 fallback: GBU-10 clsids: diff --git a/resources/weapons/bombs/Mk-82-2X.yaml b/resources/weapons/bombs/Mk-82-2X.yaml new file mode 100644 index 00000000..3a3d7ea7 --- /dev/null +++ b/resources/weapons/bombs/Mk-82-2X.yaml @@ -0,0 +1,18 @@ +name: 2xMk 82 +fallback: Mk 82 +clsids: + - "{M2KC_RAFAUT_MK82}" + - "{BRU33_2X_MK-82}" + - "DIS_MK_82_DUAL_GDJ_II19_L" + - "DIS_MK_82_DUAL_GDJ_II19_R" + - "{D5D51E24-348C-4702-96AF-97A714E72697}" + - "{TER_9A_2L*MK-82}" + - "{TER_9A_2R*MK-82}" + - "{BRU-42_2*Mk-82_LEFT}" + - "{BRU-42_2*Mk-82_RIGHT}" + - "{BRU42_2*MK82 RS}" + - "{BRU3242_2*MK82 RS}" + - "{PHXBRU3242_2*MK82 RS}" + - "{BRU42_2*MK82 LS}" + - "{BRU3242_2*MK82 LS}" + - "{PHXBRU3242_2*MK82 LS}" diff --git a/resources/weapons/bombs/Mk-82.yaml b/resources/weapons/bombs/Mk-82.yaml new file mode 100644 index 00000000..70733a5b --- /dev/null +++ b/resources/weapons/bombs/Mk-82.yaml @@ -0,0 +1,11 @@ +name: Mk 82 +clsids: + - "{BRU-32 MK-82}" + - "{Mk_82B}" + - "{Mk_82BT}" + - "{Mk_82P}" + - "{Mk_82PT}" + - "{Mk_82SB}" + - "{Mk_82SP}" + - "{Mk_82YT}" + - "{BCE4E030-38E9-423E-98ED-24BE3DA87C32}" diff --git a/resources/weapons/bombs/Mk-83-2X.yaml b/resources/weapons/bombs/Mk-83-2X.yaml new file mode 100644 index 00000000..4d2876ad --- /dev/null +++ b/resources/weapons/bombs/Mk-83-2X.yaml @@ -0,0 +1,7 @@ +name: 2xMk 83 +fallback: Mk 83 +clsids: + - "{BRU33_2X_MK-83}" + - "{18617C93-78E7-4359-A8CE-D754103EDF63}" + - "{BRU-42_2*Mk-83_LEFT}" + - "{BRU-42_2*Mk-83_RIGHT}" diff --git a/resources/weapons/bombs/Mk-83.yaml b/resources/weapons/bombs/Mk-83.yaml new file mode 100644 index 00000000..6df0f06e --- /dev/null +++ b/resources/weapons/bombs/Mk-83.yaml @@ -0,0 +1,16 @@ +name: Mk 83 +clsids: + - "{MAK79_MK83 1R}" + - "{MAK79_MK83 1L}" + - "{BRU-32 MK-83}" + - "{Mk_83BT}" + - "{Mk_83CT}" + - "{Mk_83P}" + - "{Mk_83PT}" + - "{BRU42_MK83 RS}" + - "{BRU3242_MK83 RS}" + - "{PHXBRU3242_MK83 RS}" + - "{7A44FF09-527C-4B7E-B42B-3F111CFE50FB}" + - "{BRU42_MK83 LS}" + - "{BRU3242_MK83 LS}" + - "{PHXBRU3242_MK83 LS}" diff --git a/resources/weapons/pods/atflir.yaml b/resources/weapons/pods/atflir.yaml index 3733a299..a33ee9ca 100644 --- a/resources/weapons/pods/atflir.yaml +++ b/resources/weapons/pods/atflir.yaml @@ -1,4 +1,5 @@ name: AN/ASQ-228 ATFLIR +type: TGP year: 2003 # A bit of a hack, but fixes the common case where the Hornet cheek station is # empty because no TGP is available. diff --git a/resources/weapons/pods/lantirn.yaml b/resources/weapons/pods/lantirn.yaml new file mode 100644 index 00000000..c9af761c --- /dev/null +++ b/resources/weapons/pods/lantirn.yaml @@ -0,0 +1,7 @@ +name: AN/AAQ-14 LANTIRN +type: TGP +year: 1990 +clsids: + - "{F14-LANTIRN-TP}" + - "{CAAC1CFD-6745-416B-AFA4-CB57414856D0}" + - "{D1744B93-2A8A-4C4D-B004-7A09CD8C8F3F}" diff --git a/resources/weapons/pods/litening.yaml b/resources/weapons/pods/litening.yaml index e6fd5141..4bee3ed6 100644 --- a/resources/weapons/pods/litening.yaml +++ b/resources/weapons/pods/litening.yaml @@ -1,4 +1,5 @@ name: AN/AAQ-28 LITENING +type: TGP year: 1999 # A bit of a hack, but fixes the common case where the Hornet cheek station is # empty because no TGP is available. For the Viper this will have no effect From d11174da21fccbf348b42212f911300fa7d09d49 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 22:25:40 -0700 Subject: [PATCH 096/167] Stop cluttering the kneeboard with empty notes. --- gen/kneeboard.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 35aac4e3..a9c1d1c2 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -578,20 +578,16 @@ class NotesPage(KneeboardPage): def __init__( self, - game: "Game", + notes: str, dark_kneeboard: bool, ) -> None: - self.game = game + self.notes = notes self.dark_kneeboard = dark_kneeboard def write(self, path: Path) -> None: writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) writer.title(f"Notes") - - try: - writer.text(self.game.notes) - except AttributeError: # old saves may not have .notes ;) - writer.text("") + writer.text(self.notes) writer.write(path) @@ -663,12 +659,12 @@ class KneeboardGenerator(MissionInfoGenerator): self.mission.start_time, self.dark_kneeboard, ), - NotesPage( - self.game, - self.dark_kneeboard, - ), ] + # Only create the notes page if there are notes to show. + if notes := self.game.notes: + pages.append(NotesPage(notes, self.dark_kneeboard)) + if (target_page := self.generate_task_page(flight)) is not None: pages.append(target_page) From 28f98aed881d69c023beb875656b552f9c888bd3 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 22:38:41 -0700 Subject: [PATCH 097/167] Migrate pressure to a typed unit. --- game/utils.py | 24 ++++++++++++++++++++++++ game/weather.py | 23 ++++++++++++++++------- gen/environmentgen.py | 3 +-- gen/kneeboard.py | 11 +++-------- gen/units.py | 16 ---------------- 5 files changed, 44 insertions(+), 33 deletions(-) delete mode 100644 gen/units.py diff --git a/game/utils.py b/game/utils.py index 291e098b..bdd849ce 100644 --- a/game/utils.py +++ b/game/utils.py @@ -16,6 +16,9 @@ KPH_TO_KNOTS = 1 / KNOTS_TO_KPH MS_TO_KPH = 3.6 KPH_TO_MS = 1 / MS_TO_KPH +INHG_TO_HPA = 33.86389 +INHG_TR_MMHG = 25.400002776728 + def heading_sum(h: int, a: int) -> int: h += a @@ -181,6 +184,27 @@ def mach(value: float, altitude: Distance) -> Speed: SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5) +@dataclass(frozen=True, order=True) +class Pressure: + pressure_in_inches_hg: float + + @property + def inches_hg(self) -> float: + return self.pressure_in_inches_hg + + @property + def mm_hg(self) -> float: + return self.pressure_in_inches_hg * INHG_TR_MMHG + + @property + def hecto_pascals(self) -> float: + return self.pressure_in_inches_hg * INHG_TO_HPA + + +def inches_hg(value: float) -> Pressure: + return Pressure(value) + + def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]: """ itertools recipe diff --git a/game/weather.py b/game/weather.py index ae31fa7f..952335bd 100644 --- a/game/weather.py +++ b/game/weather.py @@ -5,13 +5,14 @@ import logging import random from dataclasses import dataclass, field from enum import Enum -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Any from dcs.cloud_presets import Clouds as PydcsClouds from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind +from game.savecompat import has_save_compat_for from game.settings import Settings -from game.utils import Distance, meters, interpolate +from game.utils import Distance, meters, interpolate, Pressure, inches_hg if TYPE_CHECKING: from game.theater import ConflictTheater @@ -27,11 +28,19 @@ class TimeOfDay(Enum): @dataclass(frozen=True) class AtmosphericConditions: - #: Pressure at sea level in inches of mercury. - qnh_inches_mercury: float + #: Pressure at sea level. + qnh: Pressure + #: Temperature at sea level in Celcius. temperature_celsius: float + @has_save_compat_for(5) + def __setstate__(self, state: dict[str, Any]) -> None: + if "qnh" not in state: + state["qnh"] = inches_hg(state["qnh_inches_mercury"]) + del state["qnh_inches_mercury"] + self.__dict__.update(state) + @dataclass(frozen=True) class WindConditions: @@ -111,7 +120,7 @@ class Weather: pressure += self.pressure_adjustment temperature += self.temperature_adjustment conditions = AtmosphericConditions( - qnh_inches_mercury=self.random_pressure(pressure), + qnh=self.random_pressure(pressure), temperature_celsius=self.random_temperature(temperature), ) return conditions @@ -162,14 +171,14 @@ class Weather: return random.randint(100, 400) @staticmethod - def random_pressure(average_pressure: float) -> float: + def random_pressure(average_pressure: float) -> Pressure: # "Safe" constants based roughly on ME and viper altimeter. # Units are inches of mercury. SAFE_MIN = 28.4 SAFE_MAX = 30.9 # Use normalvariate to get normal distribution, more realistic than uniform pressure = random.normalvariate(average_pressure, 0.1) - return max(SAFE_MIN, min(SAFE_MAX, pressure)) + return inches_hg(max(SAFE_MIN, min(SAFE_MAX, pressure))) @staticmethod def random_temperature(average_temperature: float) -> float: diff --git a/gen/environmentgen.py b/gen/environmentgen.py index 2bc9da84..84f5bd59 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -3,7 +3,6 @@ from typing import Optional from dcs.mission import Mission from game.weather import Clouds, Fog, Conditions, WindConditions, AtmosphericConditions -from .units import inches_hg_to_mm_hg class EnvironmentGenerator: @@ -12,7 +11,7 @@ class EnvironmentGenerator: self.conditions = conditions def set_atmospheric(self, atmospheric: AtmosphericConditions) -> None: - self.mission.weather.qnh = inches_hg_to_mm_hg(atmospheric.qnh_inches_mercury) + self.mission.weather.qnh = atmospheric.qnh.mm_hg self.mission.weather.season_temperature = atmospheric.temperature_celsius def set_clouds(self, clouds: Optional[Clouds]) -> None: diff --git a/gen/kneeboard.py b/gen/kneeboard.py index a9c1d1c2..20fb8ca1 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -47,7 +47,6 @@ from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator from .flights.flight import FlightWaypoint, FlightWaypointType, FlightType from .radios import RadioFrequency from .runways import RunwayData -from .units import inches_hg_to_mm_hg, inches_hg_to_hpa if TYPE_CHECKING: from game import Game @@ -308,13 +307,9 @@ class BriefingPage(KneeboardPage): writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}") - qnh_in_hg = "{:.2f}".format(self.weather.atmospheric.qnh_inches_mercury) - qnh_mm_hg = "{:.1f}".format( - inches_hg_to_mm_hg(self.weather.atmospheric.qnh_inches_mercury) - ) - qnh_hpa = "{:.1f}".format( - inches_hg_to_hpa(self.weather.atmospheric.qnh_inches_mercury) - ) + qnh_in_hg = f"{self.weather.atmospheric.qnh.inches_hg:.2f}" + qnh_mm_hg = f"{self.weather.atmospheric.qnh.mm_hg:.1f}" + qnh_hpa = f"{self.weather.atmospheric.qnh.hecto_pascals:.1f}" writer.text( f"Temperature: {round(self.weather.atmospheric.temperature_celsius)} °C at sea level" ) diff --git a/gen/units.py b/gen/units.py deleted file mode 100644 index 9aec8348..00000000 --- a/gen/units.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Unit conversions.""" - - -def meters_to_feet(meters: float) -> float: - """Converts meters to feet.""" - return meters * 3.28084 - - -def inches_hg_to_mm_hg(inches_hg: float) -> float: - """Converts inches mercury to millimeters mercury.""" - return inches_hg * 25.400002776728 - - -def inches_hg_to_hpa(inches_hg: float) -> float: - """Converts inches mercury to hectopascal.""" - return inches_hg * 33.86389 From f2dc95b86dc93349abe9018162410b1d3bea07f8 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 22:43:59 -0700 Subject: [PATCH 098/167] Fix typo. --- game/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/game/utils.py b/game/utils.py index bdd849ce..39daa058 100644 --- a/game/utils.py +++ b/game/utils.py @@ -17,7 +17,7 @@ MS_TO_KPH = 3.6 KPH_TO_MS = 1 / MS_TO_KPH INHG_TO_HPA = 33.86389 -INHG_TR_MMHG = 25.400002776728 +INHG_TO_MMHG = 25.400002776728 def heading_sum(h: int, a: int) -> int: @@ -194,7 +194,7 @@ class Pressure: @property def mm_hg(self) -> float: - return self.pressure_in_inches_hg * INHG_TR_MMHG + return self.pressure_in_inches_hg * INHG_TO_MMHG @property def hecto_pascals(self) -> float: From 9bb8e00c3d3e72b1117bf6f1ec4b603129da1bc5 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 26 May 2021 23:35:46 -0700 Subject: [PATCH 099/167] Allow configuration of the air wing at game start. After completing the new game wizard but before initializing turn 0, open a dialog to allow the player to customize their air wing. With this they can remove squadrons from the game, rename them, add players, or change allowed mission types. *Adding* squadrons is not currently supported, nor is changing the squadron's livery (the data in pydcs is an arbitrary class hierarchy that can't be safely indexed by country). This only applies to the blue air wing for now. Future improvements: * Add squadron button. * Collapse disable squadrons to declutter? * Tabs on the side like the settings dialog to group by aircraft type. * Top tab bar to switch between red and blue air wings. --- game/coalition.py | 7 + game/game.py | 2 + game/squadrons.py | 12 +- game/theater/start_generator.py | 1 - qt_ui/main.py | 4 +- qt_ui/windows/AirWingConfigurationDialog.py | 220 ++++++++++++++++++++ qt_ui/windows/newgame/QNewGameWizard.py | 5 + 7 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 qt_ui/windows/AirWingConfigurationDialog.py diff --git a/game/coalition.py b/game/coalition.py index 1922f3ce..01c1e2cb 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -150,6 +150,13 @@ class Coalition: # is handled correctly. self.transfers.perform_transfers() + def preinit_turn_0(self) -> None: + """Runs final Coalition initialization. + + Final initialization occurs before Game.initialize_turn runs for turn 0. + """ + self.air_wing.populate_for_turn_0() + def initialize_turn(self) -> None: """Processes coalition-specific turn initialization. diff --git a/game/game.py b/game/game.py index 6ce7b178..7125cc24 100644 --- a/game/game.py +++ b/game/game.py @@ -294,6 +294,8 @@ class Game: def begin_turn_0(self) -> None: """Initialization for the first turn of the game.""" self.turn = 0 + self.blue.preinit_turn_0() + self.red.preinit_turn_0() self.initialize_turn() def pass_turn(self, no_action: bool = False) -> None: diff --git a/game/squadrons.py b/game/squadrons.py index 3a23d4ea..45ebe7de 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -101,9 +101,6 @@ class Squadron: settings: Settings = field(hash=False, compare=False) def __post_init__(self) -> None: - if any(p.status is not PilotStatus.Active for p in self.pilot_pool): - raise ValueError("Squadrons can only be created with active pilots.") - self._recruit_pilots(self.settings.squadron_pilot_limit) self.auto_assignable_mission_types = set(self.mission_types) def __str__(self) -> str: @@ -181,6 +178,11 @@ class Squadron: self.current_roster.extend(new_pilots) self.available_pilots.extend(new_pilots) + def populate_for_turn_0(self) -> None: + if any(p.status is not PilotStatus.Active for p in self.pilot_pool): + raise ValueError("Squadrons can only be created with active pilots.") + self._recruit_pilots(self.settings.squadron_pilot_limit) + def replenish_lost_pilots(self) -> None: if not self.pilot_limits_enabled: return @@ -414,6 +416,10 @@ class AirWing: def squadron_at_index(self, index: int) -> Squadron: return list(self.iter_squadrons())[index] + def populate_for_turn_0(self) -> None: + for squadron in self.iter_squadrons(): + squadron.populate_for_turn_0() + def replenish(self) -> None: for squadron in self.iter_squadrons(): squadron.replenish_lost_pilots() diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 0bf85391..aee758e9 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -123,7 +123,6 @@ class GameGenerator: GroundObjectGenerator(game, self.generator_settings).generate() game.settings.version = VERSION - game.begin_turn_0() return game def prepare_theater(self) -> None: diff --git a/qt_ui/main.py b/qt_ui/main.py index 26c5cb48..ee614287 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -246,7 +246,9 @@ def create_game( high_digit_sams=False, ), ) - return generator.generate() + game = generator.generate() + game.begin_turn_0() + return game def lint_weapon_data() -> None: diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py new file mode 100644 index 00000000..fb6bdc8b --- /dev/null +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -0,0 +1,220 @@ +import itertools +import logging +from collections import defaultdict +from typing import Optional, Callable, Iterator + +from PySide2.QtCore import ( + QItemSelectionModel, + QModelIndex, + QSize, + Qt, +) +from PySide2.QtWidgets import ( + QAbstractItemView, + QDialog, + QListView, + QVBoxLayout, + QGroupBox, + QGridLayout, + QLabel, + QWidget, + QScrollArea, + QLineEdit, + QTextEdit, + QCheckBox, + QHBoxLayout, +) + +from game import Game +from game.squadrons import Squadron, AirWing, Pilot +from gen.flights.flight import FlightType +from qt_ui.models import AirWingModel, SquadronModel +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): + def __init__(self, squadron: Squadron) -> None: + super().__init__() + self.squadron = squadron + self.allowed_mission_types = set() + + self.addWidget(QLabel("Allowed mission types")) + + def make_callback(toggled_task: FlightType) -> Callable[[bool], None]: + def callback(checked: bool) -> None: + self.on_toggled(toggled_task, checked) + + return callback + + for task in FlightType: + enabled = task in squadron.mission_types + if enabled: + self.allowed_mission_types.add(task) + checkbox = QCheckBox(text=task.value) + checkbox.setChecked(enabled) + checkbox.toggled.connect(make_callback(task)) + self.addWidget(checkbox) + + self.addStretch() + + def on_toggled(self, task: FlightType, checked: bool) -> None: + if checked: + self.allowed_mission_types.add(task) + else: + self.allowed_mission_types.remove(task) + + +class SquadronConfigurationBox(QGroupBox): + def __init__(self, squadron: Squadron) -> None: + super().__init__() + self.setCheckable(True) + self.squadron = squadron + self.reset_title() + + columns = QHBoxLayout() + self.setLayout(columns) + + left_column = QVBoxLayout() + columns.addLayout(left_column) + + left_column.addWidget(QLabel("Name:")) + self.name_edit = QLineEdit(squadron.name) + self.name_edit.textChanged.connect(self.on_name_changed) + left_column.addWidget(self.name_edit) + + left_column.addWidget(QLabel("Nickname:")) + self.nickname_edit = QLineEdit(squadron.nickname) + self.nickname_edit.textChanged.connect(self.on_nickname_changed) + left_column.addWidget(self.nickname_edit) + + left_column.addWidget( + QLabel("Players (one per line, leave empty for an AI-only squadron):") + ) + players = [p for p in squadron.available_pilots if p.player] + for player in players: + squadron.available_pilots.remove(player) + self.player_list = QTextEdit("
".join(p.name for p in players)) + self.player_list.setAcceptRichText(False) + left_column.addWidget(self.player_list) + + left_column.addStretch() + + self.allowed_missions = AllowedMissionTypeControls(squadron) + columns.addLayout(self.allowed_missions) + + def on_name_changed(self, text: str) -> None: + self.squadron.name = text + self.reset_title() + + def on_nickname_changed(self, text: str) -> None: + self.squadron.nickname = text + + def reset_title(self) -> None: + self.setTitle(f"{self.squadron.name} - {self.squadron.aircraft}") + + def apply(self) -> Squadron: + player_names = self.player_list.toPlainText().splitlines() + # Prepend player pilots so they get set active first. + self.squadron.pilot_pool = [ + Pilot(n, player=True) for n in player_names + ] + self.squadron.pilot_pool + self.squadron.mission_types = tuple(self.allowed_missions.allowed_mission_types) + return self.squadron + + +class AirWingConfigurationLayout(QVBoxLayout): + def __init__(self, air_wing: AirWing) -> None: + super().__init__() + self.air_wing = air_wing + self.squadron_configs = [] + + doc_url = ( + "https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots" + ) + doc_label = QLabel( + "Use this opportunity to customize the squadrons available to your " + "coalition. This is your
" + "only opportunity to make changes.

" + "
" + "To accept your changes and continue, close this window.
" + "
" + "To remove a squadron from the game, uncheck the box in the title. New " + "squadrons cannot
" + "be added via the UI at this time. To add a custom squadron, see " + f'
the wiki.' + ) + + doc_label.setOpenExternalLinks(True) + self.addWidget(doc_label) + for squadron in self.air_wing.iter_squadrons(): + squadron_config = SquadronConfigurationBox(squadron) + self.squadron_configs.append(squadron_config) + self.addWidget(squadron_config) + + def apply(self) -> None: + keep_squadrons = defaultdict(list) + for squadron_config in self.squadron_configs: + if squadron_config.isChecked(): + squadron = squadron_config.apply() + keep_squadrons[squadron.aircraft].append(squadron) + self.air_wing.squadrons = keep_squadrons + + +class AirWingConfigurationDialog(QDialog): + """Dialog window for air wing configuration.""" + + def __init__(self, game: Game, parent) -> None: + super().__init__(parent) + self.air_wing = game.blue.air_wing + + self.setMinimumSize(500, 800) + self.setWindowTitle(f"Air Wing Configuration") + # TODO: self.setWindowIcon() + + self.air_wing_config = AirWingConfigurationLayout(self.air_wing) + + scrolling_layout = QVBoxLayout() + scrolling_widget = QWidget() + scrolling_widget.setLayout(self.air_wing_config) + + scrolling_area = QScrollArea() + scrolling_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scrolling_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + scrolling_area.setWidgetResizable(True) + scrolling_area.setWidget(scrolling_widget) + + scrolling_layout.addWidget(scrolling_area) + self.setLayout(scrolling_layout) + + def reject(self) -> None: + self.air_wing_config.apply() + super().reject() diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 264f73cf..b29a4806 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -15,6 +15,7 @@ from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSe from game.factions.faction import Faction from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner +from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog from qt_ui.windows.newgame.QCampaignList import ( Campaign, QCampaignList, @@ -125,6 +126,10 @@ class NewGameWizard(QtWidgets.QWizard): ) self.generatedGame = generator.generate() + AirWingConfigurationDialog(self.generatedGame, self).exec_() + + self.generatedGame.begin_turn_0() + super(NewGameWizard, self).accept() From adab00bc0e1656904d1484790536797cd77e3943 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 14:31:14 -0700 Subject: [PATCH 100/167] Update changelog. --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index ba02af20..16a2c500 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. +* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons. ## Fixes From 04a8040292198f821e27f2b065af42e991576749 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 15:37:45 -0700 Subject: [PATCH 101/167] Prevent carriers from claiming most TGOs. The naval CP generators will only spawn ships, so if any of the other TGO types were closest to the CV or LHA they just would not be generated. --- changelog.md | 2 ++ game/theater/conflicttheater.py | 14 ++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/changelog.md b/changelog.md index 16a2c500..4b131551 100644 --- a/changelog.md +++ b/changelog.md @@ -13,6 +13,8 @@ Saves from 3.x are not compatible with 5.0. ## Fixes +* **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning. + # 4.1.0 Saves from 4.0.0 are compatible with 4.1.0. diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 43cd2c9d..95e53ac9 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -389,8 +389,10 @@ class MizCampaignLoader: origin, list(reversed(waypoints)) ) - def objective_info(self, near: Positioned) -> Tuple[ControlPoint, Distance]: - closest = self.theater.closest_control_point(near.position) + def objective_info( + self, near: Positioned, allow_naval: bool = False + ) -> Tuple[ControlPoint, Distance]: + closest = self.theater.closest_control_point(near.position, allow_naval) distance = meters(closest.position.distance_to_point(near.position)) return closest, distance @@ -402,7 +404,7 @@ class MizCampaignLoader: ) for ship in self.ships: - closest, distance = self.objective_info(ship) + closest, distance = self.objective_info(ship, allow_naval=True) closest.preset_locations.ships.append( PointWithHeading.from_point(ship.position, ship.units[0].heading) ) @@ -644,10 +646,14 @@ class ConflictTheater: def enemy_points(self) -> List[ControlPoint]: return list(self.control_points_for(player=False)) - def closest_control_point(self, point: Point) -> ControlPoint: + def closest_control_point( + self, point: Point, allow_naval: bool = False + ) -> ControlPoint: closest = self.controlpoints[0] closest_distance = point.distance_to_point(closest.position) for control_point in self.controlpoints[1:]: + if control_point.is_fleet and not allow_naval: + continue distance = point.distance_to_point(control_point.position) if distance < closest_distance: closest = control_point From c65ac5a7cfb714bba7e0f34a92721dac6eb8d9f7 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 15:31:52 -0700 Subject: [PATCH 102/167] Move mission range data into the aircraft type. The doctrine/task limits were capturing a reasonable average for the era, but it did a bad job for cases like the Harrier vs the Hornet, which perform similar missions but have drastically different max ranges. It also forced us into limiting CAS missions (even those flown by long range aircraft like the A-10) to 50nm since helicopters could commonly be fragged to them. This should allow us to design campaigns without needing airfields to be a max of ~50-100nm apart. --- changelog.md | 1 + game/commander/aircraftallocator.py | 18 +++++----- game/commander/missionproposals.py | 4 --- game/commander/packagebuilder.py | 2 +- game/commander/packagefulfiller.py | 1 - game/commander/tasks/packageplanningtask.py | 28 ++++------------ game/commander/tasks/primitive/aewc.py | 5 ++- game/commander/tasks/primitive/antiship.py | 12 ++----- .../commander/tasks/primitive/antishipping.py | 7 ++-- game/commander/tasks/primitive/bai.py | 7 ++-- game/commander/tasks/primitive/barcap.py | 5 ++- game/commander/tasks/primitive/cas.py | 7 ++-- .../tasks/primitive/convoyinterdiction.py | 6 ++-- game/commander/tasks/primitive/dead.py | 22 +++---------- game/commander/tasks/primitive/oca.py | 11 +++---- game/commander/tasks/primitive/refueling.py | 5 ++- game/commander/tasks/primitive/strike.py | 7 ++-- game/data/doctrine.py | 19 ++++------- game/dcs/aircrafttype.py | 19 +++++++++-- game/procurement.py | 33 +++++++------------ game/transfers.py | 4 +-- resources/units/aircraft/A-50.yaml | 1 + resources/units/aircraft/AV8BNA.yaml | 1 + resources/units/aircraft/An-26B.yaml | 1 + resources/units/aircraft/B-1B.yaml | 4 ++- resources/units/aircraft/B-52H.yaml | 4 ++- resources/units/aircraft/C-130.yaml | 1 + resources/units/aircraft/C-17A.yaml | 1 + resources/units/aircraft/E-2C.yaml | 1 + resources/units/aircraft/E-3A.yaml | 1 + resources/units/aircraft/F-14A-135-GR.yaml | 1 + resources/units/aircraft/F-14B.yaml | 1 + resources/units/aircraft/F-16A.yaml | 1 + resources/units/aircraft/F-16C_50.yaml | 1 + resources/units/aircraft/Hercules.yaml | 4 ++- resources/units/aircraft/IL-76MD.yaml | 1 + resources/units/aircraft/IL-78M.yaml | 1 + resources/units/aircraft/KC-135.yaml | 1 + resources/units/aircraft/KC130.yaml | 4 ++- resources/units/aircraft/KC135MPRS.yaml | 4 ++- resources/units/aircraft/KJ-2000.yaml | 1 + resources/units/aircraft/S-3B Tanker.yaml | 4 ++- resources/units/aircraft/Yak-40.yaml | 1 + 43 files changed, 120 insertions(+), 143 deletions(-) diff --git a/changelog.md b/changelog.md index 4b131551..e0e61174 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. +* **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken. * **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons. ## Fixes diff --git a/game/commander/aircraftallocator.py b/game/commander/aircraftallocator.py index 16ea678a..523fad64 100644 --- a/game/commander/aircraftallocator.py +++ b/game/commander/aircraftallocator.py @@ -3,7 +3,8 @@ from typing import Optional, Tuple from game.commander.missionproposals import ProposedFlight from game.inventory import GlobalAircraftInventory from game.squadrons import AirWing, Squadron -from game.theater import ControlPoint +from game.theater import ControlPoint, MissionTarget +from game.utils import meters from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.closestairfields import ClosestAirfields from gen.flights.flight import FlightType @@ -25,7 +26,7 @@ class AircraftAllocator: self.is_player = is_player def find_squadron_for_flight( - self, flight: ProposedFlight + self, target: MissionTarget, flight: ProposedFlight ) -> Optional[Tuple[ControlPoint, Squadron]]: """Finds aircraft suitable for the given mission. @@ -45,17 +46,13 @@ class AircraftAllocator: on subsequent calls. If the found aircraft are not used, the caller is responsible for returning them to the inventory. """ - return self.find_aircraft_for_task(flight, flight.task) + return self.find_aircraft_for_task(target, flight, flight.task) def find_aircraft_for_task( - self, flight: ProposedFlight, task: FlightType + self, target: MissionTarget, flight: ProposedFlight, task: FlightType ) -> Optional[Tuple[ControlPoint, Squadron]]: types = aircraft_for_task(task) - airfields_in_range = self.closest_airfields.operational_airfields_within( - flight.max_distance - ) - - for airfield in airfields_in_range: + for airfield in self.closest_airfields.operational_airfields: if not airfield.is_friendly(self.is_player): continue inventory = self.global_inventory.for_control_point(airfield) @@ -64,6 +61,9 @@ class AircraftAllocator: continue if inventory.available(aircraft) < flight.num_aircraft: continue + distance_to_target = meters(target.distance_to(airfield)) + if distance_to_target > aircraft.max_mission_range: + continue # Valid location with enough aircraft available. Find a squadron to fit # the role. squadrons = self.air_wing.auto_assignable_for_task_with_type( diff --git a/game/commander/missionproposals.py b/game/commander/missionproposals.py index 2b8fc074..a13802b8 100644 --- a/game/commander/missionproposals.py +++ b/game/commander/missionproposals.py @@ -3,7 +3,6 @@ from enum import Enum, auto from typing import Optional from game.theater import MissionTarget -from game.utils import Distance from gen.flights.flight import FlightType @@ -27,9 +26,6 @@ class ProposedFlight: #: The number of aircraft required. num_aircraft: int - #: The maximum distance between the objective and the departure airfield. - max_distance: Distance - #: The type of threat this flight defends against if it is an escort. Escort #: flights will be pruned if the rest of the package is not threatened by #: the threat they defend against. If this flight is not an escort, this diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py index 490e0286..da96a8e2 100644 --- a/game/commander/packagebuilder.py +++ b/game/commander/packagebuilder.py @@ -44,7 +44,7 @@ class PackageBuilder: caller should return any previously planned flights to the inventory using release_planned_aircraft. """ - assignment = self.allocator.find_squadron_for_flight(plan) + assignment = self.allocator.find_squadron_for_flight(self.package.target, plan) if assignment is None: return False airfield, squadron = assignment diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index d4d8352b..1005bfa9 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -83,7 +83,6 @@ class PackageFulfiller: missing_types.add(flight.task) purchase_order = AircraftProcurementRequest( near=mission.location, - range=flight.max_distance, task_capability=flight.task, number=flight.num_aircraft, ) diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index fb50af23..8e2eb8a2 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -59,28 +59,23 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): coalition.ato.add_package(self.package) @abstractmethod - def propose_flights(self, doctrine: Doctrine) -> None: + def propose_flights(self) -> None: ... def propose_flight( self, task: FlightType, num_aircraft: int, - max_distance: Optional[Distance], escort_type: Optional[EscortType] = None, ) -> None: - if max_distance is None: - max_distance = Distance.inf() - self.flights.append( - ProposedFlight(task, num_aircraft, max_distance, escort_type) - ) + self.flights.append(ProposedFlight(task, num_aircraft, escort_type)) @property def asap(self) -> bool: return False def fulfill_mission(self, state: TheaterState) -> bool: - self.propose_flights(state.context.coalition.doctrine) + self.propose_flights() fulfiller = PackageFulfiller( state.context.coalition, state.context.theater, @@ -92,20 +87,9 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): ) return self.package is not None - def propose_common_escorts(self, doctrine: Doctrine) -> None: - self.propose_flight( - FlightType.SEAD_ESCORT, - 2, - doctrine.mission_ranges.offensive, - EscortType.Sead, - ) - - self.propose_flight( - FlightType.ESCORT, - 2, - doctrine.mission_ranges.offensive, - EscortType.AirToAir, - ) + def propose_common_escorts(self) -> None: + self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead) + self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir) def iter_iads_ranges( self, state: TheaterState, range_type: RangeType diff --git a/game/commander/tasks/primitive/aewc.py b/game/commander/tasks/primitive/aewc.py index 8153aac6..f9c6a7d2 100644 --- a/game/commander/tasks/primitive/aewc.py +++ b/game/commander/tasks/primitive/aewc.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater import MissionTarget from gen.flights.flight import FlightType @@ -19,8 +18,8 @@ class PlanAewc(PackagePlanningTask[MissionTarget]): def apply_effects(self, state: TheaterState) -> None: state.aewc_targets.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.AEWC, 1, doctrine.mission_ranges.aewc) + def propose_flights(self) -> None: + self.propose_flight(FlightType.AEWC, 1) @property def asap(self) -> bool: diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py index 3f85c74c..a135e1cd 100644 --- a/game/commander/tasks/primitive/antiship.py +++ b/game/commander/tasks/primitive/antiship.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from game.commander.missionproposals import EscortType from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater.theatergroundobject import NavalGroundObject from gen.flights.flight import FlightType @@ -22,11 +21,6 @@ class PlanAntiShip(PackagePlanningTask[NavalGroundObject]): def apply_effects(self, state: TheaterState) -> None: state.eliminate_ship(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive) - self.propose_flight( - FlightType.ESCORT, - 2, - doctrine.mission_ranges.offensive, - EscortType.AirToAir, - ) + def propose_flights(self) -> None: + self.propose_flight(FlightType.ANTISHIP, 2) + self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir) diff --git a/game/commander/tasks/primitive/antishipping.py b/game/commander/tasks/primitive/antishipping.py index 303a9af1..64279d1b 100644 --- a/game/commander/tasks/primitive/antishipping.py +++ b/game/commander/tasks/primitive/antishipping.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.transfers import CargoShip from gen.flights.flight import FlightType @@ -21,6 +20,6 @@ class PlanAntiShipping(PackagePlanningTask[CargoShip]): def apply_effects(self, state: TheaterState) -> None: state.enemy_shipping.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive) - self.propose_common_escorts(doctrine) + def propose_flights(self) -> None: + self.propose_flight(FlightType.ANTISHIP, 2) + self.propose_common_escorts() diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py index f9d61818..4878171d 100644 --- a/game/commander/tasks/primitive/bai.py +++ b/game/commander/tasks/primitive/bai.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater.theatergroundobject import VehicleGroupGroundObject from gen.flights.flight import FlightType @@ -21,6 +20,6 @@ class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): def apply_effects(self, state: TheaterState) -> None: state.eliminate_garrison(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive) - self.propose_common_escorts(doctrine) + def propose_flights(self) -> None: + self.propose_flight(FlightType.BAI, 2) + self.propose_common_escorts() diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py index 77302adf..c2dafae7 100644 --- a/game/commander/tasks/primitive/barcap.py +++ b/game/commander/tasks/primitive/barcap.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater import ControlPoint from gen.flights.flight import FlightType @@ -19,5 +18,5 @@ class PlanBarcap(PackagePlanningTask[ControlPoint]): def apply_effects(self, state: TheaterState) -> None: state.barcaps_needed[self.target] -= 1 - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.BARCAP, 2, doctrine.mission_ranges.cap) + def propose_flights(self) -> None: + self.propose_flight(FlightType.BARCAP, 2) diff --git a/game/commander/tasks/primitive/cas.py b/game/commander/tasks/primitive/cas.py index 7a9997ff..c2785405 100644 --- a/game/commander/tasks/primitive/cas.py +++ b/game/commander/tasks/primitive/cas.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater import FrontLine from gen.flights.flight import FlightType @@ -19,6 +18,6 @@ class PlanCas(PackagePlanningTask[FrontLine]): def apply_effects(self, state: TheaterState) -> None: state.vulnerable_front_lines.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.CAS, 2, doctrine.mission_ranges.cas) - self.propose_flight(FlightType.TARCAP, 2, doctrine.mission_ranges.cap) + def propose_flights(self) -> None: + self.propose_flight(FlightType.CAS, 2) + self.propose_flight(FlightType.TARCAP, 2) diff --git a/game/commander/tasks/primitive/convoyinterdiction.py b/game/commander/tasks/primitive/convoyinterdiction.py index 11ed4ee4..285326c7 100644 --- a/game/commander/tasks/primitive/convoyinterdiction.py +++ b/game/commander/tasks/primitive/convoyinterdiction.py @@ -21,6 +21,6 @@ class PlanConvoyInterdiction(PackagePlanningTask[Convoy]): def apply_effects(self, state: TheaterState) -> None: state.enemy_convoys.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive) - self.propose_common_escorts(doctrine) + def propose_flights(self) -> None: + self.propose_flight(FlightType.BAI, 2) + self.propose_common_escorts() diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py index 3861908c..45da3cc3 100644 --- a/game/commander/tasks/primitive/dead.py +++ b/game/commander/tasks/primitive/dead.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from game.commander.missionproposals import EscortType from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater.theatergroundobject import IadsGroundObject from gen.flights.flight import FlightType @@ -25,8 +24,8 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]): def apply_effects(self, state: TheaterState) -> None: state.eliminate_air_defense(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.DEAD, 2, doctrine.mission_ranges.offensive) + def propose_flights(self) -> None: + self.propose_flight(FlightType.DEAD, 2) # Only include SEAD against SAMs that still have emitters. No need to # suppress an EWR, and SEAD isn't useful against a SAM that no longer has a @@ -41,18 +40,7 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]): # package is *only* threatened by the target though. Could be improved, but # needs a decent refactor to the escort planning to do so. if self.target.has_live_radar_sam: - self.propose_flight(FlightType.SEAD, 2, doctrine.mission_ranges.offensive) + self.propose_flight(FlightType.SEAD, 2) else: - self.propose_flight( - FlightType.SEAD_ESCORT, - 2, - doctrine.mission_ranges.offensive, - EscortType.Sead, - ) - - self.propose_flight( - FlightType.ESCORT, - 2, - doctrine.mission_ranges.offensive, - EscortType.AirToAir, - ) + self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead) + self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir) diff --git a/game/commander/tasks/primitive/oca.py b/game/commander/tasks/primitive/oca.py index 4c995f75..be88df32 100644 --- a/game/commander/tasks/primitive/oca.py +++ b/game/commander/tasks/primitive/oca.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater import ControlPoint from gen.flights.flight import FlightType @@ -23,10 +22,8 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]): def apply_effects(self, state: TheaterState) -> None: state.oca_targets.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.OCA_RUNWAY, 2, doctrine.mission_ranges.offensive) + def propose_flights(self) -> None: + self.propose_flight(FlightType.OCA_RUNWAY, 2) if self.aircraft_cold_start: - self.propose_flight( - FlightType.OCA_AIRCRAFT, 2, doctrine.mission_ranges.offensive - ) - self.propose_common_escorts(doctrine) + self.propose_flight(FlightType.OCA_AIRCRAFT, 2) + self.propose_common_escorts() diff --git a/game/commander/tasks/primitive/refueling.py b/game/commander/tasks/primitive/refueling.py index 005cbc3a..5f17f3df 100644 --- a/game/commander/tasks/primitive/refueling.py +++ b/game/commander/tasks/primitive/refueling.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater import MissionTarget from gen.flights.flight import FlightType @@ -19,5 +18,5 @@ class PlanRefueling(PackagePlanningTask[MissionTarget]): def apply_effects(self, state: TheaterState) -> None: state.refueling_targets.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.REFUELING, 1, doctrine.mission_ranges.refueling) + def propose_flights(self) -> None: + self.propose_flight(FlightType.REFUELING, 1) diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py index ce322dad..e89c9cac 100644 --- a/game/commander/tasks/primitive/strike.py +++ b/game/commander/tasks/primitive/strike.py @@ -5,7 +5,6 @@ from typing import Any from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater.theatergroundobject import TheaterGroundObject from gen.flights.flight import FlightType @@ -22,6 +21,6 @@ class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]): def apply_effects(self, state: TheaterState) -> None: state.strike_targets.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.STRIKE, 2, doctrine.mission_ranges.offensive) - self.propose_common_escorts(doctrine) + def propose_flights(self) -> None: + self.propose_flight(FlightType.STRIKE, 2) + self.propose_common_escorts() diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 21402501..359a1435 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import timedelta from typing import Any @@ -18,15 +18,6 @@ class GroundUnitProcurementRatios: return 0.0 -@dataclass(frozen=True) -class MissionPlannerMaxRanges: - cap: Distance = field(default=nautical_miles(100)) - cas: Distance = field(default=nautical_miles(50)) - offensive: Distance = field(default=nautical_miles(150)) - aewc: Distance = field(default=Distance.inf()) - refueling: Distance = field(default=nautical_miles(200)) - - @dataclass(frozen=True) class Doctrine: cas: bool @@ -88,8 +79,6 @@ class Doctrine: ground_unit_procurement_ratios: GroundUnitProcurementRatios - mission_ranges: MissionPlannerMaxRanges = field(default=MissionPlannerMaxRanges()) - @has_save_compat_for(5) def __setstate__(self, state: dict[str, Any]) -> None: if "max_ingress_distance" not in state: @@ -111,6 +100,12 @@ class Doctrine: self.__dict__.update(state) +class MissionPlannerMaxRanges: + @has_save_compat_for(5) + def __init__(self) -> None: + pass + + MODERN_DOCTRINE = Doctrine( cap=True, cas=True, diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index dd9b5282..56fa3f0f 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -29,7 +29,7 @@ from game.radio.channels import ( ViggenRadioChannelAllocator, NoOpChannelAllocator, ) -from game.utils import Distance, Speed, feet, kph, knots +from game.utils import Distance, Speed, feet, kph, knots, nautical_miles if TYPE_CHECKING: from gen.aircraft import FlightData @@ -112,13 +112,18 @@ class AircraftType(UnitType[Type[FlyingType]]): lha_capable: bool always_keeps_gun: bool - # If true, the aircraft does not use the guns as the last resort weapons, but as a main weapon. - # It'll RTB when it doesn't have gun ammo left. + # If true, the aircraft does not use the guns as the last resort weapons, but as a + # main weapon. It'll RTB when it doesn't have gun ammo left. gunfighter: bool max_group_size: int patrol_altitude: Optional[Distance] patrol_speed: Optional[Speed] + + #: The maximum range between the origin airfield and the target for which the auto- + #: planner will consider this aircraft usable for a mission. + max_mission_range: Distance + intra_flight_radio: Optional[Radio] channel_allocator: Optional[RadioChannelAllocator] channel_namer: Type[ChannelNamer] @@ -230,6 +235,13 @@ class AircraftType(UnitType[Type[FlyingType]]): radio_config = RadioConfig.from_data(data.get("radios", {})) patrol_config = PatrolConfig.from_data(data.get("patrol", {})) + try: + mission_range = nautical_miles(int(data["max_range"])) + except (KeyError, ValueError): + mission_range = ( + nautical_miles(50) if aircraft.helicopter else nautical_miles(150) + ) + try: introduction = data["introduced"] if introduction is None: @@ -257,6 +269,7 @@ class AircraftType(UnitType[Type[FlyingType]]): max_group_size=data.get("max_group_size", aircraft.group_size_max), patrol_altitude=patrol_config.altitude, patrol_speed=patrol_config.speed, + max_mission_range=mission_range, intra_flight_radio=radio_config.intra_flight, channel_allocator=radio_config.channel_allocator, channel_namer=radio_config.channel_namer, diff --git a/game/procurement.py b/game/procurement.py index 8820453c..3b1ea370 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -11,7 +11,7 @@ from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction from game.theater import ControlPoint, MissionTarget -from game.utils import Distance +from game.utils import meters from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType @@ -25,15 +25,13 @@ FRONTLINE_RESERVES_FACTOR = 1.3 @dataclass(frozen=True) class AircraftProcurementRequest: near: MissionTarget - range: Distance task_capability: FlightType number: int def __str__(self) -> str: task = self.task_capability.value - distance = self.range.nautical_miles target = self.near.name - return f"{self.number} ship {task} within {distance} nm of {target}" + return f"{self.number} ship {task} near {target}" class ProcurementAi: @@ -211,24 +209,24 @@ class ProcurementAi: return GroundUnitClass.Tank return worst_balanced - def _affordable_aircraft_for_task( - self, - task: FlightType, - airbase: ControlPoint, - number: int, - max_price: float, + def affordable_aircraft_for( + self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float ) -> Optional[AircraftType]: best_choice: Optional[AircraftType] = None - for unit in aircraft_for_task(task): + for unit in aircraft_for_task(request.task_capability): if unit not in self.faction.aircrafts: continue - if unit.price * number > max_price: + if unit.price * request.number > budget: continue if not airbase.can_operate(unit): continue + distance_to_target = meters(request.near.distance_to(airbase)) + if distance_to_target > unit.max_mission_range: + continue + for squadron in self.air_wing.squadrons_for(unit): - if task in squadron.auto_assignable_mission_types: + if request.task_capability in squadron.auto_assignable_mission_types: break else: continue @@ -241,13 +239,6 @@ class ProcurementAi: break return best_choice - def affordable_aircraft_for( - self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float - ) -> Optional[AircraftType]: - return self._affordable_aircraft_for_task( - request.task_capability, airbase, request.number, budget - ) - def fulfill_aircraft_request( self, request: AircraftProcurementRequest, budget: float ) -> Tuple[float, bool]: @@ -293,7 +284,7 @@ class ProcurementAi: ) -> Iterator[ControlPoint]: distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near) threatened = [] - for cp in distance_cache.operational_airfields_within(request.range): + for cp in distance_cache.operational_airfields: if not cp.is_friendly(self.is_player): continue if cp.unclaimed_parking(self.game) < request.number: diff --git a/game/transfers.py b/game/transfers.py index 7401b03d..68e8dea1 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -688,7 +688,5 @@ class PendingTransfers: gap += 1 self.game.procurement_requests_for(self.player).append( - AircraftProcurementRequest( - control_point, nautical_miles(200), FlightType.TRANSPORT, gap - ) + AircraftProcurementRequest(control_point, FlightType.TRANSPORT, gap) ) diff --git a/resources/units/aircraft/A-50.yaml b/resources/units/aircraft/A-50.yaml index 574f8cd9..bef84e03 100644 --- a/resources/units/aircraft/A-50.yaml +++ b/resources/units/aircraft/A-50.yaml @@ -1,5 +1,6 @@ description: The A-50 is an AWACS plane. max_group_size: 1 +max_range: 2000 price: 50 patrol: altitude: 33000 diff --git a/resources/units/aircraft/AV8BNA.yaml b/resources/units/aircraft/AV8BNA.yaml index 7e4fec82..3accfba7 100644 --- a/resources/units/aircraft/AV8BNA.yaml +++ b/resources/units/aircraft/AV8BNA.yaml @@ -27,6 +27,7 @@ manufacturer: McDonnell Douglas origin: USA/UK price: 15 role: V/STOL Attack +max_range: 100 variants: AV-8B Harrier II Night Attack: {} radios: diff --git a/resources/units/aircraft/An-26B.yaml b/resources/units/aircraft/An-26B.yaml index 4ed84aa7..01f90c19 100644 --- a/resources/units/aircraft/An-26B.yaml +++ b/resources/units/aircraft/An-26B.yaml @@ -1,4 +1,5 @@ description: The An-26B is a military transport aircraft. price: 15 +max_range: 800 variants: An-26B: null diff --git a/resources/units/aircraft/B-1B.yaml b/resources/units/aircraft/B-1B.yaml index 0d34fb01..3c309c7b 100644 --- a/resources/units/aircraft/B-1B.yaml +++ b/resources/units/aircraft/B-1B.yaml @@ -1,4 +1,5 @@ -description: The Rockwell B-1 Lancer is a supersonic variable-sweep wing, heavy bomber +description: + The Rockwell B-1 Lancer is a supersonic variable-sweep wing, heavy bomber used by the United States Air Force. It is commonly called the 'Bone' (from 'B-One').It is one of three strategic bombers in the U.S. Air Force fleet as of 2021, the other two being the B-2 Spirit and the B-52 Stratofortress. It first served in combat @@ -12,5 +13,6 @@ manufacturer: Rockwell origin: USA price: 45 role: Supersonic Strategic Bomber +max_range: 2000 variants: B-1B Lancer: {} diff --git a/resources/units/aircraft/B-52H.yaml b/resources/units/aircraft/B-52H.yaml index 65aa01c0..7221fd5e 100644 --- a/resources/units/aircraft/B-52H.yaml +++ b/resources/units/aircraft/B-52H.yaml @@ -1,4 +1,5 @@ -description: The Boeing B-52 Stratofortress is capable of carrying up to 70,000 pounds +description: + The Boeing B-52 Stratofortress is capable of carrying up to 70,000 pounds (32,000 kg) of weapons, and has a typical combat range of more than 8,800 miles (14,080 km) without aerial refueling. The B-52 completed sixty years of continuous service with its original operator in 2015. After being upgraded between 2013 and @@ -8,5 +9,6 @@ manufacturer: Boeing origin: USA price: 35 role: Strategic Bomber +max_range: 2000 variants: B-52H Stratofortress: {} diff --git a/resources/units/aircraft/C-130.yaml b/resources/units/aircraft/C-130.yaml index 4efe0d0a..eca68ffa 100644 --- a/resources/units/aircraft/C-130.yaml +++ b/resources/units/aircraft/C-130.yaml @@ -1,4 +1,5 @@ description: The C-130 is a military transport aircraft. price: 15 +max_range: 1000 variants: C-130: null diff --git a/resources/units/aircraft/C-17A.yaml b/resources/units/aircraft/C-17A.yaml index a121e07b..692e24a9 100644 --- a/resources/units/aircraft/C-17A.yaml +++ b/resources/units/aircraft/C-17A.yaml @@ -1,4 +1,5 @@ description: The C-17 is a military transport aircraft. price: 18 +max_range: 2000 variants: C-17A: null diff --git a/resources/units/aircraft/E-2C.yaml b/resources/units/aircraft/E-2C.yaml index ca25d97e..7154813a 100644 --- a/resources/units/aircraft/E-2C.yaml +++ b/resources/units/aircraft/E-2C.yaml @@ -8,6 +8,7 @@ manufacturer: Northrop Grumman origin: USA price: 50 role: AEW&C +max_range: 2000 patrol: altitude: 30000 variants: diff --git a/resources/units/aircraft/E-3A.yaml b/resources/units/aircraft/E-3A.yaml index ca781a23..a8e676e4 100644 --- a/resources/units/aircraft/E-3A.yaml +++ b/resources/units/aircraft/E-3A.yaml @@ -1,6 +1,7 @@ description: The E-3A is a AWACS aicraft. price: 50 max_group_size: 1 +max_range: 2000 patrol: altitude: 35000 variants: diff --git a/resources/units/aircraft/F-14A-135-GR.yaml b/resources/units/aircraft/F-14A-135-GR.yaml index eb593105..b467d7e9 100644 --- a/resources/units/aircraft/F-14A-135-GR.yaml +++ b/resources/units/aircraft/F-14A-135-GR.yaml @@ -21,6 +21,7 @@ manufacturer: Grumman origin: USA price: 22 role: Carrier-based Air-Superiority Fighter/Fighter Bomber +max_range: 250 variants: F-14A Tomcat (Block 135-GR Late): {} radios: diff --git a/resources/units/aircraft/F-14B.yaml b/resources/units/aircraft/F-14B.yaml index a6244a4a..2cc64fa4 100644 --- a/resources/units/aircraft/F-14B.yaml +++ b/resources/units/aircraft/F-14B.yaml @@ -21,6 +21,7 @@ manufacturer: Grumman origin: USA price: 26 role: Carrier-based Air-Superiority Fighter/Fighter Bomber +max_range: 250 variants: F-14B Tomcat: {} radios: diff --git a/resources/units/aircraft/F-16A.yaml b/resources/units/aircraft/F-16A.yaml index 99c2c2e5..fdfcdb5c 100644 --- a/resources/units/aircraft/F-16A.yaml +++ b/resources/units/aircraft/F-16A.yaml @@ -1,4 +1,5 @@ description: The early verison of the F-16. It flew in Desert Storm. price: 15 +max_range: 200 variants: F-16A: null diff --git a/resources/units/aircraft/F-16C_50.yaml b/resources/units/aircraft/F-16C_50.yaml index 9e5b3740..adefe831 100644 --- a/resources/units/aircraft/F-16C_50.yaml +++ b/resources/units/aircraft/F-16C_50.yaml @@ -27,6 +27,7 @@ manufacturer: General Dynamics origin: USA price: 22 role: Multirole Fighter +max_range: 200 variants: F-16CM Fighting Falcon (Block 50): {} F-2A: {} diff --git a/resources/units/aircraft/Hercules.yaml b/resources/units/aircraft/Hercules.yaml index 070409fa..af82aaa6 100644 --- a/resources/units/aircraft/Hercules.yaml +++ b/resources/units/aircraft/Hercules.yaml @@ -1,4 +1,5 @@ -description: The Lockheed Martin C-130J Super Hercules is a four-engine turboprop +description: + The Lockheed Martin C-130J Super Hercules is a four-engine turboprop military transport aircraft. The C-130J is a comprehensive update of the Lockheed C-130 Hercules, with new engines, flight deck, and other systems. As of February 2018, 400 C-130J aircraft have been delivered to 17 nations. @@ -7,5 +8,6 @@ manufacturer: Lockheed origin: USA price: 18 role: Transport +max_range: 1000 variants: C-130J-30 Super Hercules: {} diff --git a/resources/units/aircraft/IL-76MD.yaml b/resources/units/aircraft/IL-76MD.yaml index 97020aca..74ca1ab1 100644 --- a/resources/units/aircraft/IL-76MD.yaml +++ b/resources/units/aircraft/IL-76MD.yaml @@ -1,3 +1,4 @@ price: 20 +max_range: 1000 variants: IL-76MD: null diff --git a/resources/units/aircraft/IL-78M.yaml b/resources/units/aircraft/IL-78M.yaml index 2acd1ea3..de5b76f2 100644 --- a/resources/units/aircraft/IL-78M.yaml +++ b/resources/units/aircraft/IL-78M.yaml @@ -1,5 +1,6 @@ price: 20 max_group_size: 1 +max_range: 1000 patrol: # ~280 knots IAS. speed: 400 diff --git a/resources/units/aircraft/KC-135.yaml b/resources/units/aircraft/KC-135.yaml index 138ae873..2cb5e40d 100644 --- a/resources/units/aircraft/KC-135.yaml +++ b/resources/units/aircraft/KC-135.yaml @@ -8,6 +8,7 @@ manufacturer: Beoing origin: USA price: 25 role: Tanker +max_range: 1000 patrol: # ~300 knots IAS. speed: 445 diff --git a/resources/units/aircraft/KC130.yaml b/resources/units/aircraft/KC130.yaml index 802a16bb..5b9ffdca 100644 --- a/resources/units/aircraft/KC130.yaml +++ b/resources/units/aircraft/KC130.yaml @@ -1,10 +1,12 @@ -description: The Lockheed Martin (previously Lockheed) KC-130 is a family of the extended-range +description: + The Lockheed Martin (previously Lockheed) KC-130 is a family of the extended-range tanker version of the C-130 Hercules transport aircraft modified for aerial refueling. introduced: 1962 manufacturer: Lockheed Martin origin: USA price: 25 role: Tanker +max_range: 1000 patrol: # ~210 knots IAS, roughly the max for the KC-130 at altitude. speed: 370 diff --git a/resources/units/aircraft/KC135MPRS.yaml b/resources/units/aircraft/KC135MPRS.yaml index 4ba28ff5..c59d3098 100644 --- a/resources/units/aircraft/KC135MPRS.yaml +++ b/resources/units/aircraft/KC135MPRS.yaml @@ -1,4 +1,5 @@ -description: The Boeing KC-135 Stratotanker is a military aerial refueling aircraft +description: + The Boeing KC-135 Stratotanker is a military aerial refueling aircraft that was developed from the Boeing 367-80 prototype, alongside the Boeing 707 airliner. This model has the Multi-point Refueling System modification, allowing for probe and drogue refuelling. @@ -7,6 +8,7 @@ manufacturer: Boeing origin: USA price: 25 role: Tanker +max_range: 1000 patrol: # 300 knots IAS. speed: 440 diff --git a/resources/units/aircraft/KJ-2000.yaml b/resources/units/aircraft/KJ-2000.yaml index cbb843c8..8078c359 100644 --- a/resources/units/aircraft/KJ-2000.yaml +++ b/resources/units/aircraft/KJ-2000.yaml @@ -1,4 +1,5 @@ price: 50 +max_range: 2000 patrol: altitude: 40000 variants: diff --git a/resources/units/aircraft/S-3B Tanker.yaml b/resources/units/aircraft/S-3B Tanker.yaml index dabe7056..6a4680e3 100644 --- a/resources/units/aircraft/S-3B Tanker.yaml +++ b/resources/units/aircraft/S-3B Tanker.yaml @@ -1,5 +1,6 @@ carrier_capable: true -description: The Lockheed S-3 Viking is a 4-crew, twin-engine turbofan-powered jet +description: + The Lockheed S-3 Viking is a 4-crew, twin-engine turbofan-powered jet aircraft that was used by the U.S. Navy (USN) primarily for anti-submarine warfare. In the late 1990s, the S-3B's mission focus shifted to surface warfare and aerial refueling. The Viking also provided electronic warfare and surface surveillance @@ -16,6 +17,7 @@ origin: USA price: 20 max_group_size: 1 role: Carrier-based Tanker +max_range: 1000 patrol: # ~265 knots IAS. speed: 320 diff --git a/resources/units/aircraft/Yak-40.yaml b/resources/units/aircraft/Yak-40.yaml index d56a2b65..d69242fc 100644 --- a/resources/units/aircraft/Yak-40.yaml +++ b/resources/units/aircraft/Yak-40.yaml @@ -1,3 +1,4 @@ price: 25 +max_range: 600 variants: Yak-40: null From 0a57bb5029cb0e60cc3169b4c6da8296c657c8df Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 15:30:43 -0700 Subject: [PATCH 103/167] Increase airfield distance in Battle of Abu Dhabi. Removes some of the low capacity airfields from the campaign now that missions can plan longer ranges if needed. This removes Khasab, Bandar Lengeh, and Qeshm from the blue side, so blue no longer has any airfields on the peninsula. The CVN has moved quite a ways west to make it a good platform for attacking the area around Dubai, and to prevent it from being the primary mission source (with a 90 aircraft limit, a *lot* of missions can get planned there before other airbases will be used). The LHA moves to near where the CVN was, making it a good platform for early game missions. Once the LHA's 20 aircraft limit is exhausted, Kish and Bandar Abbas will be the primary airfields early game. Bandar Abbas is still close enough to source Hornet and Viper missions to most of the area around Dubai. It's unable to reach Lar with those aircraft, but Kish and the CVN can (as can captured airfields). --- resources/campaigns/battle_of_abu_dhabi.json | 2 +- resources/campaigns/battle_of_abu_dhabi.miz | Bin 46467 -> 42833 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/campaigns/battle_of_abu_dhabi.json b/resources/campaigns/battle_of_abu_dhabi.json index 9cfa5476..8bbd80fb 100644 --- a/resources/campaigns/battle_of_abu_dhabi.json +++ b/resources/campaigns/battle_of_abu_dhabi.json @@ -4,7 +4,7 @@ "authors": "Colonel Panic", "recommended_player_faction": "Iran 2015", "recommended_enemy_faction": "United Arab Emirates 2015", - "description": "

You have managed to establish a foothold at Khasab. Continue pushing south.

", + "description": "

You have managed to establish a foothold near Ras Al Khaima. Continue pushing south.

", "miz": "battle_of_abu_dhabi.miz", "performance": 2, "version": "7.0" diff --git a/resources/campaigns/battle_of_abu_dhabi.miz b/resources/campaigns/battle_of_abu_dhabi.miz index a3c11d5e240898976648c436d938a7cfac8832d1..dcc30acffbf65f5e9f658c56fb700d5f553dce36 100644 GIT binary patch delta 38694 zcmZVF1yoeq{{VVKxXNrwDj(@F*y7aBwf-wtQ4fXLaD<0%FkNkYVpw znK?L^*;wy3>o`pJMwfc->n|QSHD*=FYdIb2_15g1Rbfv>H@HJVteM}88cIyHjy4uJ zK+msR0XRAc9x9q>)(7~L#Aj0RC=87~dFq?^cm&;V?)kk=fYu0d!u!kh zm?CgYYx<4GWz5li`kmtyOZIZn(iaxsX8BhC0%+y)x;i<$*jZjaR;X;{ckn-~J)9gX zB&@q#O==ZvX=&oUuUML&zdQOh4>Sn82OXB>wenuq=4H34-%dly2E(s!c9x}Lx>9a^ z{VyqW!|A)LRe z9v19ozh_R>zp-#UJj{>q1(8mV5+2<2?D3r|IPA|1q+A%C2(rkBmw^-l7lHcA!|^4P zal+*U+Pvj2*(}RnI2PNN%H~@()@#9)HxV{o>zr9c`s9Fqss|Gpv@xZNpH%OTzrH)h zKEE}rm4fLq_x1UTGyFYqM5(}#_93X)<8<}r$l`ElIlE|IzX$w&p;dm+JoS2m-n>M^ zU6Q#qylBAV-Es_Ajqm3GCSmuXxxsjK_{v#4`e)W{&~pbsZtc#~yjv^Sf%D3iSK!L= zPUBRZ%@dHoDq|7AQ`(!f>xwztT{&9mgls~_&I2?}6whP8T->4)}x*{3N4xg|{Z4+H>Rl>qy7)=bJKkRi8hhjs4dadUfda%0(4 zq26$Pnq-lRFJN#beD7-S%+YeOYFAzxF-Wd|2i)F5cA5nUw(?!ZhnQ^=uID7JOX@tF zLJ}AEm*(Q;T-#&&6RhpClSlxbdzYh4!<)l{6=P!bp^*Ux_3+j!!Vt0gJC{kr3vm|N zkC46@mxEU;#0vX&hBim7DYVC}$DbTsk7q8riZbvtxbNJYoV6->YgG#DcfVkdGqqgk z0yhh5TAmeLEp>b^(wZ9tZXMTaWP-0W4v)=SZ}*X~=UPqD8_>x3njy0^7u8n*JF+ju`sc4GtS00ntM#^YS4*$@8VLHC^KWFQyB;qjpylRb z(=gn5j?I{@=f}Y^gmkwfpVp!3Sf%zi4>OfW_pMXTY*Oz9zr0Ud>4?aS zN!7O^S>qbojG(QoI<_#Ntvo>|Od7QJnz3e+N!O?6N}?Xp|0&1BWAlzHDObPl%rM@| z>nrSUf`YDG)vF!Vw3Ruo+4T@cz_tCUtO<{xwO~?iMs0Y;uh9XP;*6`*%gu|;r|$)E zVQ=dk@V1!GAIC^F_*=-*Y_6Fs)5bWWcc#m-4YGVpOtxR_`7OnK zu#8?|9&(u>pSIn>qL8+I^UZZjcOBsMRENFH3a(|Gb9<_;uao7er*g@r$__K>8nSh^ z$z*3ykk~)}!8_X--=iHyxpLxjBJ=qp7l|z+{c-2vy)Kj5%BmpwNDTFxPpvPcC(0Ub zD-B+d`@z??+8XW$upXBI*J2xY!}{|#*HK!WfhGfM2bQd%7SFo)>8i{!X<8yQo=?pu zfOqb-gbeQMCTHFZiqD6)q-QXCuohGIRswal13G#xwYaT}A5OO`i@^I~!B+$NtDbrQ znu@HtkZAygfazinxRo1NUca}yI6AECY8-kQJw&UYp$hW6B=Z8krX$~-ED8c2)r<5$ z1_-69EBmbij%wU_{j}BR`c@dnQ!Z}1_~Le+&U{9YyzK6GZ`AM2Q!S9VnB5@g!+Q-3 z$=c3~B<60%)H?U02iW_=r!k$|;L4cFR`UHgi-Vj^Cui(0hELy!-}z<;y4F_PuX#uln3lM75vMOh{TAW?Xf9sCmBEP@s4?LDKJp(V$>q`)3 zvm^{iFp@ZZ)=1s2fzONG@2>o+bM;k&uXshFE4=(wxA0b5zvR|sPv-~wY^{Nc3nw{QE$64Z)uH#h^?I|n(T9Z@eF!aw zlZTEeRAjUnRMms75z9)M3i5Xiv+iEgK=kQWhJ=a9@^8}p`CD|@2*≪BZKLpX4+{ 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%0D`+YH#Bkrxx8dN@IJh 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 z Date: Sat, 17 Jul 2021 17:27:40 -0700 Subject: [PATCH 104/167] Log a warning for unknown max ranges. --- game/dcs/aircrafttype.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index 56fa3f0f..c51ef65d 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -241,6 +241,10 @@ class AircraftType(UnitType[Type[FlyingType]]): mission_range = ( nautical_miles(50) if aircraft.helicopter else nautical_miles(150) ) + logging.warning( + f"{aircraft.id} does not specify a max_range. Defaulting to " + f"{mission_range.nautical_miles}NM" + ) try: introduction = data["introduced"] From 3c90a9264136640b25a2750cabb4789357e4382a Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 18:23:20 -0700 Subject: [PATCH 105/167] Add fuel consumption data for the Hornet. Will be used to calculate bingo and min remaining fuel for the kneeboard. --- doc/fuel-consumption-measurement.md | 80 +++++++++++++++++++++ resources/units/aircraft/FA-18C_hornet.yaml | 10 +++ 2 files changed, 90 insertions(+) create mode 100644 doc/fuel-consumption-measurement.md diff --git a/doc/fuel-consumption-measurement.md b/doc/fuel-consumption-measurement.md new file mode 100644 index 00000000..62dd0a1b --- /dev/null +++ b/doc/fuel-consumption-measurement.md @@ -0,0 +1,80 @@ +# Measuring estimated fuel consumption + +To estimate fuel consumption numbers for an aircraft, create a mission with a +typical heavy load for the aircraft. For example, to measure for the F/A-18C, a +loadout with two bags, two GBU-31s, two sidewinders, an AMRAAM, and an ATFLIR. +Do **not** drop bags or weapons during the test flight. + +Start the aircraft on the ground at a large airport (for example, Akrotiri) at a +parking space at the opposite end of the takeoff runway so you can estimate long +taxi fuel consumption. + +When you enter the jet, note the amount of fuel below, then taxi to the far end +of the runway. Hold short and note the remaining fuel below. + +Follow a typical takeoff pattern for the aircraft. For the F/A-18C, this might +be AB takeoff, reduce to MIL at 350KIAS, and maintian 350KIAS/0.85 mach until +cruise altitude (angles 25). + +Once you reach angels 25, pause the game. Note your remaining fuel below and +measure the distance traveled from takeoff. Mark your location on the map. + +Level out and increase to cruise speed if needed. Liberation assumes 0.85 mach +for supersonic aircraft, for subsonic aircraft it depends so pick something +reasonable and note your descision in a comment in the file when done. Maintain +speed, heading, and altitude for a long distance (the longer the distance, the +more accurate the result, but be careful to leave enough fuel for the final +section). Once complete, note the distance traveled and the remaining fuel. + +Finally, increase speed as you would for an attack. At least MIL power, +potentially use AB sparingly, etc. The goal is to measure fuel consumption per +mile traveled during an attack run. + +``` +start: +taxi end: +to 25k distance: +at 25k fuel: +cruise (.85 mach) distance: +cruise (.85 mach) end fuel: +combat distance: +combat end fuel: +``` + +Finally, fill out the data in the aircraft data. Below is an example for the +F/A-18C: + +``` +start: 15290 +taxi end: 15120 +climb distance: 40NM +at 25k fuel: 13350 +cruise (.85 mach) distance: 100NM +cruise (.85 mach) end fuel: 11140 +combat distance: 100NM +combat end fuel: 8390 + +taxi = start - taxi end = 15290 - 15120 = 170 +climb fuel = taxi end - at 25k fuel = 15120 - 13350 = 1770 +climb ppm = climb fuel / climb distance = 1770 / 40 = 44.25 +cruise fuel = at 25k fuel - cruise end fuel = 13350 - 11140 = 2210 +cruise ppm = cruise fuel / cruise distance = 2210 / 100 = 22.1 +combat fuel = cruise end fuel - combat end fuel = 11140 - 8390 = 2750 +combat ppm = combat fuel / combat distance = 2750 / 100 = 27.5 +``` + +```yaml +fuel: + # Parking A1 to RWY 32 at Akrotiri. + taxi: 170 + # AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft. + climb_ppm: 44.25 + # 0.85 mach for 100NM. + cruise_ppm: 22.1 + # ~0.9 mach for 100NM. Occasional AB use. + combat_ppm: 27.5 + min_safe: 2000 +``` + +The last entry (`min_safe`) is the minimum amount of fuel that the aircraft +should land with. diff --git a/resources/units/aircraft/FA-18C_hornet.yaml b/resources/units/aircraft/FA-18C_hornet.yaml index 06f616d2..511a0b48 100644 --- a/resources/units/aircraft/FA-18C_hornet.yaml +++ b/resources/units/aircraft/FA-18C_hornet.yaml @@ -21,6 +21,16 @@ manufacturer: McDonnell Douglas origin: USA price: 24 role: Carrier-based Multirole Fighter +fuel: + # Parking A1 to RWY 32 at Akrotiri. + taxi: 170 + # AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft. + climb_ppm: 44.25 + # 0.85 mach for 100NM. + cruise_ppm: 22.1 + # ~0.9 mach for 100NM. Occasional AB use. + combat_ppm: 27.5 + min_safe: 2000 variants: CF-188 Hornet: {} EF-18A+ Hornet: {} From c11c6f40d5010bf4c9418d68780977e4820abb0d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 19:51:55 -0700 Subject: [PATCH 106/167] Add minimum fuel per waypoint on the kneeboard. --- game/dcs/aircrafttype.py | 40 ++++++++++++++++++++++++++++ game/utils.py | 7 +++-- gen/aircraft.py | 56 ++++++++++++++++++++++++++++++++++++--- gen/flights/flight.py | 13 +++++++-- gen/flights/flightplan.py | 12 +++++++++ gen/kneeboard.py | 37 +++++++++++++++++++++++--- 6 files changed, 154 insertions(+), 11 deletions(-) diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index c51ef65d..5158f240 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -105,6 +105,35 @@ class PatrolConfig: ) +@dataclass(frozen=True) +class FuelConsumption: + #: The estimated taxi fuel requirement, in pounds. + taxi: int + + #: The estimated fuel consumption for a takeoff climb, in pounds per nautical mile. + climb: float + + #: The estimated fuel consumption for cruising, in pounds per nautical mile. + cruise: float + + #: The estimated fuel consumption for combat speeds, in pounds per nautical mile. + combat: float + + #: The minimum amount of fuel that the aircraft should land with, in pounds. This is + #: a reserve amount for landing delays or emergencies. + min_safe: int + + @classmethod + def from_data(cls, data: dict[str, Any]) -> FuelConsumption: + return FuelConsumption( + int(data["taxi"]), + float(data["climb_ppm"]), + float(data["cruise_ppm"]), + float(data["combat_ppm"]), + int(data["min_safe"]), + ) + + # TODO: Split into PlaneType and HelicopterType? @dataclass(frozen=True) class AircraftType(UnitType[Type[FlyingType]]): @@ -124,6 +153,8 @@ class AircraftType(UnitType[Type[FlyingType]]): #: planner will consider this aircraft usable for a mission. max_mission_range: Distance + fuel_consumption: Optional[FuelConsumption] + intra_flight_radio: Optional[Radio] channel_allocator: Optional[RadioChannelAllocator] channel_namer: Type[ChannelNamer] @@ -246,6 +277,14 @@ class AircraftType(UnitType[Type[FlyingType]]): f"{mission_range.nautical_miles}NM" ) + fuel_data = data.get("fuel") + if fuel_data is not None: + fuel_consumption: Optional[FuelConsumption] = FuelConsumption.from_data( + fuel_data + ) + else: + fuel_consumption = None + try: introduction = data["introduced"] if introduction is None: @@ -274,6 +313,7 @@ class AircraftType(UnitType[Type[FlyingType]]): patrol_altitude=patrol_config.altitude, patrol_speed=patrol_config.speed, max_mission_range=mission_range, + fuel_consumption=fuel_consumption, intra_flight_radio=radio_config.intra_flight, channel_allocator=radio_config.channel_allocator, channel_namer=radio_config.channel_namer, diff --git a/game/utils.py b/game/utils.py index 39daa058..b21b11df 100644 --- a/game/utils.py +++ b/game/utils.py @@ -4,7 +4,7 @@ import itertools import math from collections import Iterable from dataclasses import dataclass -from typing import Union, Any +from typing import Union, Any, TypeVar METERS_TO_FEET = 3.28084 FEET_TO_METERS = 1 / METERS_TO_FEET @@ -205,7 +205,10 @@ def inches_hg(value: float) -> Pressure: return Pressure(value) -def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]: +PairwiseT = TypeVar("PairwiseT") + + +def pairwise(iterable: Iterable[PairwiseT]) -> Iterable[tuple[PairwiseT, PairwiseT]]: """ itertools recipe s -> (s0,s1), (s1,s2), (s2, s3), ... diff --git a/gen/aircraft.py b/gen/aircraft.py index 392cd70c..144344df 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import logging import random from dataclasses import dataclass @@ -80,7 +81,7 @@ from game.theater.missiontarget import MissionTarget from game.theater.theatergroundobject import TheaterGroundObject from game.transfers import MultiGroupTransport from game.unitmap import UnitMap -from game.utils import Distance, meters, nautical_miles +from game.utils import Distance, meters, nautical_miles, pairwise from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit from gen.flights.flight import ( @@ -1194,8 +1195,57 @@ class AircraftConflictGenerator: ).build() # Set here rather than when the FlightData is created so they waypoints - # have their TOTs set. - self.flights[-1].waypoints = [takeoff_point] + flight.points + # have their TOTs and fuel minimums set. Once we're more confident in our fuel + # estimation ability the minimum fuel amounts will be calculated during flight + # plan construction, but for now it's only used by the kneeboard so is generated + # late. + waypoints = [takeoff_point] + flight.points + self._estimate_min_fuel_for(flight, waypoints) + self.flights[-1].waypoints = waypoints + + @staticmethod + def _estimate_min_fuel_for(flight: Flight, waypoints: list[FlightWaypoint]) -> None: + if flight.unit_type.fuel_consumption is None: + return + + combat_speed_types = { + FlightWaypointType.INGRESS_BAI, + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_DEAD, + FlightWaypointType.INGRESS_ESCORT, + FlightWaypointType.INGRESS_OCA_AIRCRAFT, + FlightWaypointType.INGRESS_OCA_RUNWAY, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + FlightWaypointType.INGRESS_SWEEP, + FlightWaypointType.SPLIT, + } | set(TARGET_WAYPOINTS) + + consumption = flight.unit_type.fuel_consumption + min_fuel: float = consumption.min_safe + + # The flight plan (in reverse) up to and including the arrival point. + main_flight_plan = reversed(waypoints) + try: + while waypoint := next(main_flight_plan): + if waypoint.waypoint_type is FlightWaypointType.LANDING_POINT: + waypoint.min_fuel = min_fuel + main_flight_plan = itertools.chain([waypoint], main_flight_plan) + break + except StopIteration: + # Some custom flight plan without a landing point. Skip it. + return + + for b, a in pairwise(main_flight_plan): + distance = meters(a.position.distance_to_point(b.position)) + if a.waypoint_type is FlightWaypointType.TAKEOFF: + ppm = consumption.climb + elif b.waypoint_type in combat_speed_types: + ppm = consumption.combat + else: + ppm = consumption.cruise + min_fuel += distance.nautical_miles * ppm + a.min_fuel = min_fuel def should_delay_flight(self, flight: Flight, start_time: timedelta) -> bool: if start_time.total_seconds() <= 0: diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 05ee4c19..5c3241f7 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -2,13 +2,14 @@ from __future__ import annotations from datetime import timedelta from enum import Enum -from typing import List, Optional, TYPE_CHECKING, Union, Sequence +from typing import List, Optional, TYPE_CHECKING, Union, Sequence, Any from dcs.mapping import Point from dcs.point import MovingPoint, PointAction from dcs.unit import Unit from game.dcs.aircrafttype import AircraftType +from game.savecompat import has_save_compat_for from game.squadrons import Pilot, Squadron from game.theater.controlpoint import ControlPoint, MissionTarget from game.utils import Distance, meters @@ -138,7 +139,7 @@ class FlightWaypoint: Args: waypoint_type: The waypoint type. - x: X cooidinate of the waypoint. + x: X coordinate of the waypoint. y: Y coordinate of the waypoint. alt: Altitude of the waypoint. By default this is AGL, but it can be changed to MSL by setting alt_type to "RADIO". @@ -158,6 +159,8 @@ class FlightWaypoint: self.pretty_name = "" self.only_for_player = False self.flyover = False + # The minimum amount of fuel remaining at this waypoint in pounds. + self.min_fuel: Optional[float] = None # These are set very late by the air conflict generator (part of mission # generation). We do it late so that we don't need to propagate changes @@ -166,6 +169,12 @@ class FlightWaypoint: self.tot: Optional[timedelta] = None self.departure_time: Optional[timedelta] = None + @has_save_compat_for(5) + def __setstate__(self, state: dict[str, Any]) -> None: + if "min_fuel" not in state: + state["min_fuel"] = None + self.__dict__.update(state) + @property def position(self) -> Point: return Point(self.x, self.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 4f28e4cf..6c25babd 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,6 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine +from game.dcs.aircrafttype import FuelConsumption from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry from game.theater import ( Airfield, @@ -138,6 +139,17 @@ class FlightPlan: @cached_property def bingo_fuel(self) -> int: """Bingo fuel value for the FlightPlan""" + if (fuel := self.flight.unit_type.fuel_consumption) is not None: + return self._bingo_estimate(fuel) + return self._legacy_bingo_estimate() + + def _bingo_estimate(self, fuel: FuelConsumption) -> int: + distance_to_arrival = self.max_distance_from(self.flight.arrival) + fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles + bingo = fuel_consumed + fuel.min_safe + return math.ceil(bingo / 100) * 100 + + def _legacy_bingo_estimate(self) -> int: distance_to_arrival = self.max_distance_from(self.flight.arrival) bingo = 1000.0 # Minimum Emergency Fuel diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 20fb8ca1..1b0f09b1 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -23,6 +23,7 @@ only be added per airframe, so PvP missions where each side have the same aircraft will be able to see the enemy's kneeboard for the same airframe. """ import datetime +import math import textwrap from collections import defaultdict from dataclasses import dataclass @@ -39,8 +40,8 @@ from game.db import unit_type_from_name from game.dcs.aircrafttype import AircraftType from game.theater import ConflictTheater, TheaterGroundObject, LatLon from game.theater.bullseye import Bullseye -from game.weather import Weather from game.utils import meters +from game.weather import Weather from .aircraft import FlightData from .airsupportgen import AwacsInfo, TankerInfo from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator @@ -111,12 +112,17 @@ class KneeboardPageWriter: self.text(text, font=self.heading_font, fill=self.foreground_fill) def table( - self, cells: List[List[str]], headers: Optional[List[str]] = None + self, + cells: List[List[str]], + headers: Optional[List[str]] = None, + font: Optional[ImageFont.FreeTypeFont] = None, ) -> None: if headers is None: headers = [] + if font is None: + font = self.table_font table = tabulate(cells, headers=headers, numalign="right") - self.text(table, font=self.table_font, fill=self.foreground_fill) + self.text(table, font, fill=self.foreground_fill) def write(self, path: Path) -> None: self.image.save(path) @@ -199,6 +205,7 @@ class FlightPlanBuilder: self._ground_speed(self.target_points[0].waypoint), self._format_time(self.target_points[0].waypoint.tot), self._format_time(self.target_points[0].waypoint.departure_time), + self._format_min_fuel(self.target_points[0].waypoint.min_fuel), ] ) self.last_waypoint = self.target_points[-1].waypoint @@ -216,6 +223,7 @@ class FlightPlanBuilder: self._ground_speed(waypoint.waypoint), self._format_time(waypoint.waypoint.tot), self._format_time(waypoint.waypoint.departure_time), + self._format_min_fuel(waypoint.waypoint.min_fuel), ] ) @@ -254,6 +262,12 @@ class FlightPlanBuilder: duration = (waypoint.tot - last_time).total_seconds() / 3600 return f"{int(distance.nautical_miles / duration)} kt" + @staticmethod + def _format_min_fuel(min_fuel: Optional[float]) -> str: + if min_fuel is None: + return "" + return str(math.ceil(min_fuel / 100) * 100) + def build(self) -> List[List[str]]: return self.rows @@ -276,6 +290,11 @@ class BriefingPage(KneeboardPage): self.weather = weather self.start_time = start_time self.dark_kneeboard = dark_kneeboard + self.flight_plan_font = ImageFont.truetype( + "resources/fonts/Inconsolata.otf", + 16, + layout_engine=ImageFont.LAYOUT_BASIC, + ) def write(self, path: Path) -> None: writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) @@ -302,7 +321,17 @@ class BriefingPage(KneeboardPage): flight_plan_builder.add_waypoint(num, waypoint) writer.table( flight_plan_builder.build(), - headers=["#", "Action", "Alt", "Dist", "GSPD", "Time", "Departure"], + headers=[ + "#", + "Action", + "Alt", + "Dist", + "GSPD", + "Time", + "Departure", + "Min fuel", + ], + font=self.flight_plan_font, ) writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}") From 2580fe6b79c89fa73018dfe754183f67ae8ebc30 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 19:53:16 -0700 Subject: [PATCH 107/167] Update the changelog. --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index e0e61174..0d4174d9 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. * **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken. +* **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet). * **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons. ## Fixes From e22e8669e18194b4a3246120f50bd860f96d2d53 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 14:41:22 -0700 Subject: [PATCH 108/167] Add fallback locations for join zones. It's rare with the current 5NM buffer around the origin, but if we use the hold distance as the buffer like we maybe should it's possible for the preferred join locations to fall entirely within the home zone. In that case, fall back to a location within the max-turn-zone that's outside the home zone and is nearest the IP. --- game/flightplan/joinzonegeometry.py | 32 +++++++++++++--------- gen/flights/flightplan.py | 1 - qt_ui/widgets/map/mapmodel.py | 42 ++++++++++++++++------------- resources/ui/map/map.js | 16 ++++++++++- 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/game/flightplan/joinzonegeometry.py b/game/flightplan/joinzonegeometry.py index 48cff780..02e00fa4 100644 --- a/game/flightplan/joinzonegeometry.py +++ b/game/flightplan/joinzonegeometry.py @@ -11,7 +11,6 @@ from shapely.geometry import ( MultiLineString, ) -from game.theater import ConflictTheater from game.utils import nautical_miles if TYPE_CHECKING: @@ -26,12 +25,7 @@ class JoinZoneGeometry: """ def __init__( - self, - target: Point, - home: Point, - ip: Point, - coalition: Coalition, - theater: ConflictTheater, + self, target: Point, home: Point, ip: Point, coalition: Coalition ) -> None: # Normal join placement is based on the path from home to the IP. If no path is # found it means that the target is on a direct path. In that case we instead @@ -82,14 +76,28 @@ class JoinZoneGeometry: ] ) - permissible_lines = ip_direction_limit_wedge.intersection( + permissible_zones = ip_direction_limit_wedge.difference( + self.excluded_zones + ).difference(self.home_bubble) + if permissible_zones.is_empty: + permissible_zones = MultiPolygon([]) + if not isinstance(permissible_zones, MultiPolygon): + permissible_zones = MultiPolygon([permissible_zones]) + self.permissible_zones = permissible_zones + + preferred_lines = ip_direction_limit_wedge.intersection( self.excluded_zones.boundary ).difference(self.home_bubble) - if not isinstance(permissible_lines, MultiLineString): - permissible_lines = MultiLineString([permissible_lines]) - self.permissible_lines = permissible_lines + if preferred_lines.is_empty: + preferred_lines = MultiLineString([]) + if not isinstance(preferred_lines, MultiLineString): + preferred_lines = MultiLineString([preferred_lines]) + self.preferred_lines = preferred_lines def find_best_join_point(self) -> Point: - join, _ = shapely.ops.nearest_points(self.permissible_lines, self.home) + if self.preferred_lines.is_empty: + join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip) + else: + join, _ = shapely.ops.nearest_points(self.preferred_lines, self.home) return Point(join.x, join.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 6c25babd..fde43ee9 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -975,7 +975,6 @@ class FlightPlanBuilder: package_airfield.position, ingress_point, self.coalition, - self.theater, ).find_best_join_point() # And the split point based on the best route from the IP. Since that's no diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index e8a75298..fce7a5d9 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -869,7 +869,8 @@ class JoinZonesJs(QObject): targetBubbleChanged = Signal() ipBubbleChanged = Signal() excludedZonesChanged = Signal() - permissibleLinesChanged = Signal() + permissibleZonesChanged = Signal() + preferredLinesChanged = Signal() def __init__( self, @@ -877,14 +878,16 @@ class JoinZonesJs(QObject): target_bubble: LeafletPoly, ip_bubble: LeafletPoly, excluded_zones: list[LeafletPoly], - permissible_lines: list[list[LeafletLatLon]], + permissible_zones: list[LeafletPoly], + preferred_lines: list[list[LeafletLatLon]], ) -> None: super().__init__() self._home_bubble = home_bubble self._target_bubble = target_bubble self._ip_bubble = ip_bubble self._excluded_zones = excluded_zones - self._permissible_lines = permissible_lines + self._permissible_zones = permissible_zones + self._preferred_lines = preferred_lines @Property(list, notify=homeBubbleChanged) def homeBubble(self) -> LeafletPoly: @@ -902,13 +905,17 @@ class JoinZonesJs(QObject): def excludedZones(self) -> list[LeafletPoly]: return self._excluded_zones - @Property(list, notify=permissibleLinesChanged) - def permissibleLines(self) -> list[list[LeafletLatLon]]: - return self._permissible_lines + @Property(list, notify=permissibleZonesChanged) + def permissibleZones(self) -> list[LeafletPoly]: + return self._permissible_zones + + @Property(list, notify=preferredLinesChanged) + def preferredLines(self) -> list[list[LeafletLatLon]]: + return self._preferred_lines @classmethod def empty(cls) -> JoinZonesJs: - return JoinZonesJs([], [], [], [], []) + return JoinZonesJs([], [], [], [], [], []) @classmethod def for_flight(cls, flight: Flight, game: Game) -> JoinZonesJs: @@ -919,15 +926,14 @@ class JoinZonesJs(QObject): if flight.package.waypoints is None: return JoinZonesJs.empty() ip = flight.package.waypoints.ingress - geometry = JoinZoneGeometry( - target.position, home.position, ip, game.blue, game.theater - ) + geometry = JoinZoneGeometry(target.position, home.position, ip, game.blue) return JoinZonesJs( shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater), shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater), shapely_to_leaflet_polys(geometry.excluded_zones, game.theater), - shapely_lines_to_leaflet_points(geometry.permissible_lines, game.theater), + shapely_to_leaflet_polys(geometry.permissible_zones, game.theater), + shapely_lines_to_leaflet_points(geometry.preferred_lines, game.theater), ) @@ -937,7 +943,7 @@ class HoldZonesJs(QObject): joinBubbleChanged = Signal() excludedZonesChanged = Signal() permissibleZonesChanged = Signal() - permissibleLinesChanged = Signal() + preferredLinesChanged = Signal() def __init__( self, @@ -946,7 +952,7 @@ class HoldZonesJs(QObject): join_bubble: LeafletPoly, excluded_zones: list[LeafletPoly], permissible_zones: list[LeafletPoly], - permissible_lines: list[list[LeafletLatLon]], + preferred_lines: list[list[LeafletLatLon]], ) -> None: super().__init__() self._home_bubble = home_bubble @@ -954,7 +960,7 @@ class HoldZonesJs(QObject): self._join_bubble = join_bubble self._excluded_zones = excluded_zones self._permissible_zones = permissible_zones - self._permissible_lines = permissible_lines + self._preferred_lines = preferred_lines @Property(list, notify=homeBubbleChanged) def homeBubble(self) -> LeafletPoly: @@ -976,9 +982,9 @@ class HoldZonesJs(QObject): def permissibleZones(self) -> list[LeafletPoly]: return self._permissible_zones - @Property(list, notify=permissibleLinesChanged) - def permissibleLines(self) -> list[list[LeafletLatLon]]: - return self._permissible_lines + @Property(list, notify=preferredLinesChanged) + def preferredLines(self) -> list[list[LeafletLatLon]]: + return self._preferred_lines @classmethod def empty(cls) -> HoldZonesJs: @@ -1003,7 +1009,7 @@ class HoldZonesJs(QObject): shapely_poly_to_leaflet_points(geometry.join_bubble, game.theater), shapely_to_leaflet_polys(geometry.excluded_zones, game.theater), shapely_to_leaflet_polys(geometry.permissible_zones, game.theater), - [], # shapely_to_leaflet_polys(geometry.permissible_lines, game.theater), + shapely_lines_to_leaflet_points(geometry.preferred_lines, game.theater), ) diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 831701a9..4e4ff59e 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -1066,7 +1066,14 @@ function drawJoinZones() { }).addTo(joinZones); } - for (const line of game.joinZones.permissibleLines) { + for (const zone of game.joinZones.permissibleZones) { + L.polygon(zone, { + color: Colors.Green, + interactive: false, + }).addTo(joinZones); + } + + for (const line of game.joinZones.preferredLines) { L.polyline(line, { color: Colors.Green, interactive: false, @@ -1114,6 +1121,13 @@ function drawHoldZones() { interactive: false, }).addTo(holdZones); } + + for (const line of game.holdZones.preferredLines) { + L.polyline(line, { + color: Colors.Green, + interactive: false, + }).addTo(holdZones); + } } function drawInitialMap() { From c2951e5e4131f33b91dc172a9d822755b6258528 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 14:39:51 -0700 Subject: [PATCH 109/167] Increase minimum hold distance. The previous values were far too optimistic for a non-AB climb to hold altitude, especially for the AI. --- game/data/doctrine.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 359a1435..7ef7c59a 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -113,7 +113,7 @@ MODERN_DOCTRINE = Doctrine( strike=True, antiship=True, rendezvous_altitude=feet(25000), - hold_distance=nautical_miles(15), + hold_distance=nautical_miles(25), push_distance=nautical_miles(20), join_distance=nautical_miles(20), max_ingress_distance=nautical_miles(45), @@ -150,7 +150,7 @@ COLDWAR_DOCTRINE = Doctrine( strike=True, antiship=True, rendezvous_altitude=feet(22000), - hold_distance=nautical_miles(10), + hold_distance=nautical_miles(15), push_distance=nautical_miles(10), join_distance=nautical_miles(10), max_ingress_distance=nautical_miles(30), @@ -186,7 +186,7 @@ WWII_DOCTRINE = Doctrine( sead=False, strike=True, antiship=True, - hold_distance=nautical_miles(5), + hold_distance=nautical_miles(10), push_distance=nautical_miles(5), join_distance=nautical_miles(5), rendezvous_altitude=feet(10000), From 270f87f193652a6b6da3eae074ae1d099d174dd4 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 15:49:58 -0700 Subject: [PATCH 110/167] Add per-aircraft tabs to air wing configuration. --- qt_ui/windows/AirWingConfigurationDialog.py | 176 ++++++++++++++------ 1 file changed, 127 insertions(+), 49 deletions(-) diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py index fb6bdc8b..87ba13bb 100644 --- a/qt_ui/windows/AirWingConfigurationDialog.py +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -1,21 +1,20 @@ -import itertools -import logging -from collections import defaultdict -from typing import Optional, Callable, Iterator +from typing import Optional, Callable from PySide2.QtCore import ( QItemSelectionModel, QModelIndex, QSize, Qt, + QItemSelection, + Signal, ) +from PySide2.QtGui import QStandardItemModel, QStandardItem, QIcon from PySide2.QtWidgets import ( QAbstractItemView, QDialog, QListView, QVBoxLayout, QGroupBox, - QGridLayout, QLabel, QWidget, QScrollArea, @@ -23,12 +22,15 @@ from PySide2.QtWidgets import ( QTextEdit, QCheckBox, QHBoxLayout, + QStackedLayout, ) from game import Game +from game.dcs.aircrafttype import AircraftType from game.squadrons import Squadron, AirWing, Pilot 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 @@ -151,60 +153,33 @@ class SquadronConfigurationBox(QGroupBox): return self.squadron -class AirWingConfigurationLayout(QVBoxLayout): - def __init__(self, air_wing: AirWing) -> None: +class SquadronConfigurationLayout(QVBoxLayout): + def __init__(self, squadrons: list[Squadron]) -> None: super().__init__() - self.air_wing = air_wing self.squadron_configs = [] - - doc_url = ( - "https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots" - ) - doc_label = QLabel( - "Use this opportunity to customize the squadrons available to your " - "coalition. This is your
" - "only opportunity to make changes.

" - "
" - "To accept your changes and continue, close this window.
" - "
" - "To remove a squadron from the game, uncheck the box in the title. New " - "squadrons cannot
" - "be added via the UI at this time. To add a custom squadron, see " - f'the wiki.' - ) - - doc_label.setOpenExternalLinks(True) - self.addWidget(doc_label) - for squadron in self.air_wing.iter_squadrons(): + for squadron in squadrons: squadron_config = SquadronConfigurationBox(squadron) self.squadron_configs.append(squadron_config) self.addWidget(squadron_config) - def apply(self) -> None: - keep_squadrons = defaultdict(list) + def apply(self) -> list[Squadron]: + keep_squadrons = [] for squadron_config in self.squadron_configs: if squadron_config.isChecked(): - squadron = squadron_config.apply() - keep_squadrons[squadron.aircraft].append(squadron) - self.air_wing.squadrons = keep_squadrons + keep_squadrons.append(squadron_config.apply()) + return keep_squadrons -class AirWingConfigurationDialog(QDialog): - """Dialog window for air wing configuration.""" +class AircraftSquadronsPage(QWidget): + def __init__(self, squadrons: list[Squadron]) -> None: + super().__init__() + layout = QVBoxLayout() + self.setLayout(layout) - def __init__(self, game: Game, parent) -> None: - super().__init__(parent) - self.air_wing = game.blue.air_wing + self.squadrons_config = SquadronConfigurationLayout(squadrons) - self.setMinimumSize(500, 800) - self.setWindowTitle(f"Air Wing Configuration") - # TODO: self.setWindowIcon() - - self.air_wing_config = AirWingConfigurationLayout(self.air_wing) - - scrolling_layout = QVBoxLayout() scrolling_widget = QWidget() - scrolling_widget.setLayout(self.air_wing_config) + scrolling_widget.setLayout(self.squadrons_config) scrolling_area = QScrollArea() scrolling_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -212,9 +187,112 @@ class AirWingConfigurationDialog(QDialog): scrolling_area.setWidgetResizable(True) scrolling_area.setWidget(scrolling_widget) - scrolling_layout.addWidget(scrolling_area) - self.setLayout(scrolling_layout) + layout.addWidget(scrolling_area) + + def apply(self) -> list[Squadron]: + return self.squadrons_config.apply() + + +class AircraftSquadronsPanel(QStackedLayout): + def __init__(self, air_wing: AirWing) -> None: + super().__init__() + self.air_wing = air_wing + self.squadrons_pages: dict[AircraftType, AircraftSquadronsPage] = {} + for aircraft, squadrons in self.air_wing.squadrons.items(): + page = AircraftSquadronsPage(squadrons) + self.addWidget(page) + self.squadrons_pages[aircraft] = page + + def apply(self) -> None: + for aircraft, page in self.squadrons_pages.items(): + self.air_wing.squadrons[aircraft] = page.apply() + + +class AircraftTypeList(QListView): + page_index_changed = Signal(int) + + def __init__(self, air_wing: AirWing) -> None: + super().__init__() + self.setIconSize(QSize(91, 24)) + self.setMinimumWidth(300) + + model = QStandardItemModel(self) + self.setModel(model) + + self.selectionModel().setCurrentIndex( + model.index(0, 0), QItemSelectionModel.Select + ) + self.selectionModel().selectionChanged.connect(self.on_selection_changed) + for aircraft in air_wing.squadrons: + aircraft_item = QStandardItem(aircraft.name) + icon = self.icon_for(aircraft) + if icon is not None: + aircraft_item.setIcon(icon) + aircraft_item.setEditable(False) + aircraft_item.setSelectable(True) + model.appendRow(aircraft_item) + + def on_selection_changed( + self, selected: QItemSelection, _deselected: QItemSelection + ) -> None: + indexes = selected.indexes() + if len(indexes) > 1: + raise RuntimeError("Aircraft list should not allow multi-selection") + if not indexes: + return + self.page_index_changed.emit(indexes[0].row()) + + @staticmethod + def icon_for(aircraft: AircraftType) -> Optional[QIcon]: + name = aircraft.dcs_id + if name in AIRCRAFT_ICONS: + return QIcon(AIRCRAFT_ICONS[name]) + return None + + +class AirWingConfigurationDialog(QDialog): + """Dialog window for air wing configuration.""" + + def __init__(self, game: Game, parent) -> None: + super().__init__(parent) + self.setMinimumSize(500, 800) + self.setWindowTitle(f"Air Wing Configuration") + # TODO: self.setWindowIcon() + + layout = QVBoxLayout() + self.setLayout(layout) + + doc_url = ( + "https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots" + ) + doc_label = QLabel( + "Use this opportunity to customize the squadrons available to your " + "coalition. This is your only opportunity to make changes." + "

" + "To accept your changes and continue, close this window.
" + "
" + "To remove a squadron from the game, uncheck the box in the title. New " + "squadrons cannot be added via the UI at this time. To add a custom " + "squadron,
" + f'see the wiki.' + ) + + doc_label.setOpenExternalLinks(True) + layout.addWidget(doc_label) + + columns = QHBoxLayout() + layout.addLayout(columns) + + type_list = AircraftTypeList(game.blue.air_wing) + type_list.page_index_changed.connect(self.on_aircraft_changed) + columns.addWidget(type_list) + + self.squadrons_panel = AircraftSquadronsPanel(game.blue.air_wing) + columns.addLayout(self.squadrons_panel) def reject(self) -> None: - self.air_wing_config.apply() + self.squadrons_panel.apply() super().reject() + + def on_aircraft_changed(self, index: QModelIndex) -> None: + self.squadrons_panel.setCurrentIndex(index) From 0eb8ec70d96d7de132b86a0a5110936eb9a454b5 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 16:09:20 -0700 Subject: [PATCH 111/167] Make opfor airwing configurable. --- qt_ui/windows/AirWingConfigurationDialog.py | 62 +++++++++++++++------ 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py index 87ba13bb..85539d06 100644 --- a/qt_ui/windows/AirWingConfigurationDialog.py +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -23,6 +23,7 @@ from PySide2.QtWidgets import ( QCheckBox, QHBoxLayout, QStackedLayout, + QTabWidget, ) from game import Game @@ -118,14 +119,22 @@ class SquadronConfigurationBox(QGroupBox): self.nickname_edit.textChanged.connect(self.on_nickname_changed) left_column.addWidget(self.nickname_edit) - left_column.addWidget( - QLabel("Players (one per line, leave empty for an AI-only squadron):") - ) - players = [p for p in squadron.available_pilots if p.player] + if squadron.player: + player_label = QLabel( + "Players (one per line, leave empty for an AI-only squadron):" + ) + else: + player_label = QLabel("Player slots not available for opfor") + left_column.addWidget(player_label) + + players = [p for p in squadron.pilot_pool if p.player] for player in players: - squadron.available_pilots.remove(player) + squadron.pilot_pool.remove(player) + if not squadron.player: + players = [] self.player_list = QTextEdit("
".join(p.name for p in players)) self.player_list.setAcceptRichText(False) + self.player_list.setEnabled(squadron.player) left_column.addWidget(self.player_list) left_column.addStretch() @@ -250,6 +259,27 @@ class AircraftTypeList(QListView): return None +class AirWingConfigurationTab(QWidget): + def __init__(self, air_wing: AirWing) -> None: + super().__init__() + + layout = QHBoxLayout() + self.setLayout(layout) + + type_list = AircraftTypeList(air_wing) + type_list.page_index_changed.connect(self.on_aircraft_changed) + layout.addWidget(type_list) + + self.squadrons_panel = AircraftSquadronsPanel(air_wing) + layout.addLayout(self.squadrons_panel) + + def apply(self) -> None: + self.squadrons_panel.apply() + + def on_aircraft_changed(self, index: QModelIndex) -> None: + self.squadrons_panel.setCurrentIndex(index) + + class AirWingConfigurationDialog(QDialog): """Dialog window for air wing configuration.""" @@ -280,19 +310,17 @@ class AirWingConfigurationDialog(QDialog): doc_label.setOpenExternalLinks(True) layout.addWidget(doc_label) - columns = QHBoxLayout() - layout.addLayout(columns) + tab_widget = QTabWidget() + layout.addWidget(tab_widget) - type_list = AircraftTypeList(game.blue.air_wing) - type_list.page_index_changed.connect(self.on_aircraft_changed) - columns.addWidget(type_list) - - self.squadrons_panel = AircraftSquadronsPanel(game.blue.air_wing) - columns.addLayout(self.squadrons_panel) + self.tabs = [] + for coalition in game.coalitions: + coalition_tab = AirWingConfigurationTab(coalition.air_wing) + name = "Blue" if coalition.player else "Red" + tab_widget.addTab(coalition_tab, name) + self.tabs.append(coalition_tab) def reject(self) -> None: - self.squadrons_panel.apply() + for tab in self.tabs: + tab.apply() super().reject() - - def on_aircraft_changed(self, index: QModelIndex) -> None: - self.squadrons_panel.setCurrentIndex(index) From ce01ad2083118597368c871344e3e5bc0acdf7df Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 17:12:34 -0700 Subject: [PATCH 112/167] Default to aircraft at only appropriate bases. --- changelog.md | 1 + game/commander/aircraftallocator.py | 4 ++- game/procurement.py | 6 ++++- game/squadrons.py | 40 +++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 0d4174d9..203eaeac 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. * **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken. +* **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron. * **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet). * **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons. diff --git a/game/commander/aircraftallocator.py b/game/commander/aircraftallocator.py index 523fad64..a50dbd22 100644 --- a/game/commander/aircraftallocator.py +++ b/game/commander/aircraftallocator.py @@ -70,7 +70,9 @@ class AircraftAllocator: aircraft, task ) for squadron in squadrons: - if squadron.can_provide_pilots(flight.num_aircraft): + if squadron.operates_from(airfield) and squadron.can_provide_pilots( + flight.num_aircraft + ): inventory.remove_aircraft(aircraft, flight.num_aircraft) return airfield, squadron return None diff --git a/game/procurement.py b/game/procurement.py index 3b1ea370..d1c254f0 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -226,7 +226,11 @@ class ProcurementAi: continue for squadron in self.air_wing.squadrons_for(unit): - if request.task_capability in squadron.auto_assignable_mission_types: + if ( + squadron.operates_from(airbase) + and request.task_capability + in squadron.auto_assignable_mission_types + ): break else: continue diff --git a/game/squadrons.py b/game/squadrons.py index 45ebe7de..5777102f 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -1,5 +1,6 @@ from __future__ import annotations +import dataclasses import itertools import logging import random @@ -26,6 +27,7 @@ if TYPE_CHECKING: from game import Game from game.coalition import Coalition from gen.flights.flight import FlightType + from game.theater import ControlPoint @dataclass @@ -73,6 +75,33 @@ class Pilot: return Pilot(faker.name()) +@dataclass(frozen=True) +class OperatingBases: + shore: bool + carrier: bool + lha: bool + + @classmethod + def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases: + if aircraft.dcs_unit_type.helicopter: + # Helicopters operate from anywhere by default. + return OperatingBases(shore=True, carrier=True, lha=True) + if aircraft.lha_capable: + # Marine aircraft operate from LHAs and the shore by default. + return OperatingBases(shore=True, carrier=False, lha=True) + if aircraft.carrier_capable: + # Carrier aircraft operate from carriers by default. + return OperatingBases(shore=False, carrier=True, lha=False) + # And the rest are only capable of shore operation. + return OperatingBases(shore=True, carrier=False, lha=False) + + @classmethod + def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases: + return dataclasses.replace( + OperatingBases.default_for_aircraft(aircraft), **data + ) + + @dataclass class Squadron: name: str @@ -82,6 +111,7 @@ class Squadron: aircraft: AircraftType livery: Optional[str] mission_types: tuple[FlightType, ...] + operating_bases: OperatingBases #: The pool of pilots that have not yet been assigned to the squadron. This only #: happens when a preset squadron defines more preset pilots than the squadron limit @@ -252,6 +282,14 @@ class Squadron: def can_auto_assign(self, task: FlightType) -> bool: return task in self.auto_assignable_mission_types + def operates_from(self, control_point: ControlPoint) -> bool: + if control_point.is_carrier: + return self.operating_bases.carrier + elif control_point.is_lha: + return self.operating_bases.lha + else: + return self.operating_bases.shore + def pilot_at_index(self, index: int) -> Pilot: return self.current_roster[index] @@ -290,6 +328,7 @@ class Squadron: aircraft=unit_type, livery=data.get("livery"), mission_types=tuple(mission_types), + operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})), pilot_pool=pilots, coalition=coalition, settings=game.settings, @@ -379,6 +418,7 @@ class AirWing: aircraft=aircraft, livery=None, mission_types=tuple(tasks_for_aircraft(aircraft)), + operating_bases=OperatingBases.default_for_aircraft(aircraft), pilot_pool=[], coalition=coalition, settings=game.settings, From c9b6b5d4a8474ebcb9eccd4e2cfea73ec3d9f3ee Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 19:38:55 -0700 Subject: [PATCH 113/167] Correct changelog. --- changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 203eaeac..d8d0cc36 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,6 @@ # 5.0.0 -Saves from 3.x are not compatible with 5.0. +Saves from 4.x are not compatible with 5.0. ## Features/Improvements From e87aa8366607a6f7c28c0b65323319a365e1be23 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Jul 2021 16:27:20 -0700 Subject: [PATCH 114/167] Add CLI generator options for date restrictions. --- qt_ui/main.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/qt_ui/main.py b/qt_ui/main.py index ee614287..28288c43 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -183,6 +183,19 @@ def parse_args() -> argparse.Namespace: "--inverted", action="store_true", help="Invert the campaign." ) + new_game.add_argument( + "--date", + type=datetime.fromisoformat, + default=datetime.today(), + help="Start date of the campaign.", + ) + + new_game.add_argument( + "--restrict-weapons-by-date", + action="store_true", + help="Enable campaign date restricted weapons.", + ) + new_game.add_argument("--cheats", action="store_true", help="Enable cheats.") return parser.parse_args() @@ -196,6 +209,8 @@ def create_game( auto_procurement: bool, inverted: bool, cheats: bool, + start_date: datetime, + restrict_weapons_by_date: bool, ) -> Game: first_start = liberation_install.init() if first_start: @@ -224,9 +239,10 @@ def create_game( automate_aircraft_reinforcements=auto_procurement, enable_frontline_cheats=cheats, enable_base_capture_cheat=cheats, + restrict_weapons_by_date=restrict_weapons_by_date, ), GeneratorSettings( - start_date=datetime.today(), + start_date=start_date, player_budget=DEFAULT_BUDGET, enemy_budget=DEFAULT_BUDGET, midgame=False, @@ -279,6 +295,8 @@ def main(): args.auto_procurement, args.inverted, args.cheats, + args.date, + args.restrict_weapons_by_date, ) run_ui(game) From 5e2ed04d728986843853b74ad508de29737d3b1d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Jul 2021 16:47:43 -0700 Subject: [PATCH 115/167] Add weapon data for the CBU-87 and CBU-97. --- resources/weapons/bombs/CBU-87-2X.yaml | 6 ++++++ resources/weapons/bombs/CBU-87-3X.yaml | 5 +++++ resources/weapons/bombs/CBU-87.yaml | 5 +++++ resources/weapons/bombs/CBU-97-2X.yaml | 6 ++++++ resources/weapons/bombs/CBU-97-3X.yaml | 5 +++++ resources/weapons/bombs/CBU-97.yaml | 5 +++++ 6 files changed, 32 insertions(+) create mode 100644 resources/weapons/bombs/CBU-87-2X.yaml create mode 100644 resources/weapons/bombs/CBU-87-3X.yaml create mode 100644 resources/weapons/bombs/CBU-87.yaml create mode 100644 resources/weapons/bombs/CBU-97-2X.yaml create mode 100644 resources/weapons/bombs/CBU-97-3X.yaml create mode 100644 resources/weapons/bombs/CBU-97.yaml diff --git a/resources/weapons/bombs/CBU-87-2X.yaml b/resources/weapons/bombs/CBU-87-2X.yaml new file mode 100644 index 00000000..7bfc2840 --- /dev/null +++ b/resources/weapons/bombs/CBU-87-2X.yaml @@ -0,0 +1,6 @@ +name: 2xCBU-87 +year: 1986 +fallback: 2xMk 82 +clsids: + - "{TER_9A_2L*CBU-87}" + - "{TER_9A_2R*CBU-87}" diff --git a/resources/weapons/bombs/CBU-87-3X.yaml b/resources/weapons/bombs/CBU-87-3X.yaml new file mode 100644 index 00000000..34fab5c3 --- /dev/null +++ b/resources/weapons/bombs/CBU-87-3X.yaml @@ -0,0 +1,5 @@ +name: 3xCBU-87 +year: 1986 +fallback: 3xMk 82 +clsids: + - "{TER_9A_3*CBU-87}" diff --git a/resources/weapons/bombs/CBU-87.yaml b/resources/weapons/bombs/CBU-87.yaml new file mode 100644 index 00000000..1f7257dc --- /dev/null +++ b/resources/weapons/bombs/CBU-87.yaml @@ -0,0 +1,5 @@ +name: CBU-87 +year: 1986 +fallback: Mk 82 +clsids: + - "{CBU-87}" diff --git a/resources/weapons/bombs/CBU-97-2X.yaml b/resources/weapons/bombs/CBU-97-2X.yaml new file mode 100644 index 00000000..a5019110 --- /dev/null +++ b/resources/weapons/bombs/CBU-97-2X.yaml @@ -0,0 +1,6 @@ +name: 2xCBU-97 +year: 1992 +fallback: 2xCBU-87 +clsids: + - "{TER_9A_2L*CBU-97}" + - "{TER_9A_2R*CBU-97}" diff --git a/resources/weapons/bombs/CBU-97-3X.yaml b/resources/weapons/bombs/CBU-97-3X.yaml new file mode 100644 index 00000000..35aac1dd --- /dev/null +++ b/resources/weapons/bombs/CBU-97-3X.yaml @@ -0,0 +1,5 @@ +name: 3xCBU-97 +year: 1992 +fallback: 3xCBU-87 +clsids: + - "{TER_9A_3*CBU-97}" diff --git a/resources/weapons/bombs/CBU-97.yaml b/resources/weapons/bombs/CBU-97.yaml new file mode 100644 index 00000000..57527755 --- /dev/null +++ b/resources/weapons/bombs/CBU-97.yaml @@ -0,0 +1,5 @@ +name: CBU-97 +year: 1992 +fallback: CBU-87 +clsids: + - "{5335D97A-35A5-4643-9D9B-026C75961E52}" From fab550157a7d5b255792e2320f40257a4f46f722 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Jul 2021 20:07:58 -0700 Subject: [PATCH 116/167] Add a per-aircraft weapon linter. Run with `main.py lint-weapons $AIRCRAFT` to show all the weapons the aircraft can carry that do not have data. --- qt_ui/main.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/qt_ui/main.py b/qt_ui/main.py index 28288c43..70d4dd5b 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -13,8 +13,9 @@ from PySide2.QtWidgets import QApplication, QSplashScreen from dcs.payloads import PayloadDirectories from game import Game, VERSION, persistency -from game.data.weapons import WeaponGroup +from game.data.weapons import WeaponGroup, Pylon, Weapon from game.db import FACTIONS +from game.dcs.aircrafttype import AircraftType from game.profiling import logged_duration from game.settings import Settings from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings @@ -198,6 +199,9 @@ def parse_args() -> argparse.Namespace: new_game.add_argument("--cheats", action="store_true", help="Enable cheats.") + lint_weapons = subparsers.add_parser("lint-weapons") + lint_weapons.add_argument("aircraft", help="Name of the aircraft variant to lint.") + return parser.parse_args() @@ -267,11 +271,21 @@ def create_game( return game -def lint_weapon_data() -> None: +def lint_all_weapon_data() -> None: for weapon in WeaponGroup.named("Unknown").weapons: logging.warning(f"No weapon data for {weapon}: {weapon.clsid}") +def lint_weapon_data_for_aircraft(aircraft: AircraftType) -> None: + all_weapons: set[Weapon] = set() + for pylon in Pylon.iter_pylons(aircraft): + all_weapons |= pylon.allowed + + for weapon in all_weapons: + if weapon.weapon_group.name == "Unknown": + logging.warning(f'{weapon.clsid} "{weapon.name}" has no weapon data') + + def main(): logging_config.init_logging(VERSION) @@ -283,7 +297,7 @@ def main(): # TODO: Flesh out data and then make unconditional. if args.warn_missing_weapon_data: - lint_weapon_data() + lint_all_weapon_data() if args.subcommand == "new-game": with logged_duration("New game creation"): @@ -298,6 +312,9 @@ def main(): args.date, args.restrict_weapons_by_date, ) + if args.subcommand == "lint-weapons": + lint_weapon_data_for_aircraft(AircraftType.named(args.aircraft)) + return run_ui(game) From 91d430085e6bc40234414339ebf17991f4ebb8f7 Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Wed, 21 Jul 2021 10:29:37 -0400 Subject: [PATCH 117/167] Addresses #478, adding a heading class to represent headings and angles (#1387) * Addresses #478, adding a heading class to represent headings and angles Removed some unused code * Fixing bad merge * Formatting * Fixing type issues and other merge resolution misses --- game/point_with_heading.py | 5 +- game/theater/conflicttheater.py | 58 ++++++++++++++------ game/theater/controlpoint.py | 31 ++++++----- game/theater/frontline.py | 12 ++--- game/theater/start_generator.py | 5 +- game/theater/theatergroundobject.py | 26 ++++----- game/utils.py | 64 ++++++++++++++++++---- gen/aircraft.py | 2 +- gen/airsupportgen.py | 7 ++- gen/armor.py | 84 ++++++++++++++--------------- gen/coastal/silkworm.py | 3 +- gen/conflictgen.py | 34 ++++++------ gen/fleet/carrier_group.py | 3 +- gen/flights/flightplan.py | 47 ++++++++-------- gen/groundobjectsgen.py | 24 +++++---- gen/missiles/scud_site.py | 3 +- gen/missiles/v1_group.py | 3 +- gen/runways.py | 21 ++++---- gen/sam/aaa_flak.py | 3 +- gen/sam/aaa_ww2_ally_flak.py | 9 ++-- gen/sam/cold_war_flak.py | 8 +-- gen/sam/freya_ewr.py | 3 +- gen/sam/group_generator.py | 24 ++++----- gen/visualgen.py | 2 +- qt_ui/widgets/map/mapmodel.py | 4 +- 25 files changed, 296 insertions(+), 189 deletions(-) diff --git a/game/point_with_heading.py b/game/point_with_heading.py index a87914a1..7eed4da2 100644 --- a/game/point_with_heading.py +++ b/game/point_with_heading.py @@ -1,15 +1,16 @@ from __future__ import annotations from dcs import Point +from game.utils import Heading class PointWithHeading(Point): def __init__(self) -> None: super(PointWithHeading, self).__init__(0, 0) - self.heading = 0 + self.heading: Heading = Heading.from_degrees(0) @staticmethod - def from_point(point: Point, heading: int) -> PointWithHeading: + def from_point(point: Point, heading: Heading) -> PointWithHeading: p = PointWithHeading() p.x = point.x p.y = point.y diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 95e53ac9..8e88bda2 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -59,7 +59,7 @@ from ..point_with_heading import PointWithHeading from ..positioned import Positioned from ..profiling import logged_duration from ..scenery_group import SceneryGroup -from ..utils import Distance, meters +from ..utils import Distance, Heading, meters if TYPE_CHECKING: from . import TheaterGroundObject @@ -400,85 +400,113 @@ class MizCampaignLoader: for static in self.offshore_strike_targets: closest, distance = self.objective_info(static) closest.preset_locations.offshore_strike_locations.append( - PointWithHeading.from_point(static.position, static.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) for ship in self.ships: closest, distance = self.objective_info(ship, allow_naval=True) closest.preset_locations.ships.append( - PointWithHeading.from_point(ship.position, ship.units[0].heading) + PointWithHeading.from_point( + ship.position, Heading.from_degrees(ship.units[0].heading) + ) ) for group in self.missile_sites: closest, distance = self.objective_info(group) closest.preset_locations.missile_sites.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.coastal_defenses: closest, distance = self.objective_info(group) closest.preset_locations.coastal_defenses.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.long_range_sams: closest, distance = self.objective_info(group) closest.preset_locations.long_range_sams.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.medium_range_sams: closest, distance = self.objective_info(group) closest.preset_locations.medium_range_sams.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.short_range_sams: closest, distance = self.objective_info(group) closest.preset_locations.short_range_sams.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.aaa: closest, distance = self.objective_info(group) closest.preset_locations.aaa.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.ewrs: closest, distance = self.objective_info(group) closest.preset_locations.ewrs.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.armor_groups: closest, distance = self.objective_info(group) closest.preset_locations.armor_groups.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for static in self.helipads: closest, distance = self.objective_info(static) closest.helipads.append( - PointWithHeading.from_point(static.position, static.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) for static in self.factories: closest, distance = self.objective_info(static) closest.preset_locations.factories.append( - PointWithHeading.from_point(static.position, static.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) for static in self.ammunition_depots: closest, distance = self.objective_info(static) closest.preset_locations.ammunition_depots.append( - PointWithHeading.from_point(static.position, static.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) for static in self.strike_targets: closest, distance = self.objective_info(static) closest.preset_locations.strike_locations.append( - PointWithHeading.from_point(static.position, static.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) for scenery_group in self.scenery: diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 075f4f5e..12786d32 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -35,6 +35,7 @@ from dcs.unit import Unit from game import db from game.point_with_heading import PointWithHeading from game.scenery_group import SceneryGroup +from game.utils import Heading from gen.flights.closestairfields import ObjectiveDistanceCache from gen.ground_forces.combat_stance import CombatStance from gen.runways import RunwayAssigner, RunwayData @@ -335,7 +336,7 @@ class ControlPoint(MissionTarget, ABC): @property @abstractmethod - def heading(self) -> int: + def heading(self) -> Heading: ... def __str__(self) -> str: @@ -838,8 +839,8 @@ class Airfield(ControlPoint): return len(self.airport.parking_slots) @property - def heading(self) -> int: - return self.airport.runways[0].heading + def heading(self) -> Heading: + return Heading.from_degrees(self.airport.runways[0].heading) def runway_is_operational(self) -> bool: return not self.runway_status.damaged @@ -903,8 +904,8 @@ class NavalControlPoint(ControlPoint, ABC): yield from super().mission_types(for_player) @property - def heading(self) -> int: - return 0 # TODO compute heading + def heading(self) -> Heading: + return Heading.from_degrees(0) # TODO compute heading def find_main_tgo(self) -> GenericCarrierGroundObject: for g in self.ground_objects: @@ -933,7 +934,9 @@ class NavalControlPoint(ControlPoint, ABC): self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData] ) -> RunwayData: # TODO: Assign TACAN and ICLS earlier so we don't need this. - fallback = RunwayData(self.full_name, runway_heading=0, runway_name="") + fallback = RunwayData( + self.full_name, runway_heading=Heading.from_degrees(0), runway_name="" + ) return dynamic_runways.get(self.name, fallback) @property @@ -1071,14 +1074,16 @@ class OffMapSpawn(ControlPoint): return True @property - def heading(self) -> int: - return 0 + def heading(self) -> Heading: + return Heading.from_degrees(0) def active_runway( self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData] ) -> RunwayData: logging.warning("TODO: Off map spawns have no runways.") - return RunwayData(self.full_name, runway_heading=0, runway_name="") + return RunwayData( + self.full_name, runway_heading=Heading.from_degrees(0), runway_name="" + ) @property def runway_status(self) -> RunwayStatus: @@ -1120,7 +1125,9 @@ class Fob(ControlPoint): self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData] ) -> RunwayData: logging.warning("TODO: FOBs have no runways.") - return RunwayData(self.full_name, runway_heading=0, runway_name="") + return RunwayData( + self.full_name, runway_heading=Heading.from_degrees(0), runway_name="" + ) @property def runway_status(self) -> RunwayStatus: @@ -1142,8 +1149,8 @@ class Fob(ControlPoint): return False @property - def heading(self) -> int: - return 0 + def heading(self) -> Heading: + return Heading.from_degrees(0) @property def can_deploy_ground_units(self) -> bool: diff --git a/game/theater/frontline.py b/game/theater/frontline.py index 2f1b6067..98aa88f6 100644 --- a/game/theater/frontline.py +++ b/game/theater/frontline.py @@ -11,7 +11,7 @@ from .controlpoint import ( ControlPoint, MissionTarget, ) -from ..utils import pairwise +from ..utils import Heading, pairwise FRONTLINE_MIN_CP_DISTANCE = 5000 @@ -27,9 +27,9 @@ class FrontLineSegment: point_b: Point @property - def attack_heading(self) -> float: + def attack_heading(self) -> Heading: """The heading of the frontline segment from player to enemy control point""" - return self.point_a.heading_between_point(self.point_b) + return Heading.from_degrees(self.point_a.heading_between_point(self.point_b)) @property def attack_distance(self) -> float: @@ -123,7 +123,7 @@ class FrontLine(MissionTarget): return sum(i.attack_distance for i in self.segments) @property - def attack_heading(self) -> float: + def attack_heading(self) -> Heading: """The heading of the active attack segment from player to enemy control point""" return self.active_segment.attack_heading @@ -150,13 +150,13 @@ class FrontLine(MissionTarget): """ if distance < self.segments[0].attack_distance: return self.blue_cp.position.point_from_heading( - self.segments[0].attack_heading, distance + self.segments[0].attack_heading.degrees, distance ) remaining_dist = distance for segment in self.segments: if remaining_dist < segment.attack_distance: return segment.point_a.point_from_heading( - segment.attack_heading, remaining_dist + segment.attack_heading.degrees, remaining_dist ) else: remaining_dist -= segment.attack_distance diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index aee758e9..61cc25af 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -28,6 +28,7 @@ from game.theater.theatergroundobject import ( VehicleGroupGroundObject, CoastalSiteGroundObject, ) +from game.utils import Heading from game.version import VERSION from gen import namegen from gen.coastal.coastal_group_generator import generate_coastal_group @@ -385,7 +386,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): group_id, object_id, position + template_point, - unit["heading"], + Heading.from_degrees(unit["heading"]), self.control_point, unit["type"], ) @@ -585,7 +586,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator): group_id, object_id, point + template_point, - unit["heading"], + Heading.from_degrees(unit["heading"]), self.control_point, unit["type"], is_fob_structure=True, diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index f063a1ea..d3bfae64 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -17,7 +17,7 @@ from ..data.radar_db import ( TELARS, LAUNCHER_TRACKER_PAIRS, ) -from ..utils import Distance, meters +from ..utils import Distance, Heading, meters if TYPE_CHECKING: from .controlpoint import ControlPoint @@ -58,7 +58,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): category: str, group_id: int, position: Point, - heading: int, + heading: Heading, control_point: ControlPoint, dcs_identifier: str, sea_object: bool, @@ -222,7 +222,7 @@ class BuildingGroundObject(TheaterGroundObject[VehicleGroup]): group_id: int, object_id: int, position: Point, - heading: int, + heading: Heading, control_point: ControlPoint, dcs_identifier: str, is_fob_structure: bool = False, @@ -310,7 +310,7 @@ class SceneryGroundObject(BuildingGroundObject): group_id=group_id, object_id=object_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier=dcs_identifier, is_fob_structure=False, @@ -334,7 +334,7 @@ class FactoryGroundObject(BuildingGroundObject): name: str, group_id: int, position: Point, - heading: int, + heading: Heading, control_point: ControlPoint, ) -> None: super().__init__( @@ -385,7 +385,7 @@ class CarrierGroundObject(GenericCarrierGroundObject): category="CARRIER", group_id=group_id, position=control_point.position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="CARRIER", sea_object=True, @@ -406,7 +406,7 @@ class LhaGroundObject(GenericCarrierGroundObject): category="LHA", group_id=group_id, position=control_point.position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="LHA", sea_object=True, @@ -428,7 +428,7 @@ class MissileSiteGroundObject(TheaterGroundObject[VehicleGroup]): category="missile", group_id=group_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="AA", sea_object=False, @@ -450,7 +450,7 @@ class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]): group_id: int, position: Point, control_point: ControlPoint, - heading: int, + heading: Heading, ) -> None: super().__init__( name=name, @@ -497,7 +497,7 @@ class SamGroundObject(IadsGroundObject): category="aa", group_id=group_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="AA", sea_object=False, @@ -565,7 +565,7 @@ class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]): category="armor", group_id=group_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="AA", sea_object=False, @@ -593,7 +593,7 @@ class EwrGroundObject(IadsGroundObject): category="ewr", group_id=group_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="EWR", sea_object=False, @@ -627,7 +627,7 @@ class ShipGroundObject(NavalGroundObject): category="ship", group_id=group_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="AA", sea_object=True, diff --git a/game/utils.py b/game/utils.py index b21b11df..119a741a 100644 --- a/game/utils.py +++ b/game/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations import itertools import math +import random from collections import Iterable from dataclasses import dataclass from typing import Union, Any, TypeVar @@ -20,15 +21,6 @@ INHG_TO_HPA = 33.86389 INHG_TO_MMHG = 25.400002776728 -def heading_sum(h: int, a: int) -> int: - h += a - return h % 360 - - -def opposite_heading(h: int) -> int: - return heading_sum(h, 180) - - @dataclass(frozen=True, order=True) class Distance: distance_in_meters: float @@ -184,6 +176,60 @@ def mach(value: float, altitude: Distance) -> Speed: SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5) +@dataclass(frozen=True, order=True) +class Heading: + heading_in_degrees: int + + @property + def degrees(self) -> int: + return Heading.reduce_angle(self.heading_in_degrees) + + @property + def radians(self) -> float: + return math.radians(Heading.reduce_angle(self.heading_in_degrees)) + + @property + def opposite(self) -> Heading: + return self + Heading.from_degrees(180) + + @property + def right(self) -> Heading: + return self + Heading.from_degrees(90) + + @property + def left(self) -> Heading: + return self - Heading.from_degrees(90) + + def angle_between(self, other: Heading) -> Heading: + angle_between = abs(self.degrees - other.degrees) + if angle_between > 180: + angle_between = 360 - angle_between + return Heading.from_degrees(angle_between) + + @staticmethod + def reduce_angle(angle: int) -> int: + return angle % 360 + + @classmethod + def from_degrees(cls, angle: Union[int, float]) -> Heading: + return cls(Heading.reduce_angle(round(angle))) + + @classmethod + def from_radians(cls, angle: Union[int, float]) -> Heading: + deg = round(math.degrees(angle)) + return cls(Heading.reduce_angle(deg)) + + @classmethod + def random(cls, min_angle: int = 0, max_angle: int = 0) -> Heading: + return Heading.from_degrees(random.randint(min_angle, max_angle)) + + def __add__(self, other: Heading) -> Heading: + return Heading.from_degrees(self.degrees + other.degrees) + + def __sub__(self, other: Heading) -> Heading: + return Heading.from_degrees(self.degrees - other.degrees) + + @dataclass(frozen=True, order=True) class Pressure: pressure_in_inches_hg: float diff --git a/gen/aircraft.py b/gen/aircraft.py index 144344df..998696ac 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -81,7 +81,7 @@ from game.theater.missiontarget import MissionTarget from game.theater.theatergroundobject import TheaterGroundObject from game.transfers import MultiGroupTransport from game.unitmap import UnitMap -from game.utils import Distance, meters, nautical_miles, pairwise +from game.utils import Distance, Heading, meters, nautical_miles, pairwise from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit from gen.flights.flight import ( diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 409a0959..72f4fecc 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -17,6 +17,9 @@ from dcs.task import ( ) from dcs.unittype import UnitType +from game.utils import Heading +from .flights.ai_flight_planner_db import AEWC_CAPABLE +from .naming import namegen from .callsigns import callsign_for_support_unit from .conflictgen import Conflict from .flights.ai_flight_planner_db import AEWC_CAPABLE @@ -122,14 +125,14 @@ class AirSupportConflictGenerator: alt, airspeed = self._get_tanker_params(tanker_unit_type.dcs_unit_type) freq = self.radio_registry.alloc_uhf() tacan = self.tacan_registry.alloc_for_band(TacanBand.Y) - tanker_heading = ( + tanker_heading = Heading.from_degrees( self.conflict.red_cp.position.heading_between_point( self.conflict.blue_cp.position ) + TANKER_HEADING_OFFSET * i ) tanker_position = player_cp.position.point_from_heading( - tanker_heading, TANKER_DISTANCE + tanker_heading.degrees, TANKER_DISTANCE ) tanker_group = self.mission.refuel_flight( country=country, diff --git a/gen/armor.py b/gen/armor.py index 7e92169b..a000c8cb 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -32,7 +32,7 @@ from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.theater.controlpoint import ControlPoint from game.unitmap import UnitMap -from game.utils import heading_sum, opposite_heading +from game.utils import Heading from gen.ground_forces.ai_ground_planner import ( DISTANCE_FROM_FRONTLINE, CombatGroup, @@ -130,7 +130,7 @@ class GroundConflictGenerator: self.player_stance, player_groups, enemy_groups, - self.conflict.heading + 90, + self.conflict.heading.right, self.conflict.blue_cp, self.conflict.red_cp, ) @@ -138,7 +138,7 @@ class GroundConflictGenerator: self.enemy_stance, enemy_groups, player_groups, - self.conflict.heading - 90, + self.conflict.heading.left, self.conflict.red_cp, self.conflict.blue_cp, ) @@ -182,7 +182,11 @@ class GroundConflictGenerator: ) def gen_infantry_group_for_group( - self, group: VehicleGroup, is_player: bool, side: Country, forward_heading: int + self, + group: VehicleGroup, + is_player: bool, + side: Country, + forward_heading: Heading, ) -> None: infantry_position = self.conflict.find_ground_position( @@ -217,7 +221,7 @@ class GroundConflictGenerator: u.dcs_unit_type, position=infantry_position, group_size=1, - heading=forward_heading, + heading=forward_heading.degrees, move_formation=PointAction.OffRoad, ) return @@ -244,7 +248,7 @@ class GroundConflictGenerator: units[0].dcs_unit_type, position=infantry_position, group_size=1, - heading=forward_heading, + heading=forward_heading.degrees, move_formation=PointAction.OffRoad, ) @@ -256,17 +260,19 @@ class GroundConflictGenerator: unit.dcs_unit_type, position=position, group_size=1, - heading=forward_heading, + heading=forward_heading.degrees, move_formation=PointAction.OffRoad, ) def _set_reform_waypoint( - self, dcs_group: VehicleGroup, forward_heading: int + self, dcs_group: VehicleGroup, forward_heading: Heading ) -> None: """Setting a waypoint close to the spawn position allows the group to reform gracefully rather than spin """ - reform_point = dcs_group.position.point_from_heading(forward_heading, 50) + reform_point = dcs_group.position.point_from_heading( + forward_heading.degrees, 50 + ) dcs_group.add_waypoint(reform_point) def _plan_artillery_action( @@ -274,7 +280,7 @@ class GroundConflictGenerator: stance: CombatStance, gen_group: CombatGroup, dcs_group: VehicleGroup, - forward_heading: int, + forward_heading: Heading, target: Point, ) -> bool: """ @@ -308,7 +314,7 @@ class GroundConflictGenerator: dcs_group, forward_heading, (int)(RETREAT_DISTANCE / 3) ) dcs_group.add_waypoint( - dcs_group.position.point_from_heading(forward_heading, 1), + dcs_group.position.point_from_heading(forward_heading.degrees, 1), PointAction.OffRoad, ) dcs_group.points[2].tasks.append(Hold()) @@ -336,7 +342,7 @@ class GroundConflictGenerator: self.mission.triggerrules.triggers.append(artillery_fallback) for u in dcs_group.units: - u.heading = forward_heading + random.randint(-5, 5) + u.heading = (forward_heading + Heading.random(-5, 5)).degrees return True return False @@ -345,7 +351,7 @@ class GroundConflictGenerator: stance: CombatStance, enemy_groups: List[Tuple[VehicleGroup, CombatGroup]], dcs_group: VehicleGroup, - forward_heading: int, + forward_heading: Heading, to_cp: ControlPoint, ) -> bool: """ @@ -378,9 +384,7 @@ class GroundConflictGenerator: else: # We use an offset heading here because DCS doesn't always # force vehicles to move if there's no heading change. - offset_heading = forward_heading - 2 - if offset_heading < 0: - offset_heading = 358 + offset_heading = forward_heading - Heading.from_degrees(2) attack_point = self.find_offensive_point( dcs_group, offset_heading, AGGRESIVE_MOVE_DISTANCE ) @@ -398,9 +402,7 @@ class GroundConflictGenerator: else: # We use an offset heading here because DCS doesn't always # force vehicles to move if there's no heading change. - offset_heading = forward_heading - 1 - if offset_heading < 0: - offset_heading = 359 + offset_heading = forward_heading - Heading.from_degrees(1) attack_point = self.find_offensive_point( dcs_group, offset_heading, BREAKTHROUGH_OFFENSIVE_DISTANCE ) @@ -436,7 +438,7 @@ class GroundConflictGenerator: self, stance: CombatStance, dcs_group: VehicleGroup, - forward_heading: int, + forward_heading: Heading, to_cp: ControlPoint, ) -> bool: """ @@ -473,7 +475,7 @@ class GroundConflictGenerator: stance: CombatStance, ally_groups: List[Tuple[VehicleGroup, CombatGroup]], enemy_groups: List[Tuple[VehicleGroup, CombatGroup]], - forward_heading: int, + forward_heading: Heading, from_cp: ControlPoint, to_cp: ControlPoint, ) -> None: @@ -514,12 +516,14 @@ class GroundConflictGenerator: else: retreat_point = self.find_retreat_point(dcs_group, forward_heading) reposition_point = retreat_point.point_from_heading( - forward_heading, 10 + forward_heading.degrees, 10 ) # Another point to make the unit face the enemy dcs_group.add_waypoint(retreat_point, PointAction.OffRoad) dcs_group.add_waypoint(reposition_point, PointAction.OffRoad) - def add_morale_trigger(self, dcs_group: VehicleGroup, forward_heading: int) -> None: + def add_morale_trigger( + self, dcs_group: VehicleGroup, forward_heading: Heading + ) -> None: """ This add a trigger to manage units fleeing whenever their group is hit hard, or being engaged by CAS """ @@ -532,7 +536,7 @@ class GroundConflictGenerator: # Force unit heading for unit in dcs_group.units: - unit.heading = forward_heading + unit.heading = forward_heading.degrees dcs_group.manualHeading = True # We add a new retreat waypoint @@ -563,7 +567,7 @@ class GroundConflictGenerator: def find_retreat_point( self, dcs_group: VehicleGroup, - frontline_heading: int, + frontline_heading: Heading, distance: int = RETREAT_DISTANCE, ) -> Point: """ @@ -573,14 +577,14 @@ class GroundConflictGenerator: :return: dcs.mapping.Point object with the desired position """ desired_point = dcs_group.points[0].position.point_from_heading( - heading_sum(frontline_heading, +180), distance + frontline_heading.opposite.degrees, distance ) if self.conflict.theater.is_on_land(desired_point): return desired_point return self.conflict.theater.nearest_land_pos(desired_point) def find_offensive_point( - self, dcs_group: VehicleGroup, frontline_heading: int, distance: int + self, dcs_group: VehicleGroup, frontline_heading: Heading, distance: int ) -> Point: """ Find a point to attack @@ -590,7 +594,7 @@ class GroundConflictGenerator: :return: dcs.mapping.Point object with the desired position """ desired_point = dcs_group.points[0].position.point_from_heading( - frontline_heading, distance + frontline_heading.degrees, distance ) if self.conflict.theater.is_on_land(desired_point): return desired_point @@ -688,14 +692,14 @@ class GroundConflictGenerator: conflict_position: Point, combat_width: int, distance_from_frontline: int, - heading: int, - spawn_heading: int, + heading: Heading, + spawn_heading: Heading, ) -> Optional[Point]: shifted = conflict_position.point_from_heading( - heading, random.randint(0, combat_width) + heading.degrees, random.randint(0, combat_width) ) desired_point = shifted.point_from_heading( - spawn_heading, distance_from_frontline + spawn_heading.degrees, distance_from_frontline ) return Conflict.find_ground_position( desired_point, combat_width, heading, self.conflict.theater @@ -704,17 +708,13 @@ class GroundConflictGenerator: def _generate_groups( self, groups: list[CombatGroup], - frontline_vector: Tuple[Point, int, int], + frontline_vector: Tuple[Point, Heading, int], is_player: bool, ) -> List[Tuple[VehicleGroup, CombatGroup]]: """Finds valid positions for planned groups and generates a pydcs group for them""" positioned_groups = [] position, heading, combat_width = frontline_vector - spawn_heading = ( - int(heading_sum(heading, -90)) - if is_player - else int(heading_sum(heading, 90)) - ) + spawn_heading = heading.left if is_player else heading.right country = self.game.coalition_for(is_player).country_name for group in groups: if group.role == CombatGroupRole.ARTILLERY: @@ -737,7 +737,7 @@ class GroundConflictGenerator: group.unit_type, group.size, final_position, - heading=opposite_heading(spawn_heading), + heading=spawn_heading.opposite, ) if is_player: g.set_skill(Skill(self.game.settings.player_skill)) @@ -750,7 +750,7 @@ class GroundConflictGenerator: g, is_player, self.mission.country(country), - opposite_heading(spawn_heading), + spawn_heading.opposite, ) else: logging.warning(f"Unable to get valid position for {group}") @@ -764,7 +764,7 @@ class GroundConflictGenerator: count: int, at: Point, move_formation: PointAction = PointAction.OffRoad, - heading: int = 0, + heading: Heading = Heading.from_degrees(0), ) -> VehicleGroup: if side == self.conflict.attackers_country: @@ -778,7 +778,7 @@ class GroundConflictGenerator: unit_type.dcs_unit_type, position=at, group_size=count, - heading=heading, + heading=heading.degrees, move_formation=move_formation, ) diff --git a/gen/coastal/silkworm.py b/gen/coastal/silkworm.py index 6712762a..b0fb98c5 100644 --- a/gen/coastal/silkworm.py +++ b/gen/coastal/silkworm.py @@ -3,6 +3,7 @@ from dcs.vehicles import MissilesSS, Unarmed, AirDefence from game import Game from game.factions.faction import Faction from game.theater.theatergroundobject import CoastalSiteGroundObject +from game.utils import Heading from gen.sam.group_generator import VehicleGroupGenerator @@ -59,5 +60,5 @@ class SilkwormGenerator(VehicleGroupGenerator[CoastalSiteGroundObject]): "STRELA#0", self.position.x + 200, self.position.y + 15, - 90, + Heading.from_degrees(90), ) diff --git a/gen/conflictgen.py b/gen/conflictgen.py index 5576805a..6693367e 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -9,7 +9,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint from game.theater.conflicttheater import ConflictTheater, FrontLine from game.theater.controlpoint import ControlPoint -from game.utils import heading_sum, opposite_heading +from game.utils import Heading FRONTLINE_LENGTH = 80000 @@ -25,7 +25,7 @@ class Conflict: attackers_country: Country, defenders_country: Country, position: Point, - heading: Optional[int] = None, + heading: Optional[Heading] = None, size: Optional[int] = None, ): @@ -55,28 +55,28 @@ class Conflict: @classmethod def frontline_position( cls, frontline: FrontLine, theater: ConflictTheater - ) -> Tuple[Point, int]: - attack_heading = int(frontline.attack_heading) + ) -> Tuple[Point, Heading]: + attack_heading = frontline.attack_heading position = cls.find_ground_position( frontline.position, FRONTLINE_LENGTH, - heading_sum(attack_heading, 90), + attack_heading.right, theater, ) if position is None: raise RuntimeError("Could not find front line position") - return position, opposite_heading(attack_heading) + return position, attack_heading.opposite @classmethod def frontline_vector( cls, front_line: FrontLine, theater: ConflictTheater - ) -> Tuple[Point, int, int]: + ) -> Tuple[Point, Heading, int]: """ Returns a vector for a valid frontline location avoiding exclusion zones. """ center_position, heading = cls.frontline_position(front_line, theater) - left_heading = heading_sum(heading, -90) - right_heading = heading_sum(heading, 90) + left_heading = heading.left + right_heading = heading.right left_position = cls.extend_ground_position( center_position, int(FRONTLINE_LENGTH / 2), left_heading, theater ) @@ -113,10 +113,14 @@ class Conflict: @classmethod def extend_ground_position( - cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater + cls, + initial: Point, + max_distance: int, + heading: Heading, + theater: ConflictTheater, ) -> Point: """Finds the first intersection with an exclusion zone in one heading from an initial point up to max_distance""" - extended = initial.point_from_heading(heading, max_distance) + extended = initial.point_from_heading(heading.degrees, max_distance) if theater.landmap is None: # TODO: Why is this possible? return extended @@ -133,14 +137,14 @@ class Conflict: return extended # Otherwise extend the front line only up to the intersection. - return initial.point_from_heading(heading, p0.distance(intersection)) + return initial.point_from_heading(heading.degrees, p0.distance(intersection)) @classmethod def find_ground_position( cls, initial: Point, max_distance: int, - heading: int, + heading: Heading, theater: ConflictTheater, coerce: bool = True, ) -> Optional[Point]: @@ -153,10 +157,10 @@ class Conflict: if theater.is_on_land(pos): return pos for distance in range(0, int(max_distance), 100): - pos = initial.point_from_heading(heading, distance) + pos = initial.point_from_heading(heading.degrees, distance) if theater.is_on_land(pos): return pos - pos = initial.point_from_heading(opposite_heading(heading), distance) + pos = initial.point_from_heading(heading.opposite.degrees, distance) if theater.is_on_land(pos): return pos if coerce: diff --git a/gen/fleet/carrier_group.py b/gen/fleet/carrier_group.py index b25902a9..74ca4c67 100644 --- a/gen/fleet/carrier_group.py +++ b/gen/fleet/carrier_group.py @@ -1,6 +1,7 @@ import random from gen.sam.group_generator import ShipGroupGenerator +from game.utils import Heading from dcs.ships import USS_Arleigh_Burke_IIa, TICONDEROG @@ -54,7 +55,7 @@ class CarrierGroupGenerator(ShipGroupGenerator): ) # Add Ticonderoga escort - if self.heading >= 180: + if self.heading >= Heading.from_degrees(180): self.add_unit( TICONDEROG, "USS Hué City", diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index fde43ee9..d3559442 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -37,8 +37,10 @@ from game.theater.theatergroundobject import ( NavalGroundObject, BuildingGroundObject, ) + from game.threatzones import ThreatZones -from game.utils import Distance, Speed, feet, meters, nautical_miles, knots +from game.utils import Distance, Heading, Speed, feet, meters, nautical_miles, knots + from .closestairfields import ObjectiveDistanceCache from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType from .traveltime import GroundSpeed, TravelTime @@ -1151,10 +1153,11 @@ class FlightPlanBuilder: """ assert self.package.waypoints is not None target = self.package.target.position - - heading = self.package.waypoints.join.heading_between_point(target) + heading = Heading.from_degrees( + self.package.waypoints.join.heading_between_point(target) + ) start_pos = target.point_from_heading( - heading, -self.doctrine.sweep_distance.meters + heading.degrees, -self.doctrine.sweep_distance.meters ) builder = WaypointBuilder(flight, self.coalition) @@ -1249,7 +1252,9 @@ class FlightPlanBuilder: else: raise PlanningError("Could not find any enemy airfields") - heading = location.position.heading_between_point(closest_airfield.position) + heading = Heading.from_degrees( + location.position.heading_between_point(closest_airfield.position) + ) position = ShapelyPoint( self.package.target.position.x, self.package.target.position.y @@ -1285,20 +1290,20 @@ class FlightPlanBuilder: ) end = location.position.point_from_heading( - heading, + heading.degrees, random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)), ) diameter = random.randint( int(self.doctrine.cap_min_track_length.meters), int(self.doctrine.cap_max_track_length.meters), ) - start = end.point_from_heading(heading - 180, diameter) + start = end.point_from_heading(heading.opposite.degrees, diameter) return start, end def aewc_orbit(self, location: MissionTarget) -> Point: closest_boundary = self.threat_zones.closest_boundary(location.position) - heading_to_threat_boundary = location.position.heading_between_point( - closest_boundary + heading_to_threat_boundary = Heading.from_degrees( + location.position.heading_between_point(closest_boundary) ) distance_to_threat = meters( location.position.distance_to_point(closest_boundary) @@ -1312,7 +1317,7 @@ class FlightPlanBuilder: orbit_distance = distance_to_threat - threat_buffer return location.position.point_from_heading( - orbit_heading, orbit_distance.meters + orbit_heading.degrees, orbit_distance.meters ) def racetrack_for_frontline( @@ -1320,9 +1325,9 @@ class FlightPlanBuilder: ) -> Tuple[Point, Point]: # Find targets waypoints ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater) - center = ingress.point_from_heading(heading, distance / 2) + center = ingress.point_from_heading(heading.degrees, distance / 2) orbit_center = center.point_from_heading( - heading - 90, + heading.left.degrees, random.randint( int(nautical_miles(6).meters), int(nautical_miles(15).meters) ), @@ -1335,8 +1340,8 @@ class FlightPlanBuilder: combat_width = 35000 radius = combat_width * 1.25 - start = orbit_center.point_from_heading(heading, radius) - end = orbit_center.point_from_heading(heading + 180, radius) + start = orbit_center.point_from_heading(heading.degrees, radius) + end = orbit_center.point_from_heading(heading.opposite.degrees, radius) if end.distance_to_point(origin) < start.distance_to_point(origin): start, end = end, start @@ -1530,8 +1535,8 @@ class FlightPlanBuilder: raise InvalidObjectiveLocation(flight.flight_type, location) ingress, heading, distance = Conflict.frontline_vector(location, self.theater) - center = ingress.point_from_heading(heading, distance / 2) - egress = ingress.point_from_heading(heading, distance) + center = ingress.point_from_heading(heading.degrees, distance / 2) + egress = ingress.point_from_heading(heading.degrees, distance) ingress_distance = ingress.distance_to_point(flight.departure.position) egress_distance = egress.distance_to_point(flight.departure.position) @@ -1566,8 +1571,8 @@ class FlightPlanBuilder: location = self.package.target closest_boundary = self.threat_zones.closest_boundary(location.position) - heading_to_threat_boundary = location.position.heading_between_point( - closest_boundary + heading_to_threat_boundary = Heading.from_degrees( + location.position.heading_between_point(closest_boundary) ) distance_to_threat = meters( location.position.distance_to_point(closest_boundary) @@ -1582,16 +1587,16 @@ class FlightPlanBuilder: orbit_distance = distance_to_threat - threat_buffer racetrack_center = location.position.point_from_heading( - orbit_heading, orbit_distance.meters + orbit_heading.degrees, orbit_distance.meters ) racetrack_half_distance = Distance.from_nautical_miles(20).meters racetrack_start = racetrack_center.point_from_heading( - orbit_heading + 90, racetrack_half_distance + orbit_heading.right.degrees, racetrack_half_distance ) racetrack_end = racetrack_center.point_from_heading( - orbit_heading - 90, racetrack_half_distance + orbit_heading.left.degrees, racetrack_half_distance ) builder = WaypointBuilder(flight, self.coalition) diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index c7b7ca53..69d76998 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -55,7 +55,7 @@ from game.theater.theatergroundobject import ( SceneryGroundObject, ) from game.unitmap import UnitMap -from game.utils import feet, knots, mps +from game.utils import Heading, feet, knots, mps from .radios import RadioFrequency, RadioRegistry from .runways import RunwayData from .tacan import TacanBand, TacanChannel, TacanRegistry @@ -166,7 +166,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject] if targets: target = random.choice(targets) real_target = target.point_from_heading( - random.randint(0, 360), random.randint(0, 2500) + Heading.random().degrees, random.randint(0, 2500) ) vg.points[0].add_task(FireAtPoint(real_target)) logging.info("Set up fire task for missile group.") @@ -246,7 +246,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]): name=self.ground_object.group_name, _type=unit_type, position=self.ground_object.position, - heading=self.ground_object.heading, + heading=self.ground_object.heading.degrees, ) self._register_fortification(group) @@ -256,7 +256,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]): name=self.ground_object.group_name, _type=static_type, position=self.ground_object.position, - heading=self.ground_object.heading, + heading=self.ground_object.heading.degrees, dead=self.ground_object.is_dead, ) self._register_building(group) @@ -387,7 +387,9 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO # time as the recovery window. brc = self.steam_into_wind(ship_group) self.activate_beacons(ship_group, tacan, tacan_callsign, icls) - self.add_runway_data(brc or 0, atc, tacan, tacan_callsign, icls) + self.add_runway_data( + brc or Heading.from_degrees(0), atc, tacan, tacan_callsign, icls + ) self._register_unit_group(group, ship_group) def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]: @@ -422,14 +424,14 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO ship.set_frequency(atc_channel.hertz) return ship - def steam_into_wind(self, group: ShipGroup) -> Optional[int]: - wind = self.game.conditions.weather.wind.at_0m - brc = wind.direction + 180 + def steam_into_wind(self, group: ShipGroup) -> Optional[Heading]: + wind = self.game.conditions.weather.wind.at_0m.direction + brc = Heading.from_degrees(wind.direction).opposite # Aim for 25kts over the deck. carrier_speed = knots(25) - mps(wind.speed) for attempt in range(5): point = group.points[0].position.point_from_heading( - brc, 100000 - attempt * 20000 + brc.degrees, 100000 - attempt * 20000 ) if self.game.theater.is_in_sea(point): group.points[0].speed = carrier_speed.meters_per_second @@ -459,7 +461,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO def add_runway_data( self, - brc: int, + brc: Heading, atc: RadioFrequency, tacan: TacanChannel, callsign: str, @@ -593,7 +595,7 @@ class HelipadGenerator: logging.info("Generating helipad : " + name) pad = SingleHeliPad(name=(name + "_unit")) pad.position = Point(helipad.x, helipad.y) - pad.heading = helipad.heading + pad.heading = helipad.heading.degrees # pad.heliport_frequency = self.radio_registry.alloc_uhf() TODO : alloc radio & callsign sg = unitgroup.StaticGroup(self.m.next_group_id(), name) sg.add_unit(pad) diff --git a/gen/missiles/scud_site.py b/gen/missiles/scud_site.py index ca7f9b94..c57b43e3 100644 --- a/gen/missiles/scud_site.py +++ b/gen/missiles/scud_site.py @@ -5,6 +5,7 @@ from dcs.vehicles import Unarmed, MissilesSS, AirDefence from game import Game from game.factions.faction import Faction from game.theater.theatergroundobject import MissileSiteGroundObject +from game.utils import Heading from gen.sam.group_generator import VehicleGroupGenerator @@ -63,5 +64,5 @@ class ScudGenerator(VehicleGroupGenerator[MissileSiteGroundObject]): "STRELA#0", self.position.x + 200, self.position.y + 15, - 90, + Heading.from_degrees(90), ) diff --git a/gen/missiles/v1_group.py b/gen/missiles/v1_group.py index 9d377754..e42a94fe 100644 --- a/gen/missiles/v1_group.py +++ b/gen/missiles/v1_group.py @@ -5,6 +5,7 @@ from dcs.vehicles import Unarmed, MissilesSS, AirDefence from game import Game from game.factions.faction import Faction from game.theater.theatergroundobject import MissileSiteGroundObject +from game.utils import Heading from gen.sam.group_generator import VehicleGroupGenerator @@ -65,5 +66,5 @@ class V1GroupGenerator(VehicleGroupGenerator[MissileSiteGroundObject]): "Blitz#0", self.position.x + 200, self.position.y + 15, - 90, + Heading.from_degrees(90), ) diff --git a/gen/runways.py b/gen/runways.py index dfb0cebe..ef9ab52f 100644 --- a/gen/runways.py +++ b/gen/runways.py @@ -8,6 +8,7 @@ from typing import Iterator, Optional from dcs.terrain.terrain import Airport from game.weather import Conditions +from game.utils import Heading from .airfields import AIRFIELD_DATA from .radios import RadioFrequency from .tacan import TacanChannel @@ -16,7 +17,7 @@ from .tacan import TacanChannel @dataclass(frozen=True) class RunwayData: airfield_name: str - runway_heading: int + runway_heading: Heading runway_name: str atc: Optional[RadioFrequency] = None tacan: Optional[TacanChannel] = None @@ -26,7 +27,7 @@ class RunwayData: @classmethod def for_airfield( - cls, airport: Airport, runway_heading: int, runway_name: str + cls, airport: Airport, runway_heading: Heading, runway_name: str ) -> RunwayData: """Creates RunwayData for the given runway of an airfield. @@ -66,12 +67,14 @@ class RunwayData: runway_number = runway.heading // 10 runway_side = ["", "L", "R"][runway.leftright] runway_name = f"{runway_number:02}{runway_side}" - yield cls.for_airfield(airport, runway.heading, runway_name) + yield cls.for_airfield( + airport, Heading.from_degrees(runway.heading), runway_name + ) # pydcs only exposes one runway per physical runway, so to expose # both sides of the runway we need to generate the other. - heading = (runway.heading + 180) % 360 - runway_number = heading // 10 + heading = Heading.from_degrees(runway.heading).opposite + runway_number = heading.degrees // 10 runway_side = ["", "R", "L"][runway.leftright] runway_name = f"{runway_number:02}{runway_side}" yield cls.for_airfield(airport, heading, runway_name) @@ -81,10 +84,10 @@ class RunwayAssigner: def __init__(self, conditions: Conditions): self.conditions = conditions - def angle_off_headwind(self, runway: RunwayData) -> int: - wind = self.conditions.weather.wind.at_0m.direction - ideal_heading = (wind + 180) % 360 - return abs(runway.runway_heading - ideal_heading) + def angle_off_headwind(self, runway: RunwayData) -> Heading: + wind = Heading.from_degrees(self.conditions.weather.wind.at_0m.direction) + ideal_heading = wind.opposite + return runway.runway_heading.angle_between(ideal_heading) def get_preferred_runway(self, airport: Airport) -> RunwayData: """Returns the preferred runway for the given airport. diff --git a/gen/sam/aaa_flak.py b/gen/sam/aaa_flak.py index 68dee391..0e27a8d2 100644 --- a/gen/sam/aaa_flak.py +++ b/gen/sam/aaa_flak.py @@ -6,6 +6,7 @@ from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, ) +from game.utils import Heading GFLAK = [ AirDefence.Flak38, @@ -88,7 +89,7 @@ class FlakGenerator(AirDefenseGroupGenerator): "BLITZ#" + str(index), self.position.x + 125 + 15 * i + random.randint(1, 5), self.position.y + 15 * j + random.randint(1, 5), - 75, + Heading.from_degrees(75), ) @classmethod diff --git a/gen/sam/aaa_ww2_ally_flak.py b/gen/sam/aaa_ww2_ally_flak.py index 5fc18ddc..4eed42f4 100644 --- a/gen/sam/aaa_ww2_ally_flak.py +++ b/gen/sam/aaa_ww2_ally_flak.py @@ -6,6 +6,7 @@ from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, ) +from game.utils import Heading class AllyWW2FlakGenerator(AirDefenseGroupGenerator): @@ -53,28 +54,28 @@ class AllyWW2FlakGenerator(AirDefenseGroupGenerator): "CMD#1", self.position.x, self.position.y - 20, - random.randint(0, 360), + Heading.random(), ) self.add_unit( Unarmed.M30_CC, "LOG#1", self.position.x, self.position.y + 20, - random.randint(0, 360), + Heading.random(), ) self.add_unit( Unarmed.M4_Tractor, "LOG#2", self.position.x + 20, self.position.y, - random.randint(0, 360), + Heading.random(), ) self.add_unit( Unarmed.Bedford_MWD, "LOG#3", self.position.x - 20, self.position.y, - random.randint(0, 360), + Heading.random(), ) @classmethod diff --git a/gen/sam/cold_war_flak.py b/gen/sam/cold_war_flak.py index 788482ec..bb538434 100644 --- a/gen/sam/cold_war_flak.py +++ b/gen/sam/cold_war_flak.py @@ -41,7 +41,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator): "SHO#1", self.position.x - 40, self.position.y - 40, - self.heading + 180, + self.heading.opposite, ), self.add_unit( AirDefence.S_60_Type59_Artillery, @@ -57,7 +57,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator): "SHO#3", self.position.x - 80, self.position.y - 40, - self.heading + 180, + self.heading.opposite, ), self.add_unit( AirDefence.ZU_23_Emplacement_Closed, @@ -113,7 +113,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator): "SHO#1", self.position.x - 40, self.position.y - 40, - self.heading + 180, + self.heading.opposite, ), self.add_unit( AirDefence.S_60_Type59_Artillery, @@ -129,7 +129,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator): "SHO#3", self.position.x - 80, self.position.y - 40, - self.heading + 180, + self.heading.opposite, ), self.add_unit( AirDefence.ZU_23_Emplacement_Closed, diff --git a/gen/sam/freya_ewr.py b/gen/sam/freya_ewr.py index 7c61a25c..e484d53e 100644 --- a/gen/sam/freya_ewr.py +++ b/gen/sam/freya_ewr.py @@ -4,6 +4,7 @@ from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, ) +from game.utils import Heading class FreyaGenerator(AirDefenseGroupGenerator): @@ -101,7 +102,7 @@ class FreyaGenerator(AirDefenseGroupGenerator): "Inf#3", self.position.x + 20, self.position.y - 24, - self.heading + 45, + self.heading + Heading.from_degrees(45), ) @classmethod diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index 2fb800f8..e8137e19 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -16,6 +16,7 @@ from dcs.unittype import VehicleType, UnitType, ShipType from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction from game.theater.theatergroundobject import TheaterGroundObject, NavalGroundObject +from game.utils import Heading if TYPE_CHECKING: from game.game import Game @@ -37,7 +38,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): self.game = game self.go = ground_object self.position = ground_object.position - self.heading = random.randint(0, 359) + self.heading: Heading = Heading.random() self.price = 0 self.vg: GroupT = group @@ -53,7 +54,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): name: str, pos_x: float, pos_y: float, - heading: int, + heading: Heading, ) -> UnitT: return self.add_unit_to_group( self.vg, unit_type, name, Point(pos_x, pos_y), heading @@ -65,7 +66,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): unit_type: UnitTypeT, name: str, position: Point, - heading: int, + heading: Heading, ) -> UnitT: raise NotImplementedError @@ -91,11 +92,11 @@ class VehicleGroupGenerator( unit_type: Type[VehicleType], name: str, position: Point, - heading: int, + heading: Heading, ) -> Vehicle: unit = Vehicle(self.game.next_unit_id(), f"{group.name}|{name}", unit_type.id) unit.position = position - unit.heading = heading + unit.heading = heading.degrees group.add_unit(unit) # get price of unit to calculate the real price of the whole group @@ -109,7 +110,7 @@ class VehicleGroupGenerator( def get_circular_position( self, num_units: int, launcher_distance: int, coverage: int = 90 - ) -> Iterable[tuple[float, float, int]]: + ) -> Iterable[tuple[float, float, Heading]]: """ Given a position on the map, array a group of units in a circle a uniform distance from the unit :param num_units: @@ -131,9 +132,9 @@ class VehicleGroupGenerator( positions = [] if num_units % 2 == 0: - current_offset = self.heading - ((coverage / (num_units - 1)) / 2) + current_offset = self.heading.degrees - ((coverage / (num_units - 1)) / 2) else: - current_offset = self.heading + current_offset = self.heading.degrees current_offset -= outer_offset * (math.ceil(num_units / 2) - 1) for _ in range(1, num_units + 1): x: float = self.position.x + launcher_distance * math.cos( @@ -142,8 +143,7 @@ class VehicleGroupGenerator( y: float = self.position.y + launcher_distance * math.sin( math.radians(current_offset) ) - heading = current_offset - positions.append((x, y, int(heading))) + positions.append((x, y, Heading.from_degrees(current_offset))) current_offset += outer_offset return positions @@ -172,10 +172,10 @@ class ShipGroupGenerator( unit_type: Type[ShipType], name: str, position: Point, - heading: int, + heading: Heading, ) -> Ship: unit = Ship(self.game.next_unit_id(), f"{self.go.group_name}|{name}", unit_type) unit.position = position - unit.heading = heading + unit.heading = heading.degrees group.add_unit(unit) return unit diff --git a/gen/visualgen.py b/gen/visualgen.py index 83be4859..3a11652e 100644 --- a/gen/visualgen.py +++ b/gen/visualgen.py @@ -86,7 +86,7 @@ class VisualGenerator: continue for offset in range(0, distance, self.game.settings.perf_smoke_spacing): - position = plane_start.point_from_heading(heading, offset) + position = plane_start.point_from_heading(heading.degrees, offset) for k, v in FRONT_SMOKE_TYPE_CHANCES.items(): if random.randint(0, 100) <= k: diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index fce7a5d9..24024bc1 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -417,12 +417,12 @@ class FrontLineJs(QObject): def extents(self) -> List[LeafletLatLon]: a = self.theater.point_to_ll( self.front_line.position.point_from_heading( - self.front_line.attack_heading + 90, nautical_miles(2).meters + self.front_line.attack_heading.right.degrees, nautical_miles(2).meters ) ) b = self.theater.point_to_ll( self.front_line.position.point_from_heading( - self.front_line.attack_heading + 270, nautical_miles(2).meters + self.front_line.attack_heading.left.degrees, nautical_miles(2).meters ) ) return [[a.latitude, a.longitude], [b.latitude, b.longitude]] From edbd3de4a4f8d62adeb98880f97e012d2bab75a8 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 21 Jul 2021 17:10:29 -0700 Subject: [PATCH 118/167] Bump campaign version to 8.0 for latest DCS. Building IDs changed again. Ack the change in my two campaigns which don't use these target types. --- game/version.py | 6 +++++- resources/campaigns/battle_of_abu_dhabi.json | 2 +- resources/campaigns/black_sea.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/game/version.py b/game/version.py index 7c989e2a..87d8a841 100644 --- a/game/version.py +++ b/game/version.py @@ -106,4 +106,8 @@ VERSION = _build_version_string() #: #: Version 7.1 #: * Support for Mariana Islands terrain -CAMPAIGN_FORMAT_VERSION = (7, 1) +#: +#: Version 8.0 +#: * DCS 2.7.4.9632 changed scenery target IDs. Any mission using map buildings as +#: strike targets must check and potentially recreate all those objectives. +CAMPAIGN_FORMAT_VERSION = (8, 0) diff --git a/resources/campaigns/battle_of_abu_dhabi.json b/resources/campaigns/battle_of_abu_dhabi.json index 8bbd80fb..5d6c25ca 100644 --- a/resources/campaigns/battle_of_abu_dhabi.json +++ b/resources/campaigns/battle_of_abu_dhabi.json @@ -7,5 +7,5 @@ "description": "

You have managed to establish a foothold near Ras Al Khaima. Continue pushing south.

", "miz": "battle_of_abu_dhabi.miz", "performance": 2, - "version": "7.0" + "version": "8.0" } \ No newline at end of file diff --git a/resources/campaigns/black_sea.json b/resources/campaigns/black_sea.json index 02f4ddbe..94cc5e02 100644 --- a/resources/campaigns/black_sea.json +++ b/resources/campaigns/black_sea.json @@ -5,5 +5,5 @@ "description": "

A medium sized theater with bases along the coast of the Black Sea.

", "miz": "black_sea.miz", "performance": 2, - "version": "7.0" + "version": "8.0" } \ No newline at end of file From 109408587259a8e453ccb202400dc0bb6c7e850a Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Thu, 22 Jul 2021 15:30:46 -0400 Subject: [PATCH 119/167] Fixes #1449 and updates another area where the Heading class can apply (#1451) --- game/weather.py | 10 +++++----- gen/groundobjectsgen.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/game/weather.py b/game/weather.py index 952335bd..2594ed91 100644 --- a/game/weather.py +++ b/game/weather.py @@ -12,7 +12,7 @@ from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind from game.savecompat import has_save_compat_for from game.settings import Settings -from game.utils import Distance, meters, interpolate, Pressure, inches_hg +from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg if TYPE_CHECKING: from game.theater import ConflictTheater @@ -149,7 +149,7 @@ class Weather: @staticmethod def random_wind(minimum: int, maximum: int) -> WindConditions: - wind_direction = random.randint(0, 360) + wind_direction = Heading.random() at_0m_factor = 1 at_2000m_factor = 2 at_8000m_factor = 3 @@ -157,9 +157,9 @@ class Weather: return WindConditions( # Always some wind to make the smoke move a bit. - at_0m=Wind(wind_direction, max(1, base_wind * at_0m_factor)), - at_2000m=Wind(wind_direction, base_wind * at_2000m_factor), - at_8000m=Wind(wind_direction, base_wind * at_8000m_factor), + at_0m=Wind(wind_direction.degrees, max(1, base_wind * at_0m_factor)), + at_2000m=Wind(wind_direction.degrees, base_wind * at_2000m_factor), + at_8000m=Wind(wind_direction.degrees, base_wind * at_8000m_factor), ) @staticmethod diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index 69d76998..4efcfb92 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -425,7 +425,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO return ship def steam_into_wind(self, group: ShipGroup) -> Optional[Heading]: - wind = self.game.conditions.weather.wind.at_0m.direction + wind = self.game.conditions.weather.wind.at_0m brc = Heading.from_degrees(wind.direction).opposite # Aim for 25kts over the deck. carrier_speed = knots(25) - mps(wind.speed) From dd50ee92a9e55e9de644701b0e9dafd5a66a082f Mon Sep 17 00:00:00 2001 From: RndName Date: Tue, 20 Jul 2021 11:44:27 +0200 Subject: [PATCH 120/167] calculate heading to center of conflict for sams --- changelog.md | 1 + gen/sam/airdefensegroupgenerator.py | 1 + gen/sam/group_generator.py | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/changelog.md b/changelog.md index d8d0cc36..9eb637f4 100644 --- a/changelog.md +++ b/changelog.md @@ -27,6 +27,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Campaign]** Air defense sites now generate a fixed number of launchers per type. * **[Campaign]** Added support for Mariana Islands map. * **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR +* **[Mission Generation]** SAM sites are now headed towards the center of the conflict * **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py index 36755036..f755cafa 100644 --- a/gen/sam/airdefensegroupgenerator.py +++ b/gen/sam/airdefensegroupgenerator.py @@ -48,6 +48,7 @@ class AirDefenseGroupGenerator(VehicleGroupGenerator[SamGroundObject], ABC): self.vg.name = self.group_name_for_role(self.vg.id, self.primary_group_role()) self.auxiliary_groups: List[VehicleGroup] = [] + self.heading = self.heading_to_conflict() def add_auxiliary_group(self, role: SkynetRole) -> VehicleGroup: gid = self.game.next_group_id() diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index e8137e19..d7266186 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import math +import operator import random from collections import Iterable from typing import TYPE_CHECKING, Type, TypeVar, Generic, Any @@ -15,6 +16,7 @@ from dcs.unittype import VehicleType, UnitType, ShipType from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction +from game.theater import MissionTarget from game.theater.theatergroundobject import TheaterGroundObject, NavalGroundObject from game.utils import Heading @@ -70,6 +72,27 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): ) -> UnitT: raise NotImplementedError + def heading_to_conflict(self) -> int: + # Heading for a Group to the enemy. + # Should be the point between the nearest and the most distant conflict + conflicts: dict[MissionTarget, float] = {} + + for conflict in self.game.theater.conflicts(): + conflicts[conflict] = conflict.distance_to(self.go) + + if len(conflicts) == 0: + return self.heading + + closest_conflict = min(conflicts.items(), key=operator.itemgetter(1))[0] + most_distant_conflict = max(conflicts.items(), key=operator.itemgetter(1))[0] + + conflict_center = Point( + (closest_conflict.position.x + most_distant_conflict.position.x) / 2, + (closest_conflict.position.y + most_distant_conflict.position.y) / 2, + ) + + return int(self.go.position.heading_between_point(conflict_center)) + class VehicleGroupGenerator( Generic[TgoT], GroupGenerator[VehicleGroup, Vehicle, Type[VehicleType], TgoT] From 458de17b8fb1e5f940a74f4e11a531512d3149d7 Mon Sep 17 00:00:00 2001 From: RndName Date: Wed, 21 Jul 2021 17:27:06 +0200 Subject: [PATCH 121/167] adopt sam heading to new heading class --- gen/sam/group_generator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index d7266186..bbe6bdb9 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -72,7 +72,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): ) -> UnitT: raise NotImplementedError - def heading_to_conflict(self) -> int: + def heading_to_conflict(self) -> Heading: # Heading for a Group to the enemy. # Should be the point between the nearest and the most distant conflict conflicts: dict[MissionTarget, float] = {} @@ -91,7 +91,9 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): (closest_conflict.position.y + most_distant_conflict.position.y) / 2, ) - return int(self.go.position.heading_between_point(conflict_center)) + return Heading.from_degrees( + self.go.position.heading_between_point(conflict_center) + ) class VehicleGroupGenerator( From 9f23cb35a95a71fd25887010d4cf5f3e1618da92 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 23 Jul 2021 22:48:01 -0700 Subject: [PATCH 122/167] Update pydcs to latest master. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 54073289..5cdc508f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ pathspec==0.8.1 pefile==2019.4.18 Pillow==8.2.0 pre-commit==2.10.1 --e git://github.com/pydcs/dcs@2baba37e32bc55fed59ef977c43dad275c9821eb#egg=pydcs +-e git://github.com/pydcs/dcs@7eb720b341c95ad4c3659cc934be4029d526c36e#egg=pydcs pyinstaller==4.3 pyinstaller-hooks-contrib==2021.1 pyparsing==2.4.7 From 80bf3c97b22e4912943b0d28083a30427f43264d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 24 Jul 2021 15:10:22 -0700 Subject: [PATCH 123/167] Remove the SA-10 from Syria 2011. They didn't get this until a few years later. This was a stand-in for the SA-5 that DCS doesn't have, but the SA-10 is so much more capable that it's not a good replacement. --- resources/factions/syria_2011.json | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/factions/syria_2011.json b/resources/factions/syria_2011.json index 07e77af1..31c8e78b 100644 --- a/resources/factions/syria_2011.json +++ b/resources/factions/syria_2011.json @@ -61,7 +61,6 @@ "SA8Generator", "SA8Generator", "SA9Generator", - "SA10Generator", "SA11Generator", "SA13Generator", "SA17Generator", From 67fa4a891077af74bc9fe99d27c662c0c32da83a Mon Sep 17 00:00:00 2001 From: RndName Date: Sun, 25 Jul 2021 14:59:56 +0200 Subject: [PATCH 124/167] fix generation of empty transfer during cp capture when a cp capture happens and the next cp has pending unit deliveries then they will be redeployed to the newly captured cp. The redeploy was drecreasing the num of pending unit deliveries for the old cp but was not removing them completly from the dict when all were removed --- changelog.md | 1 + game/unitdelivery.py | 5 ++++- qt_ui/windows/basemenu/QRecruitBehaviour.py | 2 -- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/changelog.md b/changelog.md index 9eb637f4..73dbc29f 100644 --- a/changelog.md +++ b/changelog.md @@ -43,6 +43,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Mission Generation]** The lua data for other plugins is now generated correctly * **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs * **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation. +* **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured. * **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed. * **[UI]** Statistics window tick marks are now always integers. * **[UI]** Statistics window now shows the correct info for the turn diff --git a/game/unitdelivery.py b/game/unitdelivery.py index 7dbfb0a0..cf1af512 100644 --- a/game/unitdelivery.py +++ b/game/unitdelivery.py @@ -40,7 +40,10 @@ class PendingUnitDeliveries: def sell(self, units: dict[UnitType[Any], int]) -> None: for k, v in units.items(): - self.units[k] -= v + if self.units[k] > v: + self.units[k] -= v + else: + del self.units[k] def refund_all(self, coalition: Coalition) -> None: self.refund(coalition, self.units) diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py index b3ab3d8f..77b0258b 100644 --- a/qt_ui/windows/basemenu/QRecruitBehaviour.py +++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py @@ -209,8 +209,6 @@ class QRecruitBehaviour: if self.pending_deliveries.available_next_turn(unit_type) > 0: self.budget += unit_type.price self.pending_deliveries.sell({unit_type: 1}) - if self.pending_deliveries.units[unit_type] == 0: - del self.pending_deliveries.units[unit_type] self.update_purchase_controls() self.update_available_budget() return True From 4aac2d2b7bf7089b61740f089c7063cf77dec233 Mon Sep 17 00:00:00 2001 From: Khopa Date: Tue, 27 Jul 2021 23:43:00 +0200 Subject: [PATCH 125/167] Added NASAMS support --- gen/sam/sam_group_generator.py | 3 + gen/sam/sam_nasams.py | 68 +++++++++++++++++++ resources/factions/usa_2005.json | 5 +- .../ground_units/NASAMS_Command_Post.yaml | 8 +++ resources/units/ground_units/NASAMS_LN_B.yaml | 8 +++ resources/units/ground_units/NASAMS_LN_C.yaml | 8 +++ .../ground_units/NASAMS_Radar_MPQ64F1.yaml | 8 +++ 7 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 gen/sam/sam_nasams.py create mode 100644 resources/units/ground_units/NASAMS_Command_Post.yaml create mode 100644 resources/units/ground_units/NASAMS_LN_B.yaml create mode 100644 resources/units/ground_units/NASAMS_LN_C.yaml create mode 100644 resources/units/ground_units/NASAMS_Radar_MPQ64F1.yaml diff --git a/gen/sam/sam_group_generator.py b/gen/sam/sam_group_generator.py index 3db1b70a..4bebcd27 100644 --- a/gen/sam/sam_group_generator.py +++ b/gen/sam/sam_group_generator.py @@ -28,6 +28,7 @@ from gen.sam.sam_gepard import GepardGenerator from gen.sam.sam_hawk import HawkGenerator from gen.sam.sam_hq7 import HQ7Generator from gen.sam.sam_linebacker import LinebackerGenerator +from gen.sam.sam_nasams import NasamBGenerator, NasamCGenerator from gen.sam.sam_patriot import PatriotGenerator from gen.sam.sam_rapier import RapierGenerator from gen.sam.sam_roland import RolandGenerator @@ -100,6 +101,8 @@ SAM_MAP: Dict[str, Type[AirDefenseGroupGenerator]] = { "SA20Generator": SA20Generator, "SA20BGenerator": SA20BGenerator, "SA23Generator": SA23Generator, + "NasamBGenerator": NasamBGenerator, + "NasamCGenerator": NasamCGenerator, } diff --git a/gen/sam/sam_nasams.py b/gen/sam/sam_nasams.py new file mode 100644 index 00000000..62bbf60c --- /dev/null +++ b/gen/sam/sam_nasams.py @@ -0,0 +1,68 @@ +from typing import Type + +from dcs.mapping import Point +from dcs.unittype import VehicleType +from dcs.vehicles import AirDefence + +from game import Game +from game.theater import SamGroundObject +from gen.sam.airdefensegroupgenerator import ( + AirDefenseRange, + AirDefenseGroupGenerator, +) + + +class NasamCGenerator(AirDefenseGroupGenerator): + """ + This generate a Nasams group with AIM-120C missiles + """ + + name = "NASAMS AIM-120C" + + def __init__(self, game: Game, ground_object: SamGroundObject): + super().__init__(game, ground_object) + self.launcherType: Type[VehicleType] = AirDefence.NASAMS_LN_C + + def generate(self) -> None: + # Command Post + self.add_unit( + AirDefence.NASAMS_Command_Post, + "CP", + self.position.x + 30, + self.position.y + 30, + self.heading, + ) + # Radar + self.add_unit( + AirDefence.NASAMS_Radar_MPQ64F1, + "RADAR", + self.position.x - 30, + self.position.y - 30, + self.heading, + ) + + positions = self.get_circular_position(4, launcher_distance=120, coverage=360) + for i, position in enumerate(positions): + self.add_unit( + self.launcherType, + "LN#" + str(i), + position[0], + position[1], + position[2], + ) + + @classmethod + def range(cls) -> AirDefenseRange: + return AirDefenseRange.Medium + + +class NasamBGenerator(NasamCGenerator): + """ + This generate a Nasams group with AIM-120B missiles + """ + + name = "NASAMS AIM-120B" + + def __init__(self, game: Game, ground_object: SamGroundObject): + super().__init__(game, ground_object) + self.launcherType: Type[VehicleType] = AirDefence.NASAMS_LN_B diff --git a/resources/factions/usa_2005.json b/resources/factions/usa_2005.json index a05ede7a..4206d357 100644 --- a/resources/factions/usa_2005.json +++ b/resources/factions/usa_2005.json @@ -68,7 +68,8 @@ "air_defenses": [ "AvengerGenerator", "LinebackerGenerator", - "PatriotGenerator" + "PatriotGenerator", + "NasamCGenerator" ], "ewrs": [ "PatriotEwrGenerator" @@ -126,4 +127,4 @@ "VMFA-323" ] } -} \ No newline at end of file +} diff --git a/resources/units/ground_units/NASAMS_Command_Post.yaml b/resources/units/ground_units/NASAMS_Command_Post.yaml new file mode 100644 index 00000000..1113a8d8 --- /dev/null +++ b/resources/units/ground_units/NASAMS_Command_Post.yaml @@ -0,0 +1,8 @@ +price: 18 +description: "NASAMS (National/Norwegian Advanced Surface to Air Missile System) is a distributed and networked medium to long range air-defence system. + NASAMS was the first surface-based application for the AIM-120 AMRAAM (Advanced Medium Range Air-to-Air Missile)." +introduced: 1995 +manufacturer: Kongsberg Defence & Aerospace, Raytheon +origin: Norway, USA +variants: + SAM NASAMS C2: null diff --git a/resources/units/ground_units/NASAMS_LN_B.yaml b/resources/units/ground_units/NASAMS_LN_B.yaml new file mode 100644 index 00000000..4bfca908 --- /dev/null +++ b/resources/units/ground_units/NASAMS_LN_B.yaml @@ -0,0 +1,8 @@ +price: 15 +description: "NASAMS (National/Norwegian Advanced Surface to Air Missile System) is a distributed and networked medium to long range air-defence system. + NASAMS was the first surface-based application for the AIM-120 AMRAAM (Advanced Medium Range Air-to-Air Missile)." +introduced: 1995 +manufacturer: Kongsberg Defence & Aerospace, Raytheon +origin: Norway, USA +variants: + SAM NASAMS LN AIM-120B: null diff --git a/resources/units/ground_units/NASAMS_LN_C.yaml b/resources/units/ground_units/NASAMS_LN_C.yaml new file mode 100644 index 00000000..2e1ba9d9 --- /dev/null +++ b/resources/units/ground_units/NASAMS_LN_C.yaml @@ -0,0 +1,8 @@ +price: 20 +description: "NASAMS (National/Norwegian Advanced Surface to Air Missile System) is a distributed and networked medium to long range air-defence system. + NASAMS was the first surface-based application for the AIM-120 AMRAAM (Advanced Medium Range Air-to-Air Missile)." +introduced: 1996 +manufacturer: Kongsberg Defence & Aerospace, Raytheon +origin: Norway, USA +variants: + SAM NASAMS LN AIM-120C: null diff --git a/resources/units/ground_units/NASAMS_Radar_MPQ64F1.yaml b/resources/units/ground_units/NASAMS_Radar_MPQ64F1.yaml new file mode 100644 index 00000000..d10f63ca --- /dev/null +++ b/resources/units/ground_units/NASAMS_Radar_MPQ64F1.yaml @@ -0,0 +1,8 @@ +price: 26 +description: "NASAMS (National/Norwegian Advanced Surface to Air Missile System) is a distributed and networked medium to long range air-defence system. + NASAMS was the first surface-based application for the AIM-120 AMRAAM (Advanced Medium Range Air-to-Air Missile)." +introduced: 1995 +manufacturer: Kongsberg Defence & Aerospace, Raytheon +origin: Norway, USA +variants: + SAM NASAMS SR MPQ64F1: null From 32f05dccd928ab7b5ea7c17b30f4997c5b92c358 Mon Sep 17 00:00:00 2001 From: Khopa Date: Wed, 28 Jul 2021 00:15:00 +0200 Subject: [PATCH 126/167] Added Tin Shield EWR support --- gen/sam/ewr_group_generator.py | 2 ++ gen/sam/ewrs.py | 6 ++++++ resources/factions/gdr_1985.json | 5 +++-- resources/factions/india_2010.json | 5 +++-- resources/factions/iran_1988.json | 5 +++-- resources/factions/iran_2015.json | 5 +++-- resources/factions/libya_2011.json | 5 +++-- resources/factions/north_korea_2000.json | 5 +++-- resources/factions/russia_1970_limited_air.json | 5 +++-- resources/factions/russia_1975 (Mi-24P).json | 3 ++- resources/factions/russia_1975.json | 3 ++- resources/factions/russia_1990.json | 5 +++-- resources/factions/syria_1982.json | 5 +++-- resources/factions/syria_2011.json | 5 +++-- resources/factions/syria_2012.json | 5 +++-- resources/units/ground_units/RLS_19J6.yaml | 3 +++ 16 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 resources/units/ground_units/RLS_19J6.yaml diff --git a/gen/sam/ewr_group_generator.py b/gen/sam/ewr_group_generator.py index 81ede492..32404be4 100644 --- a/gen/sam/ewr_group_generator.py +++ b/gen/sam/ewr_group_generator.py @@ -18,6 +18,7 @@ from gen.sam.ewrs import ( StraightFlushGenerator, TallRackGenerator, EwrGenerator, + TinShieldGenerator, ) EWR_MAP = { @@ -31,6 +32,7 @@ EWR_MAP = { "SnowDriftGenerator": SnowDriftGenerator, "StraightFlushGenerator": StraightFlushGenerator, "HawkEwrGenerator": HawkEwrGenerator, + "TinShieldGenerator": TinShieldGenerator, } diff --git a/gen/sam/ewrs.py b/gen/sam/ewrs.py index fdcdf061..2ffc93da 100644 --- a/gen/sam/ewrs.py +++ b/gen/sam/ewrs.py @@ -102,3 +102,9 @@ class HawkEwrGenerator(EwrGenerator): """ unit_type = AirDefence.Hawk_sr + + +class TinShieldGenerator(EwrGenerator): + """19ZH6 "Tin Shield" EWR.""" + + unit_type = AirDefence.RLS_19J6 diff --git a/resources/factions/gdr_1985.json b/resources/factions/gdr_1985.json index 45eacc60..638bb8e1 100644 --- a/resources/factions/gdr_1985.json +++ b/resources/factions/gdr_1985.json @@ -52,7 +52,8 @@ "ZU23Generator" ], "ewrs": [ - "FlatFaceGenerator" + "FlatFaceGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -66,4 +67,4 @@ "navy_generators": [], "has_jtac": true, "jtac_unit": "Mi-8MTV2 Hip" -} \ No newline at end of file +} diff --git a/resources/factions/india_2010.json b/resources/factions/india_2010.json index 5deef839..accf47bc 100644 --- a/resources/factions/india_2010.json +++ b/resources/factions/india_2010.json @@ -57,7 +57,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [ "KUZNECOW" @@ -78,4 +79,4 @@ ], "has_jtac": true, "jtac_unit": "MQ-9 Reaper" -} \ No newline at end of file +} diff --git a/resources/factions/iran_1988.json b/resources/factions/iran_1988.json index 67edce5b..b6d729c8 100644 --- a/resources/factions/iran_1988.json +++ b/resources/factions/iran_1988.json @@ -53,7 +53,8 @@ "ColdWarFlakGenerator" ], "ewrs": [ - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -81,4 +82,4 @@ "has_jtac": true, "jtac_unit": "MQ-9 Reaper", "doctrine": "coldwar" -} \ No newline at end of file +} diff --git a/resources/factions/iran_2015.json b/resources/factions/iran_2015.json index 76f1d23a..6b426d29 100644 --- a/resources/factions/iran_2015.json +++ b/resources/factions/iran_2015.json @@ -65,7 +65,8 @@ "ZU23UralGenerator" ], "ewrs": [ - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -93,4 +94,4 @@ ], "has_jtac": true, "jtac_unit": "MQ-9 Reaper" -} \ No newline at end of file +} diff --git a/resources/factions/libya_2011.json b/resources/factions/libya_2011.json index 58454e54..6c77d0e6 100644 --- a/resources/factions/libya_2011.json +++ b/resources/factions/libya_2011.json @@ -53,7 +53,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -75,4 +76,4 @@ "MolniyaGroupGenerator", "LaCombattanteIIGroupGenerator" ] -} \ No newline at end of file +} diff --git a/resources/factions/north_korea_2000.json b/resources/factions/north_korea_2000.json index 3c5033bc..fe8411d0 100644 --- a/resources/factions/north_korea_2000.json +++ b/resources/factions/north_korea_2000.json @@ -57,7 +57,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -75,4 +76,4 @@ "MolniyaGroupGenerator" ], "has_jtac": false -} \ No newline at end of file +} diff --git a/resources/factions/russia_1970_limited_air.json b/resources/factions/russia_1970_limited_air.json index 9bab53dc..45f8c938 100644 --- a/resources/factions/russia_1970_limited_air.json +++ b/resources/factions/russia_1970_limited_air.json @@ -48,7 +48,8 @@ "ZU23UralGenerator" ], "ewrs": [ - "FlatFaceGenerator" + "FlatFaceGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], @@ -66,4 +67,4 @@ ], "has_jtac": false, "doctrine": "coldwar" -} \ No newline at end of file +} diff --git a/resources/factions/russia_1975 (Mi-24P).json b/resources/factions/russia_1975 (Mi-24P).json index 5294604c..5272b589 100644 --- a/resources/factions/russia_1975 (Mi-24P).json +++ b/resources/factions/russia_1975 (Mi-24P).json @@ -63,7 +63,8 @@ "ZU23UralGenerator" ], "ewrs": [ - "FlatFaceGenerator" + "FlatFaceGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], diff --git a/resources/factions/russia_1975.json b/resources/factions/russia_1975.json index 9c4dbd3a..5c672293 100644 --- a/resources/factions/russia_1975.json +++ b/resources/factions/russia_1975.json @@ -63,7 +63,8 @@ "ZU23UralGenerator" ], "ewrs": [ - "FlatFaceGenerator" + "FlatFaceGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [], "helicopter_carrier": [], diff --git a/resources/factions/russia_1990.json b/resources/factions/russia_1990.json index a833f71b..0f3ead54 100644 --- a/resources/factions/russia_1990.json +++ b/resources/factions/russia_1990.json @@ -73,7 +73,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "aircraft_carrier": [ "KUZNECOW" @@ -96,4 +97,4 @@ ], "has_jtac": true, "jtac_unit": "MQ-9 Reaper" -} \ No newline at end of file +} diff --git a/resources/factions/syria_1982.json b/resources/factions/syria_1982.json index 04267623..0c782bc2 100644 --- a/resources/factions/syria_1982.json +++ b/resources/factions/syria_1982.json @@ -54,7 +54,8 @@ "ZSU57Generator" ], "ewrs": [ - "BoxSpringGenerator" + "BoxSpringGenerator", + "TinShieldGenerator" ], "missiles": [ "ScudGenerator" @@ -70,4 +71,4 @@ "navy_generators": [ "GrishaGroupGenerator" ] -} \ No newline at end of file +} diff --git a/resources/factions/syria_2011.json b/resources/factions/syria_2011.json index 31c8e78b..a089d47e 100644 --- a/resources/factions/syria_2011.json +++ b/resources/factions/syria_2011.json @@ -71,7 +71,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "missiles": [ "ScudGenerator" @@ -88,4 +89,4 @@ "GrishaGroupGenerator", "MolniyaGroupGenerator" ] -} \ No newline at end of file +} diff --git a/resources/factions/syria_2012.json b/resources/factions/syria_2012.json index 1b8c4719..ec4190d9 100644 --- a/resources/factions/syria_2012.json +++ b/resources/factions/syria_2012.json @@ -73,7 +73,8 @@ ], "ewrs": [ "BoxSpringGenerator", - "TallRackGenerator" + "TallRackGenerator", + "TinShieldGenerator" ], "missiles": [ "ScudGenerator" @@ -90,4 +91,4 @@ "GrishaGroupGenerator", "MolniyaGroupGenerator" ] -} \ No newline at end of file +} diff --git a/resources/units/ground_units/RLS_19J6.yaml b/resources/units/ground_units/RLS_19J6.yaml new file mode 100644 index 00000000..3f129bcf --- /dev/null +++ b/resources/units/ground_units/RLS_19J6.yaml @@ -0,0 +1,3 @@ +price: 16 +variants: + SAM SA-5 S-200 ST-68U "Tin Shield" SR: null From e88bb442f394599d0728ebbd5b52e1bd9a68f3aa Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Wed, 28 Jul 2021 10:32:47 -0700 Subject: [PATCH 127/167] Increment to Campaign v8.0 --- resources/campaigns/Battle_for_the_UAE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/campaigns/Battle_for_the_UAE.json b/resources/campaigns/Battle_for_the_UAE.json index 0c5086e2..2eabc899 100644 --- a/resources/campaigns/Battle_for_the_UAE.json +++ b/resources/campaigns/Battle_for_the_UAE.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Bluefor Modern", "recommended_enemy_faction": "Iran 2015", "description": "

Following the Battle of Abu Dhabi, Iran's invasion of the UAE has been halted approximately 20 miles Northeast of Liwa Airbase by coalition forces.

After weeks of stalemate, coalition forces have consolidated their position and are ready to launch their counterattack to push Iranian forces off the peninsula.

", - "version": "7.0", + "version": "8.0", "miz": "Battle_for_the_UAE_v3.0.2.miz", "performance": 2 } From 3670c8f8797be30d2960c9ab4ca707b2f9517316 Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Wed, 28 Jul 2021 10:34:28 -0700 Subject: [PATCH 128/167] Increment to Campaign v8.0 --- resources/campaigns/Operation_Mole_Cricket_2010.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/campaigns/Operation_Mole_Cricket_2010.json b/resources/campaigns/Operation_Mole_Cricket_2010.json index fad2810b..36c753ff 100644 --- a/resources/campaigns/Operation_Mole_Cricket_2010.json +++ b/resources/campaigns/Operation_Mole_Cricket_2010.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Bluefor Modern", "recommended_enemy_faction": "Syria 2011", "description": "

In a scenario reminescent of the First Lebanon War, hostile Syrian-backed forces have flooded into the Bekaa Valley.

The objective of this operation is twofold: drive the enemy out of the Bekaa Valley and push past the Golan Heights into Syrian territory to capture Tiyas Airbase.

", - "version": "7.0", + "version": "8.0", "miz": "Operation_Mole_Cricket_2010_v3.0.2.miz", "performance": 2 } From 274a41f0528d7f6c83eb7f2f4d85c9f8baae449d Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Wed, 28 Jul 2021 10:35:12 -0700 Subject: [PATCH 129/167] Increment to Campaign v8.0 --- resources/campaigns/First_Lebanon_War.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/campaigns/First_Lebanon_War.json b/resources/campaigns/First_Lebanon_War.json index ecb56691..6534643a 100644 --- a/resources/campaigns/First_Lebanon_War.json +++ b/resources/campaigns/First_Lebanon_War.json @@ -6,6 +6,6 @@ "recommended_enemy_faction": "Syria 1982", "description": "

1100HRS, 06 June 1982: H-hour for Operation Peace for Galilee.

Objective: Push North towards Beirut and into the Bekaa Valley, eliminating or displacing any PLO and Syrian resistance. Airbases and their surrounding infrastructure in Syria are not the main objective but are still viable strategic targets.

Background: Years of PLO encroachment into the UN neutral zone and their resulting terror attacks against Israelis have pushed tension along the border to a breaking point. On June 3, the attempted assassination of Israeli Ambassador, Shlomo Argov by gunmen with ties to the PLO have finally pushed the Israelis to action.

Recommended Starting Budget:

$1500m for recommended factions, $$2000m for modern scenarios

Income Multiplier:

Blue: 1.0x

Red: 0.7x-1.0x

", "miz": "First_Lebanon_War_v3.0.2.miz", - "version": "7.0", + "version": "8.0", "performance": 2 } From 4df12ae6751d4fc9111ca0b4a8b3b99b3c491cf6 Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Wed, 28 Jul 2021 10:36:17 -0700 Subject: [PATCH 130/167] Increment to Campaign v8.0 --- resources/campaigns/operation_allied_sword.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/campaigns/operation_allied_sword.json b/resources/campaigns/operation_allied_sword.json index 3f26c95a..027864e7 100644 --- a/resources/campaigns/operation_allied_sword.json +++ b/resources/campaigns/operation_allied_sword.json @@ -5,7 +5,7 @@ "recommended_player_faction": "Israel-USN 2005 (Allied Sword)", "recommended_enemy_faction": "Syria-Lebanon 2005 (Allied Sword)", "description": "

In this fictional scenario, a US/Israeli coalition must push north from the Israeli border, through Syria and Lebanon to Aleppo.

Backstory: A Syrian-Lebanese joint force (with Russian materiel support) has attacked Israel, attmepting to cross the northern border. With the arrival of a US carrier group, Israel prepares its counterattack. The US Navy will handle the Beirut region's coastal arena, while the IAF will push through Damascus and the inland mountain ranges.

", - "version": "7.0", + "version": "8.0", "miz": "operation_allied_sword.miz", "performance": 2 -} \ No newline at end of file +} From a5ade0c41a7183340a9c293887fa11e5c601bf1f Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Wed, 28 Jul 2021 10:36:43 -0700 Subject: [PATCH 131/167] Increment to Campaign v8.0 --- resources/campaigns/operation_blackball.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/campaigns/operation_blackball.json b/resources/campaigns/operation_blackball.json index 14ee2863..9b0483f3 100644 --- a/resources/campaigns/operation_blackball.json +++ b/resources/campaigns/operation_blackball.json @@ -5,7 +5,7 @@ "recommended_player_faction": "US Navy 2005", "recommended_enemy_faction": "Russia 2010", "description": "

Warning: This campaign will not work if the attacking faction does not have a carrier.

A lightweight, fictional showcase of Cyprus for the Syria terrain. A US Navy force must deploy from a FOB and carrier group to push from the north-east down through the island.

Backstory: The world is at war. With the help of her eastern allies, Russia has taken the Suez Canal and deployed a large naval force to the Mediterranean, trapping a US carrier group near the Turkish-Syrian border. Now, they must break out by taking Cyprus back.

", - "version": "7.0", + "version": "8.0", "miz": "operation_blackball.miz", "performance": 1 -} \ No newline at end of file +} From 0117ab8aa463497c83f313004e1fe88bb4e47cf0 Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Wed, 28 Jul 2021 10:37:14 -0700 Subject: [PATCH 132/167] Increment to Campaign v8.0 --- resources/campaigns/scenic_route.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/campaigns/scenic_route.json b/resources/campaigns/scenic_route.json index d79ae493..b6dc194c 100644 --- a/resources/campaigns/scenic_route.json +++ b/resources/campaigns/scenic_route.json @@ -5,7 +5,7 @@ "recommended_player_faction": "US Navy 2005", "recommended_enemy_faction": "Iran 2015", "description": "

A lightweight naval campaign involving a US Navy carrier group pushing across the coast of Iran. Note that the ground units purchased on turn zero must sustain you until you've taken the first hostile FOB. The starting point does not have a factory to simulate a Marine Expeditionary Force deploying from the carrier group.

Backstory: Iran has declared war on all US forces in the Gulf, resulting in all local allies withdrawing their support for American troops. A lone carrier group must pacify the southern coast of Iran and hold out until backup can arrive, lest the US and her interests be ejected from the region permanently.

", - "version": "7.0", + "version": "8.0", "miz": "scenic_route.miz", "performance": 1 -} \ No newline at end of file +} From 4c51b4b82237f2f22d48045655e93a69acb0efee Mon Sep 17 00:00:00 2001 From: Magnus Wolffelt Date: Sat, 31 Jul 2021 12:57:23 +0200 Subject: [PATCH 133/167] Seasonal weather types per theater. Adjusts the weather conditions per theater and per season. --- game/theater/conflicttheater.py | 82 +++++-------------- game/theater/seasonalconditions/__init__.py | 1 + game/theater/seasonalconditions/caucasus.py | 36 ++++++++ .../seasonalconditions/marianaislands.py | 38 +++++++++ game/theater/seasonalconditions/nevada.py | 36 ++++++++ game/theater/seasonalconditions/normandy.py | 36 ++++++++ .../theater/seasonalconditions/persiangulf.py | 37 +++++++++ .../seasonalconditions/seasonalconditions.py | 48 +++++++++++ game/theater/seasonalconditions/syria.py | 36 ++++++++ game/theater/seasonalconditions/thechannel.py | 36 ++++++++ game/weather.py | 26 ++++-- 11 files changed, 346 insertions(+), 66 deletions(-) create mode 100644 game/theater/seasonalconditions/__init__.py create mode 100644 game/theater/seasonalconditions/caucasus.py create mode 100644 game/theater/seasonalconditions/marianaislands.py create mode 100644 game/theater/seasonalconditions/nevada.py create mode 100644 game/theater/seasonalconditions/normandy.py create mode 100644 game/theater/seasonalconditions/persiangulf.py create mode 100644 game/theater/seasonalconditions/seasonalconditions.py create mode 100644 game/theater/seasonalconditions/syria.py create mode 100644 game/theater/seasonalconditions/thechannel.py diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 8e88bda2..bdae60e6 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -51,6 +51,7 @@ from .controlpoint import ( MissionTarget, OffMapSpawn, ) +from .seasonalconditions import SeasonalConditions from .frontline import FrontLine from .landmap import Landmap, load_landmap, poly_contains from .latlon import LatLon @@ -527,17 +528,6 @@ class ReferencePoint: image_coordinates: Point -@dataclass(frozen=True) -class SeasonalConditions: - # Units are inHg and degrees Celsius - # Future improvement: add clouds/precipitation - summer_avg_pressure: float - winter_avg_pressure: float - summer_avg_temperature: float - winter_avg_temperature: float - temperature_day_night_difference: float - - class ConflictTheater: terrain: Terrain @@ -799,13 +789,9 @@ class CaucasusTheater(ConflictTheater): @property def seasonal_conditions(self) -> SeasonalConditions: - return SeasonalConditions( - summer_avg_pressure=30.02, # TODO: More science - winter_avg_pressure=29.72, # TODO: More science - summer_avg_temperature=22.5, - winter_avg_temperature=3.0, - temperature_day_night_difference=6.0, - ) + from .seasonalconditions.caucasus import CONDITIONS + + return CONDITIONS @property def projection_parameters(self) -> TransverseMercator: @@ -831,13 +817,9 @@ class PersianGulfTheater(ConflictTheater): @property def seasonal_conditions(self) -> SeasonalConditions: - return SeasonalConditions( - summer_avg_pressure=29.98, # TODO: More science - winter_avg_pressure=29.80, # TODO: More science - summer_avg_temperature=32.5, - winter_avg_temperature=15.0, - temperature_day_night_difference=2.0, - ) + from .seasonalconditions.persiangulf import CONDITIONS + + return CONDITIONS @property def projection_parameters(self) -> TransverseMercator: @@ -863,13 +845,9 @@ class NevadaTheater(ConflictTheater): @property def seasonal_conditions(self) -> SeasonalConditions: - return SeasonalConditions( - summer_avg_pressure=30.02, # TODO: More science - winter_avg_pressure=29.72, # TODO: More science - summer_avg_temperature=31.5, - winter_avg_temperature=5.0, - temperature_day_night_difference=6.0, - ) + from .seasonalconditions.nevada import CONDITIONS + + return CONDITIONS @property def projection_parameters(self) -> TransverseMercator: @@ -895,13 +873,9 @@ class NormandyTheater(ConflictTheater): @property def seasonal_conditions(self) -> SeasonalConditions: - return SeasonalConditions( - summer_avg_pressure=30.02, # TODO: More science - winter_avg_pressure=29.72, # TODO: More science - summer_avg_temperature=20.0, - winter_avg_temperature=0.0, - temperature_day_night_difference=5.0, - ) + from .seasonalconditions.normandy import CONDITIONS + + return CONDITIONS @property def projection_parameters(self) -> TransverseMercator: @@ -927,13 +901,9 @@ class TheChannelTheater(ConflictTheater): @property def seasonal_conditions(self) -> SeasonalConditions: - return SeasonalConditions( - summer_avg_pressure=30.02, # TODO: More science - winter_avg_pressure=29.72, # TODO: More science - summer_avg_temperature=20.0, - winter_avg_temperature=0.0, - temperature_day_night_difference=5.0, - ) + from .seasonalconditions.thechannel import CONDITIONS + + return CONDITIONS @property def projection_parameters(self) -> TransverseMercator: @@ -959,13 +929,9 @@ class SyriaTheater(ConflictTheater): @property def seasonal_conditions(self) -> SeasonalConditions: - return SeasonalConditions( - summer_avg_pressure=29.98, # TODO: More science - winter_avg_pressure=29.86, # TODO: More science - summer_avg_temperature=28.5, - winter_avg_temperature=10.0, - temperature_day_night_difference=8.0, - ) + from .seasonalconditions.syria import CONDITIONS + + return CONDITIONS @property def projection_parameters(self) -> TransverseMercator: @@ -988,13 +954,9 @@ class MarianaIslandsTheater(ConflictTheater): @property def seasonal_conditions(self) -> SeasonalConditions: - return SeasonalConditions( - summer_avg_pressure=30.02, # TODO: More science - winter_avg_pressure=29.82, # TODO: More science - summer_avg_temperature=28.0, - winter_avg_temperature=27.0, - temperature_day_night_difference=1.0, - ) + from .seasonalconditions.marianaislands import CONDITIONS + + return CONDITIONS @property def projection_parameters(self) -> TransverseMercator: diff --git a/game/theater/seasonalconditions/__init__.py b/game/theater/seasonalconditions/__init__.py new file mode 100644 index 00000000..713a85f5 --- /dev/null +++ b/game/theater/seasonalconditions/__init__.py @@ -0,0 +1 @@ +from .seasonalconditions import * diff --git a/game/theater/seasonalconditions/caucasus.py b/game/theater/seasonalconditions/caucasus.py new file mode 100644 index 00000000..e605a543 --- /dev/null +++ b/game/theater/seasonalconditions/caucasus.py @@ -0,0 +1,36 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=30.02, # TODO: Find real-world data + winter_avg_pressure=29.72, # TODO: Find real-world data + summer_avg_temperature=22.5, + winter_avg_temperature=3.0, + temperature_day_night_difference=6.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=60, + clear_skies=20, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=40, + clear_skies=40, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=30, + cloudy=50, + clear_skies=20, + ), + }, +) diff --git a/game/theater/seasonalconditions/marianaislands.py b/game/theater/seasonalconditions/marianaislands.py new file mode 100644 index 00000000..0d662908 --- /dev/null +++ b/game/theater/seasonalconditions/marianaislands.py @@ -0,0 +1,38 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=30.02, # TODO: Find real-world data + winter_avg_pressure=29.82, # TODO: Find real-world data + summer_avg_temperature=28.0, + winter_avg_temperature=27.0, + temperature_day_night_difference=1.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=2, + raining=20, + cloudy=40, + clear_skies=40, + ), + Season.Spring: WeatherTypeChances( + # Spring is dry/sunny in Marianas + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=2, + raining=20, + cloudy=40, + clear_skies=40, + ), + Season.Fall: WeatherTypeChances( + # Rain season + thunderstorm=5, + raining=45, + cloudy=30, + clear_skies=20, + ), + }, +) diff --git a/game/theater/seasonalconditions/nevada.py b/game/theater/seasonalconditions/nevada.py new file mode 100644 index 00000000..352ca456 --- /dev/null +++ b/game/theater/seasonalconditions/nevada.py @@ -0,0 +1,36 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=30.02, # TODO: Find real-world data + winter_avg_pressure=29.72, # TODO: Find real-world data + summer_avg_temperature=31.5, + winter_avg_temperature=5.0, + temperature_day_night_difference=6.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=50, + clear_skies=40, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=5, + cloudy=45, + clear_skies=50, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=5, + cloudy=25, + clear_skies=70, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=45, + clear_skies=45, + ), + }, +) diff --git a/game/theater/seasonalconditions/normandy.py b/game/theater/seasonalconditions/normandy.py new file mode 100644 index 00000000..109c781f --- /dev/null +++ b/game/theater/seasonalconditions/normandy.py @@ -0,0 +1,36 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=30.02, # TODO: Find real-world data + winter_avg_pressure=29.72, # TODO: Find real-world data + summer_avg_temperature=20.0, + winter_avg_temperature=0.0, + temperature_day_night_difference=5.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=60, + clear_skies=20, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=40, + clear_skies=40, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=30, + cloudy=50, + clear_skies=20, + ), + }, +) diff --git a/game/theater/seasonalconditions/persiangulf.py b/game/theater/seasonalconditions/persiangulf.py new file mode 100644 index 00000000..467168ab --- /dev/null +++ b/game/theater/seasonalconditions/persiangulf.py @@ -0,0 +1,37 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=29.98, # TODO: Find real-world data + winter_avg_pressure=29.80, # TODO: Find real-world data + summer_avg_temperature=32.5, + winter_avg_temperature=15.0, + temperature_day_night_difference=2.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + # Winter there is some rain in PG (Dubai) + thunderstorm=1, + raining=15, + cloudy=35, + clear_skies=50, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=2, + cloudy=18, + clear_skies=80, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=1, + cloudy=8, + clear_skies=90, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=2, + cloudy=18, + clear_skies=80, + ), + }, +) diff --git a/game/theater/seasonalconditions/seasonalconditions.py b/game/theater/seasonalconditions/seasonalconditions.py new file mode 100644 index 00000000..2280d15e --- /dev/null +++ b/game/theater/seasonalconditions/seasonalconditions.py @@ -0,0 +1,48 @@ +import datetime +from dataclasses import dataclass +from enum import Enum + + +class Season(Enum): + Winter = "winter" + Spring = "spring" + Summer = "summer" + Fall = "fall" + + +def determine_season(day: datetime.date) -> Season: + # Note: This logic doesn't need to be very precise + # Currently refers strictly to northern-hemisphere seasons + day_of_year = day.timetuple().tm_yday + season_length = 365.0 / 4 + winter_end_day = season_length / 2 + if day_of_year < winter_end_day: + return Season.Winter + elif day_of_year < winter_end_day + season_length: + return Season.Spring + elif day_of_year < winter_end_day + season_length * 2: + return Season.Summer + elif day_of_year < winter_end_day + season_length * 3: + return Season.Fall + else: + return Season.Winter + + +@dataclass(frozen=True) +class WeatherTypeChances: + thunderstorm: float + raining: float + cloudy: float + clear_skies: float + + +@dataclass(frozen=True) +class SeasonalConditions: + # Units are inHg and degrees Celsius + summer_avg_pressure: float + winter_avg_pressure: float + summer_avg_temperature: float + winter_avg_temperature: float + temperature_day_night_difference: float + + weather_type_chances: dict[Season, WeatherTypeChances] diff --git a/game/theater/seasonalconditions/syria.py b/game/theater/seasonalconditions/syria.py new file mode 100644 index 00000000..0a6c7ec1 --- /dev/null +++ b/game/theater/seasonalconditions/syria.py @@ -0,0 +1,36 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=29.98, # TODO: Find real-world data + winter_avg_pressure=29.86, # TODO: Find real-world data + summer_avg_temperature=28.5, + winter_avg_temperature=10.0, + temperature_day_night_difference=8.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=1, + raining=25, + cloudy=25, + clear_skies=50, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=3, + cloudy=20, + clear_skies=77, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + }, +) diff --git a/game/theater/seasonalconditions/thechannel.py b/game/theater/seasonalconditions/thechannel.py new file mode 100644 index 00000000..109c781f --- /dev/null +++ b/game/theater/seasonalconditions/thechannel.py @@ -0,0 +1,36 @@ +from .seasonalconditions import SeasonalConditions, Season, WeatherTypeChances + +CONDITIONS = SeasonalConditions( + summer_avg_pressure=30.02, # TODO: Find real-world data + winter_avg_pressure=29.72, # TODO: Find real-world data + summer_avg_temperature=20.0, + winter_avg_temperature=0.0, + temperature_day_night_difference=5.0, + weather_type_chances={ + # TODO: Find real-world data for all these values + Season.Winter: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=60, + clear_skies=20, + ), + Season.Spring: WeatherTypeChances( + thunderstorm=1, + raining=20, + cloudy=40, + clear_skies=40, + ), + Season.Summer: WeatherTypeChances( + thunderstorm=1, + raining=10, + cloudy=30, + clear_skies=60, + ), + Season.Fall: WeatherTypeChances( + thunderstorm=1, + raining=30, + cloudy=50, + clear_skies=20, + ), + }, +) diff --git a/game/weather.py b/game/weather.py index 2594ed91..fb0ea68c 100644 --- a/game/weather.py +++ b/game/weather.py @@ -14,9 +14,11 @@ from game.savecompat import has_save_compat_for from game.settings import Settings from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg +from game.theater.seasonalconditions import determine_season + if TYPE_CHECKING: from game.theater import ConflictTheater - from game.theater.conflicttheater import SeasonalConditions + from game.theater.seasonalconditions import SeasonalConditions class TimeOfDay(Enum): @@ -119,10 +121,18 @@ class Weather: temperature -= seasonal_conditions.temperature_day_night_difference / 2 pressure += self.pressure_adjustment temperature += self.temperature_adjustment + logging.debug( + "Weather: Before random: temp {} press {}".format(temperature, pressure) + ) conditions = AtmosphericConditions( qnh=self.random_pressure(pressure), temperature_celsius=self.random_temperature(temperature), ) + logging.debug( + "Weather: After random: temp {} press {}".format( + conditions.temperature_celsius, conditions.qnh.pressure_in_inches_hg + ) + ) return conditions @property @@ -334,14 +344,18 @@ class Conditions: day: datetime.date, time_of_day: TimeOfDay, ) -> Weather: - # Future improvement: use seasonal weights for theaters + season = determine_season(day) + logging.debug("Weather: Season {}".format(season)) + weather_chances = seasonal_conditions.weather_type_chances[season] chances = { - Thunderstorm: 1, - Raining: 20, - Cloudy: 60, - ClearSkies: 20, + Thunderstorm: weather_chances.thunderstorm, + Raining: weather_chances.raining, + Cloudy: weather_chances.cloudy, + ClearSkies: weather_chances.clear_skies, } + logging.debug("Weather: Chances {}".format(weather_chances)) weather_type = random.choices( list(chances.keys()), weights=list(chances.values()) )[0] + logging.debug("Weather: Type {}".format(weather_type)) return weather_type(seasonal_conditions, day, time_of_day) From 58c96e132934fcd1f742c1819f450e7605e6d098 Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Sat, 31 Jul 2021 12:05:22 -0400 Subject: [PATCH 134/167] Adds more details to frontline movement logging (#1465) * adds more detailed logging for frontline movement * Fixing attribute name * Fixing if, adding else --- changelog.md | 1 + game/event/event.py | 74 ++++++++++++++++++++++++++++----------------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/changelog.md b/changelog.md index 73dbc29f..95032e81 100644 --- a/changelog.md +++ b/changelog.md @@ -34,6 +34,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[UI]** Google search link added to unit information when there is no information provided. * **[UI]** Control point name displayed with ground object group name on map. * **[UI]** Buy or Replace will now show the correct price for generated ground objects like sams. +* **[UI]** Improved logging for frontline movement to be more descriptive about what happened and why. ## Fixes diff --git a/game/event/event.py b/game/event/event.py index 2176220c..757b9be1 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -297,15 +297,16 @@ class Event: delta = 0.0 player_won = True + status_msg: str = "" ally_casualties = debriefing.casualty_count(cp) enemy_casualties = debriefing.casualty_count(enemy_cp) ally_units_alive = cp.base.total_armor enemy_units_alive = enemy_cp.base.total_armor - print(ally_units_alive) - print(enemy_units_alive) - print(ally_casualties) - print(enemy_casualties) + print(f"Remaining allied units: {ally_units_alive}") + print(f"Remaining enemy units: {enemy_units_alive}") + print(f"Allied casualties {ally_casualties}") + print(f"Enemy casualties {enemy_casualties}") ratio = (1.0 + enemy_casualties) / (1.0 + ally_casualties) @@ -318,24 +319,31 @@ class Event: if ally_units_alive == 0: player_won = False delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"No allied units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces suffer a strong defeat." elif enemy_units_alive == 0: player_won = True delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"No enemy units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces win a strong victory." elif cp.stances[enemy_cp.id] == CombatStance.RETREAT: player_won = False delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"Allied forces are retreating along the {cp.name}-{enemy_cp.name} frontline, suffering a strong defeat." else: if enemy_casualties > ally_casualties: player_won = True if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH: delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"Allied forces break through the {cp.name}-{enemy_cp.name} frontline, winning a strong victory" else: if ratio > 3: delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"Enemy casualties massively outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a strong victory." elif ratio < 1.5: delta = MINOR_DEFEAT_INFLUENCE + status_msg = f"Enemy casualties minorly outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a minor victory." else: delta = DEFEAT_INFLUENCE + status_msg = f"Enemy casualties outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces claim a victory." elif ally_casualties > enemy_casualties: if ( @@ -345,54 +353,66 @@ class Event: # Even with casualties if the enemy is overwhelmed, they are going to lose ground player_won = True delta = MINOR_DEFEAT_INFLUENCE + status_msg = f"Despite suffering losses, allied forces still outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a minor victory." elif ( ally_units_alive > 3 * enemy_units_alive and player_aggresive ): player_won = True delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"Despite suffering losses, allied forces still heavily outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a major victory." else: - # But is the enemy is not outnumbered, we lose + # But if the enemy is not outnumbered, we lose player_won = False if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH: delta = STRONG_DEFEAT_INFLUENCE + status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces have overextended themselves, suffering a major defeat." else: - delta = STRONG_DEFEAT_INFLUENCE + delta = DEFEAT_INFLUENCE + status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces suffer a defeat." # No progress with defensive strategies if player_won and cp.stances[enemy_cp.id] in [ CombatStance.DEFENSIVE, CombatStance.AMBUSH, ]: - print("Defensive stance, progress is limited") + print( + f"Allied forces have adopted a defensive stance along the {cp.name}-{enemy_cp.name} " + f"frontline, making only limited progress." + ) delta = MINOR_DEFEAT_INFLUENCE - if player_won: - print(cp.name + " won ! factor > " + str(delta)) - cp.base.affect_strength(delta) - enemy_cp.base.affect_strength(-delta) + # Handle the case where there are no casualties at all on either side but both sides still have units + if delta == 0.0: + print(status_msg) info = Information( "Frontline Report", - "Our ground forces from " - + cp.name - + " are making progress toward " - + enemy_cp.name, + f"Our ground forces from {cp.name} reached a stalemate with enemy forces from {enemy_cp.name}.", self.game.turn, ) self.game.informations.append(info) else: - print(cp.name + " lost ! factor > " + str(delta)) - enemy_cp.base.affect_strength(delta) - cp.base.affect_strength(-delta) - info = Information( - "Frontline Report", - "Our ground forces from " - + cp.name - + " are losing ground against the enemy forces from " - + enemy_cp.name, - self.game.turn, - ) - self.game.informations.append(info) + if player_won: + print(status_msg) + cp.base.affect_strength(delta) + enemy_cp.base.affect_strength(-delta) + info = Information( + "Frontline Report", + f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}", + self.game.turn, + ) + self.game.informations.append(info) + else: + print(status_msg) + enemy_cp.base.affect_strength(delta) + cp.base.affect_strength(-delta) + info = Information( + "Frontline Report", + f"Our ground forces from {cp.name} are losing ground against the enemy forces from " + f"{enemy_cp.name}. {status_msg}", + self.game.turn, + ) + self.game.informations.append(info) def redeploy_units(self, cp: ControlPoint) -> None: """ " From d2fe11ba6f71eadcb16eee8d2620ae7dafd712da Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Sat, 31 Jul 2021 12:07:57 -0400 Subject: [PATCH 135/167] Updates Gripen support, fixes missing DEAD legacy loadouts. (#1469) --- changelog.md | 1 + gen/flights/loadouts.py | 1 + pydcs_extensions/jas39/jas39.py | 707 ++++++++++-------- resources/customized_payloads/JAS39Gripen.lua | 56 +- .../customized_payloads/JAS39Gripen_AG.lua | 328 ++++---- 5 files changed, 599 insertions(+), 494 deletions(-) diff --git a/changelog.md b/changelog.md index 95032e81..6119152b 100644 --- a/changelog.md +++ b/changelog.md @@ -28,6 +28,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Campaign]** Added support for Mariana Islands map. * **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR * **[Mission Generation]** SAM sites are now headed towards the center of the conflict +* **[Mods]** Support for latest version of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts. * **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). diff --git a/gen/flights/loadouts.py b/gen/flights/loadouts.py index 0e3dd4d6..7d1a2fee 100644 --- a/gen/flights/loadouts.py +++ b/gen/flights/loadouts.py @@ -137,6 +137,7 @@ class Loadout: FlightType.CAS: ("CAS MAVERICK F", "CAS"), FlightType.STRIKE: ("STRIKE",), FlightType.ANTISHIP: ("ANTISHIP",), + FlightType.DEAD: ("DEAD",), FlightType.SEAD: ("SEAD",), FlightType.BAI: ("BAI",), FlightType.OCA_RUNWAY: ("RUNWAY_ATTACK", "RUNWAY_STRIKE"), diff --git a/pydcs_extensions/jas39/jas39.py b/pydcs_extensions/jas39/jas39.py index a9940b2d..8ce7d2c9 100644 --- a/pydcs_extensions/jas39/jas39.py +++ b/pydcs_extensions/jas39/jas39.py @@ -1,3 +1,5 @@ +from typing import Set + from dcs import task from dcs.planes import PlaneType from dcs.weapons_data import Weapons @@ -6,111 +8,177 @@ from pydcs_extensions.weapon_injector import inject_weapons class JAS39GripenWeapons: - JAS_ARAKM70BAP = { - "clsid": "JAS_ARAKM70BAP", - "name": "ARAK M70B AP", - "weight": 372.2, + EWS_39_Integrated_ECM = { + "clsid": "{JAS39_EWS39}", + "name": "EWS 39 Integrated ECM", + "weight": 1, } - JAS_ARAKM70BHE = { - "clsid": "JAS_ARAKM70BHE", - "name": "ARAK M70B HE", - "weight": 372.2, + Integrated_ELINT = { + "clsid": "{JAS39_ELINT}", + "name": "Integrated ELINT", + "weight": 1, } - JAS_BK90 = { - "clsid": "JAS_BK90", - "name": "BK-90 Unguided Cluster Munition", - "weight": 605, + JAS39_AIM120B = { + "clsid": "JAS39_AIM120B", + "name": "AIM-120B AMRAAM Active Rdr AAM", + "weight": 157, } - JAS_BRIMSTONE = { - "clsid": "JAS_BRIMSTONE", + JAS39_AIM120C5 = { + "clsid": "JAS39_AIM120C5", + "name": "AIM-120C-5 AMRAAM Active Rdr AAM", + "weight": 162.5, + } + JAS39_AIM120C7 = { + "clsid": "JAS39_AIM120C7", + "name": "AIM-120C-7 AMRAAM Active Rdr AAM", + "weight": 162.5, + } + JAS39_AIM_9L = { + "clsid": "JAS39_AIM-9L", + "name": "AIM-9L Sidewinder IR AAM", + "weight": 86, + } + JAS39_AIM_9M = { + "clsid": "JAS39_AIM-9M", + "name": "AIM-9M Sidewinder IR AAM", + "weight": 86, + } + JAS39_AIM_9X = { + "clsid": "JAS39_AIM-9X", + "name": "AIM-9X Sidewinder IR AAM", + "weight": 86.5, + } + JAS39_ASRAAM = { + "clsid": "JAS39_ASRAAM", + "name": "AIM-132 ASRAAM IR AAM", + "weight": 89, + } + JAS39_A_DARTER = { + "clsid": "JAS39_A-DARTER", + "name": "A-Darter IR AAM", + "weight": 90, + } + JAS39_BRIMSTONE = { + "clsid": "JAS39_BRIMSTONE", "name": "Brimstone Laser Guided Missile", "weight": 195.5, } - JAS_GBU10_TV = { - "clsid": "JAS_GBU10_TV", - "name": "GBU-10 2000 lb TV-guided Bomb", + JAS39_Derby = { + "clsid": "JAS39_Derby", + "name": "I-Derby ER BVRAAM Active Rdr AAM", + "weight": 119, + } + JAS39_DWS39 = { + "clsid": "JAS39_DWS39", + "name": "DWS39 Unguided Cluster Munition", + "weight": 605, + } + JAS39_GBU10 = { + "clsid": "JAS39_GBU10", + "name": "GBU-10 2000 lb Laser-guided Bomb", "weight": 934, } - JAS_GBU12 = { - "clsid": "JAS_GBU12", + JAS39_GBU12 = { + "clsid": "JAS39_GBU12", "name": "GBU-12 500 lb Laser-guided Bomb", "weight": 275, } - JAS_GBU16_TV = { - "clsid": "JAS_GBU16_TV", - "name": "GBU-16 1000lb TV Guided Bomb", - "weight": 934, + JAS39_GBU16 = { + "clsid": "JAS39_GBU16", + "name": "GBU-16 1000 lb Laser-guided Bomb", + "weight": 454, } - JAS_GBU31 = { - "clsid": "JAS_GBU31", + JAS39_GBU31 = { + "clsid": "JAS39_GBU31", "name": "GBU-31 2000lb TV Guided Glide-Bomb", "weight": 934, } - JAS_GBU49_TV = { - "clsid": "JAS_GBU49_TV", + JAS39_GBU32 = { + "clsid": "JAS39_GBU32", + "name": "GBU-32 1000lb TV Guided Glide-Bomb", + "weight": 454, + } + JAS39_GBU38 = { + "clsid": "JAS39_GBU38", + "name": "GBU-38 500lb TV Guided Glide-Bomb", + "weight": 241, + } + JAS39_GBU49 = { + "clsid": "JAS39_GBU49", "name": "GBU-49 500lb TV Guided Bomb", - "weight": 275, + "weight": 241, } - JAS_IRIS_T = { - "clsid": "JAS_IRIS-T", - "name": "Rb98 IRIS-T Sidewinder IR AAM", - "weight": 88.4, + JAS39_IRIS_T = {"clsid": "JAS39_IRIS-T", "name": "IRIS-T IR AAM", "weight": 88.4} + JAS39_Litening = { + "clsid": "JAS39_Litening", + "name": "Litening III Targeting Pod", + "weight": 208, } - JAS_Litening = { - "clsid": "JAS_Litening", - "name": "Litening III POD (LLTV)", - "weight": 295, + JAS39_M70BAP = { + "clsid": "JAS39_M70BAP", + "name": "M70B AP Unguided rocket", + "weight": 372.2, } - JAS_MAR_1 = { - "clsid": "JAS_MAR-1", + JAS39_M70BHE = { + "clsid": "JAS39_M70BHE", + "name": "M70B HE Unguided rocket", + "weight": 372.2, + } + JAS39_M71LD = { + "clsid": "JAS39_M71LD", + "name": "4x M/71 120kg GP Bomb Low-drag", + "weight": 605, + } + JAS39_MAR_1 = { + "clsid": "JAS39_MAR-1", "name": "MAR-1 High Speed Anti-Radiation Missile", "weight": 350, } - JAS_Meteor = { - "clsid": "JAS_Meteor", - "name": "Rb101 Meteor BVRAAM Active Rdr AAM", + JAS39_Meteor = { + "clsid": "JAS39_Meteor", + "name": "Meteor BVRAAM Active Rdr AAM", "weight": 191, } - JAS_RB15F = { - "clsid": "JAS_RB15F", - "name": "RBS-15 Mk. IV Gungnir Radiation Seeking Anti-ship Missile ", - "weight": None, + JAS39_PYTHON_5 = { + "clsid": "JAS39_PYTHON-5", + "name": "Python-5 IR AAM", + "weight": 106, } - JAS_RB75T = { - "clsid": "JAS_RB75T", - "name": "Rb-75T (AGM-65E Maverick) (Laser ASM Lg Whd)", - "weight": 210, + JAS39_RBS15 = { + "clsid": "JAS39_RBS15", + "name": "RBS-15 Mk4 Gungnir Anti-ship Missile", + "weight": 650, } - JAS_Rb74 = { - "clsid": "JAS_Rb74", - "name": "Rb74 AIM-9L Sidewinder IR AAM", - "weight": 90, + JAS39_RBS15AI = { + "clsid": "JAS39_RBS15AI", + "name": "RBS-15 Mk4 Gungnir Anti-ship Missile (AI)", + "weight": 650, } - JAS_Rb99 = { - "clsid": "JAS_Rb99", - "name": "Rb99 AIM-120B AMRAAM Active Rdr AAM", - "weight": 157, + JAS39_SDB = { + "clsid": "JAS39_SDB", + "name": "GBU-39 SDB 285lb TV Guided Glide-Bomb", + "weight": 661, } - JAS_Rb99_DUAL = { - "clsid": "JAS_Rb99_DUAL", - "name": "Rb99 AIM-120B AMRAAM Active Rdr AAM x 2", - "weight": 313, - } - JAS_Stormshadow = { - "clsid": "JAS_Stormshadow", + JAS39_STORMSHADOW = { + "clsid": "JAS39_STORMSHADOW", "name": "Storm Shadow Long Range Anti-Radiation Cruise-missile", - "weight": None, + "weight": 1300, } - JAS_TANK1100 = { - "clsid": "JAS_TANK1100", - "name": "External drop tank 1100 litre", + JAS39_TANK1100 = { + "clsid": "JAS39_TANK1100", + "name": "Drop tank 1100 litre", "weight": 1019, } - JAS_TANK1700 = { - "clsid": "JAS_TANK1700", - "name": "External drop tank 1700 litre", + JAS39_TANK1700 = { + "clsid": "JAS39_TANK1700", + "name": "Drop tank 1700 litre", "weight": 1533, } + Litening_III_Targeting_Pod_FLIR = { + "clsid": "{JAS39_FLIR}", + "name": "Litening III Targeting Pod FLIR", + "weight": 2, + } inject_weapons(JAS39GripenWeapons) @@ -124,17 +192,22 @@ class JAS39Gripen(PlaneType): length = 14.1 fuel_max = 2550 max_speed = 2649.996 - chaff = 90 - flare = 45 - charge_total = 180 + chaff = 80 + flare = 40 + charge_total = 120 chaff_charge_size = 1 - flare_charge_size = 2 + flare_charge_size = 1 category = "Interceptor" # {78EFB7A2-FD52-4b57-A6A6-3BF0E1D6555F} radio_frequency = 127.5 class Pylon1: - JAS_IRIS_T = (1, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (1, JAS39GripenWeapons.JAS_Rb74) + JAS39_IRIS_T = (1, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (1, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (1, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (1, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (1, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (1, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (1, JAS39GripenWeapons.JAS39_ASRAAM) AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (1, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) Smokewinder___red = (1, Weapons.Smokewinder___red) Smokewinder___green = (1, Weapons.Smokewinder___green) @@ -144,92 +217,100 @@ class JAS39Gripen(PlaneType): Smokewinder___orange = (1, Weapons.Smokewinder___orange) class Pylon2: - JAS_IRIS_T = (2, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (2, JAS39GripenWeapons.JAS_Rb74) - JAS_Meteor = (2, JAS39GripenWeapons.JAS_Meteor) - JAS_Rb99 = (2, JAS39GripenWeapons.JAS_Rb99) - JAS_Rb99_DUAL = (2, JAS39GripenWeapons.JAS_Rb99_DUAL) - LAU_115_2_LAU_127_AIM_120C = (2, Weapons.LAU_115_2_LAU_127_AIM_120C) - AIM_120C_5_AMRAAM___Active_Rdr_AAM = ( - 2, - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, - ) - - # ERRR + JAS39_IRIS_T = (2, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (2, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (2, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (2, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (2, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (2, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (2, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_Meteor = (2, JAS39GripenWeapons.JAS39_Meteor) + JAS39_AIM120B = (2, JAS39GripenWeapons.JAS39_AIM120B) + JAS39_AIM120C5 = (2, JAS39GripenWeapons.JAS39_AIM120C5) + JAS39_AIM120C7 = (2, JAS39GripenWeapons.JAS39_AIM120C7) + JAS39_Derby = (2, JAS39GripenWeapons.JAS39_Derby) class Pylon3: - JAS_Meteor = (3, JAS39GripenWeapons.JAS_Meteor) - JAS_Rb99 = (3, JAS39GripenWeapons.JAS_Rb99) - AIM_120C_5_AMRAAM___Active_Rdr_AAM = ( - 3, - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, - ) - JAS_TANK1100 = (3, JAS39GripenWeapons.JAS_TANK1100) - JAS_TANK1700 = (3, JAS39GripenWeapons.JAS_TANK1700) - - # ERRR + JAS39_AIM_9L = (3, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_IRIS_T = (3, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_A_DARTER = (3, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (3, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (3, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (3, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (3, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_Meteor = (3, JAS39GripenWeapons.JAS39_Meteor) + JAS39_AIM120B = (3, JAS39GripenWeapons.JAS39_AIM120B) + JAS39_AIM120C5 = (3, JAS39GripenWeapons.JAS39_AIM120C5) + JAS39_AIM120C7 = (3, JAS39GripenWeapons.JAS39_AIM120C7) + JAS39_Derby = (3, JAS39GripenWeapons.JAS39_Derby) + JAS39_TANK1100 = (3, JAS39GripenWeapons.JAS39_TANK1100) + JAS39_TANK1700 = (3, JAS39GripenWeapons.JAS39_TANK1700) class Pylon4: - L_081_Fantasmagoria_ELINT_pod = (4, Weapons.L_081_Fantasmagoria_ELINT_pod) + JAS39_TANK1100 = (4, JAS39GripenWeapons.JAS39_TANK1100) class Pylon5: - JAS_TANK1100 = (5, JAS39GripenWeapons.JAS_TANK1100) - JAS_Meteor = (5, JAS39GripenWeapons.JAS_Meteor) - AIM_120C_5_AMRAAM___Active_Rdr_AAM = ( - 5, - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, - ) - JAS_Rb99 = (5, JAS39GripenWeapons.JAS_Rb99) - JAS_Rb99_DUAL = (5, JAS39GripenWeapons.JAS_Rb99_DUAL) - - # ERRR + JAS39_Litening = (5, JAS39GripenWeapons.JAS39_Litening) class Pylon6: - L005_Sorbtsiya_ECM_pod__left_ = (6, Weapons.L005_Sorbtsiya_ECM_pod__left_) + JAS39_AIM_9L = (6, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_IRIS_T = (6, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_A_DARTER = (6, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (6, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (6, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (6, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (6, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_Meteor = (6, JAS39GripenWeapons.JAS39_Meteor) + JAS39_AIM120B = (6, JAS39GripenWeapons.JAS39_AIM120B) + JAS39_AIM120C5 = (6, JAS39GripenWeapons.JAS39_AIM120C5) + JAS39_AIM120C7 = (6, JAS39GripenWeapons.JAS39_AIM120C7) + JAS39_Derby = (6, JAS39GripenWeapons.JAS39_Derby) + JAS39_TANK1100 = (6, JAS39GripenWeapons.JAS39_TANK1100) + JAS39_TANK1700 = (6, JAS39GripenWeapons.JAS39_TANK1700) class Pylon7: - JAS_Litening = (7, JAS39GripenWeapons.JAS_Litening) - - # ERRR + JAS39_IRIS_T = (7, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (7, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (7, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (7, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (7, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (7, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (7, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_Meteor = (7, JAS39GripenWeapons.JAS39_Meteor) + JAS39_AIM120B = (7, JAS39GripenWeapons.JAS39_AIM120B) + JAS39_AIM120C5 = (7, JAS39GripenWeapons.JAS39_AIM120C5) + JAS39_AIM120C7 = (7, JAS39GripenWeapons.JAS39_AIM120C7) + JAS39_Derby = (7, JAS39GripenWeapons.JAS39_Derby) class Pylon8: - JAS_Meteor = (8, JAS39GripenWeapons.JAS_Meteor) - JAS_Rb99 = (8, JAS39GripenWeapons.JAS_Rb99) - AIM_120C_5_AMRAAM___Active_Rdr_AAM = ( - 8, - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, - ) - JAS_TANK1100 = (8, JAS39GripenWeapons.JAS_TANK1100) - JAS_TANK1700 = (8, JAS39GripenWeapons.JAS_TANK1700) - - # ERRR + JAS39_IRIS_T = (8, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (8, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (8, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (8, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (8, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (8, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (8, JAS39GripenWeapons.JAS39_ASRAAM) + AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (8, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) + Smokewinder___red = (8, Weapons.Smokewinder___red) + Smokewinder___green = (8, Weapons.Smokewinder___green) + Smokewinder___blue = (8, Weapons.Smokewinder___blue) + Smokewinder___white = (8, Weapons.Smokewinder___white) + Smokewinder___yellow = (8, Weapons.Smokewinder___yellow) + Smokewinder___orange = (8, Weapons.Smokewinder___orange) class Pylon9: - JAS_IRIS_T = (9, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (9, JAS39GripenWeapons.JAS_Rb74) - JAS_Meteor = (9, JAS39GripenWeapons.JAS_Meteor) - JAS_Rb99 = (9, JAS39GripenWeapons.JAS_Rb99) - JAS_Rb99_DUAL = (9, JAS39GripenWeapons.JAS_Rb99_DUAL) - LAU_115_2_LAU_127_AIM_120C = (9, Weapons.LAU_115_2_LAU_127_AIM_120C) - AIM_120C_5_AMRAAM___Active_Rdr_AAM = ( + Litening_III_Targeting_Pod_FLIR = ( 9, - Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, + JAS39GripenWeapons.Litening_III_Targeting_Pod_FLIR, ) - # ERRR - class Pylon10: - JAS_IRIS_T = (10, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (10, JAS39GripenWeapons.JAS_Rb74) - AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (10, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) - Smokewinder___red = (10, Weapons.Smokewinder___red) - Smokewinder___green = (10, Weapons.Smokewinder___green) - Smokewinder___blue = (10, Weapons.Smokewinder___blue) - Smokewinder___white = (10, Weapons.Smokewinder___white) - Smokewinder___yellow = (10, Weapons.Smokewinder___yellow) - Smokewinder___orange = (10, Weapons.Smokewinder___orange) + Integrated_ELINT = (10, JAS39GripenWeapons.Integrated_ELINT) - pylons = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + class Pylon11: + EWS_39_Integrated_ECM = (11, JAS39GripenWeapons.EWS_39_Integrated_ECM) + + pylons: Set[int] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} tasks = [ task.Intercept, @@ -249,17 +330,22 @@ class JAS39Gripen_AG(PlaneType): length = 14.1 fuel_max = 2550 max_speed = 2649.996 - chaff = 90 - flare = 45 - charge_total = 180 + chaff = 80 + flare = 40 + charge_total = 120 chaff_charge_size = 1 flare_charge_size = 1 category = "Interceptor" # {78EFB7A2-FD52-4b57-A6A6-3BF0E1D6555F} radio_frequency = 127.5 class Pylon1: - JAS_IRIS_T = (1, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (1, JAS39GripenWeapons.JAS_Rb74) + JAS39_IRIS_T = (1, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (1, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (1, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (1, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (1, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (1, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (1, JAS39GripenWeapons.JAS39_ASRAAM) AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (1, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) Smokewinder___red = (1, Weapons.Smokewinder___red) Smokewinder___green = (1, Weapons.Smokewinder___green) @@ -269,56 +355,65 @@ class JAS39Gripen_AG(PlaneType): Smokewinder___orange = (1, Weapons.Smokewinder___orange) class Pylon2: - JAS_IRIS_T = (2, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (2, JAS39GripenWeapons.JAS_Rb74) - JAS_RB75T = (2, JAS39GripenWeapons.JAS_RB75T) - AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( - 2, - Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_, - ) - JAS_BK90 = (2, JAS39GripenWeapons.JAS_BK90) - JAS_RB15F = (2, JAS39GripenWeapons.JAS_RB15F) - JAS_MAR_1 = (2, JAS39GripenWeapons.JAS_MAR_1) - JAS_GBU12 = (2, JAS39GripenWeapons.JAS_GBU12) - JAS_GBU49_TV = (2, JAS39GripenWeapons.JAS_GBU49_TV) - # ERRR JAS_GBU16 - JAS_GBU16_TV = (2, JAS39GripenWeapons.JAS_GBU16_TV) - # ERRR GBU12_TEST + JAS39_IRIS_T = (2, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (2, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (2, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (2, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (2, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (2, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (2, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_RBS15 = (2, JAS39GripenWeapons.JAS39_RBS15) + JAS39_RBS15AI = (2, JAS39GripenWeapons.JAS39_RBS15AI) + JAS39_MAR_1 = (2, JAS39GripenWeapons.JAS39_MAR_1) + JAS39_GBU49 = (2, JAS39GripenWeapons.JAS39_GBU49) + JAS39_GBU32 = (2, JAS39GripenWeapons.JAS39_GBU32) + JAS39_GBU38 = (2, JAS39GripenWeapons.JAS39_GBU38) + JAS39_SDB = (2, JAS39GripenWeapons.JAS39_SDB) + JAS39_GBU12 = (2, JAS39GripenWeapons.JAS39_GBU12) + JAS39_GBU16 = (2, JAS39GripenWeapons.JAS39_GBU16) + JAS39_DWS39 = (2, JAS39GripenWeapons.JAS39_DWS39) Mk_82___500lb_GP_Bomb_LD = (2, Weapons.Mk_82___500lb_GP_Bomb_LD) Mk_83___1000lb_GP_Bomb_LD = (2, Weapons.Mk_83___1000lb_GP_Bomb_LD) BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( 2, Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, ) - _4x_SB_M_71_120kg_GP_Bomb_Low_drag = ( + JAS39_M71LD = (2, JAS39GripenWeapons.JAS39_M71LD) + JAS39_M70BHE = (2, JAS39GripenWeapons.JAS39_M70BHE) + JAS39_M70BAP = (2, JAS39GripenWeapons.JAS39_M70BAP) + JAS39_BRIMSTONE = (2, JAS39GripenWeapons.JAS39_BRIMSTONE) + LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( 2, - Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, + Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_, ) - JAS_ARAKM70BHE = (2, JAS39GripenWeapons.JAS_ARAKM70BHE) - JAS_ARAKM70BAP = (2, JAS39GripenWeapons.JAS_ARAKM70BAP) - JAS_BRIMSTONE = (2, JAS39GripenWeapons.JAS_BRIMSTONE) - - # ERRR + LAU_117_AGM_65H = (2, Weapons.LAU_117_AGM_65H) class Pylon3: - JAS_RB75T = (3, JAS39GripenWeapons.JAS_RB75T) - AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( + JAS39_AIM_9L = (3, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_IRIS_T = (3, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_A_DARTER = (3, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (3, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (3, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (3, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (3, JAS39GripenWeapons.JAS39_ASRAAM) + LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( 3, - Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_, - ) - JAS_Stormshadow = (3, JAS39GripenWeapons.JAS_Stormshadow) - JAS_BK90 = (3, JAS39GripenWeapons.JAS_BK90) - JAS_GBU31 = (3, JAS39GripenWeapons.JAS_GBU31) - JAS_RB15F = (3, JAS39GripenWeapons.JAS_RB15F) - JAS_MAR_1 = (3, JAS39GripenWeapons.JAS_MAR_1) - JAS_GBU12 = (3, JAS39GripenWeapons.JAS_GBU12) - JAS_GBU49_TV = (3, JAS39GripenWeapons.JAS_GBU49_TV) - # ERRR JAS_GBU16 - JAS_GBU16_TV = (3, JAS39GripenWeapons.JAS_GBU16_TV) - GBU_10___2000lb_Laser_Guided_Bomb = ( - 3, - Weapons.GBU_10___2000lb_Laser_Guided_Bomb, + Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_, ) + LAU_117_AGM_65H = (3, Weapons.LAU_117_AGM_65H) + JAS39_BRIMSTONE = (3, JAS39GripenWeapons.JAS39_BRIMSTONE) + JAS39_RBS15 = (3, JAS39GripenWeapons.JAS39_RBS15) + JAS39_RBS15AI = (3, JAS39GripenWeapons.JAS39_RBS15AI) + JAS39_MAR_1 = (3, JAS39GripenWeapons.JAS39_MAR_1) + JAS39_GBU49 = (3, JAS39GripenWeapons.JAS39_GBU49) + JAS39_GBU31 = (3, JAS39GripenWeapons.JAS39_GBU31) + JAS39_GBU32 = (3, JAS39GripenWeapons.JAS39_GBU32) + JAS39_GBU38 = (3, JAS39GripenWeapons.JAS39_GBU38) + JAS39_SDB = (3, JAS39GripenWeapons.JAS39_SDB) + JAS39_GBU12 = (3, JAS39GripenWeapons.JAS39_GBU12) + JAS39_GBU10 = (3, JAS39GripenWeapons.JAS39_GBU10) + JAS39_GBU16 = (3, JAS39GripenWeapons.JAS39_GBU16) + JAS39_DWS39 = (3, JAS39GripenWeapons.JAS39_DWS39) Mk_82___500lb_GP_Bomb_LD = (3, Weapons.Mk_82___500lb_GP_Bomb_LD) Mk_83___1000lb_GP_Bomb_LD = (3, Weapons.Mk_83___1000lb_GP_Bomb_LD) Mk_84___2000lb_GP_Bomb_LD = (3, Weapons.Mk_84___2000lb_GP_Bomb_LD) @@ -326,144 +421,140 @@ class JAS39Gripen_AG(PlaneType): 3, Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, ) - _4x_SB_M_71_120kg_GP_Bomb_Low_drag = ( - 3, - Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, - ) - JAS_TANK1100 = (3, JAS39GripenWeapons.JAS_TANK1100) - JAS_TANK1700 = (3, JAS39GripenWeapons.JAS_TANK1700) - JAS_ARAKM70BHE = (3, JAS39GripenWeapons.JAS_ARAKM70BHE) - JAS_ARAKM70BAP = (3, JAS39GripenWeapons.JAS_ARAKM70BAP) - JAS_BRIMSTONE = (3, JAS39GripenWeapons.JAS_BRIMSTONE) - - # ERRR + JAS39_M71LD = (3, JAS39GripenWeapons.JAS39_M71LD) + JAS39_TANK1100 = (3, JAS39GripenWeapons.JAS39_TANK1100) + JAS39_TANK1700 = (3, JAS39GripenWeapons.JAS39_TANK1700) + JAS39_M70BHE = (3, JAS39GripenWeapons.JAS39_M70BHE) + JAS39_M70BAP = (3, JAS39GripenWeapons.JAS39_M70BAP) + JAS39_STORMSHADOW = (3, JAS39GripenWeapons.JAS39_STORMSHADOW) class Pylon4: - L_081_Fantasmagoria_ELINT_pod = (4, Weapons.L_081_Fantasmagoria_ELINT_pod) + JAS39_BRIMSTONE = (4, JAS39GripenWeapons.JAS39_BRIMSTONE) + JAS39_STORMSHADOW = (4, JAS39GripenWeapons.JAS39_STORMSHADOW) + JAS39_GBU49 = (4, JAS39GripenWeapons.JAS39_GBU49) + JAS39_GBU31 = (4, JAS39GripenWeapons.JAS39_GBU31) + JAS39_GBU32 = (4, JAS39GripenWeapons.JAS39_GBU32) + JAS39_GBU38 = (4, JAS39GripenWeapons.JAS39_GBU38) + JAS39_SDB = (4, JAS39GripenWeapons.JAS39_SDB) + JAS39_GBU10 = (4, JAS39GripenWeapons.JAS39_GBU10) + JAS39_GBU12 = (4, JAS39GripenWeapons.JAS39_GBU12) + JAS39_GBU16 = (4, JAS39GripenWeapons.JAS39_GBU16) + Mk_82___500lb_GP_Bomb_LD = (4, Weapons.Mk_82___500lb_GP_Bomb_LD) + Mk_83___1000lb_GP_Bomb_LD = (4, Weapons.Mk_83___1000lb_GP_Bomb_LD) + Mk_84___2000lb_GP_Bomb_LD = (4, Weapons.Mk_84___2000lb_GP_Bomb_LD) + BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( + 4, + Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, + ) + JAS39_M71LD = (4, JAS39GripenWeapons.JAS39_M71LD) + JAS39_TANK1100 = (4, JAS39GripenWeapons.JAS39_TANK1100) class Pylon5: - JAS_Stormshadow = (5, JAS39GripenWeapons.JAS_Stormshadow) - JAS_GBU12 = (5, JAS39GripenWeapons.JAS_GBU12) - JAS_GBU49_TV = (5, JAS39GripenWeapons.JAS_GBU49_TV) - # ERRR JAS_GBU16 - JAS_GBU16_TV = (5, JAS39GripenWeapons.JAS_GBU16_TV) - GBU_10___2000lb_Laser_Guided_Bomb = ( - 5, - Weapons.GBU_10___2000lb_Laser_Guided_Bomb, - ) - Mk_82___500lb_GP_Bomb_LD = (5, Weapons.Mk_82___500lb_GP_Bomb_LD) - Mk_83___1000lb_GP_Bomb_LD = (5, Weapons.Mk_83___1000lb_GP_Bomb_LD) - Mk_84___2000lb_GP_Bomb_LD = (5, Weapons.Mk_84___2000lb_GP_Bomb_LD) - BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( - 5, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, - ) - _4x_SB_M_71_120kg_GP_Bomb_Low_drag = ( - 5, - Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, - ) - JAS_TANK1100 = (5, JAS39GripenWeapons.JAS_TANK1100) - # ERRR JAS_WMD7 - JAS_BRIMSTONE = (5, JAS39GripenWeapons.JAS_BRIMSTONE) - - # ERRR {INV-SMOKE-RED} - # ERRR {INV-SMOKE-GREEN} - # ERRR {INV-SMOKE-BLUE} - # ERRR {INV-SMOKE-WHITE} - # ERRR {INV-SMOKE-YELLOW} - # ERRR {INV-SMOKE-ORANGE} - # ERRR + JAS39_Litening = (5, JAS39GripenWeapons.JAS39_Litening) class Pylon6: - L005_Sorbtsiya_ECM_pod__left_ = (6, Weapons.L005_Sorbtsiya_ECM_pod__left_) + JAS39_AIM_9L = (6, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_IRIS_T = (6, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_A_DARTER = (6, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (6, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (6, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (6, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (6, JAS39GripenWeapons.JAS39_ASRAAM) + LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( + 6, + Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_, + ) + LAU_117_AGM_65H = (6, Weapons.LAU_117_AGM_65H) + JAS39_BRIMSTONE = (6, JAS39GripenWeapons.JAS39_BRIMSTONE) + JAS39_RBS15 = (6, JAS39GripenWeapons.JAS39_RBS15) + JAS39_RBS15AI = (6, JAS39GripenWeapons.JAS39_RBS15AI) + JAS39_MAR_1 = (6, JAS39GripenWeapons.JAS39_MAR_1) + JAS39_GBU49 = (6, JAS39GripenWeapons.JAS39_GBU49) + JAS39_GBU31 = (6, JAS39GripenWeapons.JAS39_GBU31) + JAS39_GBU32 = (6, JAS39GripenWeapons.JAS39_GBU32) + JAS39_GBU38 = (6, JAS39GripenWeapons.JAS39_GBU38) + JAS39_SDB = (6, JAS39GripenWeapons.JAS39_SDB) + JAS39_GBU12 = (6, JAS39GripenWeapons.JAS39_GBU12) + JAS39_GBU10 = (6, JAS39GripenWeapons.JAS39_GBU10) + JAS39_GBU16 = (6, JAS39GripenWeapons.JAS39_GBU16) + JAS39_DWS39 = (6, JAS39GripenWeapons.JAS39_DWS39) + Mk_82___500lb_GP_Bomb_LD = (6, Weapons.Mk_82___500lb_GP_Bomb_LD) + Mk_83___1000lb_GP_Bomb_LD = (6, Weapons.Mk_83___1000lb_GP_Bomb_LD) + Mk_84___2000lb_GP_Bomb_LD = (6, Weapons.Mk_84___2000lb_GP_Bomb_LD) + BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( + 6, + Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, + ) + JAS39_M71LD = (6, JAS39GripenWeapons.JAS39_M71LD) + JAS39_TANK1100 = (6, JAS39GripenWeapons.JAS39_TANK1100) + JAS39_TANK1700 = (6, JAS39GripenWeapons.JAS39_TANK1700) + JAS39_M70BHE = (6, JAS39GripenWeapons.JAS39_M70BHE) + JAS39_M70BAP = (6, JAS39GripenWeapons.JAS39_M70BAP) + JAS39_STORMSHADOW = (6, JAS39GripenWeapons.JAS39_STORMSHADOW) class Pylon7: - JAS_Litening = (7, JAS39GripenWeapons.JAS_Litening) - - # ERRR + JAS39_IRIS_T = (7, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (7, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (7, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (7, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (7, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (7, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (7, JAS39GripenWeapons.JAS39_ASRAAM) + JAS39_RBS15 = (7, JAS39GripenWeapons.JAS39_RBS15) + JAS39_RBS15AI = (7, JAS39GripenWeapons.JAS39_RBS15AI) + JAS39_MAR_1 = (7, JAS39GripenWeapons.JAS39_MAR_1) + JAS39_GBU49 = (7, JAS39GripenWeapons.JAS39_GBU49) + JAS39_GBU32 = (7, JAS39GripenWeapons.JAS39_GBU32) + JAS39_GBU38 = (7, JAS39GripenWeapons.JAS39_GBU38) + JAS39_SDB = (7, JAS39GripenWeapons.JAS39_SDB) + JAS39_GBU12 = (7, JAS39GripenWeapons.JAS39_GBU12) + JAS39_GBU16 = (7, JAS39GripenWeapons.JAS39_GBU16) + JAS39_DWS39 = (7, JAS39GripenWeapons.JAS39_DWS39) + Mk_82___500lb_GP_Bomb_LD = (7, Weapons.Mk_82___500lb_GP_Bomb_LD) + Mk_83___1000lb_GP_Bomb_LD = (7, Weapons.Mk_83___1000lb_GP_Bomb_LD) + BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( + 7, + Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, + ) + JAS39_M71LD = (7, JAS39GripenWeapons.JAS39_M71LD) + JAS39_M70BHE = (7, JAS39GripenWeapons.JAS39_M70BHE) + JAS39_M70BAP = (7, JAS39GripenWeapons.JAS39_M70BAP) + JAS39_BRIMSTONE = (7, JAS39GripenWeapons.JAS39_BRIMSTONE) + LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( + 7, + Weapons.LAU_117_with_AGM_65K___Maverick_K__CCD_Imp_ASM_, + ) + LAU_117_AGM_65H = (7, Weapons.LAU_117_AGM_65H) class Pylon8: - JAS_RB75T = (8, JAS39GripenWeapons.JAS_RB75T) - AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( - 8, - Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_, - ) - JAS_Stormshadow = (8, JAS39GripenWeapons.JAS_Stormshadow) - JAS_BK90 = (8, JAS39GripenWeapons.JAS_BK90) - JAS_GBU31 = (8, JAS39GripenWeapons.JAS_GBU31) - JAS_RB15F = (8, JAS39GripenWeapons.JAS_RB15F) - JAS_MAR_1 = (8, JAS39GripenWeapons.JAS_MAR_1) - JAS_GBU12 = (8, JAS39GripenWeapons.JAS_GBU12) - JAS_GBU49_TV = (8, JAS39GripenWeapons.JAS_GBU49_TV) - # ERRR JAS_GBU16 - JAS_GBU16_TV = (8, JAS39GripenWeapons.JAS_GBU16_TV) - GBU_10___2000lb_Laser_Guided_Bomb = ( - 8, - Weapons.GBU_10___2000lb_Laser_Guided_Bomb, - ) - Mk_82___500lb_GP_Bomb_LD = (8, Weapons.Mk_82___500lb_GP_Bomb_LD) - Mk_83___1000lb_GP_Bomb_LD = (8, Weapons.Mk_83___1000lb_GP_Bomb_LD) - Mk_84___2000lb_GP_Bomb_LD = (8, Weapons.Mk_84___2000lb_GP_Bomb_LD) - BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( - 8, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, - ) - _4x_SB_M_71_120kg_GP_Bomb_Low_drag = ( - 8, - Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, - ) - JAS_TANK1100 = (8, JAS39GripenWeapons.JAS_TANK1100) - JAS_TANK1700 = (8, JAS39GripenWeapons.JAS_TANK1700) - JAS_ARAKM70BHE = (8, JAS39GripenWeapons.JAS_ARAKM70BHE) - JAS_ARAKM70BAP = (8, JAS39GripenWeapons.JAS_ARAKM70BAP) - JAS_BRIMSTONE = (8, JAS39GripenWeapons.JAS_BRIMSTONE) - - # ERRR + JAS39_IRIS_T = (8, JAS39GripenWeapons.JAS39_IRIS_T) + JAS39_AIM_9L = (8, JAS39GripenWeapons.JAS39_AIM_9L) + JAS39_A_DARTER = (8, JAS39GripenWeapons.JAS39_A_DARTER) + JAS39_AIM_9M = (8, JAS39GripenWeapons.JAS39_AIM_9M) + JAS39_AIM_9X = (8, JAS39GripenWeapons.JAS39_AIM_9X) + JAS39_PYTHON_5 = (8, JAS39GripenWeapons.JAS39_PYTHON_5) + JAS39_ASRAAM = (8, JAS39GripenWeapons.JAS39_ASRAAM) + AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (8, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) + Smokewinder___red = (8, Weapons.Smokewinder___red) + Smokewinder___green = (8, Weapons.Smokewinder___green) + Smokewinder___blue = (8, Weapons.Smokewinder___blue) + Smokewinder___white = (8, Weapons.Smokewinder___white) + Smokewinder___yellow = (8, Weapons.Smokewinder___yellow) + Smokewinder___orange = (8, Weapons.Smokewinder___orange) class Pylon9: - JAS_IRIS_T = (9, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (9, JAS39GripenWeapons.JAS_Rb74) - JAS_RB75T = (9, JAS39GripenWeapons.JAS_RB75T) - AGM_65K___Maverick_K__CCD_Imp_ASM_ = ( + Litening_III_Targeting_Pod_FLIR = ( 9, - Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_, + JAS39GripenWeapons.Litening_III_Targeting_Pod_FLIR, ) - JAS_BK90 = (9, JAS39GripenWeapons.JAS_BK90) - JAS_RB15F = (9, JAS39GripenWeapons.JAS_RB15F) - JAS_MAR_1 = (9, JAS39GripenWeapons.JAS_MAR_1) - JAS_GBU12 = (9, JAS39GripenWeapons.JAS_GBU12) - JAS_GBU49_TV = (9, JAS39GripenWeapons.JAS_GBU49_TV) - # ERRR JAS_GBU16 - JAS_GBU16_TV = (9, JAS39GripenWeapons.JAS_GBU16_TV) - # ERRR GBU12_TEST - Mk_82___500lb_GP_Bomb_LD = (9, Weapons.Mk_82___500lb_GP_Bomb_LD) - Mk_83___1000lb_GP_Bomb_LD = (9, Weapons.Mk_83___1000lb_GP_Bomb_LD) - BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( - 9, - Weapons.BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_, - ) - _4x_SB_M_71_120kg_GP_Bomb_Low_drag = ( - 9, - Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, - ) - JAS_ARAKM70BHE = (9, JAS39GripenWeapons.JAS_ARAKM70BHE) - JAS_ARAKM70BAP = (9, JAS39GripenWeapons.JAS_ARAKM70BAP) - JAS_BRIMSTONE = (9, JAS39GripenWeapons.JAS_BRIMSTONE) - - # ERRR class Pylon10: - JAS_IRIS_T = (10, JAS39GripenWeapons.JAS_IRIS_T) - JAS_Rb74 = (10, JAS39GripenWeapons.JAS_Rb74) - AN_ASQ_T50_TCTS_Pod___ACMI_Pod = (10, Weapons.AN_ASQ_T50_TCTS_Pod___ACMI_Pod) - Smokewinder___red = (10, Weapons.Smokewinder___red) - Smokewinder___green = (10, Weapons.Smokewinder___green) - Smokewinder___blue = (10, Weapons.Smokewinder___blue) - Smokewinder___white = (10, Weapons.Smokewinder___white) - Smokewinder___yellow = (10, Weapons.Smokewinder___yellow) - Smokewinder___orange = (10, Weapons.Smokewinder___orange) + Integrated_ELINT = (10, JAS39GripenWeapons.Integrated_ELINT) - pylons = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + class Pylon11: + EWS_39_Integrated_ECM = (11, JAS39GripenWeapons.EWS_39_Integrated_ECM) + + pylons: Set[int] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} tasks = [ task.SEAD, diff --git a/resources/customized_payloads/JAS39Gripen.lua b/resources/customized_payloads/JAS39Gripen.lua index 8f963be7..4b4f6dfd 100644 --- a/resources/customized_payloads/JAS39Gripen.lua +++ b/resources/customized_payloads/JAS39Gripen.lua @@ -6,39 +6,39 @@ local unitPayloads = { ["name"] = "CAP", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 1, - }, - [3] = { - ["CLSID"] = "JAS_Meteor", - ["num"] = 2, - }, - [4] = { - ["CLSID"] = "JAS_Meteor", - ["num"] = 9, - }, - [5] = { - ["CLSID"] = "JAS_Meteor", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, + [2] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [3] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [4] = { + ["CLSID"] = "JAS39_IRIS-T", + ["num"] = 1, + }, + [5] = { + ["CLSID"] = "JAS39_Meteor", + ["num"] = 7, + }, [6] = { - ["CLSID"] = "JAS_Meteor", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [8] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", + ["CLSID"] = "JAS39_Meteor", ["num"] = 6, }, - [9] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", + [7] = { + ["CLSID"] = "JAS39_Meteor", + ["num"] = 2, + }, + [8] = { + ["CLSID"] = "JAS39_Meteor", + ["num"] = 3, + }, + [9] = { + ["CLSID"] = "JAS39_TANK1100", ["num"] = 4, }, }, diff --git a/resources/customized_payloads/JAS39Gripen_AG.lua b/resources/customized_payloads/JAS39Gripen_AG.lua index db687a8e..43b23b1a 100644 --- a/resources/customized_payloads/JAS39Gripen_AG.lua +++ b/resources/customized_payloads/JAS39Gripen_AG.lua @@ -6,40 +6,40 @@ local unitPayloads = { ["name"] = "ANTISHIP", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "JAS_RB15F", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_RB15F", + [2] = { + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, + [3] = { + ["CLSID"] = "JAS39_TANK1100", + ["num"] = 4, + }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [5] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [6] = { + ["CLSID"] = "JAS39_RBS15AI", + ["num"] = 6, + }, + [7] = { + ["CLSID"] = "JAS39_RBS15AI", + ["num"] = 7, + }, [8] = { - ["CLSID"] = "JAS_RB15F", + ["CLSID"] = "JAS39_RBS15AI", ["num"] = 2, }, [9] = { - ["CLSID"] = "JAS_RB15F", - ["num"] = 9, + ["CLSID"] = "JAS39_RBS15AI", + ["num"] = 3, }, }, ["tasks"] = { @@ -51,40 +51,40 @@ local unitPayloads = { ["name"] = "SEAD", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "JAS_MAR-1", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_MAR-1", + [2] = { + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, - [8] = { - ["CLSID"] = "JAS_MAR-1", + [3] = { + ["CLSID"] = "JAS39_TANK1100", + ["num"] = 4, + }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [5] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [6] = { + ["CLSID"] = "JAS39_MAR-1", ["num"] = 2, }, + [7] = { + ["CLSID"] = "JAS39_MAR-1", + ["num"] = 3, + }, + [8] = { + ["CLSID"] = "JAS39_MAR-1", + ["num"] = 6, + }, [9] = { - ["CLSID"] = "JAS_MAR-1", - ["num"] = 9, + ["CLSID"] = "JAS39_MAR-1", + ["num"] = 7, }, }, ["tasks"] = { @@ -96,40 +96,40 @@ local unitPayloads = { ["name"] = "DEAD", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, + [2] = { + ["CLSID"] = "JAS39_IRIS-T", + ["num"] = 8, }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", + [3] = { + ["CLSID"] = "JAS39_TANK1100", ["num"] = 4, }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, }, [6] = { - ["CLSID"] = "JAS_Stormshadow", + ["CLSID"] = "JAS39_STORMSHADOW", ["num"] = 3, }, [7] = { - ["CLSID"] = "JAS_Stormshadow", - ["num"] = 8, + ["CLSID"] = "JAS39_STORMSHADOW", + ["num"] = 6, }, [8] = { - ["CLSID"] = "JAS_MAR-1", + ["CLSID"] = "JAS39_MAR-1", ["num"] = 2, }, [9] = { - ["CLSID"] = "JAS_MAR-1", - ["num"] = 9, + ["CLSID"] = "JAS39_MAR-1", + ["num"] = 7, }, }, ["tasks"] = { @@ -141,44 +141,48 @@ local unitPayloads = { ["name"] = "CAS", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "JAS_BRIMSTONE", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_BRIMSTONE", + [2] = { + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, - [8] = { - ["CLSID"] = "JAS_BRIMSTONE", + [3] = { + ["CLSID"] = "JAS39_TANK1100", + ["num"] = 4, + }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [5] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [6] = { + ["CLSID"] = "JAS39_BRIMSTONE", ["num"] = 2, }, + [7] = { + ["CLSID"] = "JAS39_BRIMSTONE", + ["num"] = 3, + }, + [8] = { + ["CLSID"] = "JAS39_BRIMSTONE", + ["num"] = 6, + }, [9] = { - ["CLSID"] = "JAS_BRIMSTONE", - ["num"] = 9, + ["CLSID"] = "JAS39_BRIMSTONE", + ["num"] = 7, }, [10] = { - ["CLSID"] = "JAS_Litening", - ["num"] = 7, + ["CLSID"] = "JAS39_Litening", + ["num"] = 5, + }, + [11] = { + ["CLSID"] = "{JAS39_FLIR}", + ["num"] = 9 }, }, ["tasks"] = { @@ -190,44 +194,48 @@ local unitPayloads = { ["name"] = "STRIKE", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "JAS_GBU31", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_GBU31", + [2] = { + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, - [8] = { - ["CLSID"] = "JAS_GBU49_TV", + [3] = { + ["CLSID"] = "JAS39_TANK1100", + ["num"] = 4, + }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [5] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [6] = { + ["CLSID"] = "JAS39_GBU31", ["num"] = 2, }, + [7] = { + ["CLSID"] = "JAS39_GBU31", + ["num"] = 7, + }, + [8] = { + ["CLSID"] = "JAS39_GBU49", + ["num"] = 3, + }, [9] = { - ["CLSID"] = "JAS_GBU49_TV", - ["num"] = 9, + ["CLSID"] = "JAS39_GBU49", + ["num"] = 6, }, [10] = { - ["CLSID"] = "JAS_Litening", - ["num"] = 7, + ["CLSID"] = "JAS39_Litening", + ["num"] = 5, + }, + [11] = { + ["CLSID"] = "{JAS39_FLIR}", + ["num"] = 9 }, }, ["tasks"] = { @@ -239,44 +247,48 @@ local unitPayloads = { ["name"] = "OCA", ["pylons"] = { [1] = { - ["CLSID"] = "JAS_IRIS-T", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "JAS_IRIS-T", + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 1, }, - [3] = { - ["CLSID"] = "JAS_TANK1100", - ["num"] = 5, - }, - [4] = { - ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "JAS_BK90", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "JAS_BK90", + [2] = { + ["CLSID"] = "JAS39_IRIS-T", ["num"] = 8, }, - [8] = { - ["CLSID"] = "JAS_ARAKM70BHE", + [3] = { + ["CLSID"] = "JAS39_TANK1100", + ["num"] = 4, + }, + [4] = { + ["CLSID"] = "{JAS39_ELINT}", + ["num"] = 10, + }, + [5] = { + ["CLSID"] = "{JAS39_EWS39}", + ["num"] = 11, + }, + [6] = { + ["CLSID"] = "JAS39_DWS39", ["num"] = 2, }, + [7] = { + ["CLSID"] = "JAS39_DWS39", + ["num"] = 7, + }, + [8] = { + ["CLSID"] = "JAS39_M70BHE", + ["num"] = 3, + }, [9] = { - ["CLSID"] = "JAS_ARAKM70BHE", - ["num"] = 9, + ["CLSID"] = "JAS39_M70BHE", + ["num"] = 6, }, [10] = { - ["CLSID"] = "JAS_Litening", - ["num"] = 7, + ["CLSID"] = "JAS39_Litening", + ["num"] = 5, + }, + [11] = { + ["CLSID"] = "{JAS39_FLIR}", + ["num"] = 9 }, }, ["tasks"] = { From 6034c899d376856d9b64c5f8a432928614f0a59c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kangwook=20Lee=20=28=EC=9D=B4=EA=B0=95=EC=9A=B1=29?= Date: Sun, 1 Aug 2021 04:34:49 +0900 Subject: [PATCH 136/167] Add flight intra radio channel to mission briefing (#1475) --- gen/briefinggen.py | 11 +++++++++++ resources/briefing/templates/briefingtemplate_CN.j2 | 1 + resources/briefing/templates/briefingtemplate_EN.j2 | 1 + resources/briefing/templates/briefingtemplate_FR.j2 | 1 + 4 files changed, 14 insertions(+) diff --git a/gen/briefinggen.py b/gen/briefinggen.py index 87029d7b..5a6911f0 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -136,6 +136,16 @@ def format_waypoint_time(waypoint: FlightWaypoint, depart_prefix: str) -> str: return "" +def format_intra_flight_channel(flight: FlightData) -> str: + frequency = flight.intra_flight_channel + channel = flight.channel_for(frequency) + if channel is None: + return str(frequency) + + channel_name = flight.aircraft_type.channel_name(channel.radio_id, channel.channel) + return f"{channel_name} ({frequency})" + + class BriefingGenerator(MissionInfoGenerator): def __init__(self, mission: Mission, game: Game): super().__init__(mission, game) @@ -151,6 +161,7 @@ class BriefingGenerator(MissionInfoGenerator): lstrip_blocks=True, ) env.filters["waypoint_timing"] = format_waypoint_time + env.filters["intra_flight_channel"] = format_intra_flight_channel self.template = env.get_template("briefingtemplate_EN.j2") def generate(self) -> None: diff --git a/resources/briefing/templates/briefingtemplate_CN.j2 b/resources/briefing/templates/briefingtemplate_CN.j2 index 5b4e38cc..3d6e3823 100644 --- a/resources/briefing/templates/briefingtemplate_CN.j2 +++ b/resources/briefing/templates/briefingtemplate_CN.j2 @@ -67,6 +67,7 @@ DCS Liberation 第 {{ game.turn }} 回合 {% for flight in flights if flight.client_units %} -------------------------------------------------- {{ flight.flight_type }} {{ flight.units[0].type }} x {{flight.size}}, departing in {{ flight.departure_delay }}, {{ flight.package.target.name}} +频率 : {{ intra_flight_channel(flight) }} {% for waypoint in flight.waypoints %} {{ loop.index0 }} {{waypoint|waypoint_timing("Depart ")}}-- {{waypoint.name}} : {{ waypoint.description}} {% endfor %} diff --git a/resources/briefing/templates/briefingtemplate_EN.j2 b/resources/briefing/templates/briefingtemplate_EN.j2 index ce5b7220..b10ed378 100644 --- a/resources/briefing/templates/briefingtemplate_EN.j2 +++ b/resources/briefing/templates/briefingtemplate_EN.j2 @@ -67,6 +67,7 @@ Your flights: {% for flight in flights if flight.client_units %} -------------------------------------------------- {{ flight.flight_type }} {{ flight.units[0].type }} x {{ flight.size }}, departing in {{ flight.departure_delay }}, {{ flight.package.target.name}} +Freq : {{ intra_flight_channel(flight) }} {% for waypoint in flight.waypoints %} {{ loop.index0 }} {{waypoint|waypoint_timing("Depart ")}}-- {{waypoint.name}} : {{ waypoint.description}} {% endfor %} diff --git a/resources/briefing/templates/briefingtemplate_FR.j2 b/resources/briefing/templates/briefingtemplate_FR.j2 index 61cf6b2e..b8a12372 100644 --- a/resources/briefing/templates/briefingtemplate_FR.j2 +++ b/resources/briefing/templates/briefingtemplate_FR.j2 @@ -67,6 +67,7 @@ Vols : {% for flight in flights if flight.client_units %} -------------------------------------------------- {{ flight.flight_type }} {{ flight.units[0].type }} x {{flight.size}}, départ dans {{ flight.departure_delay }}, {{ flight.package.target.name}} +Fréq : {{ intra_flight_channel(flight) }} {% for waypoint in flight.waypoints %} {{ loop.index0 }} {{waypoint|waypoint_timing("Départ dans ")}}-- {{waypoint.name}} : {{ waypoint.description}} {% endfor %} From 0370aa8df5af7925f2a124474d3f98f6b88ca1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kangwook=20Lee=20=28=EC=9D=B4=EA=B0=95=EC=9A=B1=29?= Date: Sun, 1 Aug 2021 04:37:18 +0900 Subject: [PATCH 137/167] Add AFAC task to JTAC unit. This causes the JTAC unit that's used for autolase to also work as a FAC over the radio. --- game/operation/operation.py | 5 ++++- gen/armor.py | 13 ++++++++++++- gen/kneeboard.py | 11 +++++++++-- resources/briefing/templates/briefingtemplate_CN.j2 | 2 +- resources/briefing/templates/briefingtemplate_EN.j2 | 2 +- resources/briefing/templates/briefingtemplate_FR.j2 | 2 +- 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/game/operation/operation.py b/game/operation/operation.py index 290076db..5e259fb9 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -399,6 +399,7 @@ class Operation: player_cp.stances[enemy_cp.id], enemy_cp.stances[player_cp.id], cls.unit_map, + cls.radio_registry, ) ground_conflict_gen.generate() cls.jtacs.extend(ground_conflict_gen.jtacs) @@ -454,6 +455,7 @@ class Operation: "zone": jtac.region, "dcsUnit": jtac.unit_name, "laserCode": jtac.code, + "radio": jtac.freq.mhz, } flight_count = 0 for flight in airgen.flights: @@ -570,7 +572,8 @@ class Operation: zone = data["zone"] laserCode = data["laserCode"] dcsUnit = data["dcsUnit"] - lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n" + radio = data["radio"] + lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}', radio='{radio}' }}, \n" lua += "}" # Process the Target Points diff --git a/gen/armor.py b/gen/armor.py index a000c8cb..18570913 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -13,9 +13,11 @@ from dcs.country import Country from dcs.mapping import Point from dcs.point import PointAction from dcs.task import ( + AFAC, EPLRS, AttackGroup, ControlledTask, + FAC, FireAtPoint, GoToWaypoint, Hold, @@ -42,6 +44,7 @@ from .callsigns import callsign_for_support_unit from .conflictgen import Conflict from .ground_forces.combat_stance import CombatStance from .naming import namegen +from .radios import MHz, RadioFrequency, RadioRegistry if TYPE_CHECKING: from game import Game @@ -74,7 +77,7 @@ class JtacInfo: region: str code: str blue: bool - # TODO: Radio info? Type? + freq: RadioFrequency class GroundConflictGenerator: @@ -88,6 +91,7 @@ class GroundConflictGenerator: player_stance: CombatStance, enemy_stance: CombatStance, unit_map: UnitMap, + radio_registry: RadioRegistry, ) -> None: self.mission = mission self.conflict = conflict @@ -97,6 +101,7 @@ class GroundConflictGenerator: self.enemy_stance = enemy_stance self.game = game self.unit_map = unit_map + self.radio_registry = radio_registry self.jtacs: List[JtacInfo] = [] def generate(self) -> None: @@ -147,6 +152,7 @@ class GroundConflictGenerator: if self.game.blue.faction.has_jtac: n = "JTAC" + str(self.conflict.blue_cp.id) + str(self.conflict.red_cp.id) code = 1688 - len(self.jtacs) + freq = self.radio_registry.alloc_uhf() utype = self.game.blue.faction.jtac_unit if utype is None: @@ -159,6 +165,10 @@ class GroundConflictGenerator: position=position[0], airport=None, altitude=5000, + maintask=AFAC, + ) + jtac.points[0].tasks.append( + FAC(callsign=len(self.jtacs) + 1, frequency=int(freq.mhz)) ) jtac.points[0].tasks.append(SetInvisibleCommand(True)) jtac.points[0].tasks.append(SetImmortalCommand(True)) @@ -178,6 +188,7 @@ class GroundConflictGenerator: frontline, str(code), blue=True, + freq=freq, ) ) diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 1b0f09b1..9c35d662 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -487,8 +487,15 @@ class SupportPage(KneeboardPage): writer.heading("JTAC") jtacs = [] for jtac in self.jtacs: - jtacs.append([jtac.callsign, jtac.region, jtac.code]) - writer.table(jtacs, headers=["Callsign", "Region", "Laser Code"]) + jtacs.append( + [ + jtac.callsign, + jtac.region, + jtac.code, + self.format_frequency(jtac.freq), + ] + ) + writer.table(jtacs, headers=["Callsign", "Region", "Laser Code", "FREQ"]) writer.write(path) diff --git a/resources/briefing/templates/briefingtemplate_CN.j2 b/resources/briefing/templates/briefingtemplate_CN.j2 index 3d6e3823..d4c700d2 100644 --- a/resources/briefing/templates/briefingtemplate_CN.j2 +++ b/resources/briefing/templates/briefingtemplate_CN.j2 @@ -109,6 +109,6 @@ AWACS: {%- if jtacs|length > 0 %} JTACS [F-10 菜单] : ==================== -{% for jtac in jtacs %}前线 {{ jtac.region }} -- 激光编码 : {{ jtac.code }} +{% for jtac in jtacs %}前线 {{ jtac.region }} -- 激光编码 : {{ jtac.code }}, 频率 : {{ jtac.freq.mhz }} {% endfor %} {% endif %} diff --git a/resources/briefing/templates/briefingtemplate_EN.j2 b/resources/briefing/templates/briefingtemplate_EN.j2 index b10ed378..37f1861c 100644 --- a/resources/briefing/templates/briefingtemplate_EN.j2 +++ b/resources/briefing/templates/briefingtemplate_EN.j2 @@ -109,6 +109,6 @@ AWACS: {%- if jtacs|length > 0 %} JTACS [F-10 Menu] : ==================== -{% for jtac in jtacs %}Frontline {{ jtac.region }} -- Code : {{ jtac.code }} +{% for jtac in jtacs %}Frontline {{ jtac.region }} -- Code : {{ jtac.code }}, Freq : {{ jtac.freq.mhz }} {% endfor %} {% endif %} \ No newline at end of file diff --git a/resources/briefing/templates/briefingtemplate_FR.j2 b/resources/briefing/templates/briefingtemplate_FR.j2 index b8a12372..b8456e9c 100644 --- a/resources/briefing/templates/briefingtemplate_FR.j2 +++ b/resources/briefing/templates/briefingtemplate_FR.j2 @@ -109,6 +109,6 @@ AWACS: {%- if jtacs|length > 0 %} JTACS [Menu F-10] : ==================== -{% for jtac in jtacs %}Ligne de front {{ jtac.region }} -- Code : {{ jtac.code }} +{% for jtac in jtacs %}Ligne de front {{ jtac.region }} -- Code : {{ jtac.code }}, Fréq : {{ jtac.freq.mhz }} {% endfor %} {% endif %} \ No newline at end of file From 119d4b9514fdd395adf2dc19b5919930a59b56ac Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Sat, 31 Jul 2021 15:43:48 -0400 Subject: [PATCH 138/167] Vendor ruler (#1476) * Fixes ruler module integrity issues by bringing module into source * Changing ruler stylesheet to vaguely match DCS theme in Liberation * Changelog --- changelog.md | 1 + resources/ui/map/canvas.html | 8 +- resources/ui/map/lib/leaflet-ruler/LICENSE.md | 22 +++ .../map/lib/leaflet-ruler/leaflet-ruler.css | 41 +++++ .../ui/map/lib/leaflet-ruler/leaflet-ruler.js | 173 ++++++++++++++++++ resources/ui/map/lib/leaflet-ruler/ruler.png | Bin 0 -> 684 bytes 6 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 resources/ui/map/lib/leaflet-ruler/LICENSE.md create mode 100644 resources/ui/map/lib/leaflet-ruler/leaflet-ruler.css create mode 100644 resources/ui/map/lib/leaflet-ruler/leaflet-ruler.js create mode 100644 resources/ui/map/lib/leaflet-ruler/ruler.png diff --git a/changelog.md b/changelog.md index 6119152b..811dae42 100644 --- a/changelog.md +++ b/changelog.md @@ -36,6 +36,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[UI]** Control point name displayed with ground object group name on map. * **[UI]** Buy or Replace will now show the correct price for generated ground objects like sams. * **[UI]** Improved logging for frontline movement to be more descriptive about what happened and why. +* **[UI]** Brought ruler map module into source, which should fix file integrity issues with the module. ## Fixes diff --git a/resources/ui/map/canvas.html b/resources/ui/map/canvas.html index ff99669a..0efc3ebf 100644 --- a/resources/ui/map/canvas.html +++ b/resources/ui/map/canvas.html @@ -25,12 +25,8 @@ crossorigin=""> - + href="lib/leaflet-ruler/leaflet-ruler.css"> +