From 4cff838de0efad2bce6fcb32bfcfded5c752e371 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 13 Nov 2020 18:46:59 -0800 Subject: [PATCH 01/19] Develop is now 2.3. --- game/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/version.py b/game/version.py index e173526f..72575c05 100644 --- a/game/version.py +++ b/game/version.py @@ -2,7 +2,7 @@ from pathlib import Path #: Current version of Liberation. -VERSION = "2.2.0-preview" +VERSION = "2.3.0-preview" if Path("buildnumber").exists(): with open("buildnumber", "r") as file: VERSION += f"-{file.readline()}" From a81254cd1830cd8ad57ac41de017d94e1c9eb0a5 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 13 Nov 2020 21:00:55 -0800 Subject: [PATCH 02/19] Fix error box in flight creation. --- qt_ui/windows/mission/flight/QFlightCreator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 7b9808f7..ee4b3354 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -3,6 +3,7 @@ from typing import Optional from PySide2.QtCore import Qt, Signal from PySide2.QtWidgets import ( QDialog, + QMessageBox, QPushButton, QVBoxLayout, ) @@ -95,7 +96,8 @@ class QFlightCreator(QDialog): def create_flight(self) -> None: error = self.verify_form() if error is not None: - self.error_box("Could not create flight", error) + QMessageBox.critical(self, "Could not create flight", error, + QMessageBox.Ok) return task = self.task_selector.currentData() From 75769df8e2e149504ff5abe2889e315fa95f4f57 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 13 Nov 2020 21:14:53 -0800 Subject: [PATCH 03/19] Handle inventory when selling aircraft. This still leaves a bit to be desired, namely that selling aircraft happens immediately but buying aircraft takes a turn. However, that's how this behaved before, so this restores the 2.1 behavior. Worth investigating further in the future. --- .../airfield/QAircraftRecruitmentMenu.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index 2dbba2f3..a01aaaa9 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -6,6 +6,7 @@ from PySide2.QtWidgets import ( QGridLayout, QHBoxLayout, QLabel, + QMessageBox, QScrollArea, QVBoxLayout, QWidget, @@ -88,7 +89,21 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): super().buy(unit_type) self.hangar_status.update_label(self.total_units, self.cp.available_aircraft_slots) - def sell(self, unit_type): + def sell(self, unit_type: UnitType): + # Don't need to remove aircraft from the inventory if we're canceling + # orders. + if self.deliveryEvent.units.get(unit_type, 0) <= 0: + global_inventory = self.game_model.game.aircraft_inventory + inventory = global_inventory.for_control_point(self.cp) + try: + inventory.remove_aircraft(unit_type, 1) + except ValueError: + QMessageBox.critical( + self, "Could not sell aircraft", + f"Attempted to sell one {unit_type.id} at {self.cp.name} " + "but none are available. Are all aircraft currently " + "assigned to a mission?", QMessageBox.Ok) + return super().sell(unit_type) self.hangar_status.update_label(self.total_units, self.cp.available_aircraft_slots) From ef84703da98f16771fe0adea1d4e23b35280e511 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 14 Nov 2020 01:39:20 -0800 Subject: [PATCH 04/19] Update the README with a more recent screenshot. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d29644a7..4d51bd19 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ ![GitHub stars](https://img.shields.io/github/stars/khopa/dcs_liberation?style=social) ## About DCS Liberation -DCS Liberation is a [DCS World](https://www.digitalcombatsimulator.com/en/products/world/) turn based single-player semi dynamic campaign. +DCS Liberation is a [DCS World](https://www.digitalcombatsimulator.com/en/products/world/) turn based single-player or co-op dynamic campaign. It is an external program that generates full and complex DCS missions and manage a persistent combat environment. -![Logo](https://imgur.com/B6tvlBJ.png) +![Logo](https://i.imgur.com/4hq0rLq.png) ## Downloads From ab26a76789e4f45a486f67c295e6d603b2772867 Mon Sep 17 00:00:00 2001 From: Khopa Date: Sat, 14 Nov 2020 14:17:07 +0100 Subject: [PATCH 05/19] A-20G won't level bomb unit groups --- gen/aircraft.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen/aircraft.py b/gen/aircraft.py index d1e93e37..a2847ba3 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -1387,7 +1387,7 @@ class SeadIngressBuilder(PydcsWaypointBuilder): class StrikeIngressBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: - if self.group.units[0].unit_type in [B_17G, A_20G, B_52H, Tu_22M3]: + if self.group.units[0].unit_type in [B_17G, B_52H, Tu_22M3]: return self.build_bombing() else: return self.build_strike() From 717ea05d38e77676013d3bdc90d1a2a0178f436e Mon Sep 17 00:00:00 2001 From: Khopa Date: Sat, 14 Nov 2020 21:24:15 +0100 Subject: [PATCH 06/19] Pulled latest pydcs version --- pydcs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydcs b/pydcs index 8e74bfb6..fa9195fb 160000 --- a/pydcs +++ b/pydcs @@ -1 +1 @@ -Subproject commit 8e74bfb61b829477d35a80148fc295f2158902dd +Subproject commit fa9195fbccbf96775d108a22c13c3ee2375e4c0b From 85f931316a4296de7316bd8027f94d3a028d7aac Mon Sep 17 00:00:00 2001 From: Khopa Date: Sat, 14 Nov 2020 21:29:03 +0100 Subject: [PATCH 07/19] Changelog update for 2.2.0 --- changelog.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/changelog.md b/changelog.md index f3f99b7b..df66d42d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,4 @@ -# 2.2.X +# 2.2.0 ## Features/Improvements : * **[Campaign Generator]** Added early warning radar generation @@ -13,10 +13,9 @@ * **[Map]** Highlight the selected flight path on the map * **[Map]** Improved SAM display settings * **[Map]** Improved flight plan display settings +* **[Map]** Caucasus and The Channel map use a new system to generate SAM and strike target location to reduce probability of targets generated in the middle of a forests * **[Misc]** Flexible Dedicated Hosting Options for Mission Files via environment variables * **[Moddability]** Custom campaigns can be designed through json files -* **[Moddability]** Custom campaigns can be designed through json files -* **[Moddability]** Custom factions can be designed through json files * **[Moddability]** LUA plugins can now be injected into Liberation missions. * **[Moddability]** Optional Skynet IADS lua plugin now included * **[New Game]** Starting budget can be freely selected @@ -25,8 +24,7 @@ * **[UI]** Add polygon drawing mode for map background * **[UI]** Added a warning if you press takeoff with no player enabled flights * **[UI]** Packages and flights now visible in the main window sidebar -* **[Units/Factions]** Added bombers to coalitions -* **[Units/Factions]** Added frenchpack mod units +* **[Units/Factions]** Added bombers to some coalitions * **[Units/Factions]** Added support for SU-57 mod by Cubanace * **[Units]** Added Freya EWR sites to german WW2 factions * **[Units]** Added support for many bombers (B-52H, B-1B, Tu-22, Tu-142) From 9019cbfd2b715c35da10ad7ed79d44ab81e57052 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 14 Nov 2020 12:37:22 -0800 Subject: [PATCH 08/19] Fix versioning for release builds. --- .github/workflows/release.yml | 4 ++++ game/version.py | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21806ed4..5580f694 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,10 @@ jobs: # For some reason the shiboken2.abi3.dll is not found properly, so I copy it instead Copy-Item .\venv\Lib\site-packages\shiboken2\shiboken2.abi3.dll .\venv\Lib\site-packages\PySide2\ -Force + - name: Finalize version + run: | + New-Item -ItemType file final + - name: mypy game run: | ./venv/scripts/activate diff --git a/game/version.py b/game/version.py index 72575c05..d8260044 100644 --- a/game/version.py +++ b/game/version.py @@ -1,8 +1,17 @@ from pathlib import Path +def _build_version_string() -> str: + components = ["2.3.0"] + if Path("buildnumber").exists(): + with open("buildnumber", "r") as file: + components.append(file.readline()) + + if not Path("final").exists(): + components.append("preview") + + return "-".join(components) + + #: Current version of Liberation. -VERSION = "2.3.0-preview" -if Path("buildnumber").exists(): - with open("buildnumber", "r") as file: - VERSION += f"-{file.readline()}" +VERSION = _build_version_string() From fae9650f56854bb3f3508ee5b2625bba8e7ad2f8 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 14 Nov 2020 13:01:11 -0800 Subject: [PATCH 09/19] Fix pyinstaller spec for release. final and buildnumber are optional files. Move them into resources to avoid naming them explicitly. --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- game/version.py | 9 +++++---- pyinstaller.spec | 1 - 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71d5cf3c..60cbf719 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,7 @@ jobs: - name: update build number run: | - [IO.File]::WriteAllLines($pwd.path + "\buildnumber", $env:GITHUB_RUN_NUMBER) + [IO.File]::WriteAllLines($pwd.path + "\resources\buildnumber", $env:GITHUB_RUN_NUMBER) - name: Build binaries run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5580f694..ca8a238e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - name: Finalize version run: | - New-Item -ItemType file final + New-Item -ItemType file resources\final - name: mypy game run: | diff --git a/game/version.py b/game/version.py index d8260044..5955ac0a 100644 --- a/game/version.py +++ b/game/version.py @@ -3,11 +3,12 @@ from pathlib import Path def _build_version_string() -> str: components = ["2.3.0"] - if Path("buildnumber").exists(): - with open("buildnumber", "r") as file: - components.append(file.readline()) + build_number_path = Path("resources/buildnumber") + if build_number_path.exists(): + with build_number_path.open("r") as build_number_file: + components.append(build_number_file.readline()) - if not Path("final").exists(): + if not Path("resources/final").exists(): components.append("preview") return "-".join(components) diff --git a/pyinstaller.spec b/pyinstaller.spec index e545d073..839c641f 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -11,7 +11,6 @@ analysis = Analysis( ('resources', 'resources'), ('resources/caucasus.p', 'dcs/terrain/'), ('resources/nevada.p', 'dcs/terrain/'), - ('buildnumber', './') ], hookspath=[], runtime_hooks=[], From 3dd07b8c2355db7ecd586e4de57af8c0158d3097 Mon Sep 17 00:00:00 2001 From: Khopa Date: Sun, 15 Nov 2020 15:49:51 +0100 Subject: [PATCH 10/19] Added factions made by Discord user HerrTom --- .../factions/france_2005_frenchpack.json | 84 +++++++++++++++++++ resources/factions/georgia_2008.json | 46 ++++++++++ resources/factions/usn_1985.json | 73 ++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 resources/factions/france_2005_frenchpack.json create mode 100644 resources/factions/georgia_2008.json create mode 100644 resources/factions/usn_1985.json diff --git a/resources/factions/france_2005_frenchpack.json b/resources/factions/france_2005_frenchpack.json new file mode 100644 index 00000000..d909c08b --- /dev/null +++ b/resources/factions/france_2005_frenchpack.json @@ -0,0 +1,84 @@ +{ + "country": "France", + "name": "France 2005 (Frenchpack)", + "authors": "HerrTom", + "description": "

French equipment using the Frenchpack, but without the Rafale mod.

", + "aircrafts": [ + "M_2000C", + "Mirage_2000_5", + "SA342M", + "SA342L", + "SA342Mistral" + ], + "awacs": [ + "E_3A" + ], + "tankers": [ + "KC_135", + "KC130" + ], + "frontline_units": [ + "AMX_10RCR", + "AMX_10RCR_SEPAR", + "ERC_90", + "TRM_2000_PAMELA", + "VAB__50", + "VAB_MEPHISTO", + "VAB_T20_13", + "VAB_T20_13", + "VBL__50", + "VBL_AANF1", + "VBAE_CRAB", + "VBAE_CRAB_MMP", + "AMX_30B2", + "Leclerc_Serie_XXI" + ], + "artillery_units": [ + "MLRS_M270", + "SPH_M109_Paladin" + ], + "logistics_units": [ + "Transport_M818" + ], + "infantry_units": [ + "Infantry_M4", + "Soldier_M249", + "Stinger_MANPADS" + ], + "shorads": [ + "HQ7Generator", + "RolandGenerator" + ], + "sams": [ + "RolandGenerator", + "HawkGenerator" + ], + "aircraft_carrier": [ + "CVN_74_John_C__Stennis" + ], + "helicopter_carrier": [ + "LHA_1_Tarawa" + ], + "destroyers": [ + "USS_Arleigh_Burke_IIa" + ], + "cruisers": [ + "Ticonderoga_class" + ], + "requirements": { + "frenchpack V3.5": "https://forums.eagle.ru/showthread.php?t=279974" + }, + "carrier_names": [ + "L9013 Mistral", + "L9014 Tonerre", + "L9015 Dixmude" + ], + "helicopter_carrier_names": [ + "Jeanne d'Arc" + ], + "navy_generators": [ + "ArleighBurkeGroupGenerator" + ], + "has_jtac": true, + "jtac_unit": "MQ_9_Reaper" +} diff --git a/resources/factions/georgia_2008.json b/resources/factions/georgia_2008.json new file mode 100644 index 00000000..f575054c --- /dev/null +++ b/resources/factions/georgia_2008.json @@ -0,0 +1,46 @@ +{ + "country": "Georgia", + "name": "Georgia 2008", + "authors": "HerrTom", + "description": "

A faction that represents Georgia during the South Ossetian War. They will have a lot more aircraft than historically, and no real A2A capability.

", + "aircrafts": [ + "L_39ZA", + "Su_25", + "Mi_8MT", + "Mi_24V", + "UH_1H" + ], + "frontline_units": [ + "APC_BTR_80", + "APC_MTLB", + "APC_Cobra", + "IFV_BMP_1", + "IFV_BMP_2", + "MBT_T_72B", + "MBT_T_55" + ], + "artillery_units": [ + "MLRS_BM21_Grad", + "SPH_2S1_Gvozdika", + "SPH_2S3_Akatsia" + ], + "logistics_units": [ + "Transport_Ural_375", + "Transport_UAZ_469" + ], + "infantry_units": [ + "Paratrooper_AKS", + "Paratrooper_RPG_16" + ], + "shorads": [ + "SA13Generator", + "SA8Generator" + ], + "sams": [ + "SA6Generator", + "SA11Generator" + ], + "requirements": {}, + "has_jtac": true, + "jtac_unit": "MQ_9_Reaper" +} diff --git a/resources/factions/usn_1985.json b/resources/factions/usn_1985.json new file mode 100644 index 00000000..6ca2f2f1 --- /dev/null +++ b/resources/factions/usn_1985.json @@ -0,0 +1,73 @@ +{ + "country": "USA", + "name": "US Navy 1985", + "authors": "HerrTom", + "description": "

Highway to the Danger Zone! For Tomcat lovers.

", + "aircrafts": [ + "F_4E", + "F_14B", + "S_3B", + "UH_1H", + "AH_1W" + ], + "awacs": [ + "E_3A" + ], + "tankers": [ + "S_3B_Tanker" + ], + "frontline_units": [ + "MBT_M60A3_Patton", + "APC_M113", + "APC_M1025_HMMWV" + ], + "artillery_units": [ + "SPH_M109_Paladin", + "MLRS_M270" + ], + "logistics_units": [ + "Transport_M818" + ], + "infantry_units": [ + "Infantry_M4", + "Soldier_M249" + ], + "shorads": [ + "VulcanGenerator", + "ChaparralGenerator" + ], + "sams": [ + "HawkGenerator", + "ChaparralGenerator" + ], + "aircraft_carrier": [ + "CVN_74_John_C__Stennis" + ], + "helicopter_carrier": [ + "LHA_1_Tarawa" + ], + "destroyers": [ + "Oliver_Hazzard_Perry_class" + ], + "cruisers": [ + "Ticonderoga_class" + ], + "carrier_names": [ + "CVN-71 Theodore Roosevelt", + "CVN-72 Abraham Lincoln", + "CVN-73 George Washington", + "CVN-74 John C. Stennis" + ], + "helicopter_carrier_names": [ + "LHA-1 Tarawa", + "LHA-2 Saipan", + "LHA-3 Belleau Wood", + "LHA-4 Nassau", + "LHA-5 Peleliu" + ], + "navy_generators": [ + "OliverHazardPerryGroupGenerator" + ], + "requirements": {}, + "doctrine": "coldwar" +} \ No newline at end of file From 6e2124252cf8734b147fd93973697ce2f3361733 Mon Sep 17 00:00:00 2001 From: Khopa Date: Sun, 15 Nov 2020 15:57:36 +0100 Subject: [PATCH 11/19] Added full persian gulf map by Plob --- .../campaigns/persian_gulf_full_map.json | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 resources/campaigns/persian_gulf_full_map.json diff --git a/resources/campaigns/persian_gulf_full_map.json b/resources/campaigns/persian_gulf_full_map.json new file mode 100644 index 00000000..82818470 --- /dev/null +++ b/resources/campaigns/persian_gulf_full_map.json @@ -0,0 +1,137 @@ +{ + "name": "Persian Gulf - Full Map", + "theater": "Persian Gulf", + "authors": "Plob", + "description": "

In this scenario, you start at Liwa Airfield, and must work your way north through the whole map.

", + "player_points": [ + { + "type": "airbase", + "id": "Liwa Airbase", + "size": 1000, + "importance": 0.2 + }, + { + "type": "lha", + "id": 1002, + "x": -164000, + "y": -257000, + "captured_invert": true + }, + { + "type": "carrier", + "id": 1001, + "x": -124000, + "y": -303000, + "captured_invert": true + } + ], + "enemy_points": [ + { + "type": "airbase", + "id": "Al Ain International Airport", + "size": 1000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Al Dhafra AB", + "size": 2000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Al Minhad AB", + "size": 1000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Ras Al Khaimah", + "size": 1000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Khasab", + "size": 1000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Bandar Abbas Intl", + "size": 2000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Jiroft Airport", + "size": 2000, + "importance": 1.4 + }, + { + "type": "airbase", + "id": "Kerman Airport", + "size": 2000, + "importance": 1.7, + "captured_invert": true + }, + { + "type": "airbase", + "id": "Lar Airbase", + "size": 1000, + "importance": 1.4 + }, + { + "type": "airbase", + "id": "Shiraz International Airport", + "size": 2000, + "importance": 1 + } + ], + "links": [ + [ + "Al Dhafra AB", + "Liwa Airbase" + ], + [ + "Al Dhafra AB", + "Al Ain International Airport" + ], + [ + "Al Ain International Airport", + "Al Minhad AB" + ], + [ + "Al Dhafra AB", + "Al Minhad AB" + ], + [ + "Al Minhad AB", + "Ras Al Khaimah" + ], + [ + "Khasab", + "Ras Al Khaimah" + ], + [ + "Bandar Abbas Intl", + "Lar Airbase" + ], + [ + "Shiraz International Airport", + "Lar Airbase" + ], + [ + "Shiraz International Airport", + "Kerman Airport" + ], + [ + "Jiroft Airport", + "Lar Airbase" + ], + [ + "Jiroft Airport", + "Kerman Airport" + ] + ] +} \ No newline at end of file From 8ffbf3267730c5651e4837bef09e48f68daa7158 Mon Sep 17 00:00:00 2001 From: Khopa Date: Sun, 15 Nov 2020 16:14:18 +0100 Subject: [PATCH 12/19] Changelog update --- changelog.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/changelog.md b/changelog.md index df66d42d..a3570323 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,9 @@ +# 2.2.1 + +# Features/Improvements +* **[Factions]** Added factions : Georgia 2008, USN 1985, France 2005 Frenchpack by HerrTom +* **[Factions]** Added map Persian Gulf full by Plob + # 2.2.0 ## Features/Improvements : From d9056acc6d0aa9f4affe29cad189d20aa08f68b6 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 15 Nov 2020 18:28:40 -0800 Subject: [PATCH 13/19] Improve hold/split/join point positioning. This also removes ascend/descend waypoints. They don't seem to be helping at all. The AI already gets an implicit ascend waypoint (they won't go to waypoint one until they've climbed sufficiently), and forcing unnecessary sharp turns toward the possibly mispredicted ascent direction can mess with the AI. It's also yet another variable to contend with when planning hold points, and hold points do essentially the same thing. Fixes https://github.com/Khopa/dcs_liberation/issues/352. (cherry picked from commit 21cd764f6625db0784059a0b59a1e8a72a7a699d) --- changelog.md | 3 ++ game/data/doctrine.py | 8 ++++ gen/flights/flightplan.py | 94 ++++++++++++++++++++++++++------------- 3 files changed, 74 insertions(+), 31 deletions(-) diff --git a/changelog.md b/changelog.md index a3570323..b5b938aa 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,9 @@ * **[Factions]** Added factions : Georgia 2008, USN 1985, France 2005 Frenchpack by HerrTom * **[Factions]** Added map Persian Gulf full by Plob +## Fixes : +* **[Flight Planner]** Hold, join, and split points are planned cautiously near enemy airfields. Ascend/descend points are no longer planned. + # 2.2.0 ## Features/Improvements : diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 8d3e1a91..99bb254a 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -16,6 +16,8 @@ class Doctrine: sead_max_range: int rendezvous_altitude: int + hold_distance: int + push_distance: int join_distance: int split_distance: int ingress_egress_distance: int @@ -44,6 +46,8 @@ MODERN_DOCTRINE = Doctrine( strike_max_range=1500000, sead_max_range=1500000, rendezvous_altitude=feet_to_meter(25000), + hold_distance=nm_to_meter(15), + push_distance=nm_to_meter(20), join_distance=nm_to_meter(20), split_distance=nm_to_meter(20), ingress_egress_distance=nm_to_meter(45), @@ -69,6 +73,8 @@ COLDWAR_DOCTRINE = Doctrine( strike_max_range=1500000, sead_max_range=1500000, rendezvous_altitude=feet_to_meter(22000), + hold_distance=nm_to_meter(10), + push_distance=nm_to_meter(10), join_distance=nm_to_meter(10), split_distance=nm_to_meter(10), ingress_egress_distance=nm_to_meter(30), @@ -93,6 +99,8 @@ WWII_DOCTRINE = Doctrine( antiship=True, strike_max_range=1500000, sead_max_range=1500000, + hold_distance=nm_to_meter(5), + push_distance=nm_to_meter(5), join_distance=nm_to_meter(5), split_distance=nm_to_meter(5), rendezvous_altitude=feet_to_meter(10000), diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index e0df2b01..c73eb3ae 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -7,6 +7,7 @@ generating the waypoints for the mission. """ from __future__ import annotations +import math from datetime import timedelta from functools import cached_property import logging @@ -275,18 +276,14 @@ class PatrollingFlightPlan(FlightPlan): @dataclass(frozen=True) class BarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint - ascent: FlightWaypoint - descent: FlightWaypoint land: FlightWaypoint @property def waypoints(self) -> List[FlightWaypoint]: return [ self.takeoff, - self.ascent, self.patrol_start, self.patrol_end, - self.descent, self.land, ] @@ -294,20 +291,16 @@ class BarCapFlightPlan(PatrollingFlightPlan): @dataclass(frozen=True) class CasFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint - ascent: FlightWaypoint target: FlightWaypoint - descent: FlightWaypoint land: FlightWaypoint @property def waypoints(self) -> List[FlightWaypoint]: return [ self.takeoff, - self.ascent, self.patrol_start, self.target, self.patrol_end, - self.descent, self.land, ] @@ -321,18 +314,14 @@ class CasFlightPlan(PatrollingFlightPlan): @dataclass(frozen=True) class FrontLineCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint - ascent: FlightWaypoint - descent: FlightWaypoint land: FlightWaypoint @property def waypoints(self) -> List[FlightWaypoint]: return [ self.takeoff, - self.ascent, self.patrol_start, self.patrol_end, - self.descent, self.land, ] @@ -360,28 +349,24 @@ class FrontLineCapFlightPlan(PatrollingFlightPlan): @dataclass(frozen=True) class StrikeFlightPlan(FormationFlightPlan): takeoff: FlightWaypoint - ascent: FlightWaypoint hold: FlightWaypoint join: FlightWaypoint ingress: FlightWaypoint targets: List[FlightWaypoint] egress: FlightWaypoint split: FlightWaypoint - descent: FlightWaypoint land: FlightWaypoint @property def waypoints(self) -> List[FlightWaypoint]: return [ self.takeoff, - self.ascent, self.hold, self.join, self.ingress ] + self.targets + [ self.egress, self.split, - self.descent, self.land, ] @@ -681,10 +666,8 @@ class FlightPlanBuilder: flight=flight, patrol_duration=self.doctrine.cap_duration, takeoff=builder.takeoff(flight.from_cp), - ascent=builder.ascent(flight.from_cp), patrol_start=start, patrol_end=end, - descent=descent, land=land ) @@ -736,10 +719,8 @@ class FlightPlanBuilder: # duration of the escorted mission, or until it is winchester/bingo. patrol_duration=self.doctrine.cap_duration, takeoff=builder.takeoff(flight.from_cp), - ascent=builder.ascent(flight.from_cp), patrol_start=start, patrol_end=end, - descent=descent, land=land ) @@ -805,14 +786,12 @@ class FlightPlanBuilder: package=self.package, flight=flight, takeoff=builder.takeoff(flight.from_cp), - ascent=builder.ascent(flight.from_cp), hold=builder.hold(self._hold_point(flight)), join=builder.join(self.package.waypoints.join), ingress=ingress, targets=[target], egress=egress, split=builder.split(self.package.waypoints.split), - descent=descent, land=land ) @@ -842,11 +821,9 @@ class FlightPlanBuilder: flight=flight, patrol_duration=self.doctrine.cas_duration, takeoff=builder.takeoff(flight.from_cp), - ascent=builder.ascent(flight.from_cp), patrol_start=builder.ingress_cas(ingress, location), target=builder.cas(center), patrol_end=builder.egress(egress, location), - descent=descent, land=land ) @@ -871,12 +848,50 @@ class FlightPlanBuilder: return builder.strike_area(location) def _hold_point(self, flight: Flight) -> Point: - heading = flight.from_cp.position.heading_between_point( - self.package.target.position - ) - return flight.from_cp.position.point_from_heading( - heading, nm_to_meter(15) + assert self.package.waypoints is not None + origin = flight.from_cp.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 + # airfield. + return join.point_from_heading(target.heading_between_point(origin), + self.doctrine.push_distance) + + heading_to_join = origin.heading_between_point(join) + hold_point = origin.point_from_heading(heading_to_join, + self.doctrine.push_distance) + if hold_point.distance_to_point(join) >= 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 ** 2 + + origin_to_join ** 2 - + self.doctrine.join_distance ** 2) / + (2 * self.doctrine.hold_distance * 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) + + return origin.point_from_heading(heading_to_join - theta, + self.doctrine.hold_distance) # TODO: Make a model for the waypoint builder and use that in the UI. def generate_ascend_point(self, flight: Flight, @@ -944,23 +959,37 @@ class FlightPlanBuilder: package=self.package, flight=flight, takeoff=builder.takeoff(flight.from_cp), - ascent=builder.ascent(flight.from_cp), hold=builder.hold(self._hold_point(flight)), join=builder.join(self.package.waypoints.join), ingress=ingress, targets=target_waypoints, egress=builder.egress(self.package.waypoints.egress, location), split=builder.split(self.package.waypoints.split), - descent=descent, land=land ) def _join_point(self, ingress_point: Point) -> Point: + ingress_distance = self._distance_to_package_airfield(ingress_point) + if ingress_distance < self.doctrine.join_distance: + # If the ingress point is close to the origin, plan the join point + # farther back. + return ingress_point.point_from_heading( + self.package.target.position.heading_between_point( + self.package_airfield().position), + self.doctrine.join_distance) heading = self._heading_to_package_airfield(ingress_point) return ingress_point.point_from_heading(heading, -self.doctrine.join_distance) def _split_point(self, egress_point: Point) -> Point: + egress_distance = self._distance_to_package_airfield(egress_point) + if egress_distance < self.doctrine.split_distance: + # If the ingress point is close to the origin, plan the split point + # farther back. + return egress_point.point_from_heading( + self.package.target.position.heading_between_point( + self.package_airfield().position), + self.doctrine.split_distance) heading = self._heading_to_package_airfield(egress_point) return egress_point.point_from_heading(heading, -self.doctrine.split_distance) @@ -983,6 +1012,9 @@ class FlightPlanBuilder: def _heading_to_package_airfield(self, point: Point) -> int: return self.package_airfield().position.heading_between_point(point) + def _distance_to_package_airfield(self, point: Point) -> int: + 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. From a080d4b692d5376f83e46e883e4e62835ebf076a Mon Sep 17 00:00:00 2001 From: walterroach <37820425+walterroach@users.noreply.github.com> Date: Sun, 15 Nov 2020 22:06:05 -0600 Subject: [PATCH 14/19] Briefing tweak Fixes frontline info repeating when player has no vehicles. --- resources/briefing/templates/briefingtemplate_EN.j2 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/briefing/templates/briefingtemplate_EN.j2 b/resources/briefing/templates/briefingtemplate_EN.j2 index 0d32eeae..864fbabc 100644 --- a/resources/briefing/templates/briefingtemplate_EN.j2 +++ b/resources/briefing/templates/briefingtemplate_EN.j2 @@ -16,6 +16,7 @@ We do not have a single vehicle available to hold our position. The situation i {% if frontline.enemy_zero %} The enemy forces have been crushed, we will be able to make significant progress toward {{ frontline.enemy_base.name }} {% endif %} +{% if not frontline.player_zero %} {# Pick a random sentence to describe each frontline #} {% set fl_sent1 %}There are combats between {{ frontline.player_base.name }} and {{frontline.enemy_base.name}}. {%+ endset %} {% set fl_sent2 %}The war on the ground is still going on between {{frontline.player_base.name}} and {{frontline.enemy_base.name}}. {%+ endset %} @@ -57,8 +58,9 @@ On this location, our ground forces have been ordered to hold still, and defend {# TODO: Write a retreat sentence #} {% endif %} {% endif %} +{% endif %} -{% endfor %}{% endif %} +{%+ endfor %}{% endif %} Your flights: ==================== From e60166dc89bafcc8d207663062d292b6e6f7751a Mon Sep 17 00:00:00 2001 From: walterroach <37820425+walterroach@users.noreply.github.com> Date: Sun, 15 Nov 2020 22:50:14 -0600 Subject: [PATCH 15/19] Change default CAS loadout for Viggen Reported that AI can't hit the broad side of a barn with the rockets. --- resources/customized_payloads/AJS37.lua | 35 ++++++++++++++++--------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/resources/customized_payloads/AJS37.lua b/resources/customized_payloads/AJS37.lua index c8ebdcf1..9fe5c251 100644 --- a/resources/customized_payloads/AJS37.lua +++ b/resources/customized_payloads/AJS37.lua @@ -5,28 +5,37 @@ local unitPayloads = { ["name"] = "CAS", ["pylons"] = { [1] = { - ["CLSID"] = "{ARAKM70BHE}", - ["num"] = 3, + ["CLSID"] = "{RB75}", + ["num"] = 5, }, [2] = { - ["CLSID"] = "{ARAKM70BHE}", - ["num"] = 2, + ["CLSID"] = "{RB75}", + ["num"] = 3, }, [3] = { + ["CLSID"] = "{RB75}", + ["num"] = 2, + }, + [4] = { + ["CLSID"] = "{RB75}", + ["num"] = 6, + }, + [5] = { + ["CLSID"] = "{Robot24J}", + ["num"] = 1, + }, + [6] = { + ["CLSID"] = "{Robot24J}", + ["num"] = 7, + }, + [7] = { ["CLSID"] = "{VIGGEN_X-TANK}", ["num"] = 4, }, - [4] = { - ["CLSID"] = "{ARAKM70BHE}", - ["num"] = 5, - }, - [5] = { - ["CLSID"] = "{ARAKM70BHE}", - ["num"] = 6, - }, }, ["tasks"] = { - [1] = 31, + [1] = 32, + [2] = 31, }, }, [2] = { From d369ce8847049cdb639ed5fcb86505edb7813b80 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 15 Nov 2020 23:12:38 -0800 Subject: [PATCH 16/19] Add fighter sweep tasks. Fighter sweeps arrive at the target ahead of the rest of the package (currently a fixed 5 minute lead) to clear out enemy fighters and then RTB. Fixes https://github.com/Khopa/dcs_liberation/issues/348 --- changelog.md | 5 + game/data/doctrine.py | 5 + gen/aircraft.py | 50 ++++++-- gen/ato.py | 1 + gen/flights/flight.py | 3 + gen/flights/flightplan.py | 131 ++++++++++++++++++-- gen/flights/traveltime.py | 12 +- gen/flights/waypointbuilder.py | 50 ++++++++ qt_ui/widgets/combos/QFlightTypeComboBox.py | 1 + 9 files changed, 237 insertions(+), 21 deletions(-) diff --git a/changelog.md b/changelog.md index b5b938aa..bbb34464 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,8 @@ +# 2.3.0 + +# Features/Improvements +* **[Flight Planner]** Added fighter sweep missions. + # 2.2.1 # Features/Improvements diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 99bb254a..fce67b1b 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -36,6 +36,8 @@ class Doctrine: cas_duration: timedelta + sweep_distance: int + MODERN_DOCTRINE = Doctrine( cap=True, @@ -62,6 +64,7 @@ MODERN_DOCTRINE = Doctrine( cap_min_distance_from_cp=nm_to_meter(10), cap_max_distance_from_cp=nm_to_meter(40), cas_duration=timedelta(minutes=30), + sweep_distance=nm_to_meter(60), ) COLDWAR_DOCTRINE = Doctrine( @@ -89,6 +92,7 @@ COLDWAR_DOCTRINE = Doctrine( cap_min_distance_from_cp=nm_to_meter(8), cap_max_distance_from_cp=nm_to_meter(25), cas_duration=timedelta(minutes=30), + sweep_distance=nm_to_meter(40), ) WWII_DOCTRINE = Doctrine( @@ -116,4 +120,5 @@ WWII_DOCTRINE = Doctrine( cap_min_distance_from_cp=nm_to_meter(0), cap_max_distance_from_cp=nm_to_meter(5), cas_duration=timedelta(minutes=30), + sweep_distance=nm_to_meter(10), ) diff --git a/gen/aircraft.py b/gen/aircraft.py index 7c4eac80..9ef18fec 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, Union, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union from dcs import helicopters from dcs.action import AITaskPush, ActivateGroup @@ -13,10 +13,12 @@ from dcs.condition import CoalitionHasAirdrome, TimeAfter from dcs.country import Country from dcs.flyingunit import FlyingUnit from dcs.helicopters import UH_1H, helicopter_map +from dcs.mapping import Point from dcs.mission import Mission, StartType from dcs.planes import ( AJS37, B_17G, + B_52H, Bf_109K_4, FW_190A8, FW_190D9, @@ -31,7 +33,8 @@ from dcs.planes import ( P_51D_30_NA, SpitfireLFMkIX, SpitfireLFMkIXCW, - Su_33, A_20G, Tu_22M3, B_52H, + Su_33, + Tu_22M3, ) from dcs.point import MovingPoint, PointAction from dcs.task import ( @@ -49,10 +52,8 @@ from dcs.task import ( OptRTBOnBingoFuel, OptRTBOnOutOfAmmo, OptReactOnThreat, - OptRestrictAfterburner, OptRestrictJettison, OrbitAction, - PinpointStrike, SEAD, StartCommand, Targets, @@ -71,6 +72,7 @@ from game.utils import nm_to_meter from gen.airsupportgen import AirSupport from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit +from gen.conflictgen import FRONTLINE_LENGTH from gen.flights.flight import ( Flight, FlightType, @@ -79,15 +81,14 @@ from gen.flights.flight import ( ) from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio from gen.runways import RunwayData -from gen.conflictgen import FRONTLINE_LENGTH -from dcs.mapping import Point from theater import TheaterGroundObject from theater.controlpoint import ControlPoint, ControlPointType from .conflictgen import Conflict from .flights.flightplan import ( CasFlightPlan, - FormationFlightPlan, + LoiterFlightPlan, PatrollingFlightPlan, + SweepFlightPlan, ) from .flights.traveltime import TotEstimator from .naming import namegen @@ -1035,9 +1036,6 @@ class AircraftConflictGenerator: self.configure_behavior(group, rtb_winchester=ammo_type) - group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(50), - targets=[Targets.All.Air])) - def configure_cas(self, group: FlyingGroup, package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData]) -> None: @@ -1118,7 +1116,7 @@ class AircraftConflictGenerator: dynamic_runways: Dict[str, RunwayData]) -> None: flight_type = flight.flight_type if flight_type in [FlightType.BARCAP, FlightType.TARCAP, - FlightType.INTERCEPTION]: + FlightType.INTERCEPTION, FlightType.SWEEP]: self.configure_cap(group, package, flight, dynamic_runways) elif flight_type in [FlightType.CAS, FlightType.BAI]: self.configure_cas(group, package, flight, dynamic_runways) @@ -1278,6 +1276,7 @@ class PydcsWaypointBuilder: FlightWaypointType.LANDING_POINT: LandingPointBuilder, FlightWaypointType.LOITER: HoldPointBuilder, FlightWaypointType.PATROL_TRACK: RaceTrackBuilder, + FlightWaypointType.INGRESS_SWEEP: SweepIngressBuilder, } builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder) return builder(waypoint, group, package, flight, mission) @@ -1314,7 +1313,7 @@ class HoldPointBuilder(PydcsWaypointBuilder): altitude=waypoint.alt, pattern=OrbitAction.OrbitPattern.Circle )) - if not isinstance(self.flight.flight_plan, FormationFlightPlan): + if not isinstance(self.flight.flight_plan, LoiterFlightPlan): flight_plan_type = self.flight.flight_plan.__class__.__name__ logging.error( f"Cannot configure hold for for {self.flight} because " @@ -1458,6 +1457,23 @@ class StrikeIngressBuilder(PydcsWaypointBuilder): return waypoint +class SweepIngressBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + + if not isinstance(self.flight.flight_plan, SweepFlightPlan): + flight_plan_type = self.flight.flight_plan.__class__.__name__ + logging.error( + f"Cannot create sweep for {self.flight} because " + f"{flight_plan_type} is not a sweep flight plan.") + return waypoint + + waypoint.tasks.append(EngageTargets(max_distance=nm_to_meter(50), + targets=[Targets.All.Air])) + + return waypoint + + class JoinPointBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() @@ -1532,4 +1548,14 @@ class RaceTrackBuilder(PydcsWaypointBuilder): racetrack.stop_after_time( int(self.flight.flight_plan.patrol_end_time.total_seconds())) waypoint.add_task(racetrack) + + # TODO: Move the properties of this task into the flight plan? + # CAP is the only current user of this so it's not a big deal, but might + # be good to make this usable for things like BAI when we add that + # later. + cap_types = {FlightType.BARCAP, FlightType.TARCAP} + if self.flight.flight_type in cap_types: + waypoint.tasks.append(EngageTargets(max_distance=nm_to_meter(50), + targets=[Targets.All.Air])) + return waypoint diff --git a/gen/ato.py b/gen/ato.py index d814e5ee..a21563dc 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -159,6 +159,7 @@ class Package: FlightType.TARCAP, FlightType.CAP, FlightType.BARCAP, + FlightType.SWEEP, FlightType.EWAR, FlightType.ESCORT, ] diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 2462a0a5..a19d362c 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -38,6 +38,8 @@ class FlightType(Enum): RECON = 15 EWAR = 16 + SWEEP = 17 + class FlightWaypointType(Enum): TAKEOFF = 0 # Take off point @@ -61,6 +63,7 @@ class FlightWaypointType(Enum): LOITER = 18 INGRESS_ESCORT = 19 INGRESS_DEAD = 20 + INGRESS_SWEEP = 21 class FlightWaypoint: diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index c73eb3ae..ed6561f5 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -105,6 +105,15 @@ class FlightPlan: """ raise NotImplementedError + @property + def tot_offset(self) -> timedelta: + """This flight's offset from the package's TOT. + + Positive values represent later TOTs. An offset of -2 minutes is used + for a flight that has a TOT 2 minutes before the rest of the package. + """ + return timedelta() + # Not cached because changes to the package might alter the formation speed. @property def travel_time_to_target(self) -> Optional[timedelta]: @@ -147,8 +156,33 @@ class FlightPlan: @dataclass(frozen=True) -class FormationFlightPlan(FlightPlan): +class LoiterFlightPlan(FlightPlan): hold: FlightWaypoint + + @property + def waypoints(self) -> List[FlightWaypoint]: + raise NotImplementedError + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + raise NotImplementedError + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + raise NotImplementedError + + @property + def push_time(self) -> timedelta: + raise NotImplementedError + + def depart_time_for_waypoint( + self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.hold: + return self.push_time + return None + + +@dataclass(frozen=True) +class FormationFlightPlan(LoiterFlightPlan): join: FlightWaypoint split: FlightWaypoint @@ -215,12 +249,6 @@ class FormationFlightPlan(FlightPlan): return self.split_time return None - def depart_time_for_waypoint( - self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.hold: - return self.push_time - return None - @property def push_time(self) -> timedelta: return self.join_time - TravelTime.between_points( @@ -461,6 +489,64 @@ class StrikeFlightPlan(FormationFlightPlan): return super().tot_for_waypoint(waypoint) +@dataclass(frozen=True) +class SweepFlightPlan(LoiterFlightPlan): + takeoff: FlightWaypoint + sweep_start: FlightWaypoint + sweep_end: FlightWaypoint + land: FlightWaypoint + lead_time: timedelta + + @property + def waypoints(self) -> List[FlightWaypoint]: + return [ + self.takeoff, + self.hold, + self.sweep_start, + self.sweep_end, + self.land, + ] + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + return self.sweep_end + + @property + def tot_offset(self) -> timedelta: + return -self.lead_time + + @property + def sweep_start_time(self) -> timedelta: + travel_time = self.travel_time_between_waypoints( + self.sweep_start, self.sweep_end) + return self.sweep_end_time - travel_time + + @property + def sweep_end_time(self) -> timedelta: + return self.package.time_over_target + self.tot_offset + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.sweep_start: + return self.sweep_start_time + if waypoint == self.sweep_end: + return self.sweep_end_time + return None + + def depart_time_for_waypoint( + self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.hold: + return self.push_time + return None + + @property + def push_time(self) -> timedelta: + return self.sweep_end_time - TravelTime.between_points( + self.hold.position, + self.sweep_end.position, + GroundSpeed.for_flight(self.flight, self.hold.alt) + ) + + @dataclass(frozen=True) class CustomFlightPlan(FlightPlan): custom_waypoints: List[FlightWaypoint] @@ -546,6 +632,8 @@ class FlightPlanBuilder: return self.generate_sead(flight, custom_targets) elif task == FlightType.STRIKE: return self.generate_strike(flight) + elif task == FlightType.SWEEP: + return self.generate_sweep(flight) elif task == FlightType.TARCAP: return self.generate_frontline_cap(flight) elif task == FlightType.TROOP_TRANSPORT: @@ -671,6 +759,35 @@ class FlightPlanBuilder: land=land ) + def generate_sweep(self, flight: Flight) -> SweepFlightPlan: + """Generate a BARCAP flight at a given location. + + Args: + flight: The flight to generate the flight plan for. + """ + target = self.package.target.position + + heading = self._heading_to_package_airfield(target) + start = target.point_from_heading(heading, + -self.doctrine.sweep_distance) + + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) + descent, land = builder.rtb(flight.from_cp) + + start, end = builder.sweep(start, target, + self.doctrine.ingress_altitude) + + return SweepFlightPlan( + package=self.package, + flight=flight, + lead_time=timedelta(minutes=5), + takeoff=builder.takeoff(flight.from_cp), + hold=builder.hold(self._hold_point(flight)), + sweep_start=start, + sweep_end=end, + land=land + ) + def generate_frontline_cap(self, flight: Flight) -> FrontLineCapFlightPlan: """Generate a CAP flight plan for the given front line. diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index ee9a6c7e..2a75afe3 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -128,7 +128,11 @@ class TotEstimator: f"time for {flight} will be immediate.") return timedelta() else: - tot = self.package.time_over_target + tot_waypoint = flight.flight_plan.tot_waypoint + if tot_waypoint is None: + tot = self.package.time_over_target + else: + tot = flight.flight_plan.tot_for_waypoint(tot_waypoint) return tot - travel_time - self.HOLD_TIME def earliest_tot(self) -> timedelta: @@ -165,9 +169,13 @@ class TotEstimator: # Return 0 so this flight's travel time does not affect the rest # of the package. return timedelta() + # Account for TOT offsets for the flight plan. An offset of -2 minutes + # means the flight's TOT is 2 minutes ahead of the package's so it needs + # an extra two minutes. + offset = -flight.flight_plan.tot_offset startup = self.estimate_startup(flight) ground_ops = self.estimate_ground_ops(flight) - return startup + ground_ops + time_to_target + return startup + ground_ops + time_to_target + offset @staticmethod def estimate_startup(flight: Flight) -> timedelta: diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index ddc76b5f..dd82f1aa 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -326,6 +326,56 @@ class WaypointBuilder: return (self.race_track_start(start, altitude), self.race_track_end(end, altitude)) + @staticmethod + def sweep_start(position: Point, altitude: int) -> FlightWaypoint: + """Creates a sweep start waypoint. + + Args: + position: Position of the waypoint. + altitude: Altitude of the sweep in meters. + """ + waypoint = FlightWaypoint( + FlightWaypointType.INGRESS_SWEEP, + position.x, + position.y, + altitude + ) + waypoint.name = "SWEEP START" + waypoint.description = "Proceed to the target and engage enemy aircraft" + waypoint.pretty_name = "Sweep start" + return waypoint + + @staticmethod + def sweep_end(position: Point, altitude: int) -> FlightWaypoint: + """Creates a sweep end waypoint. + + Args: + position: Position of the waypoint. + altitude: Altitude of the sweep in meters. + """ + waypoint = FlightWaypoint( + FlightWaypointType.EGRESS, + position.x, + position.y, + altitude + ) + waypoint.name = "SWEEP END" + waypoint.description = "End of sweep" + waypoint.pretty_name = "Sweep end" + return waypoint + + def sweep(self, start: Point, end: Point, + altitude: int) -> Tuple[FlightWaypoint, FlightWaypoint]: + """Creates two waypoint for a racetrack orbit. + + Args: + start: The beginning of the sweep. + end: The end of the sweep. + altitude: The sweep altitude. + """ + return (self.sweep_start(start, altitude), + self.sweep_end(end, altitude)) + def rtb(self, arrival: ControlPoint) -> Tuple[FlightWaypoint, FlightWaypoint]: """Creates descent ant landing waypoints for the given control point. diff --git a/qt_ui/widgets/combos/QFlightTypeComboBox.py b/qt_ui/widgets/combos/QFlightTypeComboBox.py index d1a27382..c1b42ccc 100644 --- a/qt_ui/widgets/combos/QFlightTypeComboBox.py +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -21,6 +21,7 @@ class QFlightTypeComboBox(QComboBox): FlightType.ESCORT, FlightType.SEAD, FlightType.DEAD, + FlightType.SWEEP, # TODO: FlightType.ELINT, # TODO: FlightType.EWAR, # TODO: FlightType.RECON, From e361a857a474b4610275a98a3ab655c1eff697de Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 15 Nov 2020 23:53:12 -0800 Subject: [PATCH 17/19] Only engage fighters with sweep. --- gen/aircraft.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gen/aircraft.py b/gen/aircraft.py index 9ef18fec..5aa6ba55 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -1468,8 +1468,9 @@ class SweepIngressBuilder(PydcsWaypointBuilder): f"{flight_plan_type} is not a sweep flight plan.") return waypoint - waypoint.tasks.append(EngageTargets(max_distance=nm_to_meter(50), - targets=[Targets.All.Air])) + waypoint.tasks.append(EngageTargets( + max_distance=nm_to_meter(50), + targets=[Targets.All.Air.Planes.Fighters])) return waypoint From 8eef1eaa7cdcd8f9672a93dc5e8f9771a1ce9a3d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 15 Nov 2020 23:59:20 -0800 Subject: [PATCH 18/19] Fix mypy issue. --- gen/flights/traveltime.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 2a75afe3..4756d287 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -133,6 +133,9 @@ class TotEstimator: tot = self.package.time_over_target else: tot = flight.flight_plan.tot_for_waypoint(tot_waypoint) + if tot is None: + logging.error(f"TOT waypoint for {flight} has no TOT") + tot = self.package.time_over_target return tot - travel_time - self.HOLD_TIME def earliest_tot(self) -> timedelta: From 28e00055ab7d6756fc4b25a432a90b51da7f9713 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 16 Nov 2020 00:32:50 -0800 Subject: [PATCH 19/19] Differentiate BARCAP and TARCAP. Previously the only difference between these was the objective type: TARCAP was for front lines and BARCAP was for everything else. Now BARCAP is for friendly areas and TARCAP is for enemy areas. The practical difference between the two is that a TARCAP package is like the old front line CAP in that it will adjust its patrol time to match the package if it can, and it will also arrive two minutes ahead of the rest of the package to clear the area if needed. --- changelog.md | 1 + gen/flights/ai_flight_planner.py | 6 +- gen/flights/flightplan.py | 125 ++++++++++-------- qt_ui/widgets/combos/QFlightTypeComboBox.py | 4 +- .../flight/waypoints/QFlightWaypointTab.py | 6 +- theater/frontline.py | 3 + theater/missiontarget.py | 4 + theater/theatergroundobject.py | 3 + 8 files changed, 93 insertions(+), 59 deletions(-) diff --git a/changelog.md b/changelog.md index bbb34464..4f71438e 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,7 @@ # Features/Improvements * **[Flight Planner]** Added fighter sweep missions. +* **[Flight Planner]** Differentiated BARCAP and TARCAP. TARCAP is now for hostile areas and will arrive before the package. # 2.2.1 diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index ce68be2d..008c344e 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -496,7 +496,11 @@ class CoalitionMissionPlanner: error = random.randint(-margin, margin) yield timedelta(minutes=max(0, time + error)) - dca_types = (FlightType.BARCAP, FlightType.INTERCEPTION) + dca_types = { + FlightType.BARCAP, + FlightType.INTERCEPTION, + FlightType.TARCAP, + } non_dca_packages = [p for p in self.ato.packages if p.primary_task not in dca_types] diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index ed6561f5..b16732d0 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -340,9 +340,10 @@ class CasFlightPlan(PatrollingFlightPlan): @dataclass(frozen=True) -class FrontLineCapFlightPlan(PatrollingFlightPlan): +class TarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint land: FlightWaypoint + lead_time: timedelta @property def waypoints(self) -> List[FlightWaypoint]: @@ -353,6 +354,10 @@ class FrontLineCapFlightPlan(PatrollingFlightPlan): self.land, ] + @property + def tot_offset(self) -> timedelta: + return -self.lead_time + def depart_time_for_waypoint( self, waypoint: FlightWaypoint) -> Optional[timedelta]: if waypoint == self.patrol_end: @@ -363,8 +368,8 @@ class FrontLineCapFlightPlan(PatrollingFlightPlan): def patrol_start_time(self) -> timedelta: start = self.package.escort_start_time if start is not None: - return start - return super().patrol_start_time + return start + self.tot_offset + return super().patrol_start_time + self.tot_offset @property def patrol_end_time(self) -> timedelta: @@ -374,6 +379,10 @@ class FrontLineCapFlightPlan(PatrollingFlightPlan): return super().patrol_end_time +# TODO: Remove when breaking save compat. +FrontLineCapFlightPlan = TarCapFlightPlan + + @dataclass(frozen=True) class StrikeFlightPlan(FormationFlightPlan): takeoff: FlightWaypoint @@ -635,7 +644,7 @@ class FlightPlanBuilder: elif task == FlightType.SWEEP: return self.generate_sweep(flight) elif task == FlightType.TARCAP: - return self.generate_frontline_cap(flight) + return self.generate_tarcap(flight) elif task == FlightType.TROOP_TRANSPORT: logging.error( "Troop transport flight plan generation not implemented" @@ -704,47 +713,12 @@ class FlightPlanBuilder: if isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) + start, end = self.racetrack_for_objective(location) patrol_alt = random.randint( self.doctrine.min_patrol_altitude, self.doctrine.max_patrol_altitude ) - closest_cache = ObjectiveDistanceCache.get_closest_airfields(location) - for airfield in closest_cache.closest_airfields: - # If the mission is a BARCAP of an enemy airfield, find the *next* - # closest enemy airfield. - if airfield == self.package.target: - continue - if airfield.captured != self.is_player: - closest_airfield = airfield - break - else: - raise PlanningError("Could not find any enemy airfields") - - heading = location.position.heading_between_point( - closest_airfield.position - ) - - min_distance_from_enemy = nm_to_meter(20) - distance_to_airfield = int(closest_airfield.position.distance_to_point( - self.package.target.position - )) - distance_to_no_fly = distance_to_airfield - min_distance_from_enemy - min_cap_distance = min(self.doctrine.cap_min_distance_from_cp, - distance_to_no_fly) - max_cap_distance = min(self.doctrine.cap_max_distance_from_cp, - distance_to_no_fly) - - end = location.position.point_from_heading( - heading, - random.randint(min_cap_distance, max_cap_distance) - ) - diameter = random.randint( - self.doctrine.cap_min_track_length, - self.doctrine.cap_max_track_length - ) - start = end.point_from_heading(heading - 180, diameter) - builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) start, end = builder.race_track(start, end, patrol_alt) descent, land = builder.rtb(flight.from_cp) @@ -788,20 +762,48 @@ class FlightPlanBuilder: land=land ) - def generate_frontline_cap(self, flight: Flight) -> FrontLineCapFlightPlan: - """Generate a CAP flight plan for the given front line. + def racetrack_for_objective(self, + location: MissionTarget) -> Tuple[Point, Point]: + closest_cache = ObjectiveDistanceCache.get_closest_airfields(location) + for airfield in closest_cache.closest_airfields: + # If the mission is a BARCAP of an enemy airfield, find the *next* + # closest enemy airfield. + if airfield == self.package.target: + continue + if airfield.captured != self.is_player: + closest_airfield = airfield + break + else: + raise PlanningError("Could not find any enemy airfields") - Args: - flight: The flight to generate the flight plan for. - """ - location = self.package.target + heading = location.position.heading_between_point( + closest_airfield.position + ) - if not isinstance(location, FrontLine): - raise InvalidObjectiveLocation(flight.flight_type, location) + min_distance_from_enemy = nm_to_meter(20) + distance_to_airfield = int(closest_airfield.position.distance_to_point( + self.package.target.position + )) + distance_to_no_fly = distance_to_airfield - min_distance_from_enemy + min_cap_distance = min(self.doctrine.cap_min_distance_from_cp, + distance_to_no_fly) + max_cap_distance = min(self.doctrine.cap_max_distance_from_cp, + distance_to_no_fly) - ally_cp, enemy_cp = location.control_points - patrol_alt = random.randint(self.doctrine.min_patrol_altitude, - self.doctrine.max_patrol_altitude) + end = location.position.point_from_heading( + heading, + random.randint(min_cap_distance, max_cap_distance) + ) + diameter = random.randint( + self.doctrine.cap_min_track_length, + self.doctrine.cap_max_track_length + ) + start = end.point_from_heading(heading - 180, diameter) + return start, end + + def racetrack_for_frontline(self, + front_line: FrontLine) -> Tuple[Point, Point]: + ally_cp, enemy_cp = front_line.control_points # Find targets waypoints ingress, heading, distance = Conflict.frontline_vector( @@ -822,14 +824,33 @@ class FlightPlanBuilder: orbit0p = orbit_center.point_from_heading(heading, radius) orbit1p = orbit_center.point_from_heading(heading + 180, radius) + return orbit0p, orbit1p + + def generate_tarcap(self, flight: Flight) -> TarCapFlightPlan: + """Generate a CAP flight plan for the given front line. + + Args: + flight: The flight to generate the flight plan for. + """ + location = self.package.target + + patrol_alt = random.randint(self.doctrine.min_patrol_altitude, + self.doctrine.max_patrol_altitude) + # Create points builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) + if isinstance(location, FrontLine): + orbit0p, orbit1p = self.racetrack_for_frontline(location) + else: + orbit0p, orbit1p = self.racetrack_for_objective(location) + start, end = builder.race_track(orbit0p, orbit1p, patrol_alt) descent, land = builder.rtb(flight.from_cp) - return FrontLineCapFlightPlan( + return TarCapFlightPlan( package=self.package, flight=flight, + lead_time=timedelta(minutes=2), # Note that this duration only has an effect if there are no # flights in the package that have requested escort. If the package # requests an escort the CAP flight will remain on station for the diff --git a/qt_ui/widgets/combos/QFlightTypeComboBox.py b/qt_ui/widgets/combos/QFlightTypeComboBox.py index c1b42ccc..8adf0bbc 100644 --- a/qt_ui/widgets/combos/QFlightTypeComboBox.py +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -18,6 +18,7 @@ class QFlightTypeComboBox(QComboBox): """Combo box for selecting a flight task type.""" COMMON_ENEMY_MISSIONS = [ + FlightType.TARCAP, FlightType.ESCORT, FlightType.SEAD, FlightType.DEAD, @@ -50,7 +51,6 @@ class QFlightTypeComboBox(QComboBox): ] ENEMY_AIRBASE_MISSIONS = [ - FlightType.BARCAP, # TODO: FlightType.STRIKE ] + COMMON_ENEMY_MISSIONS @@ -60,13 +60,11 @@ class QFlightTypeComboBox(QComboBox): ] + COMMON_FRIENDLY_MISSIONS ENEMY_GROUND_OBJECT_MISSIONS = [ - FlightType.BARCAP, FlightType.STRIKE, ] + COMMON_ENEMY_MISSIONS FRONT_LINE_MISSIONS = [ FlightType.CAS, - FlightType.TARCAP, # TODO: FlightType.TROOP_TRANSPORT # TODO: FlightType.EVAC ] + COMMON_ENEMY_MISSIONS diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 43198d28..5f031622 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -149,10 +149,10 @@ class QFlightWaypointTab(QFrame): # departs, whereas BARCAP usually isn't part of a strike package and # has a fixed mission time. if task == FlightType.CAP: - if isinstance(self.package.target, FrontLine): - task = FlightType.TARCAP - else: + if self.package.target.is_friendly(to_player=True): task = FlightType.BARCAP + else: + task = FlightType.TARCAP self.flight.flight_type = task self.planner.populate_flight_plan(self.flight) self.flight_waypoint_list.update_list() diff --git a/theater/frontline.py b/theater/frontline.py index c70b3417..0ef17079 100644 --- a/theater/frontline.py +++ b/theater/frontline.py @@ -42,3 +42,6 @@ class FrontLine(MissionTarget): def control_points(self) -> Tuple[ControlPoint, ControlPoint]: """Returns a tuple of the two control points.""" return self.control_point_a, self.control_point_b + + def is_friendly(self, to_player: bool) -> bool: + return False diff --git a/theater/missiontarget.py b/theater/missiontarget.py index fb4da0f3..ea9ccec8 100644 --- a/theater/missiontarget.py +++ b/theater/missiontarget.py @@ -17,3 +17,7 @@ class MissionTarget: def distance_to(self, other: MissionTarget) -> int: """Computes the distance to the given mission target.""" return self.position.distance_to_point(other.position) + + def is_friendly(self, to_player: bool) -> bool: + """Returns True if the objective is in friendly territory.""" + raise NotImplementedError diff --git a/theater/theatergroundobject.py b/theater/theatergroundobject.py index 0e8b3c87..293c392f 100644 --- a/theater/theatergroundobject.py +++ b/theater/theatergroundobject.py @@ -113,6 +113,9 @@ class TheaterGroundObject(MissionTarget): def faction_color(self) -> str: return "BLUE" if self.control_point.captured else "RED" + def is_friendly(self, to_player: bool) -> bool: + return not self.control_point.is_friendly(to_player) + class BuildingGroundObject(TheaterGroundObject): def __init__(self, name: str, category: str, group_id: int, object_id: int,