diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 6c5955b8..fa261ee8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -9,6 +9,8 @@ assignees: '' Before filing, please search the issue tracker to see if the issue has already been reported. +If reporting a DCS AI bug, check https://github.com/dcs-liberation/dcs_liberation#dcs-bugs. + **Describe the bug** A clear and concise description of what the bug is. diff --git a/.github/ISSUE_TEMPLATE/campaign_update.md b/.github/ISSUE_TEMPLATE/campaign_update.md new file mode 100644 index 00000000..6e0882ee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/campaign_update.md @@ -0,0 +1,28 @@ +--- +name: Campaign update submission +about: Submit an update to a campaign you maintain. +title: 'Update for ' +labels: campaign-update-submission +assignees: '' + +--- + +This form should only be used for submitted updated miz/json files for campaigns +distributed with Liberation. If you are _requesting_ an update to a campaign, see +https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance. If the +campaign has an owner, it will be updated before release. If it does not, you can +volunteer to own it. + +If you are not the owner of the campaign listed on +https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance, please start +there. + +Otherwise, delete everything above the line below and fill out the following form. Note: +GitHub does not accept .miz files. You can either rename the file to .miz.txt or add the +file to a .zip file. + +--- + +* Campaign name: +* Files: +* Update summary (optional): \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index ddf2c8f3..4176751f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -9,6 +9,8 @@ assignees: '' Before filing, please search the issue tracker to see if this feature has already been requested. +If requesting a DCS AI feature, check If reporting a DCS AI bug, check https://github.com/dcs-liberation/dcs_liberation#dcs-bugs. + **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ac6fc4f..4eaede79 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,10 +11,10 @@ jobs: with: submodules: true - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install environment run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e146388b..488c043d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,10 +13,10 @@ jobs: with: submodules: true - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install environment run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f795289..d0e863f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ Note that you may need to remove the filter for open bugs if it's something we'v ## Making content for Liberation -You can create new campaigns : See [campaign creation wiki](https://github.com/Khopa/dcs_liberation/wiki/Custom-Campaigns). +You can create new campaigns : See [campaign creation wiki](https://github.com/dcs-liberation/dcs_liberation/wiki/Custom-Campaigns). You can also improve existing campaigns. You can then submit new campaigns on the "campaigns" channel on Discord, or by making a pull request if you are comfortable with git. diff --git a/README.md b/README.md index 863fd7d4..7c487395 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ ![Logo](https://i.imgur.com/c2k18E1.png) -[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg?logo=paypal)](https://www.paypal.com/paypalme/KhopaDCSL) [![Patreon](https://img.shields.io/badge/patreon-become%20a%20patron-orange?logo=patreon)](https://patreon.com/khopa) -[![Download](https://img.shields.io/github/downloads/khopa/dcs_liberation/total?label=Download)](https://github.com/Khopa/dcs_liberation/releases) +[![Download](https://img.shields.io/github/downloads/dcs-liberation/dcs_liberation/total?label=Download)](https://github.com/dcs-liberation/dcs_liberation/releases) [![Discord](https://img.shields.io/discord/595702951800995872?label=Discord&logo=discord)](https://discord.gg/bKrtrkJ) -[![GitHub pull requests](https://img.shields.io/github/issues-pr/khopa/dcs_liberation)](https://github.com/Khopa/dcs_liberation) -[![GitHub issues](https://img.shields.io/github/issues/khopa/dcs_liberation)](https://github.com/Khopa/dcs_liberation/issues) -![GitHub stars](https://img.shields.io/github/stars/khopa/dcs_liberation?style=social) +[![GitHub pull requests](https://img.shields.io/github/issues-pr/dcs-liberation/dcs_liberation)](https://github.com/dcs-liberation/dcs_liberation) +[![GitHub issues](https://img.shields.io/github/issues/dcs-liberation/dcs_liberation)](https://github.com/dcs-liberation/dcs_liberation/issues) +![GitHub stars](https://img.shields.io/github/stars/dcs-liberation/dcs_liberation?style=social) ## About DCS Liberation DCS Liberation is a [DCS World](https://www.digitalcombatsimulator.com/en/products/world/) turn based single-player or co-op dynamic campaign. @@ -19,21 +18,29 @@ It is an external program that generates full and complex DCS missions and manag ## Downloads -Latest release is available here : https://github.com/Khopa/dcs_liberation/releases +Latest release is available here : https://github.com/dcs-liberation/dcs_liberation/releases -To download preview builds of the next version of DCS Liberation, see https://github.com/Khopa/dcs_liberation/wiki/Preview-builds. +To download preview builds of the next version of DCS Liberation, see https://github.com/dcs-liberation/dcs_liberation/wiki/Preview-builds. + +## DCS bugs + +These DCS bugs prevent us from improving AI behavior. Please upvote them! (But please +_don't_ spam them with comments): + +* [A2A and SEAD escorts don't escort](https://forums.eagle.ru/topic/251798-options-for-alternate-ai-escort-behavior/?tab=comments#comment-4668033) +* [DEAD can't use mixed loadouts effectively](https://forums.eagle.ru/topic/271941-ai-rtbs-after-firing-decoys-despite-full-load-of-bombs/) ## Bugs and feature requests -If you need to report a bug or want to suggest a new feature, you can do this on our [bug tracker](https://github.com/Khopa/dcs_liberation/issues). In either case, please use the search bar at the top of the page to see if it has already been reported. Note that you may need to remove the filter for open bugs if it's something we've recently fixed. +If you need to report a bug or want to suggest a new feature, you can do this on our [bug tracker](https://github.com/dcs-liberation/dcs_liberation/issues). In either case, please use the search bar at the top of the page to see if it has already been reported. Note that you may need to remove the filter for open bugs if it's something we've recently fixed. ## Roadmap -Our plans for future releases can be found on our [Projects page](https://github.com/Khopa/dcs_liberation/projects). Each planned release has a Project, and the page for that project has columns for to do, in progress, and done. Items in the Done column are in the [preview build](https://github.com/Khopa/dcs_liberation/wiki/Preview-builds) for that release. Items in the To do column are planned to be added to that release. +Our plans for future releases can be found on our [Projects page](https://github.com/dcs-liberation/dcs_liberation/projects). Each planned release has a Project, and the page for that project has columns for to do, in progress, and done. Items in the Done column are in the [preview build](https://github.com/dcs-liberation/dcs_liberation/wiki/Preview-builds) for that release. Items in the To do column are planned to be added to that release. ## Resources -Tutorials, contributors and developer's guides are available in the project's [Wiki](https://github.com/Khopa/dcs_liberation/wiki/) +Tutorials, contributors and developer's guides are available in the project's [Wiki](https://github.com/dcs-liberation/dcs_liberation/wiki/) ## Special Thanks diff --git a/changelog.md b/changelog.md index 85053e9c..d85dce66 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,59 @@ +# 3.0.0 + +Saves from 2.5 are not compatible with 3.0. + +## Features/Improvements + +* **[Campaign]** Ground units can now be transferred by road, airlift, and cargo ship. See https://github.com/dcs-liberation/dcs_liberation/wiki/Unit-Transfers for more information. +* **[Campaign]** Ground units can no longer be sold. To move units to a new location, transfer them. +* **[Campaign]** Ground units must now be recruited at a base with a factory and transferred to their destination. When buying units in the UI, the purchase will automatically be fulfilled at the closest factory, and a transfer will be created on the next turn. +* **[Campaign]** Non-control point FOBs will no longer spawn. +* **[Campaign]** Added squadrons and pilots. See https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots for more information. +* **[Campaign]** Capturing a base now depopulates all of its attached objectives with units: air defenses, EWRs, ships, armor groups, etc. Buildings are captured. +* **[Campaign]** Ammunition Depots determine how many ground units can be deployed on the frontline by a control point. +* **[Campaign AI]** AI now considers Ju-88s for CAS, strike, and DEAD missions. +* **[Campaign AI]** AI planned AEW&C missions will now be scheduled ASAP. +* **[Campaign AI]** AI now considers the range to the SAM's threat zone rather than the range to the SAM itself when determining target priorities. +* **[Campaign AI]** Auto purchase of ground units will now maintain unit composition instead of buying randomly. The unit composition is predefined. +* **[Campaign AI]** Auto purchase will aim to purchase enough ground units to support the frontline, plus 30% reserve units. +* **[Campaign AI]** Auto purchase will now adjust its air/ground balance to favor whichever is under-funded. +* **[Flight Planner]** Desired mission length is now configurable (defaults to 60 minutes). A BARCAP will be planned every 30 minutes. Other packages will simply have their takeoffs spread out or compressed such that the last flight will take off around the mission end time. +* **[Flight Planner]** Flight plans now include bullseye waypoints. +* **[Flight Planner]** Differentiated SEAD and SEAD escort. SEAD is tasked with suppressing the package target, SEAD escort is tasked with protecting the package from all SAMs along its route. +* **[Flight Planner]** Planned airspeed increased to 0.85 mach for supersonic airframes and 85% of max speed for subsonic. +* **[Flight Planner]** Taxi time estimation for airfields increased from 5 minutes to 8 minutes. +* **[Flight Planner]** Reduce expected error margin for flight plans from 10% to 5%. +* **[Flight Planner]** SEAD flights are scheduled one minute ahead of the package's TOT so that they can suppress the site ahead of the strike. +* **[Flight Planner]** Automatic ATO generation for the player's coalition can now be disabled in the settings. +* **[Payloads]** AI flights for most air to ground mission types (CAS excluded) will have their guns emptied to prevent strafing fully armed and operational battle stations. Gun-reliant airframes like A-10s and warbirds will keep their bullets. +* **[Kneeboard]** ATC table overflow alleviated by wrapping long airfield names and splitting ATC frequency and channel into separate rows. +* **[UI]** Overhauled the map implementation. Now uses satellite imagery instead of low res map images. Display options have moved from the toolbar to panels in the map. +* **[UI]** Campaigns generated for an older or newer version of the game will now be marked as incompatible. They can still be played, but bugs may be present. +* **[UI]** DCS loadouts are now selectable in the loadout setup menu. +* **[UI]** Added global aircraft inventory view under Air Wing dialog. +* **[UI]** Base menu now shows information about ground unit deployment limits. +* **[Modding]** Campaigns now choose locations for factories to spawn. +* **[Modding]** Campaigns now choose locations for ammunition depots to spawn. +* **[Modding]** Campaigns now use map structures as strike targets. +* **[Modding]** Campaigns may now set *any* objective type to be a required spawn rather than random chance. Support for random objective generation was removed. +* **[Modding]** Campaigns may now place AAA objectives. +* **[Modding]** Can now install custom factions to /Liberation/Factions instead of the Liberation install directory. +* **[Performance Settings]** Added a settings to lower the number of smoke effects generated on frontlines. Lowered default settings for frontline smoke generators, so less smoke should be generated by default. +* **[Configuration]** Liberation preferences (DCS install and save game location) are now saved to `%LOCALAPPDATA%/DCSLiberation` to prevent needing to reconfigure each new install. +* **[Skynet]** Updated to 2.1.0. + +## Fixes + +* **[Campaign AI]** Fix purchase of aircraft by priority (the faction's list was being used as the priority list rather than the game's). +* **[Campaign AI]** Fixed bug causing AI to over-purchase cheap aircraft. +* **[Campaign AI]** Auto planner will no longer attempt to plan missions for which the faction has no compatible aircraft. +* **[Campaign AI]** Stop purchasing aircraft after the first unaffordable package to attempt to complete more packages rather than filling airfields with cheap escorts that will never be used. +* **[Campaign]** Fixed bug where offshore strike locations were being used to spawn ship objectives. +* **[Campaign]** EWR sites are now purchasable. +* **[Flight Planner]** AI strike flight plans now include the correct target actions for building groups. +* **[Flight Planner]** AI BAI/DEAD/SEAD flights now have tasks to attack all groups at the target location, not just the primary group (for multi-group SAM sites). +* **[Flight Planner]** Fixed some contexts where damaged runways would be used. Destroying a carrier will no longer break the game. + # 2.5.1 ## Features/Improvements diff --git a/game/data/__init__.py b/game/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/game/data/aaa_db.py b/game/data/aaa_db.py index fba276a3..3008011e 100644 --- a/game/data/aaa_db.py +++ b/game/data/aaa_db.py @@ -7,9 +7,9 @@ AAA_UNITS = [ AirDefence.AAA_ZU_23_Closed_Emplacement, AirDefence.AAA_ZU_23_Emplacement, AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375, - AirDefence.AAA_ZU_23_Closed_Emplacement_Insurgent, + AirDefence.AAA_ZU_23_Insurgent_Closed_Emplacement, AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375, - AirDefence.AAA_ZU_23_Insurgent, + AirDefence.AAA_ZU_23_Insurgent_Emplacement, AirDefence.AAA_8_8cm_Flak_18, AirDefence.AAA_Flak_38_20mm, AirDefence.AAA_8_8cm_Flak_36, @@ -17,6 +17,6 @@ AAA_UNITS = [ AirDefence.AAA_Flak_Vierling_38_Quad_20mm, AirDefence.AAA_SP_Kdo_G_40, AirDefence.AAA_8_8cm_Flak_41, - AirDefence.AAA_40mm_Bofors, + AirDefence.AAA_Bofors_40mm, AirDefence.AAA_S_60_57mm, ] diff --git a/game/data/alic.py b/game/data/alic.py new file mode 100644 index 00000000..84436837 --- /dev/null +++ b/game/data/alic.py @@ -0,0 +1,40 @@ +from dcs.unit import Unit +from dcs.vehicles import AirDefence + + +class AlicCodes: + CODES = { + AirDefence.EWR_1L13.id: 101, + AirDefence.EWR_55G6.id: 102, + AirDefence.SAM_SA_10_S_300_Grumble_Clam_Shell_SR.id: 103, + AirDefence.SAM_SA_10_S_300_Grumble_Big_Bird_SR.id: 104, + AirDefence.SAM_SA_11_Buk_Gadfly_Snow_Drift_SR.id: 107, + AirDefence.SAM_SA_6_Kub_Straight_Flush_STR.id: 108, + AirDefence.MCC_SR_Sborka_Dog_Ear_SR.id: 109, + AirDefence.SAM_SA_10_S_300_Grumble_Flap_Lid_TR.id: 110, + AirDefence.SAM_SA_11_Buk_Gadfly_Fire_Dome_TEL.id: 115, + AirDefence.SAM_SA_8_Osa_Gecko_TEL.id: 117, + AirDefence.SAM_SA_13_Strela_10M3_Gopher_TEL.id: 118, + AirDefence.SAM_SA_15_Tor_Gauntlet.id: 119, + AirDefence.SAM_SA_19_Tunguska_Grison.id: 120, + AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish.id: 121, + AirDefence.SAM_P19_Flat_Face_SR__SA_2_3.id: 122, + AirDefence.SAM_SA_3_S_125_Low_Blow_TR.id: 123, + AirDefence.SAM_Rapier_Blindfire_TR.id: 124, + AirDefence.SAM_Rapier_LN.id: 125, + AirDefence.SAM_SA_2_S_75_Fan_Song_TR.id: 126, + AirDefence.HQ_7_Self_Propelled_LN.id: 127, + AirDefence.HQ_7_Self_Propelled_STR.id: 128, + AirDefence.SAM_Roland_ADS.id: 201, + AirDefence.SAM_Patriot_STR.id: 202, + AirDefence.SAM_Hawk_SR__AN_MPQ_50.id: 203, + AirDefence.SAM_Hawk_TR__AN_MPQ_46.id: 204, + AirDefence.SAM_Roland_EWR.id: 205, + AirDefence.SAM_Hawk_CWAR_AN_MPQ_55.id: 206, + AirDefence.SPAAA_Gepard.id: 207, + AirDefence.SPAAA_Vulcan_M163.id: 208, + } + + @classmethod + def code_for(cls, unit: Unit) -> int: + return cls.CODES[unit.type] diff --git a/game/data/building_data.py b/game/data/building_data.py index 5331ebbd..9b0dd2a4 100644 --- a/game/data/building_data.py +++ b/game/data/building_data.py @@ -3,37 +3,30 @@ import dcs DEFAULT_AVAILABLE_BUILDINGS = [ "fuel", - "ammo", "comms", "oil", "ware", "farp", - "fob", "power", - "factory", "derrick", ] -WW2_FREE = ["fuel", "factory", "ware", "fob"] +WW2_FREE = ["fuel", "ware"] WW2_GERMANY_BUILDINGS = [ "fuel", - "factory", "ww2bunker", "ww2bunker", "ww2bunker", "allycamp", "allycamp", - "fob", ] WW2_ALLIES_BUILDINGS = [ "fuel", - "factory", "allycamp", "allycamp", "allycamp", "allycamp", "allycamp", - "fob", ] FORTIFICATION_BUILDINGS = [ diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 31b0a03b..262d5fa5 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,7 +1,20 @@ from dataclasses import dataclass from datetime import timedelta +from dcs.task import Reconnaissance from game.utils import Distance, feet, nautical_miles +from game.data.groundunitclass import GroundUnitClass + + +@dataclass +class GroundUnitProcurementRatios: + ratios: dict[GroundUnitClass, float] + + def for_unit_class(self, unit_class: GroundUnitClass) -> float: + try: + return self.ratios[unit_class] / sum(self.ratios.values()) + except KeyError: + return 0.0 @dataclass(frozen=True) @@ -50,6 +63,8 @@ class Doctrine: sweep_distance: Distance + ground_unit_procurement_ratios: GroundUnitProcurementRatios + MODERN_DOCTRINE = Doctrine( cap=True, @@ -76,6 +91,17 @@ MODERN_DOCTRINE = Doctrine( cap_engagement_range=nautical_miles(50), cas_duration=timedelta(minutes=30), sweep_distance=nautical_miles(60), + ground_unit_procurement_ratios=GroundUnitProcurementRatios( + { + GroundUnitClass.Tank: 3, + GroundUnitClass.Atgm: 2, + GroundUnitClass.Apc: 2, + GroundUnitClass.Ifv: 3, + GroundUnitClass.Artillery: 1, + GroundUnitClass.Shorads: 2, + GroundUnitClass.Recon: 1, + } + ), ) COLDWAR_DOCTRINE = Doctrine( @@ -103,6 +129,17 @@ COLDWAR_DOCTRINE = Doctrine( cap_engagement_range=nautical_miles(35), cas_duration=timedelta(minutes=30), sweep_distance=nautical_miles(40), + ground_unit_procurement_ratios=GroundUnitProcurementRatios( + { + GroundUnitClass.Tank: 4, + GroundUnitClass.Atgm: 2, + GroundUnitClass.Apc: 3, + GroundUnitClass.Ifv: 2, + GroundUnitClass.Artillery: 1, + GroundUnitClass.Shorads: 2, + GroundUnitClass.Recon: 1, + } + ), ) WWII_DOCTRINE = Doctrine( @@ -130,4 +167,14 @@ WWII_DOCTRINE = Doctrine( cap_engagement_range=nautical_miles(20), cas_duration=timedelta(minutes=30), sweep_distance=nautical_miles(10), + ground_unit_procurement_ratios=GroundUnitProcurementRatios( + { + GroundUnitClass.Tank: 3, + GroundUnitClass.Atgm: 3, + GroundUnitClass.Apc: 3, + GroundUnitClass.Artillery: 1, + GroundUnitClass.Shorads: 3, + GroundUnitClass.Recon: 1, + } + ), ) diff --git a/game/data/groundunitclass.py b/game/data/groundunitclass.py new file mode 100644 index 00000000..4b2f6e58 --- /dev/null +++ b/game/data/groundunitclass.py @@ -0,0 +1,239 @@ +from enum import unique, Enum +from typing import Type + +from dcs.vehicles import AirDefence, Infantry, Unarmed, Artillery, Armor +from dcs.unittype import VehicleType + +from pydcs_extensions.frenchpack import frenchpack + + +@unique +class GroundUnitClass(Enum): + Tank = ( + "Tank", + ( + Armor.MBT_T_55, + Armor.MBT_T_72B, + Armor.MBT_T_72B3, + Armor.MBT_T_80U, + Armor.MBT_T_90, + Armor.MBT_Leopard_2A4, + Armor.MBT_Leopard_2A4_Trs, + Armor.MBT_Leopard_2A5, + Armor.MBT_Leopard_2A6M, + Armor.MBT_Leopard_1A3, + Armor.MBT_Leclerc, + Armor.MBT_Challenger_II, + Armor.MBT_Chieftain_Mk_3, + Armor.MBT_M1A2_Abrams, + Armor.MBT_M60A3_Patton, + Armor.MBT_Merkava_IV, + Armor.ZTZ_96B, + # WW2 + # Axis + Armor.Tk_PzIV_H, + Armor.SPG_Sturmpanzer_IV_Brummbar, + Armor.MT_Pz_Kpfw_V_Panther_Ausf_G, + Armor.HT_Pz_Kpfw_VI_Tiger_I, + Armor.HT_Pz_Kpfw_VI_Ausf__B_Tiger_II, + # Allies + Armor.Tk_M4_Sherman, + Armor.CT_Centaur_IV, + Armor.CT_Cromwell_IV, + Armor.HIT_Churchill_VII, + # Mods + frenchpack.DIM__TOYOTA_BLUE, + frenchpack.DIM__TOYOTA_GREEN, + frenchpack.DIM__TOYOTA_DESERT, + frenchpack.DIM__KAMIKAZE, + frenchpack.AMX_30B2, + frenchpack.Leclerc_Serie_XXI, + ), + ) + + Atgm = ( + "ATGM", + ( + Armor.ATGM_HMMWV, + Armor.ATGM_VAB_Mephisto, + Armor.ATGM_Stryker, + Armor.IFV_BMP_2, + # WW2 (Tank Destroyers) + # Axxis + Armor.SPG_StuG_III_Ausf__G, + Armor.SPG_StuG_IV, + Armor.SPG_Jagdpanzer_IV, + Armor.SPG_Jagdpanther_G1, + Armor.SPG_Sd_Kfz_184_Elefant, + # Allies + Armor.SPG_M10_GMC, + Armor.MT_M4A4_Sherman_Firefly, + # Mods + frenchpack.VBAE_CRAB_MMP, + frenchpack.VAB_MEPHISTO, + frenchpack.TRM_2000_PAMELA, + ), + ) + + Ifv = ( + "IFV", + ( + Armor.IFV_BMP_3, + Armor.IFV_BMP_2, + Armor.IFV_BMP_1, + Armor.IFV_Marder, + Armor.IFV_Warrior, + Armor.SPG_Stryker_MGS, + Armor.IFV_M2A2_Bradley, + Armor.IFV_BMD_1, + Armor.ZBD_04A, + # Mods + frenchpack.VBAE_CRAB, + frenchpack.VAB_T20_13, + ), + ) + + Apc = ( + "APC", + ( + Armor.IFV_M1126_Stryker_ICV, + Armor.APC_M113, + Armor.APC_BTR_80, + Armor.IFV_BTR_82A, + Armor.APC_MTLB, + Armor.APC_AAV_7_Amphibious, + Armor.APC_TPz_Fuchs, + Armor.APC_BTR_RD, + # WW2 + Armor.APC_M2A1_Halftrack, + Armor.APC_Sd_Kfz_251_Halftrack, + # Mods + frenchpack.VAB__50, + frenchpack.VBL__50, + frenchpack.VBL_AANF1, + ), + ) + + Artillery = ( + "Artillery", + ( + Artillery.Grad_MRL_FDDM__FC, + Artillery.MLRS_9A52_Smerch_HE_300mm, + Artillery.SPH_2S1_Gvozdika_122mm, + Artillery.SPH_2S3_Akatsia_152mm, + Artillery.MLRS_BM_21_Grad_122mm, + Artillery.MLRS_9K57_Uragan_BM_27_220mm, + Artillery.SPH_M109_Paladin_155mm, + Artillery.MLRS_M270_227mm, + Artillery.SPM_2S9_Nona_120mm_M, + Artillery.SPH_Dana_vz77_152mm, + Artillery.SPH_T155_Firtina_155mm, + Artillery.PLZ_05, + Artillery.SPH_2S19_Msta_152mm, + Artillery.MLRS_9A52_Smerch_CM_300mm, + # WW2 + Artillery.SPG_M12_GMC_155mm, + ), + ) + + Logistics = ( + "Logistics", + ( + Unarmed.Carrier_M30_Cargo, + Unarmed.Truck_M818_6x6, + Unarmed.Truck_KAMAZ_43101, + Unarmed.Truck_Ural_375, + Unarmed.Truck_GAZ_66, + Unarmed.Truck_GAZ_3307, + Unarmed.Truck_GAZ_3308, + Unarmed.Truck_Ural_4320_31_Arm_d, + Unarmed.Truck_Ural_4320T, + Unarmed.Truck_Opel_Blitz, + Unarmed.LUV_Kubelwagen_82, + Unarmed.Carrier_Sd_Kfz_7_Tractor, + Unarmed.LUV_Kettenrad, + Unarmed.Car_Willys_Jeep, + Unarmed.LUV_Land_Rover_109, + Unarmed.Truck_Land_Rover_101_FC, + # Mods + frenchpack.VBL, + frenchpack.VAB, + ), + ) + + Recon = ( + "Recon", + ( + Armor.Scout_HMMWV, + Armor.Scout_Cobra, + Armor.LT_PT_76, + Armor.IFV_LAV_25, + Armor.Scout_BRDM_2, + # WW2 + Armor.LT_Mk_VII_Tetrarch, + Armor.IFV_Sd_Kfz_234_2_Puma, + Armor.Car_M8_Greyhound_Armored, + Armor.Car_Daimler_Armored, + # Mods + frenchpack.ERC_90, + frenchpack.AMX_10RCR, + frenchpack.AMX_10RCR_SEPAR, + ), + ) + + Infantry = ( + "Infantry", + ( + Infantry.Insurgent_AK_74, + Infantry.Infantry_AK_74, + Infantry.Infantry_M1_Garand, + Infantry.Infantry_Mauser_98, + Infantry.Infantry_SMLE_No_4_Mk_1, + Infantry.Infantry_M4_Georgia, + Infantry.Infantry_AK_74_Rus, + Infantry.Paratrooper_AKS, + Infantry.Paratrooper_RPG_16, + Infantry.Infantry_M249, + Infantry.Infantry_M4, + Infantry.Infantry_RPG, + ), + ) + + Shorads = ( + "SHORADS", + ( + AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375, + AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375, + AirDefence.SPAAA_ZSU_57_2, + AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish, + AirDefence.SAM_SA_8_Osa_Gecko_TEL, + AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL, + AirDefence.SAM_SA_13_Strela_10M3_Gopher_TEL, + AirDefence.SAM_SA_15_Tor_Gauntlet, + AirDefence.SAM_SA_19_Tunguska_Grison, + AirDefence.SPAAA_Gepard, + AirDefence.SPAAA_Vulcan_M163, + AirDefence.SAM_Linebacker___Bradley_M6, + AirDefence.SAM_Chaparral_M48, + AirDefence.SAM_Avenger__Stinger, + AirDefence.SAM_Roland_ADS, + AirDefence.HQ_7_Self_Propelled_LN, + AirDefence.AAA_8_8cm_Flak_18, + AirDefence.AAA_8_8cm_Flak_36, + AirDefence.AAA_8_8cm_Flak_37, + AirDefence.AAA_8_8cm_Flak_41, + AirDefence.AAA_Bofors_40mm, + AirDefence.AAA_S_60_57mm, + AirDefence.AAA_M1_37mm, + AirDefence.AAA_QF_3_7, + ), + ) + + def __init__( + self, class_name: str, unit_list: tuple[Type[VehicleType], ...] + ) -> None: + self.class_name = class_name + self.unit_list = unit_list + + def __contains__(self, unit_type: Type[VehicleType]) -> bool: + return unit_type in self.unit_list diff --git a/game/data/radar_db.py b/game/data/radar_db.py index 0dcf9cc2..9bd384af 100644 --- a/game/data/radar_db.py +++ b/game/data/radar_db.py @@ -22,17 +22,49 @@ from dcs.ships import ( ) from dcs.vehicles import AirDefence -UNITS_WITH_RADAR = [ - # Radars +TELARS = { + AirDefence.SAM_SA_19_Tunguska_Grison, + AirDefence.SAM_SA_11_Buk_Gadfly_Fire_Dome_TEL, + AirDefence.SAM_SA_8_Osa_Gecko_TEL, + AirDefence.SAM_SA_15_Tor_Gauntlet, + AirDefence.SAM_Roland_ADS, +} + +TRACK_RADARS = { + AirDefence.SAM_SA_6_Kub_Straight_Flush_STR, + AirDefence.SAM_SA_3_S_125_Low_Blow_TR, + AirDefence.SAM_SA_10_S_300_Grumble_Flap_Lid_TR, + AirDefence.SAM_Hawk_TR__AN_MPQ_46, + AirDefence.SAM_Patriot_STR, + AirDefence.SAM_SA_2_S_75_Fan_Song_TR, + AirDefence.SAM_Rapier_Blindfire_TR, + AirDefence.HQ_7_Self_Propelled_STR, +} + +LAUNCHER_TRACKER_PAIRS = { + AirDefence.SAM_SA_6_Kub_Gainful_TEL: AirDefence.SAM_SA_6_Kub_Straight_Flush_STR, + AirDefence.SAM_SA_3_S_125_Goa_LN: AirDefence.SAM_SA_3_S_125_Low_Blow_TR, + AirDefence.SAM_SA_10_S_300_Grumble_TEL_D: AirDefence.SAM_SA_10_S_300_Grumble_Flap_Lid_TR, + AirDefence.SAM_SA_10_S_300_Grumble_TEL_C: AirDefence.SAM_SA_10_S_300_Grumble_Flap_Lid_TR, + AirDefence.SAM_Hawk_LN_M192: AirDefence.SAM_Hawk_TR__AN_MPQ_46, + AirDefence.SAM_Patriot_LN: AirDefence.SAM_Patriot_STR, + AirDefence.SAM_SA_2_S_75_Guideline_LN: AirDefence.SAM_SA_2_S_75_Fan_Song_TR, + AirDefence.SAM_Rapier_LN: AirDefence.SAM_Rapier_Blindfire_TR, + AirDefence.HQ_7_Self_Propelled_LN: AirDefence.HQ_7_Self_Propelled_STR, +} + +UNITS_WITH_RADAR = { + # Radars + AirDefence.SAM_SA_19_Tunguska_Grison, + AirDefence.SAM_SA_11_Buk_Gadfly_Fire_Dome_TEL, + AirDefence.SAM_SA_8_Osa_Gecko_TEL, AirDefence.SAM_SA_15_Tor_Gauntlet, - AirDefence.SAM_SA_11_Buk_Gadfly_C2, - AirDefence.SAM_Patriot_CR__AMG_AN_MRC_137, - AirDefence.SAM_Patriot_ECS, AirDefence.SPAAA_Gepard, AirDefence.SPAAA_Vulcan_M163, + AirDefence.SAM_Roland_ADS, AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish, AirDefence.EWR_1L13, - AirDefence.SAM_SA_6_Kub_Long_Track_STR, + AirDefence.SAM_SA_6_Kub_Straight_Flush_STR, AirDefence.SAM_SA_10_S_300_Grumble_Flap_Lid_TR, AirDefence.SAM_SA_10_S_300_Grumble_Clam_Shell_SR, AirDefence.EWR_55G6, @@ -47,7 +79,11 @@ UNITS_WITH_RADAR = [ AirDefence.SAM_Roland_EWR, AirDefence.SAM_SA_3_S_125_Low_Blow_TR, AirDefence.SAM_SA_2_S_75_Fan_Song_TR, + AirDefence.SAM_Rapier_Blindfire_TR, + AirDefence.HQ_7_Self_Propelled_LN, AirDefence.HQ_7_Self_Propelled_STR, + AirDefence.EWR_FuMG_401_Freya_LZ, + AirDefence.EWR_FuSe_65_Würzburg_Riese, # Ships CVN_70_Carl_Vinson, FFG_Oliver_Hazzard_Perry, @@ -69,4 +105,4 @@ UNITS_WITH_RADAR = [ Type_052B_Destroyer, Type_054A_Frigate, Type_052C_Destroyer, -] +} diff --git a/game/data/weapons.py b/game/data/weapons.py index 5fe60a3c..7aa21beb 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -59,6 +59,9 @@ class Weapon: @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: return None return cls.from_pydcs(data) @@ -70,11 +73,19 @@ class Pylon: allowed: Set[Weapon] def can_equip(self, weapon: Weapon) -> bool: - return weapon in self.allowed + # TODO: Fix pydcs to support the "weapon". + # is a special case because pydcs doesn't know about that "weapon", so + # it's not compatible with *any* pylon. Just trust the loadout and try to equip + # it. + # + # 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 == "" def equip(self, group: FlyingGroup, weapon: Weapon) -> None: if not self.can_equip(weapon): - raise ValueError(f"Pylon {self.number} cannot equip {weapon.name}") + logging.error(f"Pylon {self.number} cannot equip {weapon.name}") group.load_pylon(self.make_pydcs_assignment(weapon), self.number) def make_pydcs_assignment(self, weapon: Weapon) -> PydcsWeaponAssignment: @@ -134,20 +145,26 @@ _WEAPON_FALLBACKS = [ 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.GBU_12), + ( + 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.BRU_33_with_2_x_GBU_12___500lb_Laser_Guided_Bomb, + Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, ), ( Weapons.BRU_57_with_2_x_AGM_154A___JSOW_CEB__CBU_type_, - None, + 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_CEM__CBU_with_WCMD), - (Weapons.AGM_154C___JSOW_Unitary_BROACH, Weapons.GBU_12), + (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.BRU_33_with_2_x_GBU_12___500lb_Laser_Guided_Bomb, + Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_, ), # AGM-45 Shrike (Weapons.AGM_45A_Shrike_ARM, None), @@ -472,29 +489,29 @@ _WEAPON_FALLBACKS = [ # CBU-87 CEM (Weapons.CBU_87___202_x_CEM_Cluster_Bomb, Weapons.Mk_82), ( - Weapons.TER_9A_with_2_x_CBU_87___202_x_Anti_Armor_Skeet_SFW_Cluster_Bomb, + 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_Anti_Armor_Skeet_SFW_Cluster_Bomb_, + 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_Anti_Armor_Skeet_SFW_Cluster_Bomb, + 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_CEM_Cluster_Bomb, Weapons.Mk_82), + (Weapons.CBU_97___10_x_SFW_Cluster_Bomb, Weapons.Mk_82), ( - Weapons.TER_9A_with_2_x_CBU_97___10_x_Anti_Armor_Skeet_SFW_Cluster_Bomb, + 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_Anti_Armor_Skeet_SFW_Cluster_Bomb_, + 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_Anti_Armor_Skeet_SFW_Cluster_Bomb, + 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) @@ -504,7 +521,7 @@ _WEAPON_FALLBACKS = [ Weapons.CBU_87___202_x_CEM_Cluster_Bomb, ), # CBU-105 - (Weapons.CBU_105___10_x_CEM__CBU_with_WCMD, Weapons.CBU_97___10_x_CEM_Cluster_Bomb), + (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, @@ -830,6 +847,8 @@ WEAPON_INTRODUCTION_YEARS = { 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, @@ -958,26 +977,14 @@ WEAPON_INTRODUCTION_YEARS = { 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_Anti_Armor_Skeet_SFW_Cluster_Bomb - ): 1986, - Weapon.from_pydcs( - Weapons.TER_9A_with_2_x_CBU_87___202_x_Anti_Armor_Skeet_SFW_Cluster_Bomb_ - ): 1986, - Weapon.from_pydcs( - Weapons.TER_9A_with_3_x_CBU_87___202_x_Anti_Armor_Skeet_SFW_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_CEM_Cluster_Bomb): 1992, - Weapon.from_pydcs( - Weapons.TER_9A_with_2_x_CBU_97___10_x_Anti_Armor_Skeet_SFW_Cluster_Bomb - ): 1992, - Weapon.from_pydcs( - Weapons.TER_9A_with_2_x_CBU_97___10_x_Anti_Armor_Skeet_SFW_Cluster_Bomb_ - ): 1992, - Weapon.from_pydcs( - Weapons.TER_9A_with_3_x_CBU_97___10_x_Anti_Armor_Skeet_SFW_Cluster_Bomb - ): 1992, + 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 @@ -1019,11 +1026,11 @@ WEAPON_INTRODUCTION_YEARS = { 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): 2000, + 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): 2000, - Weapon.from_pydcs(Weapons.CBU_105___10_x_CEM__CBU_with_WCMD): 2000, + 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 diff --git a/game/db.py b/game/db.py index 809a43b1..b374f16a 100644 --- a/game/db.py +++ b/game/db.py @@ -1,17 +1,20 @@ +import json from datetime import datetime from enum import Enum -from typing import Dict, List, Optional, Tuple, Type, Union -import json from pathlib import Path +from typing import Dict, List, Optional, Tuple, Type, Union from dcs.countries import country_dict from dcs.helicopters import ( AH_1W, AH_64A, AH_64D, + CH_47D, + CH_53E, HelicopterType, Ka_50, Mi_24V, + Mi_26, Mi_28N, Mi_8MT, OH_58D, @@ -43,6 +46,7 @@ from dcs.planes import ( Bf_109K_4, C_101CC, C_130, + C_17A, E_3A, E_2C, FA_18C_hornet, @@ -58,7 +62,6 @@ from dcs.planes import ( F_4E, F_5E_3, F_86F_Sabre, - F_A_18C, IL_76MD, IL_78M, JF_17, @@ -68,7 +71,6 @@ from dcs.planes import ( KC_135, KC135MPRS, KJ_2000, - L_39C, L_39ZA, MQ_9_Reaper, M_2000C, @@ -88,7 +90,6 @@ from dcs.planes import ( P_47D_40, P_51D, P_51D_30_NA, - PlaneType, RQ_1A_Predator, S_3B, S_3B_Tanker, @@ -99,7 +100,6 @@ from dcs.planes import ( Su_24MR, Su_25, Su_25T, - Su_25TM, Su_27, Su_30, Su_33, @@ -113,6 +113,7 @@ from dcs.planes import ( Yak_40, plane_map, I_16, + Tu_142, ) from dcs.ships import ( Boat_Armed_Hi_speed, @@ -172,6 +173,7 @@ from game.factions.faction_loader import FactionLoader from pydcs_extensions.a4ec.a4ec import A_4E_C from pydcs_extensions.f22a.f22a import F_22A from pydcs_extensions.hercules.hercules import Hercules +from pydcs_extensions.jas39.jas39 import JAS39Gripen, JAS39Gripen_AG from pydcs_extensions.mb339.mb339 import MB_339PAN from pydcs_extensions.su57.su57 import Su_57 @@ -182,6 +184,8 @@ plane_map["F-22A"] = F_22A plane_map["MB-339PAN"] = MB_339PAN plane_map["Su-57"] = Su_57 plane_map["Hercules"] = Hercules +plane_map["JAS39Gripen"] = JAS39Gripen +plane_map["JAS39Gripen_AG"] = JAS39Gripen_AG vehicle_map["FieldHL"] = frenchpack._FIELD_HIDE vehicle_map["HARRIERH"] = frenchpack._FIELD_HIDE_SMALL @@ -413,6 +417,7 @@ PRICES = { F_22A: 40, Tornado_IDS: 20, Tornado_GR4: 20, + JAS39Gripen: 26, # bomber Su_17M4: 10, Su_25: 15, @@ -426,6 +431,7 @@ PRICES = { A_10C: 22, A_10C_2: 24, S_3B: 10, + JAS39Gripen_AG: 26, # heli Ka_50: 13, SA342M: 8, @@ -441,6 +447,10 @@ PRICES = { AH_64D: 30, OH_58D: 6, SH_60B: 6, + CH_47D: 4, + CH_53E: 4, + UH_60A: 4, + Mi_26: 4, # Bombers B_52H: 35, B_1B: 50, @@ -448,6 +458,7 @@ PRICES = { Tu_160: 50, Tu_22M3: 40, Tu_95MS: 35, + Tu_142: 35, # special IL_76MD: 30, An_26B: 25, @@ -464,6 +475,7 @@ PRICES = { E_2C: 50, C_130: 25, Hercules: 25, + C_17A: 20, # WW2 P_51D_30_NA: 18, P_51D: 16, @@ -478,10 +490,10 @@ PRICES = { # armor Armor.APC_MTLB: 4, Artillery.Grad_MRL_FDDM__FC: 4, - Armor.IFV_BRDM_2: 6, + Armor.Scout_BRDM_2: 6, Armor.APC_BTR_RD: 6, Armor.APC_BTR_80: 8, - Armor.APC_BTR_82A: 10, + Armor.IFV_BTR_82A: 10, Armor.MBT_T_55: 18, Armor.MBT_T_72B: 20, Armor.MBT_T_72B3: 25, @@ -491,12 +503,14 @@ PRICES = { Armor.IFV_BMP_1: 14, Armor.IFV_BMP_2: 16, Armor.IFV_BMP_3: 18, + Armor.LT_PT_76: 9, Armor.ZBD_04A: 12, Armor.ZTZ_96B: 30, - Armor.APC_Cobra__Scout: 4, + Armor.Scout_Cobra: 4, Armor.APC_M113: 6, - Armor.APC_HMMWV__Scout: 2, + Armor.Scout_HMMWV: 2, Armor.ATGM_HMMWV: 8, + Armor.ATGM_VAB_Mephisto: 12, Armor.IFV_M2A2_Bradley: 12, Armor.IFV_M1126_Stryker_ICV: 10, Armor.SPG_Stryker_MGS: 14, @@ -504,34 +518,49 @@ PRICES = { Armor.MBT_M60A3_Patton: 16, Armor.MBT_M1A2_Abrams: 25, Armor.MBT_Leclerc: 25, - Armor.MBT_Leopard_1A3: 20, - Armor.MBT_Leopard_2: 25, + Armor.MBT_Leopard_1A3: 18, + Armor.MBT_Leopard_2A4: 20, + Armor.MBT_Leopard_2A4_Trs: 20, + Armor.MBT_Leopard_2A5: 22, + Armor.MBT_Leopard_2A6M: 25, Armor.MBT_Merkava_IV: 25, Armor.APC_TPz_Fuchs: 5, Armor.MBT_Challenger_II: 25, + Armor.MBT_Chieftain_Mk_3: 20, Armor.IFV_Marder: 10, Armor.IFV_Warrior: 10, Armor.IFV_LAV_25: 7, + Armor.APC_AAV_7_Amphibious: 10, Artillery.MLRS_M270_227mm: 55, Artillery.SPH_M109_Paladin_155mm: 25, - Artillery.SPH_2S9_Nona_120mm_M: 12, + Artillery.SPM_2S9_Nona_120mm_M: 12, Artillery.SPH_2S1_Gvozdika_122mm: 18, Artillery.SPH_2S3_Akatsia_152mm: 24, Artillery.SPH_2S19_Msta_152mm: 30, Artillery.MLRS_BM_21_Grad_122mm: 15, - Artillery.MLRS_BM_27_Uragan_220mm: 50, + Artillery.MLRS_9K57_Uragan_BM_27_220mm: 50, Artillery.MLRS_9A52_Smerch_HE_300mm: 40, Artillery.Mortar_2B11_120mm: 4, Artillery.SPH_Dana_vz77_152mm: 26, Artillery.PLZ_05: 25, + Artillery.SPH_T155_Firtina_155mm: 28, + Artillery.MLRS_9A52_Smerch_CM_300mm: 60, Unarmed.LUV_UAZ_469_Jeep: 3, Unarmed.Truck_Ural_375: 3, + Unarmed.Truck_GAZ_3307: 2, Infantry.Infantry_M4: 1, Infantry.Infantry_AK_74: 1, Unarmed.Truck_M818_6x6: 3, + Unarmed.LUV_Land_Rover_109: 1, + Unarmed.Truck_GAZ_3308: 1, + Unarmed.Truck_GAZ_66: 1, + Unarmed.Truck_KAMAZ_43101: 1, + Unarmed.Truck_Land_Rover_101_FC: 1, + Unarmed.Truck_Ural_4320_31_Arm_d: 1, + Unarmed.Truck_Ural_4320T: 1, # WW2 Armor.MT_Pz_Kpfw_V_Panther_Ausf_G: 24, - Armor.MT_PzIV_H: 16, + Armor.Tk_PzIV_H: 16, Armor.HT_Pz_Kpfw_VI_Tiger_I: 24, Armor.HT_Pz_Kpfw_VI_Ausf__B_Tiger_II: 26, Armor.SPG_Jagdpanther_G1: 18, @@ -539,7 +568,7 @@ PRICES = { Armor.SPG_Sd_Kfz_184_Elefant: 18, Armor.APC_Sd_Kfz_251_Halftrack: 4, Armor.IFV_Sd_Kfz_234_2_Puma: 8, - Armor.MT_M4_Sherman: 12, + Armor.Tk_M4_Sherman: 12, Armor.MT_M4A4_Sherman_Firefly: 16, Armor.CT_Cromwell_IV: 12, Unarmed.Carrier_M30_Cargo: 2, @@ -551,10 +580,17 @@ PRICES = { Armor.SPG_StuG_III_Ausf__G: 12, Armor.SPG_StuG_IV: 14, Artillery.SPG_M12_GMC_155mm: 10, - Artillery.SPG_Sturmpanzer_IV_Brummbar: 10, + Armor.SPG_Sturmpanzer_IV_Brummbar: 10, Armor.Car_Daimler_Armored: 8, Armor.LT_Mk_VII_Tetrarch: 8, Unarmed.Tractor_M4_Hi_Speed: 2, + Unarmed.Carrier_Sd_Kfz_7_Tractor: 1, + Unarmed.LUV_Kettenrad: 1, + Unarmed.LUV_Kubelwagen_82: 1, + Unarmed.Truck_Opel_Blitz: 1, + Unarmed.Truck_Bedford: 1, + Unarmed.Truck_GMC_Jimmy_6x6_Truck: 1, + Unarmed.Car_Willys_Jeep: 1, # ship CV_1143_5_Admiral_Kuznetsov: 100, CVN_74_John_C__Stennis: 100, @@ -578,7 +614,7 @@ PRICES = { AirDefence.SAM_Patriot_CR__AMG_AN_MRC_137: 35, AirDefence.SAM_Patriot_ECS: 30, AirDefence.SPAAA_Gepard: 24, - AirDefence.SAM_Hawk_Generator__PCP: 14, + AirDefence.SAM_Hawk_Platoon_Command_Post__PCP: 14, AirDefence.SPAAA_Vulcan_M163: 10, AirDefence.SAM_Hawk_LN_M192: 8, AirDefence.SAM_Chaparral_M48: 16, @@ -596,15 +632,15 @@ PRICES = { AirDefence.AAA_ZU_23_Closed_Emplacement: 6, AirDefence.AAA_ZU_23_Emplacement: 6, AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375: 7, - AirDefence.AAA_ZU_23_Closed_Emplacement_Insurgent: 6, + AirDefence.AAA_ZU_23_Insurgent_Closed_Emplacement: 6, AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375: 7, - AirDefence.AAA_ZU_23_Insurgent: 6, + AirDefence.AAA_ZU_23_Insurgent_Emplacement: 6, AirDefence.MANPADS_SA_18_Igla_Grouse: 10, AirDefence.MANPADS_SA_18_Igla_Grouse_C2: 8, AirDefence.MANPADS_SA_18_Igla_S_Grouse: 12, AirDefence.MANPADS_SA_18_Igla_S_Grouse_C2: 8, AirDefence.EWR_1L13: 30, - AirDefence.SAM_SA_6_Kub_Long_Track_STR: 22, + AirDefence.SAM_SA_6_Kub_Straight_Flush_STR: 22, AirDefence.EWR_55G6: 30, AirDefence.MCC_SR_Sborka_Dog_Ear_SR: 10, AirDefence.SAM_Hawk_TR__AN_MPQ_46: 14, @@ -631,7 +667,7 @@ PRICES = { AirDefence.PU_Maschinensatz_33: 10, AirDefence.AAA_8_8cm_Flak_41: 10, AirDefence.EWR_FuMG_401_Freya_LZ: 25, - AirDefence.AAA_40mm_Bofors: 8, + AirDefence.AAA_Bofors_40mm: 8, AirDefence.AAA_S_60_57mm: 8, AirDefence.AAA_M1_37mm: 7, AirDefence.AAA_M45_Quadmount_HB_12_7mm: 4, @@ -729,42 +765,43 @@ Following tasks are present: """ UNIT_BY_TASK = { CAP: [ + A_4E_C, + Bf_109K_4, + C_101CC, + FA_18C_hornet, + FW_190A8, + FW_190D9, + F_14A_135_GR, + F_14B, + F_15C, + F_16A, + F_16C_50, + F_22A, + F_4E, F_5E_3, - Su_27, - Su_33, - Su_57, + I_16, + JAS39Gripen, + JF_17, + J_11A, + M_2000C, MiG_19P, MiG_21Bis, MiG_23MLD, MiG_25PD, MiG_29A, + MiG_29G, MiG_29S, MiG_31, - FA_18C_hornet, - F_15C, - F_22A, - F_14A_135_GR, - F_14B, - F_16A, - F_16C_50, - M_2000C, Mirage_2000_5, - P_51D_30_NA, P_51D, - MiG_29G, - Su_30, - J_11A, - JF_17, - F_4E, - C_101CC, - I_16, - Bf_109K_4, - FW_190D9, - FW_190A8, - SpitfireLFMkIXCW, - SpitfireLFMkIX, - A_4E_C, + P_51D_30_NA, SA342Mistral, + SpitfireLFMkIX, + SpitfireLFMkIXCW, + Su_27, + Su_30, + Su_33, + Su_57, ], CAS: [ AH_1W, @@ -782,6 +819,8 @@ UNIT_BY_TASK = { F_117A, F_15E, F_86F_Sabre, + Hercules, + JAS39Gripen_AG, Ju_88A4, Ka_50, L_39ZA, @@ -797,10 +836,11 @@ UNIT_BY_TASK = { P_47D_30bl1, P_47D_40, RQ_1A_Predator, - S_3B, SA342L, SA342M, SA342Minigun, + SH_60B, + S_3B, Su_17M4, Su_24M, Su_24MR, @@ -813,19 +853,33 @@ UNIT_BY_TASK = { Tu_22M3, Tu_95MS, UH_1H, - SH_60B, WingLoong_I, - Hercules, ], - Transport: [IL_76MD, An_26B, An_30M, Yak_40, C_130], + Transport: [ + An_26B, + An_30M, + CH_47D, + CH_53E, + C_130, + C_17A, + IL_76MD, + Mi_26, + UH_60A, + Yak_40, + ], Refueling: [ IL_78M, - KC_135, KC130, - S_3B_Tanker, KC135MPRS, + KC_135, + S_3B_Tanker, + ], + AWACS: [ + A_50, + E_2C, + E_3A, + KJ_2000, ], - AWACS: [E_3A, E_2C, A_50, KJ_2000], PinpointStrike: [ Armor.APC_MTLB, Armor.APC_MTLB, @@ -837,9 +891,9 @@ UNIT_BY_TASK = { Artillery.Grad_MRL_FDDM__FC, Artillery.Grad_MRL_FDDM__FC, Artillery.Grad_MRL_FDDM__FC, - Armor.IFV_BRDM_2, - Armor.IFV_BRDM_2, - Armor.IFV_BRDM_2, + Armor.Scout_BRDM_2, + Armor.Scout_BRDM_2, + Armor.Scout_BRDM_2, Armor.APC_BTR_RD, Armor.APC_BTR_RD, Armor.APC_BTR_RD, @@ -849,8 +903,8 @@ UNIT_BY_TASK = { Armor.APC_BTR_80, Armor.APC_BTR_80, Armor.APC_BTR_80, - Armor.APC_BTR_82A, - Armor.APC_BTR_82A, + Armor.IFV_BTR_82A, + Armor.IFV_BTR_82A, Armor.IFV_BMP_1, Armor.IFV_BMP_1, Armor.IFV_BMP_1, @@ -859,6 +913,7 @@ UNIT_BY_TASK = { Armor.IFV_BMP_3, Armor.IFV_BMP_3, Armor.IFV_BMD_1, + Armor.LT_PT_76, Armor.ZBD_04A, Armor.ZBD_04A, Armor.ZBD_04A, @@ -873,10 +928,10 @@ UNIT_BY_TASK = { Armor.MBT_T_80U, Armor.MBT_T_90, Armor.ZTZ_96B, - Armor.APC_Cobra__Scout, - Armor.APC_Cobra__Scout, - Armor.APC_Cobra__Scout, - Armor.APC_Cobra__Scout, + Armor.Scout_Cobra, + Armor.Scout_Cobra, + Armor.Scout_Cobra, + Armor.Scout_Cobra, Armor.APC_M113, Armor.APC_M113, Armor.APC_M113, @@ -887,8 +942,10 @@ UNIT_BY_TASK = { Armor.APC_TPz_Fuchs, Armor.ATGM_HMMWV, Armor.ATGM_HMMWV, - Armor.APC_HMMWV__Scout, - Armor.APC_HMMWV__Scout, + Armor.ATGM_VAB_Mephisto, + Armor.ATGM_VAB_Mephisto, + Armor.Scout_HMMWV, + Armor.Scout_HMMWV, Armor.IFV_M2A2_Bradley, Armor.IFV_M2A2_Bradley, Armor.ATGM_Stryker, @@ -913,11 +970,12 @@ UNIT_BY_TASK = { Armor.MBT_Leopard_1A3, Armor.MBT_M1A2_Abrams, Armor.MBT_Leclerc, - Armor.MBT_Leopard_2, + Armor.MBT_Leopard_2A6M, Armor.MBT_Challenger_II, + Armor.MBT_Chieftain_Mk_3, Armor.MBT_Merkava_IV, Armor.MT_Pz_Kpfw_V_Panther_Ausf_G, - Armor.MT_PzIV_H, + Armor.Tk_PzIV_H, Armor.HT_Pz_Kpfw_VI_Tiger_I, Armor.HT_Pz_Kpfw_VI_Ausf__B_Tiger_II, Armor.APC_Sd_Kfz_251_Halftrack, @@ -926,7 +984,7 @@ UNIT_BY_TASK = { Armor.APC_Sd_Kfz_251_Halftrack, Armor.IFV_Sd_Kfz_234_2_Puma, Armor.IFV_Sd_Kfz_234_2_Puma, - Armor.MT_M4_Sherman, + Armor.Tk_M4_Sherman, Armor.MT_M4A4_Sherman_Firefly, Armor.CT_Cromwell_IV, Unarmed.Carrier_M30_Cargo, @@ -936,7 +994,7 @@ UNIT_BY_TASK = { Armor.APC_M2A1_Halftrack, Armor.APC_M2A1_Halftrack, Armor.MT_Pz_Kpfw_V_Panther_Ausf_G, - Armor.MT_PzIV_H, + Armor.Tk_PzIV_H, Armor.HT_Pz_Kpfw_VI_Tiger_I, Armor.HT_Pz_Kpfw_VI_Ausf__B_Tiger_II, Armor.SPG_Jagdpanther_G1, @@ -944,7 +1002,7 @@ UNIT_BY_TASK = { Armor.SPG_Sd_Kfz_184_Elefant, Armor.APC_Sd_Kfz_251_Halftrack, Armor.IFV_Sd_Kfz_234_2_Puma, - Armor.MT_M4_Sherman, + Armor.Tk_M4_Sherman, Armor.MT_M4A4_Sherman_Firefly, Armor.CT_Cromwell_IV, Unarmed.Carrier_M30_Cargo, @@ -962,23 +1020,24 @@ UNIT_BY_TASK = { Armor.SPG_StuG_III_Ausf__G, Armor.SPG_StuG_IV, Artillery.SPG_M12_GMC_155mm, - Artillery.SPG_Sturmpanzer_IV_Brummbar, + Armor.SPG_Sturmpanzer_IV_Brummbar, Armor.Car_Daimler_Armored, Armor.LT_Mk_VII_Tetrarch, Artillery.MLRS_M270_227mm, Artillery.SPH_M109_Paladin_155mm, - Artillery.SPH_2S9_Nona_120mm_M, + Artillery.SPM_2S9_Nona_120mm_M, Artillery.SPH_2S1_Gvozdika_122mm, Artillery.SPH_2S3_Akatsia_152mm, Artillery.SPH_2S19_Msta_152mm, Artillery.MLRS_BM_21_Grad_122mm, Artillery.MLRS_BM_21_Grad_122mm, - Artillery.MLRS_BM_27_Uragan_220mm, + Artillery.MLRS_9K57_Uragan_BM_27_220mm, Artillery.MLRS_9A52_Smerch_HE_300mm, Artillery.SPH_Dana_vz77_152mm, + Artillery.SPH_T155_Firtina_155mm, Artillery.PLZ_05, Artillery.SPG_M12_GMC_155mm, - Artillery.SPG_Sturmpanzer_IV_Brummbar, + Armor.SPG_Sturmpanzer_IV_Brummbar, AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375, AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375, AirDefence.SPAAA_ZSU_57_2, @@ -999,7 +1058,7 @@ UNIT_BY_TASK = { AirDefence.AAA_8_8cm_Flak_36, AirDefence.AAA_8_8cm_Flak_37, AirDefence.AAA_8_8cm_Flak_41, - AirDefence.AAA_40mm_Bofors, + AirDefence.AAA_Bofors_40mm, AirDefence.AAA_S_60_57mm, AirDefence.AAA_M1_37mm, AirDefence.AAA_QF_3_7, @@ -1060,7 +1119,7 @@ SAM_BAN = [ AirDefence.SAM_SA_6_Kub_Gainful_TEL, AirDefence.SAM_SA_8_Osa_Gecko_TEL, AirDefence.SAM_SA_3_S_125_Goa_LN, - AirDefence.SAM_Hawk_Generator__PCP, + AirDefence.SAM_Hawk_Platoon_Command_Post__PCP, AirDefence.SAM_SA_2_S_75_Guideline_LN, AirDefence.SAM_SA_11_Buk_Gadfly_Fire_Dome_TEL, ] @@ -1073,15 +1132,15 @@ SAM_CONVERT = { AirDefence.SAM_SA_3_S_125_Low_Blow_TR: AirDefence.SAM_SA_3_S_125_Goa_LN, AirDefence.SAM_SA_3_S_125_Goa_LN: AirDefence.SAM_SA_3_S_125_Goa_LN, AirDefence.SAM_SA_6_Kub_Gainful_TEL: AirDefence.SAM_SA_6_Kub_Gainful_TEL, - AirDefence.SAM_SA_6_Kub_Long_Track_STR: AirDefence.SAM_SA_6_Kub_Gainful_TEL, + AirDefence.SAM_SA_6_Kub_Straight_Flush_STR: AirDefence.SAM_SA_6_Kub_Gainful_TEL, AirDefence.SAM_SA_10_S_300_Grumble_TEL_C: AirDefence.SAM_SA_10_S_300_Grumble_TEL_C, AirDefence.SAM_SA_10_S_300_Grumble_Clam_Shell_SR: AirDefence.SAM_SA_10_S_300_Grumble_TEL_C, AirDefence.SAM_SA_10_S_300_Grumble_Flap_Lid_TR: AirDefence.SAM_SA_10_S_300_Grumble_TEL_C, AirDefence.SAM_SA_10_S_300_Grumble_C2: AirDefence.SAM_SA_10_S_300_Grumble_TEL_C, AirDefence.SAM_SA_10_S_300_Grumble_Big_Bird_SR: AirDefence.SAM_SA_10_S_300_Grumble_C2, - AirDefence.SAM_Hawk_TR__AN_MPQ_46: AirDefence.SAM_Hawk_Generator__PCP, - AirDefence.SAM_Hawk_SR__AN_MPQ_50: AirDefence.SAM_Hawk_Generator__PCP, - AirDefence.SAM_Hawk_LN_M192: AirDefence.SAM_Hawk_Generator__PCP, + AirDefence.SAM_Hawk_TR__AN_MPQ_46: AirDefence.SAM_Hawk_Platoon_Command_Post__PCP, + AirDefence.SAM_Hawk_SR__AN_MPQ_50: AirDefence.SAM_Hawk_Platoon_Command_Post__PCP, + AirDefence.SAM_Hawk_LN_M192: AirDefence.SAM_Hawk_Platoon_Command_Post__PCP, "except": { # this radar is shared between the two S300's. if we attempt to find a SAM site at a base and can't find one # model, we can safely assume the other was deployed @@ -1151,140 +1210,6 @@ COMMON_OVERRIDE = { AWACS: "AEW&C", } -""" -This is a list of mappings from the FlightType of a Flight to the type of payload defined in the -resources/payloads/UNIT_TYPE.lua file. A Flight has no concept of a PyDCS task, so COMMON_OVERRIDE cannot be -used here. This is used in the payload editor, for setting the default loadout of an object. -The left element is the FlightType name, and the right element is a tuple containing what is used in the lua file. -Some aircraft differ from the standard loadout names, so those have been included here too. -The priority goes from first to last - the first element in the tuple will be tried first, then the second, etc. -""" - -EXPANDED_TASK_PAYLOAD_OVERRIDE = { - "TARCAP": ("CAP HEAVY", "CAP"), - "BARCAP": ("CAP HEAVY", "CAP"), - "CAS": ("CAS MAVERICK F", "CAS"), - "INTERCEPTION": ("CAP HEAVY", "CAP"), - "STRIKE": ("STRIKE",), - "ANTISHIP": ("ANTISHIP",), - "SEAD": ("SEAD",), - "DEAD": ("SEAD",), - "ESCORT": ("CAP HEAVY", "CAP"), - "BAI": ("BAI", "CAS MAVERICK F", "CAS"), - "SWEEP": ("CAP HEAVY", "CAP"), - "OCA_RUNWAY": ("RUNWAY_ATTACK", "RUNWAY_STRIKE", "STRIKE"), - "OCA_AIRCRAFT": ("OCA", "CAS MAVERICK F", "CAS"), -} - -PLANE_PAYLOAD_OVERRIDES: Dict[Type[PlaneType], Dict[Type[Task], str]] = { - B_1B: COMMON_OVERRIDE, - B_52H: COMMON_OVERRIDE, - F_117A: COMMON_OVERRIDE, - F_15E: COMMON_OVERRIDE, - FA_18C_hornet: { - CAP: "CAP HEAVY", - Intercept: "CAP HEAVY", - CAS: "CAS MAVERICK F", - PinpointStrike: "STRIKE", - SEAD: "SEAD", - AntishipStrike: "ANTISHIP", - GroundAttack: "STRIKE", - Escort: "CAP HEAVY", - FighterSweep: "CAP HEAVY", - }, - F_A_18C: { - CAP: "CAP HEAVY", - Intercept: "CAP HEAVY", - CAS: "CAS MAVERICK F", - PinpointStrike: "STRIKE", - SEAD: "SEAD", - AntishipStrike: "ANTISHIP", - GroundAttack: "STRIKE", - Escort: "CAP HEAVY", - FighterSweep: "CAP HEAVY", - }, - Tu_160: { - PinpointStrike: "Kh-65*12", - }, - Tu_22M3: COMMON_OVERRIDE, - Tu_95MS: COMMON_OVERRIDE, - A_10A: COMMON_OVERRIDE, - A_10C: COMMON_OVERRIDE, - A_10C_2: COMMON_OVERRIDE, - AV8BNA: COMMON_OVERRIDE, - C_101CC: COMMON_OVERRIDE, - F_5E_3: COMMON_OVERRIDE, - F_14A_135_GR: COMMON_OVERRIDE, - F_14B: COMMON_OVERRIDE, - F_15C: COMMON_OVERRIDE, - F_22A: COMMON_OVERRIDE, - F_16C_50: COMMON_OVERRIDE, - JF_17: COMMON_OVERRIDE, - M_2000C: COMMON_OVERRIDE, - MiG_15bis: COMMON_OVERRIDE, - MiG_19P: COMMON_OVERRIDE, - MiG_21Bis: COMMON_OVERRIDE, - AJS37: COMMON_OVERRIDE, - Su_25T: COMMON_OVERRIDE, - Su_25: COMMON_OVERRIDE, - Su_27: COMMON_OVERRIDE, - Su_33: COMMON_OVERRIDE, - MiG_29A: COMMON_OVERRIDE, - MiG_29G: COMMON_OVERRIDE, - MiG_29S: COMMON_OVERRIDE, - Su_24M: COMMON_OVERRIDE, - Su_30: COMMON_OVERRIDE, - Su_34: COMMON_OVERRIDE, - Su_57: COMMON_OVERRIDE, - MiG_23MLD: COMMON_OVERRIDE, - MiG_27K: COMMON_OVERRIDE, - Tornado_GR4: COMMON_OVERRIDE, - Tornado_IDS: COMMON_OVERRIDE, - Mirage_2000_5: COMMON_OVERRIDE, - MiG_31: COMMON_OVERRIDE, - S_3B: COMMON_OVERRIDE, - SA342M: COMMON_OVERRIDE, - SA342L: COMMON_OVERRIDE, - SA342Mistral: COMMON_OVERRIDE, - Mi_8MT: COMMON_OVERRIDE, - Mi_24V: COMMON_OVERRIDE, - Mi_28N: COMMON_OVERRIDE, - Ka_50: COMMON_OVERRIDE, - L_39ZA: COMMON_OVERRIDE, - L_39C: COMMON_OVERRIDE, - Su_17M4: COMMON_OVERRIDE, - F_4E: COMMON_OVERRIDE, - P_47D_30: COMMON_OVERRIDE, - P_47D_30bl1: COMMON_OVERRIDE, - P_47D_40: COMMON_OVERRIDE, - B_17G: COMMON_OVERRIDE, - P_51D: COMMON_OVERRIDE, - P_51D_30_NA: COMMON_OVERRIDE, - FW_190D9: COMMON_OVERRIDE, - FW_190A8: COMMON_OVERRIDE, - Bf_109K_4: COMMON_OVERRIDE, - I_16: COMMON_OVERRIDE, - SpitfireLFMkIXCW: COMMON_OVERRIDE, - SpitfireLFMkIX: COMMON_OVERRIDE, - A_20G: COMMON_OVERRIDE, - A_4E_C: COMMON_OVERRIDE, - MB_339PAN: COMMON_OVERRIDE, - OH_58D: COMMON_OVERRIDE, - F_16A: COMMON_OVERRIDE, - MQ_9_Reaper: COMMON_OVERRIDE, - RQ_1A_Predator: COMMON_OVERRIDE, - WingLoong_I: COMMON_OVERRIDE, - AH_1W: COMMON_OVERRIDE, - AH_64D: COMMON_OVERRIDE, - AH_64A: COMMON_OVERRIDE, - SH_60B: COMMON_OVERRIDE, - Hercules: COMMON_OVERRIDE, - F_86F_Sabre: COMMON_OVERRIDE, - Su_25TM: { - SEAD: "Kh-31P*2_Kh-25ML*4_R-73*2_L-081_MPS410", - }, -} - """ Aircraft livery overrides. Syntax as follows: @@ -1349,7 +1274,8 @@ REWARDS = { "fuel": 2, "ammo": 2, "farp": 1, - "fob": 1, + # TODO: Should generate no cash once they generate units. + # https://github.com/dcs-liberation/dcs_liberation/issues/1036 "factory": 10, "comms": 10, "oil": 10, @@ -1442,11 +1368,11 @@ def unit_task(unit: UnitType) -> Optional[Task]: return None -def find_unittype(for_task: Task, country_name: str) -> List[Type[UnitType]]: +def find_unittype(for_task: Type[MainTask], country_name: str) -> List[Type[UnitType]]: return [x for x in UNIT_BY_TASK[for_task] if x in FACTIONS[country_name].units] -MANPADS: List[VehicleType] = [ +MANPADS: List[Type[VehicleType]] = [ AirDefence.MANPADS_SA_18_Igla_Grouse, AirDefence.MANPADS_SA_18_Igla_S_Grouse, AirDefence.MANPADS_Stinger, @@ -1521,7 +1447,7 @@ def unit_get_expanded_info(country_name: str, unit_type, request_type: str) -> s default_value = None faction_value = None with UNITINFOTEXT_PATH.open("r", encoding="utf-8") as fdata: - data = json.load(fdata, encoding="utf-8") + data = json.load(fdata) type_exists = data.get(original_name) if type_exists is not None: for faction in type_exists: @@ -1558,6 +1484,13 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]: return None +def flying_type_from_name(name: str) -> Optional[Type[FlyingType]]: + unit_type = plane_map.get(name) + if unit_type is not None: + return unit_type + return helicopter_map.get(name) + + def unit_type_of(unit: Unit) -> UnitType: if isinstance(unit, Vehicle): return vehicle_map[unit.type] @@ -1702,3 +1635,39 @@ F_16C_50.Liveries = DefaultLiveries P_51D_30_NA.Liveries = DefaultLiveries Ju_88A4.Liveries = DefaultLiveries B_17G.Liveries = DefaultLiveries + +# List of airframes that rely on their gun as a primary weapon. We confiscate bullets +# from most AI air-to-ground missions since they aren't smart enough to RTB when they're +# out of everything other than bullets (DCS does not have an all-but-gun winchester +# option) and we don't want to be attacking fully functional Tors with a Vulcan. +# +# These airframes are the exceptions. They probably should be using their gun regardless +# of the mission type. +GUN_RELIANT_AIRFRAMES: List[Type[FlyingType]] = [ + AH_1W, + AH_64A, + AH_64D, + A_10A, + A_10C, + A_10C_2, + A_20G, + Bf_109K_4, + FW_190A8, + FW_190D9, + F_86F_Sabre, + Ju_88A4, + Ka_50, + MiG_15bis, + MiG_19P, + Mi_24V, + Mi_28N, + P_47D_30, + P_47D_30bl1, + P_47D_40, + P_51D, + P_51D_30_NA, + SpitfireLFMkIX, + SpitfireLFMkIXCW, + Su_25, + Su_25T, +] diff --git a/game/debriefing.py b/game/debriefing.py index c0221838..88c9f8ae 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -22,7 +22,16 @@ from dcs.unittype import FlyingType, UnitType from game import db from game.theater import Airfield, ControlPoint -from game.unitmap import Building, FrontLineUnit, GroundObjectUnit, UnitMap +from game.transfers import CargoShip +from game.unitmap import ( + AirliftUnits, + Building, + ConvoyUnit, + FrontLineUnit, + GroundObjectUnit, + UnitMap, + FlyingUnit, +) from gen.flights.flight import Flight if TYPE_CHECKING: @@ -33,24 +42,24 @@ DEBRIEFING_LOG_EXTENSION = "log" @dataclass(frozen=True) class AirLosses: - player: List[Flight] - enemy: List[Flight] + player: List[FlyingUnit] + enemy: List[FlyingUnit] @property - def losses(self) -> Iterator[Flight]: + def losses(self) -> Iterator[FlyingUnit]: return itertools.chain(self.player, self.enemy) def by_type(self, player: bool) -> Dict[Type[FlyingType], int]: losses_by_type: Dict[Type[FlyingType], int] = defaultdict(int) losses = self.player if player else self.enemy for loss in losses: - losses_by_type[loss.unit_type] += 1 + losses_by_type[loss.flight.unit_type] += 1 return losses_by_type def surviving_flight_members(self, flight: Flight) -> int: losses = 0 for loss in self.losses: - if loss == flight: + if loss.flight == flight: losses += 1 return flight.count - losses @@ -60,6 +69,15 @@ class GroundLosses: player_front_line: List[FrontLineUnit] = field(default_factory=list) enemy_front_line: List[FrontLineUnit] = field(default_factory=list) + player_convoy: List[ConvoyUnit] = field(default_factory=list) + enemy_convoy: List[ConvoyUnit] = field(default_factory=list) + + player_cargo_ships: List[CargoShip] = field(default_factory=list) + enemy_cargo_ships: List[CargoShip] = field(default_factory=list) + + 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) @@ -70,6 +88,12 @@ class GroundLosses: enemy_airfields: List[Airfield] = field(default_factory=list) +@dataclass(frozen=True) +class BaseCaptureEvent: + control_point: ControlPoint + captured_by_player: bool + + @dataclass(frozen=True) class StateData: #: True if the mission ended. If False, the mission exited abnormally. @@ -94,7 +118,10 @@ class StateData: killed_aircraft=data["killed_aircrafts"], # Airfields emit a new "dead" event every time a bomb is dropped on # them when they've already dead. Dedup. - killed_ground_units=list(set(data["killed_ground_units"])), + # + # Also normalize dead map objects (which are ints) to strings. The unit map + # only stores strings. + killed_ground_units=list({str(u) for u in data["killed_ground_units"]}), destroyed_statics=data["destroyed_objects_positions"], base_capture_events=data["base_capture_events"], ) @@ -105,6 +132,7 @@ class Debriefing: self, state_data: Dict[str, Any], game: Game, unit_map: UnitMap ) -> None: self.state_data = StateData.from_json(state_data) + self.game = game self.unit_map = unit_map self.player_country = game.player_country @@ -114,12 +142,28 @@ class Debriefing: self.air_losses = self.dead_aircraft() self.ground_losses = self.dead_ground_units() + self.base_captures = self.base_capture_events() @property def front_line_losses(self) -> Iterator[FrontLineUnit]: yield from self.ground_losses.player_front_line yield from self.ground_losses.enemy_front_line + @property + def convoy_losses(self) -> Iterator[ConvoyUnit]: + yield from self.ground_losses.player_convoy + yield from self.ground_losses.enemy_convoy + + @property + def cargo_ship_losses(self) -> Iterator[CargoShip]: + yield from self.ground_losses.player_cargo_ships + yield from self.ground_losses.enemy_cargo_ships + + @property + def airlift_losses(self) -> Iterator[AirliftUnits]: + yield from self.ground_losses.player_airlifts + yield from self.ground_losses.enemy_airlifts + @property def ground_object_losses(self) -> Iterator[GroundObjectUnit]: yield from self.ground_losses.player_ground_objects @@ -148,6 +192,38 @@ class Debriefing: losses_by_type[loss.unit_type] += 1 return losses_by_type + def convoy_losses_by_type(self, player: bool) -> Dict[Type[UnitType], int]: + losses_by_type: Dict[Type[UnitType], int] = defaultdict(int) + if player: + losses = self.ground_losses.player_convoy + else: + losses = self.ground_losses.enemy_convoy + for loss in losses: + losses_by_type[loss.unit_type] += 1 + return losses_by_type + + def cargo_ship_losses_by_type(self, player: bool) -> Dict[Type[UnitType], int]: + losses_by_type: Dict[Type[UnitType], int] = defaultdict(int) + if player: + ships = self.ground_losses.player_cargo_ships + else: + ships = self.ground_losses.enemy_cargo_ships + for ship in ships: + for unit_type, count in ship.units.items(): + losses_by_type[unit_type] += count + return losses_by_type + + def airlift_losses_by_type(self, player: bool) -> Dict[Type[UnitType], int]: + losses_by_type: Dict[Type[UnitType], int] = defaultdict(int) + if player: + losses = self.ground_losses.player_airlifts + else: + losses = self.ground_losses.enemy_airlifts + for loss in losses: + for unit_type in loss.cargo: + losses_by_type[unit_type] += 1 + return losses_by_type + def building_losses_by_type(self, player: bool) -> Dict[str, int]: losses_by_type: Dict[str, int] = defaultdict(int) if player: @@ -165,14 +241,14 @@ class Debriefing: player_losses = [] enemy_losses = [] for unit_name in self.state_data.killed_aircraft: - flight = self.unit_map.flight(unit_name) - if flight is None: + aircraft = self.unit_map.flight(unit_name) + if aircraft is None: logging.error(f"Could not find Flight matching {unit_name}") continue - if flight.departure.captured: - player_losses.append(flight) + if aircraft.flight.departure.captured: + player_losses.append(aircraft) else: - enemy_losses.append(flight) + enemy_losses.append(aircraft) return AirLosses(player_losses, enemy_losses) def dead_ground_units(self) -> GroundLosses: @@ -186,6 +262,22 @@ class Debriefing: losses.enemy_front_line.append(front_line_unit) continue + convoy_unit = self.unit_map.convoy_unit(unit_name) + if convoy_unit is not None: + if convoy_unit.convoy.player_owned: + losses.player_convoy.append(convoy_unit) + else: + losses.enemy_convoy.append(convoy_unit) + continue + + cargo_ship = self.unit_map.cargo_ship(unit_name) + if cargo_ship is not None: + if cargo_ship.player_owned: + losses.player_cargo_ships.append(cargo_ship) + else: + losses.enemy_cargo_ships.append(cargo_ship) + continue + ground_object_unit = self.unit_map.ground_object_unit(unit_name) if ground_object_unit is not None: if ground_object_unit.ground_object.control_point.captured: @@ -224,17 +316,46 @@ class Debriefing: "have no effect. This may be normal behavior." ) + for unit_name in self.state_data.killed_aircraft: + airlift_unit = self.unit_map.airlift_unit(unit_name) + if airlift_unit is not None: + if airlift_unit.transfer.player: + losses.player_airlifts.append(airlift_unit) + else: + losses.enemy_airlifts.append(airlift_unit) + continue + return losses - @property - def base_capture_events(self): + def base_capture_events(self) -> List[BaseCaptureEvent]: """Keeps only the last instance of a base capture event for each base ID.""" - reversed_captures = list(reversed(self.state_data.base_capture_events)) - last_base_cap_indexes = [] - for idx, base in enumerate(i.split("||")[0] for i in reversed_captures): - if base not in [x[1] for x in last_base_cap_indexes]: - last_base_cap_indexes.append((idx, base)) - return [reversed_captures[idx[0]] for idx in last_base_cap_indexes] + blue_coalition_id = 2 + seen = set() + captures = [] + for capture in reversed(self.state_data.base_capture_events): + cp_id_str, new_owner_id_str, _name = capture.split("||") + cp_id = int(cp_id_str) + + # Only the most recent capture event matters. + if cp_id in seen: + continue + seen.add(cp_id) + + try: + control_point = self.game.theater.find_control_point_by_id(cp_id) + except KeyError: + # Captured base is not a part of the campaign. This happens when neutral + # bases are near the conflict. Nothing to do. + continue + + captured_by_player = int(new_owner_id_str) == blue_coalition_id + if control_point.is_friendly(to_player=captured_by_player): + # Base is currently friendly to the new owner. Was captured and + # recaptured in the same mission. Nothing to do. + continue + + captures.append(BaseCaptureEvent(control_point, captured_by_player)) + return captures class PollDebriefingFileThread(threading.Thread): diff --git a/game/event/event.py b/game/event/event.py index ea7f0e17..ae1c3a4a 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -1,12 +1,11 @@ from __future__ import annotations import logging -import math -from typing import Dict, Iterator, List, TYPE_CHECKING, Tuple, Type +from typing import List, TYPE_CHECKING, Type from dcs.mapping import Point from dcs.task import Task -from dcs.unittype import UnitType, VehicleType +from dcs.unittype import VehicleType from game import persistency from game.debriefing import AirLosses, Debriefing @@ -15,7 +14,6 @@ from game.operation.operation import Operation from game.theater import ControlPoint from gen import AirTaskingOrder from gen.ground_forces.combat_stance import CombatStance -from ..db import PRICES from ..unitmap import UnitMap if TYPE_CHECKING: @@ -122,11 +120,15 @@ class Event: self.game.red_ato, debriefing.air_losses, for_player=False ) - @staticmethod - def commit_air_losses(debriefing: Debriefing) -> None: + def commit_air_losses(self, debriefing: Debriefing) -> None: for loss in debriefing.air_losses.losses: - aircraft = loss.unit_type - cp = loss.departure + if ( + not loss.pilot.player + or not self.game.settings.invulnerable_player_pilots + ): + loss.pilot.kill() + aircraft = loss.flight.unit_type + cp = loss.flight.departure available = cp.base.total_units_of_type(aircraft) if available <= 0: logging.error( @@ -138,6 +140,23 @@ class Event: logging.info(f"{aircraft} destroyed from {cp}") cp.base.aircraft[aircraft] -= 1 + @staticmethod + def _commit_pilot_experience(ato: AirTaskingOrder) -> None: + for package in ato.packages: + for flight in package.flights: + for idx, pilot in enumerate(flight.roster.pilots): + if pilot is None: + logging.error( + f"Cannot award experience to pilot #{idx} of {flight} " + "because no pilot is assigned" + ) + continue + 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) + @staticmethod def commit_front_line_losses(debriefing: Debriefing) -> None: for loss in debriefing.front_line_losses: @@ -154,6 +173,47 @@ class Event: logging.info(f"{unit_type} destroyed from {control_point}") control_point.base.armor[unit_type] -= 1 + @staticmethod + def commit_convoy_losses(debriefing: Debriefing) -> None: + for loss in debriefing.convoy_losses: + unit_type = loss.unit_type + convoy = loss.convoy + available = loss.convoy.units.get(unit_type, 0) + convoy_name = f"convoy from {convoy.origin} to {convoy.destination}" + if available <= 0: + logging.error( + f"Found killed {unit_type} in {convoy_name} but that convoy has " + "none available." + ) + continue + + logging.info(f"{unit_type} destroyed in {convoy_name}") + convoy.kill_unit(unit_type) + + @staticmethod + def commit_cargo_ship_losses(debriefing: Debriefing) -> None: + for ship in debriefing.cargo_ship_losses: + logging.info( + f"All units destroyed in cargo ship from {ship.origin} to " + f"{ship.destination}." + ) + ship.kill_all() + + @staticmethod + def commit_airlift_losses(debriefing: Debriefing) -> None: + for loss in debriefing.airlift_losses: + transfer = loss.transfer + airlift_name = f"airlift from {transfer.origin} to {transfer.destination}" + for unit_type in loss.cargo: + try: + transfer.kill_unit(unit_type) + logging.info(f"{unit_type} destroyed in {airlift_name}") + except KeyError: + logging.exception( + f"Found killed {unit_type} in {airlift_name} but that airlift " + "has none available." + ) + @staticmethod def commit_ground_object_losses(debriefing: Debriefing) -> None: for loss in debriefing.ground_object_losses: @@ -181,62 +241,41 @@ class Event: for damaged_runway in debriefing.damaged_runways: damaged_runway.damage_runway() + def commit_captures(self, debriefing: Debriefing) -> None: + for captured in debriefing.base_captures: + try: + if captured.captured_by_player: + info = Information( + f"{captured.control_point} captured!", + f"We took control of {captured.control_point}.", + self.game.turn, + ) + else: + info = Information( + f"{captured.control_point} lost!", + f"The enemy took control of {captured.control_point}.", + self.game.turn, + ) + + self.game.informations.append(info) + captured.control_point.capture(self.game, captured.captured_by_player) + logging.info(f"Will run redeploy for {captured.control_point}") + self.redeploy_units(captured.control_point) + except Exception: + logging.exception(f"Could not process base capture {captured}") + def commit(self, debriefing: Debriefing): logging.info("Committing mission results") self.commit_air_losses(debriefing) + self.commit_pilot_experience() self.commit_front_line_losses(debriefing) + self.commit_convoy_losses(debriefing) + self.commit_airlift_losses(debriefing) self.commit_ground_object_losses(debriefing) self.commit_building_losses(debriefing) self.commit_damaged_runways(debriefing) - - # ------------------------------ - # Captured bases - # if self.game.player_country in db.BLUEFOR_FACTIONS: - coalition = 2 # Value in DCS mission event for BLUE - # else: - # coalition = 1 # Value in DCS mission event for RED - - for captured in debriefing.base_capture_events: - try: - id = int(captured.split("||")[0]) - new_owner_coalition = int(captured.split("||")[1]) - - captured_cps = [] - for cp in self.game.theater.controlpoints: - if cp.id == id: - - if cp.captured and new_owner_coalition != coalition: - for_player = False - info = Information( - cp.name + " lost !", - "The ennemy took control of " - + cp.name - + "\nShame on us !", - self.game.turn, - ) - self.game.informations.append(info) - captured_cps.append(cp) - elif not (cp.captured) and new_owner_coalition == coalition: - for_player = True - info = Information( - cp.name + " captured !", - "We took control of " + cp.name + "! Great job !", - self.game.turn, - ) - self.game.informations.append(info) - captured_cps.append(cp) - else: - continue - - cp.capture(self.game, for_player) - - for cp in captured_cps: - logging.info("Will run redeploy for " + cp.name) - self.redeploy_units(cp) - except Exception: - logging.exception(f"Could not process base capture {captured}") - + self.commit_captures(debriefing) self.complete_aircraft_transfers(debriefing) # Destroyed units carcass @@ -418,63 +457,3 @@ class Event: info = Information("Units redeployed", text, self.game.turn) self.game.informations.append(info) logging.info(text) - - -class UnitsDeliveryEvent: - def __init__(self, control_point: ControlPoint) -> None: - self.to_cp = control_point - self.units: Dict[Type[UnitType], int] = {} - - def __str__(self) -> str: - return "Pending delivery to {}".format(self.to_cp) - - def order(self, units: Dict[Type[UnitType], int]) -> None: - for k, v in units.items(): - self.units[k] = self.units.get(k, 0) + v - - def sell(self, units: Dict[Type[UnitType], int]) -> None: - for k, v in units.items(): - self.units[k] = self.units.get(k, 0) - v - - def consume_each_order(self) -> Iterator[Tuple[Type[UnitType], int]]: - while self.units: - yield self.units.popitem() - - def refund_all(self, game: Game) -> None: - for unit_type, count in self.consume_each_order(): - try: - price = PRICES[unit_type] - except KeyError: - logging.error(f"Could not refund {unit_type.id}, price unknown") - continue - - logging.info(f"Refunding {count} {unit_type.id} at {self.to_cp.name}") - game.adjust_budget(price * count, player=self.to_cp.captured) - - def available_next_turn(self, unit_type: Type[UnitType]) -> int: - pending_units = self.units.get(unit_type) - if pending_units is None: - pending_units = 0 - current_units = self.to_cp.base.total_units_of_type(unit_type) - return pending_units + current_units - - def process(self, game: Game) -> None: - bought_units: Dict[Type[UnitType], int] = {} - sold_units: Dict[Type[UnitType], int] = {} - for unit_type, count in self.units.items(): - coalition = "Ally" if self.to_cp.captured else "Enemy" - aircraft = unit_type.id - name = self.to_cp.name - if count >= 0: - bought_units[unit_type] = count - game.message( - f"{coalition} reinforcements: {aircraft} x {count} at {name}" - ) - else: - sold_units[unit_type] = -count - game.message(f"{coalition} sold: {aircraft} x {-count} at {name}") - self.to_cp.base.commision_units(bought_units) - self.to_cp.base.commit_losses(sold_units) - self.units = {} - bought_units = {} - sold_units = {} diff --git a/game/factions/faction.py b/game/factions/faction.py index b1cecc55..ef06ef0d 100644 --- a/game/factions/faction.py +++ b/game/factions/faction.py @@ -1,4 +1,5 @@ from __future__ import annotations +from game.data.groundunitclass import GroundUnitClass import logging from dataclasses import dataclass, field @@ -27,6 +28,9 @@ from pydcs_extensions.mod_units import MODDED_VEHICLES, MODDED_AIRPLANES @dataclass class Faction: + #: List of locales to use when generating random names. If not set, Faker will + #: choose the default locale. + locales: Optional[List[str]] # Country used by this faction country: str = field(default="") @@ -130,10 +134,19 @@ class Faction: #: both will use it. unrestricted_satnav: bool = False + def has_access_to_unittype(self, unitclass: GroundUnitClass) -> bool: + has_access = False + for vehicle in unitclass.unit_list: + if vehicle in self.frontline_units: + return True + if vehicle in self.artillery_units: + return True + + return has_access + @classmethod def from_json(cls: Type[Faction], json: Dict[str, Any]) -> Faction: - - faction = Faction() + faction = Faction(locales=json.get("locales")) faction.country = json.get("country", "/") if faction.country not in [c.name for c in country_dict.values()]: @@ -154,6 +167,8 @@ class Faction: faction.awacs = load_all_aircraft(json.get("awacs", [])) faction.tankers = load_all_aircraft(json.get("tankers", [])) + faction.aircrafts = list(set(faction.aircrafts + faction.awacs)) + faction.frontline_units = load_all_vehicles(json.get("frontline_units", [])) faction.artillery_units = load_all_vehicles(json.get("artillery_units", [])) faction.infantry_units = load_all_vehicles(json.get("infantry_units", [])) diff --git a/game/factions/faction_loader.py b/game/factions/faction_loader.py index fa4cf04f..d0722a00 100644 --- a/game/factions/faction_loader.py +++ b/game/factions/faction_loader.py @@ -2,8 +2,9 @@ from __future__ import annotations import json import logging from pathlib import Path -from typing import Dict, Iterator, Optional, Type +from typing import Dict, Iterator, List, Optional, Type +from game import persistency from game.factions.faction import Faction FACTION_DIRECTORY = Path("./resources/factions/") @@ -23,15 +24,22 @@ class FactionLoader: if self._factions is None: self._factions = self.load_factions() + @staticmethod + def find_faction_files_in(path: Path) -> List[Path]: + return [f for f in path.glob("*.json") if f.is_file()] + @classmethod def load_factions(cls: Type[FactionLoader]) -> Dict[str, Faction]: - files = [f for f in FACTION_DIRECTORY.glob("*.json") if f.is_file()] + user_faction_path = Path(persistency.base_path()) / "Liberation/Factions" + files = cls.find_faction_files_in( + FACTION_DIRECTORY + ) + cls.find_faction_files_in(user_faction_path) factions = {} for f in files: try: with f.open("r", encoding="utf-8") as fdata: - data = json.load(fdata, encoding="utf-8") + data = json.load(fdata) factions[data["name"]] = Faction.from_json(data) logging.info("Loaded faction : " + str(f)) except Exception: diff --git a/game/game.py b/game/game.py index 22598504..a6d3c97b 100644 --- a/game/game.py +++ b/game/game.py @@ -4,17 +4,19 @@ import random import sys from datetime import date, datetime, timedelta from enum import Enum -from typing import Any, Dict, List +from typing import Any, Dict, List, Iterator from dcs.action import Coalition from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike from dcs.vehicles import AirDefence +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 game.theater.theatergroundobject import MissileSiteGroundObject from gen.ato import AirTaskingOrder from gen.conflictgen import Conflict from gen.flights.ai_flight_planner import CoalitionMissionPlanner @@ -23,17 +25,21 @@ from gen.flights.flight import FlightType from gen.ground_forces.ai_ground_planner import GroundPlanner from . import persistency from .debriefing import Debriefing -from .event.event import Event, UnitsDeliveryEvent +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 ProcurementAi -from .settings import Settings -from .theater import ConflictTheater, ControlPoint, TheaterGroundObject -from game.theater.theatergroundobject import MissileSiteGroundObject +from .procurement import AircraftProcurementRequest, ProcurementAi +from .profiling import logged_duration +from .settings import Settings, AutoAtoBehavior +from .squadrons import Pilot, AirWing +from .theater import ConflictTheater +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 @@ -95,7 +101,9 @@ class Game: self.player_country = db.FACTIONS[player_name].country self.enemy_name = enemy_name self.enemy_country = db.FACTIONS[enemy_name].country - self.turn = 0 + # pass_turn() will be called when initialization is complete which will + # increment this to turn 0 before it reaches the player. + self.turn = -1 # 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() @@ -105,8 +113,6 @@ 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] = [] - # Culling Points are for individual theater ground objects that we don't wish to cull. - self.__culling_points: List[Point] = [] self.__destroyed_units: List[str] = [] self.savepath = "" self.budget = player_budget @@ -116,25 +122,31 @@ 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.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints) + self.transfers = PendingTransfers(self) + self.sanitize_sides() - self.on_load() + self.blue_faker = Faker(self.player_faction.locales) + self.red_faker = Faker(self.enemy_faction.locales) - # Turn 0 procurement. We don't actually have any missions to plan, but - # the planner will tell us what it would like to plan so we can use that - # to drive purchase decisions. - blue_planner = CoalitionMissionPlanner(self, is_player=True) - blue_planner.plan_missions() + self.blue_air_wing = AirWing(self, player=True) + self.red_air_wing = AirWing(self, player=False) - red_planner = CoalitionMissionPlanner(self, is_player=False) - red_planner.plan_missions() - - self.plan_procurement(blue_planner, red_planner) + self.on_load(game_still_initializing=True) def __getstate__(self) -> Dict[str, Any]: state = self.__dict__.copy() @@ -144,6 +156,8 @@ class Game: 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: @@ -151,6 +165,23 @@ class Game: # Regenerate any state that was not persisted. self.on_load() + def ato_for(self, player: bool) -> AirTaskingOrder: + if player: + return self.blue_ato + return self.red_ato + + def procurement_requests_for( + self, player: bool + ) -> List[AircraftProcurementRequest]: + if player: + return self.blue_procurement_requests + return self.red_procurement_requests + + def transit_network_for(self, player: bool) -> TransitNetwork: + if player: + return self.blue_transit_network + return self.red_transit_network + def generate_conditions(self) -> Conditions: return Conditions.generate( self.theater, self.current_day, self.current_turn_time_of_day, self.settings @@ -182,6 +213,26 @@ class Game: return self.player_faction return self.enemy_faction + def faker_for(self, player: bool) -> Faker: + if player: + return self.blue_faker + return self.red_faker + + def air_wing_for(self, player: bool) -> AirWing: + if player: + return self.blue_air_wing + return self.red_air_wing + + def country_for(self, player: bool) -> str: + if player: + return self.player_country + return self.enemy_country + + def bullseye_for(self, player: bool) -> Bullseye: + if player: + return self.blue_bullseye + return self.red_bullseye + def _roll(self, prob, mult): if self.settings.version == "dev": # always generate all events for dev @@ -202,11 +253,11 @@ class Game: ) def _generate_events(self): - for front_line in self.theater.conflicts(True): + for front_line in self.theater.conflicts(): self._generate_player_event( FrontlineAttackEvent, - front_line.control_point_a, - front_line.control_point_b, + front_line.blue_cp, + front_line.red_cp, ) def adjust_budget(self, amount: float, player: bool) -> None: @@ -248,27 +299,40 @@ class Game: else: raise RuntimeError(f"{event} was passed when an Event type was expected") - def on_load(self) -> None: + def on_load(self, game_still_initializing: bool = False) -> None: LuaPluginManager.load_settings(self.settings) ObjectiveDistanceCache.set_theater(self.theater) self.compute_conflicts_position() - self.compute_threat_zones() + 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 pass_turn(self, no_action: bool = False) -> None: - logging.info("Pass turn") + def reset_ato(self) -> None: + self.blue_ato.clear() + self.red_ato.clear() + + def finish_turn(self, skipped: bool = False) -> None: self.informations.append( Information("End of turn #" + str(self.turn), "-" * 40, 0) ) 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() + + # 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 the + self.reset_ato() for control_point in self.theater.controlpoints: control_point.process_turn(self) - self.process_enemy_income() - - self.process_player_income() - - if not no_action and self.turn > 1: + if not skipped and self.turn > 1: for cp in self.theater.player_points(): cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY) else: @@ -278,8 +342,20 @@ class Game: self.conditions = self.generate_conditions() + self.process_enemy_income() + self.process_player_income() + + def begin_turn_0(self) -> None: + self.turn = 0 self.initialize_turn() + def pass_turn(self, no_action: bool = False) -> None: + logging.info("Pass turn") + with logged_duration("Turn finalization"): + self.finish_turn(no_action) + with logged_duration("Turn initialization"): + self.initialize_turn() + # Autosave progress persistency.autosave(self) @@ -298,13 +374,22 @@ class Game: return TurnState.CONTINUE + 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) + def initialize_turn(self) -> None: 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) @@ -315,17 +400,30 @@ class Game: return self.process_win_loss(turn_state) # Plan flights & combat for next turn - self.compute_conflicts_position() - self.compute_threat_zones() + 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.blue_ato.clear() - self.red_ato.clear() - blue_planner = CoalitionMissionPlanner(self, is_player=True) - blue_planner.plan_missions() + self.blue_procurement_requests.clear() + self.red_procurement_requests.clear() - red_planner = CoalitionMissionPlanner(self, is_player=False) - red_planner.plan_missions() + 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() + + with logged_duration("Red mission planning"): + red_planner = CoalitionMissionPlanner(self, is_player=False) + red_planner.plan_missions() for cp in self.theater.controlpoints: if cp.has_frontline: @@ -333,19 +431,15 @@ class Game: gplanner.plan_groundwar() self.ground_planners[cp.id] = gplanner - self.plan_procurement(blue_planner, red_planner) + self.plan_procurement() - def plan_procurement( - self, - blue_planner: CoalitionMissionPlanner, - red_planner: CoalitionMissionPlanner, - ) -> None: + 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. - ground_portion = 0.3 if self.turn == 0 else 0.5 + # aircraft. After that the budget will be spend proportionally based on how much is already invested + self.budget = ProcurementAi( self, for_player=True, @@ -353,8 +447,7 @@ class Game: manage_runways=self.settings.automate_runway_repair, manage_front_line=self.settings.automate_front_line_reinforcements, manage_aircraft=self.settings.automate_aircraft_reinforcements, - front_line_budget_share=ground_portion, - ).spend_budget(self.budget, blue_planner.procurement_requests) + ).spend_budget(self.budget) self.enemy_budget = ProcurementAi( self, @@ -363,8 +456,7 @@ class Game: manage_runways=True, manage_front_line=True, manage_aircraft=True, - front_line_budget_share=ground_portion, - ).spend_budget(self.enemy_budget, red_planner.procurement_requests) + ).spend_budget(self.enemy_budget) def message(self, text: str) -> None: self.informations.append(Information(text, turn=self.turn)) @@ -391,6 +483,13 @@ 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) @@ -417,23 +516,15 @@ class Game: :return: List of points of interests """ zones = [] - points = [] # By default, use the existing frontline conflict position for front_line in self.theater.conflicts(): - position = Conflict.frontline_position( - front_line.control_point_a, front_line.control_point_b, self.theater - ) + position = Conflict.frontline_position(front_line, self.theater) zones.append(position[0]) - zones.append(front_line.control_point_a.position) - zones.append(front_line.control_point_b.position) + zones.append(front_line.blue_cp.position) + zones.append(front_line.red_cp.position) for cp in self.theater.controlpoints: - # Don't cull missile sites - their range is long enough to make them - # easily culled despite being a threat. - for tgo in cp.ground_objects: - if isinstance(tgo, MissileSiteGroundObject): - points.append(tgo.position) # If do_not_cull_carrier is enabled, add carriers as culling point if self.settings.perf_do_not_cull_carrier: if cp.is_carrier or cp.is_lha: @@ -477,7 +568,6 @@ class Game: zones.append(Point(0, 0)) self.__culling_zones = zones - self.__culling_points = points def add_destroyed_units(self, data): pos = Point(data["x"], data["z"]) @@ -493,19 +583,12 @@ class Game: :param pos: Position you are tryng to spawn stuff at :return: True if units can not be added at given position """ - if self.settings.perf_culling == False: + if not self.settings.perf_culling: return False - else: - for z in self.__culling_zones: - if ( - z.distance_to_point(pos) - < self.settings.perf_culling_distance * 1000 - ): - return False - for p in self.__culling_points: - if p.distance_to_point(pos) < 2500: - return False - return True + for z in self.__culling_zones: + if z.distance_to_point(pos) < self.settings.perf_culling_distance * 1000: + return False + return True def get_culling_zones(self): """ @@ -514,13 +597,6 @@ class Game: """ return self.__culling_zones - def get_culling_points(self): - """ - Check culling points - :return: List of culling points - """ - return self.__culling_points - # 1 = red, 2 = blue def get_player_coalition_id(self): return 2 diff --git a/game/models/frontline_data.py b/game/models/frontline_data.py deleted file mode 100644 index f60d5ccd..00000000 --- a/game/models/frontline_data.py +++ /dev/null @@ -1,13 +0,0 @@ -from game.theater import ControlPoint - - -class FrontlineData: - """ - This Data structure will store information about an existing frontline - """ - - def __init__(self, from_cp: ControlPoint, to_cp: ControlPoint): - self.to_cp = to_cp - self.from_cp = from_cp - self.enemy_units_position = [] - self.blue_units_position = [] diff --git a/game/navmesh.py b/game/navmesh.py index 193dd31e..e18052d0 100644 --- a/game/navmesh.py +++ b/game/navmesh.py @@ -103,7 +103,7 @@ class NavMesh: # currently. p = ShapelyPoint(point.x, point.y) for navpoly in self.polys: - if navpoly.poly.contains(p): + if navpoly.poly.intersects(p): return navpoly return None diff --git a/game/operation/operation.py b/game/operation/operation.py index 271a99aa..3df80df7 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -1,10 +1,9 @@ from __future__ import annotations -from game.theater.theatergroundobject import TheaterGroundObject import logging import os from pathlib import Path -from typing import TYPE_CHECKING, Iterable, List, Optional, Set +from typing import Iterable, List, Set, TYPE_CHECKING from dcs import Mission from dcs.action import DoScript, DoScriptFile @@ -14,7 +13,9 @@ from dcs.lua.parse import loads from dcs.mapping import Point from dcs.translation import String from dcs.triggers import TriggerStart + from game.plugins import LuaPluginManager +from game.theater.theatergroundobject import TheaterGroundObject from gen import Conflict, FlightType, VisualGenerator from gen.aircraft import AIRCRAFT_DATA, AircraftConflictGenerator, FlightData from gen.airfields import AIRFIELD_DATA @@ -22,6 +23,8 @@ from gen.airsupportgen import AirSupport, AirSupportConflictGenerator from gen.armor import GroundConflictGenerator, JtacInfo from gen.beacons import load_beacons_for_terrain from gen.briefinggen import BriefingGenerator, MissionInfoGenerator +from gen.cargoshipgen import CargoShipGenerator +from gen.convoygen import ConvoyGenerator from gen.environmentgen import EnvironmentGenerator from gen.forcedoptionsgen import ForcedOptionsGenerator from gen.groundobjectsgen import GroundObjectsGenerator @@ -30,9 +33,8 @@ from gen.naming import namegen from gen.radios import RadioFrequency, RadioRegistry from gen.tacan import TacanRegistry from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator - from .. import db -from ..theater import Airfield +from ..theater import Airfield, FrontLine from ..unitmap import UnitMap if TYPE_CHECKING: @@ -42,18 +44,13 @@ if TYPE_CHECKING: class Operation: """Static class for managing the final Mission generation""" - current_mission = None # type: Mission - airgen = None # type: AircraftConflictGenerator - triggersgen = None # type: TriggersGenerator - airsupportgen = None # type: AirSupportConflictGenerator - visualgen = None # type: VisualGenerator - groundobjectgen = None # type: GroundObjectsGenerator - briefinggen = None # type: BriefingGenerator - forcedoptionsgen = None # type: ForcedOptionsGenerator - radio_registry: Optional[RadioRegistry] = None - tacan_registry: Optional[TacanRegistry] = None - game = None # type: Game - environment_settings = None + current_mission: Mission + airgen: AircraftConflictGenerator + airsupportgen: AirSupportConflictGenerator + groundobjectgen: GroundObjectsGenerator + radio_registry: RadioRegistry + tacan_registry: TacanRegistry + game: Game trigger_radius = TRIGGER_RADIUS_MEDIUM is_quick = None player_awacs_enabled = True @@ -79,8 +76,7 @@ class Operation: for frontline in cls.game.theater.conflicts(): yield Conflict( cls.game.theater, - frontline.control_point_a, - frontline.control_point_b, + frontline, cls.game.player_name, cls.game.enemy_name, cls.game.player_country, @@ -98,8 +94,7 @@ class Operation: ) return Conflict( cls.game.theater, - player_cp, - enemy_cp, + FrontLine(player_cp, enemy_cp), cls.game.player_name, cls.game.enemy_name, cls.game.player_country, @@ -113,8 +108,12 @@ class Operation: @classmethod def _setup_mission_coalitions(cls): - cls.current_mission.coalition["blue"] = Coalition("blue") - cls.current_mission.coalition["red"] = Coalition("red") + cls.current_mission.coalition["blue"] = Coalition( + "blue", bullseye=cls.game.blue_bullseye.to_pydcs() + ) + cls.current_mission.coalition["red"] = Coalition( + "red", bullseye=cls.game.red_bullseye.to_pydcs() + ) p_country = cls.game.player_country e_country = cls.game.enemy_country @@ -176,13 +175,16 @@ class Operation: gen.add_dynamic_runway(dynamic_runway) for tanker in airsupportgen.air_support.tankers: - gen.add_tanker(tanker) + if tanker.blue: + gen.add_tanker(tanker) for aewc in airsupportgen.air_support.awacs: - gen.add_awacs(aewc) + if aewc.blue: + gen.add_awacs(aewc) for jtac in jtacs: - gen.add_jtac(jtac) + if jtac.blue: + gen.add_jtac(jtac) for flight in airgen.flights: gen.add_flight(flight) @@ -308,6 +310,7 @@ class Operation: # Set mission time and weather conditions. EnvironmentGenerator(cls.current_mission, cls.game.conditions).generate() cls._generate_ground_units() + cls._generate_transports() cls._generate_destroyed_units() cls._generate_air_units() cls.assign_channels_to_flights( @@ -321,13 +324,8 @@ class Operation: # Setup combined arms parameters cls.current_mission.groundControl.pilot_can_control_vehicles = cls.ca_slots > 0 - if cls.game.player_country in [ - country.name - for country in cls.current_mission.coalition["blue"].countries.values() - ]: - cls.current_mission.groundControl.blue_tactical_commander = cls.ca_slots - else: - cls.current_mission.groundControl.red_tactical_commander = cls.ca_slots + cls.current_mission.groundControl.blue_tactical_commander = cls.ca_slots + cls.current_mission.groundControl.blue_observer = 1 # Options forcedoptionsgen = ForcedOptionsGenerator(cls.current_mission, cls.game) @@ -401,16 +399,16 @@ class Operation: @classmethod def _generate_ground_conflicts(cls) -> None: """For each frontline in the Operation, generate the ground conflicts and JTACs""" - for front_line in cls.game.theater.conflicts(True): - player_cp = front_line.control_point_a - enemy_cp = front_line.control_point_b + cls.jtacs = [] + for front_line in cls.game.theater.conflicts(): + player_cp = front_line.blue_cp + enemy_cp = front_line.red_cp conflict = Conflict.frontline_cas_conflict( cls.game.player_name, cls.game.enemy_name, cls.current_mission.country(cls.game.player_country), cls.current_mission.country(cls.game.enemy_country), - player_cp, - enemy_cp, + front_line, cls.game.theater, ) # Generate frontline ops @@ -428,6 +426,12 @@ class Operation: ground_conflict_gen.generate() cls.jtacs.extend(ground_conflict_gen.jtacs) + @classmethod + def _generate_transports(cls) -> None: + """Generates convoys for unit transfers by road.""" + ConvoyGenerator(cls.current_mission, cls.game, cls.unit_map).generate() + CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate() + @classmethod def reset_naming_ids(cls): namegen.reset_numbers() @@ -452,7 +456,7 @@ class Operation: for tanker in airsupportgen.air_support.tankers: luaData["Tankers"][tanker.callsign] = { - "dcsGroupName": tanker.dcsGroupName, + "dcsGroupName": tanker.group_name, "callsign": tanker.callsign, "variant": tanker.variant, "radio": tanker.freq.mhz, @@ -462,14 +466,14 @@ class Operation: if airsupportgen.air_support.awacs: for awacs in airsupportgen.air_support.awacs: luaData["AWACs"][awacs.callsign] = { - "dcsGroupName": awacs.dcsGroupName, + "dcsGroupName": awacs.group_name, "callsign": awacs.callsign, "radio": awacs.freq.mhz, } for jtac in jtacs: luaData["JTACs"][jtac.callsign] = { - "dcsGroupName": jtac.dcsGroupName, + "dcsGroupName": jtac.group_name, "callsign": jtac.callsign, "zone": jtac.region, "dcsUnit": jtac.unit_name, diff --git a/game/procurement.py b/game/procurement.py index 0ff83155..021014ce 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -3,22 +3,24 @@ from __future__ import annotations import math import random from dataclasses import dataclass -from typing import Iterator, List, Optional, TYPE_CHECKING, Type +from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple, Type from dcs.unittype import FlyingType, VehicleType from game import db +from game.data.groundunitclass import GroundUnitClass from game.factions.faction import Faction from game.theater import ControlPoint, MissionTarget from game.utils import Distance from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType -from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD if TYPE_CHECKING: from game import Game +FRONTLINE_RESERVES_FACTOR = 1.3 + @dataclass(frozen=True) class AircraftProcurementRequest: @@ -43,27 +45,43 @@ class ProcurementAi: manage_runways: bool, manage_front_line: bool, manage_aircraft: bool, - front_line_budget_share: float, ) -> None: - if front_line_budget_share > 1.0: - raise ValueError self.game = game self.is_player = for_player + self.air_wing = game.air_wing_for(for_player) self.faction = faction self.manage_runways = manage_runways self.manage_front_line = manage_front_line self.manage_aircraft = manage_aircraft - self.front_line_budget_share = front_line_budget_share self.threat_zones = self.game.threat_zone_for(not self.is_player) - def spend_budget( - self, budget: float, aircraft_requests: List[AircraftProcurementRequest] - ) -> float: + def calculate_ground_unit_budget_share(self) -> float: + armor_investment = 0 + aircraft_investment = 0 + for cp in self.owned_points: + cp_ground_units = cp.allocated_ground_units(self.game.transfers) + armor_investment += cp_ground_units.total_value + cp_aircraft = cp.allocated_aircraft(self.game) + aircraft_investment += cp_aircraft.total_value + + total_investment = aircraft_investment + armor_investment + if total_investment == 0: + # Turn 0 or all units were destroyed. Either way, split 30/70. + return 0.3 + + # the more planes we have, the more ground units we want and vice versa + ground_unit_share = aircraft_investment / total_investment + if ground_unit_share > 1.0: + raise ValueError + + return ground_unit_share + + def spend_budget(self, budget: float) -> float: if self.manage_runways: budget = self.repair_runways(budget) if self.manage_front_line: - armor_budget = math.ceil(budget * self.front_line_budget_share) + armor_budget = budget * self.calculate_ground_unit_budget_share() budget -= armor_budget budget += self.reinforce_front_line(armor_budget) @@ -72,20 +90,20 @@ class ProcurementAi: if not self.is_player: budget += self.sell_incomplete_squadrons() if self.manage_aircraft: - budget = self.purchase_aircraft(budget, aircraft_requests) + budget = self.purchase_aircraft(budget) return budget def sell_incomplete_squadrons(self) -> float: # Selling incomplete squadrons gives us more money to spend on the next # turn. This serves as a short term fix for - # https://github.com/Khopa/dcs_liberation/issues/41. + # https://github.com/dcs-liberation/dcs_liberation/issues/41. # # Only incomplete squadrons which are unlikely to get used will be sold # rather than all unused aircraft because the unused aircraft are what # make OCA strikes worthwhile. # # This option is only used by the AI since players cannot cancel sales - # (https://github.com/Khopa/dcs_liberation/issues/365). + # (https://github.com/dcs-liberation/dcs_liberation/issues/365). total = 0.0 for cp in self.game.theater.control_points_for(self.is_player): inventory = self.game.aircraft_inventory.for_control_point(cp) @@ -115,28 +133,14 @@ class ProcurementAi: ) return budget - def random_affordable_ground_unit( - self, budget: float, cp: ControlPoint + def affordable_ground_unit_of_class( + self, budget: float, unit_class: GroundUnitClass ) -> Optional[Type[VehicleType]]: - affordable_units = [ - u - for u in self.faction.frontline_units + self.faction.artillery_units - if db.PRICES[u] <= budget - ] - - total_number_aa = ( - cp.base.total_frontline_aa + cp.pending_frontline_aa_deliveries_count + faction_units = set(self.faction.frontline_units) | set( + self.faction.artillery_units ) - total_non_aa = ( - cp.base.total_armor + cp.pending_deliveries_count - total_number_aa - ) - max_aa = math.ceil(total_non_aa / 8) - - # Limit the number of AA units the AI will buy - if not total_number_aa < max_aa: - for unit in [u for u in affordable_units if u in TYPE_SHORAD]: - affordable_units.remove(unit) - + of_class = set(unit_class.unit_list) & faction_units + affordable_units = [u for u in of_class if db.PRICES[u] <= budget] if not affordable_units: return None return random.choice(affordable_units) @@ -145,13 +149,15 @@ class ProcurementAi: if not self.faction.frontline_units and not self.faction.artillery_units: return budget + # TODO: Attempt to transfer from reserves. + while budget > 0: - candidates = self.front_line_candidates() - if not candidates: + cp = self.ground_reinforcement_candidate() + if cp is None: break - cp = random.choice(candidates) - unit = self.random_affordable_ground_unit(budget, cp) + most_needed_type = self.most_needed_unit_class(cp) + unit = self.affordable_ground_unit_of_class(budget, most_needed_type) if unit is None: # Can't afford any more units. break @@ -161,23 +167,56 @@ class ProcurementAi: return budget - def _affordable_aircraft_of_types( + def most_needed_unit_class(self, cp: ControlPoint) -> GroundUnitClass: + worst_balanced: Optional[GroundUnitClass] = None + worst_fulfillment = math.inf + for unit_class in GroundUnitClass: + if not self.faction.has_access_to_unittype(unit_class): + continue + + current_ratio = self.cost_ratio_of_ground_unit(cp, unit_class) + desired_ratio = ( + self.faction.doctrine.ground_unit_procurement_ratios.for_unit_class( + unit_class + ) + ) + if not desired_ratio: + continue + if current_ratio >= desired_ratio: + continue + fulfillment = current_ratio / desired_ratio + if fulfillment < worst_fulfillment: + worst_fulfillment = fulfillment + worst_balanced = unit_class + if worst_balanced is None: + return GroundUnitClass.Tank + return worst_balanced + + def _affordable_aircraft_for_task( self, - types: List[Type[FlyingType]], + task: FlightType, airbase: ControlPoint, number: int, max_price: float, ) -> Optional[Type[FlyingType]]: best_choice: Optional[Type[FlyingType]] = None - for unit in [u for u in self.faction.aircrafts if u in types]: + for unit in aircraft_for_task(task): + if unit not in self.faction.aircrafts: + continue if db.PRICES[unit] * number > max_price: continue if not airbase.can_operate(unit): continue - # Affordable and compatible. To keep some variety, skip with a 50/50 - # chance. Might be a good idea to have the chance to skip based on - # the price compared to the rest of the choices. + for squadron in self.air_wing.squadrons_for(unit): + if task in squadron.auto_assignable_mission_types: + break + else: + continue + + # Affordable, compatible, and we have a squadron capable of the task. To + # keep some variety, skip with a 50/50 chance. Might be a good idea to have + # the chance to skip based on the price compared to the rest of the choices. best_choice = unit if random.choice([True, False]): break @@ -186,27 +225,41 @@ class ProcurementAi: def affordable_aircraft_for( self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float ) -> Optional[Type[FlyingType]]: - return self._affordable_aircraft_of_types( - aircraft_for_task(request.task_capability), airbase, request.number, budget + return self._affordable_aircraft_for_task( + request.task_capability, airbase, request.number, budget ) - def purchase_aircraft( - self, budget: float, aircraft_requests: List[AircraftProcurementRequest] - ) -> float: - for request in aircraft_requests: - for airbase in self.best_airbases_for(request): - unit = self.affordable_aircraft_for(request, airbase, budget) - if unit is None: - # Can't afford any aircraft capable of performing the - # required mission that can operate from this airbase. We - # might be able to afford aircraft at other airbases though, - # in the case where the airbase we attempted to use is only - # able to operate expensive aircraft. - continue + def fulfill_aircraft_request( + self, request: AircraftProcurementRequest, budget: float + ) -> Tuple[float, bool]: + for airbase in self.best_airbases_for(request): + unit = self.affordable_aircraft_for(request, airbase, budget) + if unit is None: + # Can't afford any aircraft capable of performing the + # required mission that can operate from this airbase. We + # might be able to afford aircraft at other airbases though, + # in the case where the airbase we attempted to use is only + # able to operate expensive aircraft. + continue - budget -= db.PRICES[unit] * request.number - airbase.pending_unit_deliveries.order({unit: request.number}) + budget -= db.PRICES[unit] * request.number + airbase.pending_unit_deliveries.order({unit: request.number}) + return budget, True + return budget, False + def purchase_aircraft(self, budget: float) -> float: + for request in self.game.procurement_requests_for(self.is_player): + if not list(self.best_airbases_for(request)): + # No airbases in range of this request. Skip it. + continue + budget, fulfilled = self.fulfill_aircraft_request(request, budget) + if not fulfilled: + # The request was not fulfilled because we could not afford any suitable + # aircraft. Rather than continuing, which could proceed to buy tons of + # cheap escorts that will never allow us to plan a strike package, stop + # buying so we can save the budget until a turn where we *can* afford to + # fill the package. + break return budget @property @@ -221,11 +274,9 @@ class ProcurementAi: ) -> Iterator[ControlPoint]: distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near) threatened = [] - for cp in distance_cache.airfields_within(request.range): + for cp in distance_cache.operational_airfields_within(request.range): if not cp.is_friendly(self.is_player): continue - if not cp.runway_is_operational(): - continue if cp.unclaimed_parking(self.game) < request.number: continue if self.threat_zones.threatened(cp.position): @@ -233,37 +284,69 @@ class ProcurementAi: yield cp yield from threatened - def front_line_candidates(self) -> List[ControlPoint]: - candidates = [] + def ground_reinforcement_candidate(self) -> Optional[ControlPoint]: + worst_supply = math.inf + understaffed: Optional[ControlPoint] = None # Prefer to buy front line units at active front lines that are not # already overloaded. for cp in self.owned_points: - if cp.expected_ground_units_next_turn.total >= 30: + if not cp.has_active_frontline: + continue + + if not cp.has_ground_unit_source(self.game): + # No source of ground units, so can't buy anything. + continue + + purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR + allocated = cp.allocated_ground_units(self.game.transfers) + if allocated.total >= purchase_target: # Control point is already sufficiently defended. continue - for connected in cp.connected_points: - if not connected.is_friendly(to_player=self.is_player): - candidates.append(cp) + if allocated.total < worst_supply: + worst_supply = allocated.total + understaffed = cp - if not candidates: - # Otherwise buy reserves, but don't exceed 10 reserve units per CP. - # These units do not exist in the world until the CP becomes - # connected to an active front line, at which point all these units - # will suddenly appear at the gates of the newly captured CP. - # - # To avoid sudden overwhelming numbers of units we avoid buying - # many. - # - # Also, do not bother buying units at bases that will never connect - # to a front line. - for cp in self.owned_points: - if not cp.can_deploy_ground_units: - continue - if cp.expected_ground_units_next_turn.total >= 10: - continue - if cp.is_global: - continue - candidates.append(cp) + if understaffed is not None: + return understaffed - return candidates + # Otherwise buy reserves, but don't exceed the amount defined in the settings. + # These units do not exist in the world until the CP becomes + # connected to an active front line, at which point all these units + # will suddenly appear at the gates of the newly captured CP. + # + # To avoid sudden overwhelming numbers of units we avoid buying + # many. + # + # Also, do not bother buying units at bases that will never connect + # to a front line. + for cp in self.owned_points: + if cp.is_global: + continue + if not cp.can_recruit_ground_units(self.game): + continue + + allocated = cp.allocated_ground_units(self.game.transfers) + if allocated.total >= self.game.settings.reserves_procurement_target: + continue + + if allocated.total < worst_supply: + worst_supply = allocated.total + understaffed = cp + + return understaffed + + def cost_ratio_of_ground_unit( + self, control_point: ControlPoint, unit_class: GroundUnitClass + ) -> float: + allocations = control_point.allocated_ground_units(self.game.transfers) + class_cost = 0 + total_cost = 0 + for unit_type, count in allocations.all.items(): + cost = db.PRICES[unit_type] * count + total_cost += cost + if unit_type in unit_class: + class_cost += cost + if not total_cost: + return 0 + return class_cost / total_cost diff --git a/game/profiling.py b/game/profiling.py new file mode 100644 index 00000000..82c2c326 --- /dev/null +++ b/game/profiling.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import logging +import timeit +from collections import defaultdict +from contextlib import contextmanager +from datetime import timedelta +from typing import Iterator + + +@contextmanager +def logged_duration(event: str) -> Iterator[None]: + start = timeit.default_timer() + yield + end = timeit.default_timer() + logging.debug("%s took %s", event, timedelta(seconds=end - start)) + + +class MultiEventTracer: + def __init__(self) -> None: + self.events: dict[str, timedelta] = defaultdict(timedelta) + + def __enter__(self) -> MultiEventTracer: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + for event, duration in self.events.items(): + logging.debug("%s took %s", event, duration) + + @contextmanager + def trace(self, event: str) -> Iterator[None]: + start = timeit.default_timer() + yield + end = timeit.default_timer() + self.events[event] += timedelta(seconds=end - start) diff --git a/game/scenery_group.py b/game/scenery_group.py new file mode 100644 index 00000000..0015ab8f --- /dev/null +++ b/game/scenery_group.py @@ -0,0 +1,89 @@ +from __future__ import annotations +from game.theater.theatergroundobject import NAME_BY_CATEGORY +from dcs.triggers import TriggerZone + +from typing import Iterable, List + + +class SceneryGroupError(RuntimeError): + """Error for when there are insufficient conditions to create a SceneryGroup.""" + + pass + + +class SceneryGroup: + """Store information about a scenery objective.""" + + def __init__( + self, zone_def: TriggerZone, zones: Iterable[TriggerZone], category: str + ) -> None: + + self.zone_def = zone_def + self.zones = zones + self.position = zone_def.position + self.category = category + + @staticmethod + def from_trigger_zones(trigger_zones: Iterable[TriggerZone]) -> List[SceneryGroup]: + """Define scenery objectives based on their encompassing blue/red circle.""" + zone_definitions = [] + white_zones = [] + + scenery_groups = [] + + # Aggregate trigger zones into different groups based on color. + for zone in trigger_zones: + if SceneryGroup.is_blue(zone): + zone_definitions.append(zone) + if SceneryGroup.is_white(zone): + white_zones.append(zone) + + # For each objective definition. + for zone_def in zone_definitions: + + zone_def_radius = zone_def.radius + zone_def_position = zone_def.position + zone_def_name = zone_def.name + + if len(zone_def.properties) == 0: + raise SceneryGroupError( + "Undefined SceneryGroup category in TriggerZone: " + zone_def_name + ) + + # Arbitrary campaign design requirement: First property must define the category. + zone_def_category = zone_def.properties[1].get("value").lower() + + valid_white_zones = [] + + for zone in list(white_zones): + if zone.position.distance_to_point(zone_def_position) < zone_def_radius: + valid_white_zones.append(zone) + white_zones.remove(zone) + + if len(valid_white_zones) > 0 and zone_def_category in NAME_BY_CATEGORY: + scenery_groups.append( + SceneryGroup(zone_def, valid_white_zones, zone_def_category) + ) + elif len(valid_white_zones) == 0: + raise SceneryGroupError( + "No white triggerzones found in: " + zone_def_name + ) + elif zone_def_category not in NAME_BY_CATEGORY: + raise SceneryGroupError( + "Incorrect TriggerZone category definition for: " + + zone_def_name + + " in campaign definition. TriggerZone category: " + + zone_def_category + ) + + return scenery_groups + + @staticmethod + def is_blue(zone: TriggerZone) -> bool: + # Blue in RGB is [0 Red], [0 Green], [1 Blue]. Ignore the fourth position: Transparency. + return zone.color[1] == 0 and zone.color[2] == 0 and zone.color[3] == 1 + + @staticmethod + def is_white(zone: TriggerZone) -> bool: + # White in RGB is [1 Red], [1 Green], [1 Blue]. Ignore the fourth position: Transparency. + return zone.color[1] == 1 and zone.color[2] == 1 and zone.color[3] == 1 diff --git a/game/settings.py b/game/settings.py index 0e6f968a..e869283e 100644 --- a/game/settings.py +++ b/game/settings.py @@ -1,9 +1,19 @@ from dataclasses import dataclass, field +from datetime import timedelta +from enum import Enum, unique from typing import Dict, Optional from dcs.forcedoptions import ForcedOptions +@unique +class AutoAtoBehavior(Enum): + Disabled = "Disabled" + Never = "Never assign player pilots" + Default = "No preference" + Prefer = "Prefer player pilots" + + @dataclass class Settings: @@ -25,21 +35,29 @@ class Settings: default_start_type: str = "Cold" + # Mission specific + desired_player_mission_duration: timedelta = timedelta(minutes=60) + # Campaign management automate_runway_repair: bool = False automate_front_line_reinforcements: bool = False automate_aircraft_reinforcements: bool = False restrict_weapons_by_date: bool = False - disable_legacy_aewc: bool = False + disable_legacy_aewc: bool = True generate_dark_kneeboard: bool = False + invulnerable_player_pilots: bool = True + auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default + auto_ato_player_missions_asap: bool = True # Performance oriented perf_red_alert_state: bool = True perf_smoke_gen: bool = True + perf_smoke_spacing = 1600 perf_artillery: bool = True perf_moving_units: bool = True perf_infantry: bool = True perf_destroyed_units: bool = True + reserves_procurement_target: int = 10 # Performance culling perf_culling: bool = False diff --git a/game/squadrons.py b/game/squadrons.py new file mode 100644 index 00000000..4e550465 --- /dev/null +++ b/game/squadrons.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import itertools +import logging +import random +from collections import defaultdict +from dataclasses import dataclass, field +from enum import unique, Enum +from pathlib import Path +from typing import ( + Type, + Tuple, + TYPE_CHECKING, + Optional, + Iterator, + Sequence, +) + +import yaml +from dcs.unittype import FlyingType +from faker import Faker + +from game.db import flying_type_from_name +from game.settings import AutoAtoBehavior + +if TYPE_CHECKING: + from game import Game + from gen.flights.flight import FlightType + + +@dataclass +class PilotRecord: + missions_flown: int = field(default=0) + + +@unique +class PilotStatus(Enum): + Active = "Active" + OnLeave = "On leave" + Dead = "Dead" + + +@dataclass +class Pilot: + name: str + player: bool = field(default=False) + status: PilotStatus = field(default=PilotStatus.Active) + record: PilotRecord = field(default_factory=PilotRecord) + + @property + def alive(self) -> bool: + return self.status is not PilotStatus.Dead + + @property + def on_leave(self) -> bool: + return self.status is PilotStatus.OnLeave + + def send_on_leave(self) -> None: + if self.status is not PilotStatus.Active: + raise RuntimeError("Only active pilots may be sent on leave") + self.status = PilotStatus.OnLeave + + def return_from_leave(self) -> None: + if self.status is not PilotStatus.OnLeave: + raise RuntimeError("Only pilots on leave may be returned from leave") + self.status = PilotStatus.Active + + def kill(self) -> None: + self.status = PilotStatus.Dead + + @classmethod + def random(cls, faker: Faker) -> Pilot: + return Pilot(faker.name()) + + +@dataclass +class Squadron: + name: str + nickname: str + country: str + role: str + aircraft: Type[FlyingType] + livery: Optional[str] + mission_types: tuple[FlightType, ...] + pilots: list[Pilot] + available_pilots: list[Pilot] = field(init=False, hash=False, compare=False) + auto_assignable_mission_types: set[FlightType] = field( + 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 + + def __post_init__(self) -> None: + self.available_pilots = list(self.active_pilots) + self.auto_assignable_mission_types = set(self.mission_types) + + def __str__(self) -> str: + return f'{self.name} "{self.nickname}"' + + def claim_available_pilot(self) -> Optional[Pilot]: + # No pilots available, so the preference is irrelevant. Create a new pilot and + # return it. + if not self.available_pilots: + self.enlist_new_pilots(1) + return self.available_pilots.pop() + + # For opfor, so player/AI option is irrelevant. + if not self.player: + return self.available_pilots.pop() + + preference = self.game.settings.auto_ato_behavior + + # No preference, so the first pilot is fine. + if preference is AutoAtoBehavior.Default: + return self.available_pilots.pop() + + prefer_players = preference is AutoAtoBehavior.Prefer + for pilot in self.available_pilots: + if pilot.player == prefer_players: + self.available_pilots.remove(pilot) + return pilot + + # No pilot was found that matched the user's preference. + # + # If they chose to *never* assign players and only players remain in the pool, + # we cannot fill the slot with the available pilots. Recruit a new one. + # + # If they prefer players and we're out of players, just return an AI pilot. + if not prefer_players: + self.enlist_new_pilots(1) + return self.available_pilots.pop() + + def claim_pilot(self, pilot: Pilot) -> None: + if pilot not in self.available_pilots: + raise ValueError( + f"Cannot assign {pilot} to {self} because they are not available" + ) + self.available_pilots.remove(pilot) + + def return_pilot(self, pilot: Pilot) -> None: + self.available_pilots.append(pilot) + + def return_pilots(self, pilots: Sequence[Pilot]) -> None: + # Return in reverse so that returning two pilots and then getting two more + # results in the same ordering. This happens commonly when resetting rosters in + # the UI, when we clear the roster because the UI is updating, then end up + # repopulating the same size flight from the same squadron. + self.available_pilots.extend(reversed(pilots)) + + def enlist_new_pilots(self, count: int) -> None: + new_pilots = [Pilot(self.faker.name()) for _ in range(count)] + self.pilots.extend(new_pilots) + self.available_pilots.extend(new_pilots) + + def return_all_pilots(self) -> None: + self.available_pilots = list(self.active_pilots) + + @property + def faker(self) -> Faker: + return self.game.faker_for(self.player) + + def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]: + return [p for p in self.pilots if p.status == status] + + def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]: + return [p for p in self.pilots if p.status != status] + + @property + def active_pilots(self) -> list[Pilot]: + return self._pilots_with_status(PilotStatus.Active) + + @property + def pilots_on_leave(self) -> list[Pilot]: + return self._pilots_with_status(PilotStatus.OnLeave) + + @property + def number_of_pilots_including_dead(self) -> int: + return len(self.pilots) + + @property + def number_of_living_pilots(self) -> int: + return len(self._pilots_without_status(PilotStatus.Dead)) + + def pilot_at_index(self, index: int) -> Pilot: + return self.pilots[index] + + @classmethod + def from_yaml(cls, path: Path, game: Game, player: bool) -> Squadron: + from gen.flights.ai_flight_planner_db import tasks_for_aircraft + from gen.flights.flight import FlightType + + with path.open() as squadron_file: + data = yaml.safe_load(squadron_file) + + unit_type = flying_type_from_name(data["aircraft"]) + if unit_type is None: + raise KeyError(f"Could not find any aircraft with the ID {unit_type}") + + pilots = [Pilot(n, player=False) for n in data.get("pilots", [])] + pilots.extend([Pilot(n, player=True) for n in data.get("players", [])]) + + mission_types = [FlightType.from_name(n) for n in data["mission_types"]] + tasks = tasks_for_aircraft(unit_type) + for mission_type in list(mission_types): + if mission_type not in tasks: + logging.error( + f"Squadron has mission type {mission_type} but {unit_type} is not " + f"capable of that task: {path}" + ) + mission_types.remove(mission_type) + + return Squadron( + name=data["name"], + nickname=data["nickname"], + country=data["country"], + role=data["role"], + aircraft=unit_type, + livery=data.get("livery"), + mission_types=tuple(mission_types), + pilots=pilots, + game=game, + player=player, + ) + + def __setstate__(self, state) -> None: + # TODO: Remove save compat. + if "auto_assignable_mission_types" not in state: + state["auto_assignable_mission_types"] = set(state["mission_types"]) + self.__dict__.update(state) + + +class SquadronLoader: + def __init__(self, game: Game, player: bool) -> None: + self.game = game + self.player = player + + @staticmethod + def squadron_directories() -> Iterator[Path]: + from game import persistency + + yield Path(persistency.base_path()) / "Liberation/Squadrons" + yield Path("resources/squadrons") + + def load(self) -> dict[Type[FlyingType], list[Squadron]]: + squadrons: dict[Type[FlyingType], list[Squadron]] = defaultdict(list) + country = self.game.country_for(self.player) + faction = self.game.faction_for(self.player) + any_country = country.startswith("Combined Joint Task Forces ") + for directory in self.squadron_directories(): + for path, squadron in self.load_squadrons_from(directory): + if not any_country and squadron.country != country: + logging.debug( + "Not using squadron for non-matching country (is " + f"{squadron.country}, need {country}: {path}" + ) + continue + if squadron.aircraft not in faction.aircrafts: + logging.debug( + f"Not using squadron because {faction.name} cannot use " + f"{squadron.aircraft}: {path}" + ) + continue + logging.debug( + f"Found {squadron.name} {squadron.aircraft} {squadron.role} " + f"compatible with {faction.name}" + ) + squadrons[squadron.aircraft].append(squadron) + # Convert away from defaultdict because defaultdict doesn't unpickle so we don't + # want it in the save state. + return dict(squadrons) + + def load_squadrons_from(self, directory: Path) -> Iterator[Tuple[Path, Squadron]]: + logging.debug(f"Looking for factions in {directory}") + # First directory level is the aircraft type so that historical squadrons that + # have flown multiple airframes can be defined as many times as needed. The main + # load() method is responsible for filtering out squadrons that aren't + # compatible with the faction. + for squadron_path in directory.glob("*/*.yaml"): + try: + yield squadron_path, Squadron.from_yaml( + squadron_path, self.game, self.player + ) + except Exception as ex: + raise RuntimeError( + f"Failed to load squadron defined by {squadron_path}" + ) from ex + + +class AirWing: + def __init__(self, game: Game, player: bool) -> None: + from gen.flights.ai_flight_planner_db import tasks_for_aircraft + + self.game = game + self.player = player + self.squadrons = SquadronLoader(game, player).load() + + count = itertools.count(1) + for aircraft in game.faction_for(player).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), + role="Flying Squadron", + aircraft=aircraft, + livery=None, + mission_types=tuple(tasks_for_aircraft(aircraft)), + pilots=[], + game=game, + player=player, + ) + ] + + def squadrons_for(self, aircraft: Type[FlyingType]) -> Sequence[Squadron]: + return self.squadrons[aircraft] + + def squadrons_for_task(self, task: FlightType) -> Iterator[Squadron]: + for squadron in self.iter_squadrons(): + if task in squadron.mission_types: + yield squadron + + def squadron_for(self, aircraft: Type[FlyingType]) -> Squadron: + return self.squadrons_for(aircraft)[0] + + def iter_squadrons(self) -> Iterator[Squadron]: + return itertools.chain.from_iterable(self.squadrons.values()) + + def squadron_at_index(self, index: int) -> Squadron: + return list(self.iter_squadrons())[index] + + def reset(self) -> None: + for squadron in self.iter_squadrons(): + squadron.return_all_pilots() + + @property + def size(self) -> int: + return sum(len(s) for s in self.squadrons.values()) + + @staticmethod + def _make_random_nickname() -> str: + from gen.naming import ANIMALS + + animal = random.choice(ANIMALS) + adjective = random.choice( + ( + None, + "Red", + "Blue", + "Green", + "Golden", + "Black", + "Fighting", + "Flying", + ) + ) + if adjective is None: + return animal.title() + return f"{adjective} {animal}".title() + + def random_nickname(self) -> str: + while True: + nickname = self._make_random_nickname() + for squadron in self.iter_squadrons(): + if squadron.nickname == nickname: + break + else: + return nickname diff --git a/game/theater/__init__.py b/game/theater/__init__.py index c5b83a16..d741edb7 100644 --- a/game/theater/__init__.py +++ b/game/theater/__init__.py @@ -1,5 +1,6 @@ from .base import * from .conflicttheater import * from .controlpoint import * +from .frontline import FrontLine from .missiontarget import MissionTarget from .theatergroundobject import SamGroundObject diff --git a/game/theater/base.py b/game/theater/base.py index 5db7bba7..27390b2d 100644 --- a/game/theater/base.py +++ b/game/theater/base.py @@ -4,13 +4,12 @@ import math import typing from typing import Dict, Type -from dcs.task import AWACS, CAP, CAS, Embarking, PinpointStrike, Task +from dcs.task import AWACS, CAP, CAS, Embarking, PinpointStrike, Task, Transport from dcs.unittype import FlyingType, UnitType, VehicleType from dcs.vehicles import AirDefence, Armor from game import db from game.db import PRICES -from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD STRENGTH_AA_ASSEMBLE_MIN = 0.2 PLANES_SCRAMBLE_MIN_BASE = 2 @@ -25,6 +24,7 @@ class Base: def __init__(self): self.aircraft: Dict[Type[FlyingType], int] = {} self.armor: Dict[Type[VehicleType], int] = {} + # TODO: Appears unused. self.aa: Dict[AirDefence, int] = {} self.commision_points: Dict[Type, float] = {} self.strength = 1 @@ -47,10 +47,6 @@ class Base: logging.exception(f"No price found for {unit_type.id}") return total - @property - def total_frontline_aa(self) -> int: - return sum([v for k, v in self.armor.items() if k in TYPE_SHORAD]) - @property def total_aa(self) -> int: return sum(self.aa.values()) @@ -152,6 +148,7 @@ class Base: or for_task == CAS or for_task == CAP or for_task == Embarking + or for_task == Transport ): target_dict = self.aircraft elif for_task == PinpointStrike: diff --git a/game/theater/bullseye.py b/game/theater/bullseye.py new file mode 100644 index 00000000..6d39821c --- /dev/null +++ b/game/theater/bullseye.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, TYPE_CHECKING + +from dcs import Point + +from game.theater import LatLon + +if TYPE_CHECKING: + from game.theater import ConflictTheater + + +@dataclass +class Bullseye: + position: Point + + @classmethod + def from_pydcs(cls, bulls: Dict[str, float]) -> Bullseye: + return cls(Point(bulls["x"], bulls["y"])) + + def to_pydcs(self) -> Dict[str, float]: + return {"x": self.position.x, "y": self.position.y} + + def to_lat_lon(self, theater: ConflictTheater) -> LatLon: + return theater.point_to_ll(self.position) diff --git a/game/theater/caucasus.py b/game/theater/caucasus.py new file mode 100644 index 00000000..8d0d0adc --- /dev/null +++ b/game/theater/caucasus.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=33, + false_easting=-99516.9999999732, + false_northing=-4998114.999999984, + scale_factor=0.9996, +) diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 16a8a0b0..867a7c5b 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -1,16 +1,11 @@ from __future__ import annotations import itertools -import json -import logging +import math from dataclasses import dataclass from functools import cached_property -from itertools import tee from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union, cast - -from shapely import geometry -from shapely import ops +from typing import Any, Dict, Iterator, List, Optional, Tuple from dcs import Mission from dcs.countries import ( @@ -21,11 +16,12 @@ from dcs.country import Country from dcs.mapping import Point from dcs.planes import F_15C from dcs.ships import ( + Bulker_Handy_Wind, CVN_74_John_C__Stennis, - LHA_1_Tarawa, DDG_Arleigh_Burke_IIa, + LHA_1_Tarawa, ) -from dcs.statics import Fortification +from dcs.statics import Fortification, Warehouse from dcs.terrain import ( caucasus, nevada, @@ -43,22 +39,26 @@ from dcs.unitgroup import ( VehicleGroup, ) from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed +from pyproj import CRS, Transformer +from shapely import geometry, ops -from gen.flights.flight import FlightType from .controlpoint import ( Airfield, Carrier, ControlPoint, + Fob, Lha, MissionTarget, OffMapSpawn, - Fob, ) +from .frontline import FrontLine from .landmap import Landmap, load_landmap, poly_contains +from .latlon import LatLon +from .projections import TransverseMercator from ..point_with_heading import PointWithHeading -from ..utils import Distance, meters, nautical_miles - -Numeric = Union[int, float] +from ..profiling import logged_duration +from ..scenery_group import SceneryGroup +from ..utils import Distance, meters SIZE_TINY = 150 SIZE_SMALL = 600 @@ -70,18 +70,6 @@ IMPORTANCE_LOW = 1 IMPORTANCE_MEDIUM = 1.2 IMPORTANCE_HIGH = 1.4 -FRONTLINE_MIN_CP_DISTANCE = 5000 - - -def pairwise(iterable): - """ - itertools recipe - s -> (s0,s1), (s1,s2), (s2, s3), ... - """ - a, b = tee(iterable) - next(b, None) - return zip(a, b) - class MizCampaignLoader: BLUE_COUNTRY = CombinedJointTaskForcesBlue() @@ -92,40 +80,58 @@ class MizCampaignLoader: CV_UNIT_TYPE = CVN_74_John_C__Stennis.id LHA_UNIT_TYPE = LHA_1_Tarawa.id FRONT_LINE_UNIT_TYPE = Armor.APC_M113.id + SHIPPING_LANE_UNIT_TYPE = Bulker_Handy_Wind.id FOB_UNIT_TYPE = Unarmed.Truck_SKP_11_Mobile_ATC.id FARP_HELIPAD = "SINGLE_HELIPAD" - EWR_UNIT_TYPE = AirDefence.EWR_55G6.id - SAM_UNIT_TYPE = AirDefence.SAM_SA_10_S_300_Grumble_Big_Bird_SR.id - GARRISON_UNIT_TYPE = AirDefence.SAM_SA_19_Tunguska_Grison.id OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id SHIP_UNIT_TYPE = DDG_Arleigh_Burke_IIa.id MISSILE_SITE_UNIT_TYPE = MissilesSS.SSM_SS_1C_Scud_B.id COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.AShM_SS_N_2_Silkworm.id - # Multiple options for the required SAMs so campaign designers can more - # accurately see the coverage of their IADS for the expected type. - REQUIRED_LONG_RANGE_SAM_UNIT_TYPES = { + # Multiple options for air defenses so campaign designers can more accurately see + # the coverage of their IADS for the expected type. + LONG_RANGE_SAM_UNIT_TYPES = { AirDefence.SAM_Patriot_LN.id, AirDefence.SAM_SA_10_S_300_Grumble_TEL_C.id, AirDefence.SAM_SA_10_S_300_Grumble_TEL_D.id, } - REQUIRED_MEDIUM_RANGE_SAM_UNIT_TYPES = { + MEDIUM_RANGE_SAM_UNIT_TYPES = { AirDefence.SAM_Hawk_LN_M192.id, AirDefence.SAM_SA_2_S_75_Guideline_LN.id, AirDefence.SAM_SA_3_S_125_Goa_LN.id, } - REQUIRED_EWR_UNIT_TYPE = AirDefence.EWR_1L13.id + SHORT_RANGE_SAM_UNIT_TYPES = { + AirDefence.SAM_Avenger__Stinger.id, + AirDefence.SAM_Rapier_LN.id, + AirDefence.SAM_SA_19_Tunguska_Grison.id, + AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL.id, + } - BASE_DEFENSE_RADIUS = nautical_miles(2) + AAA_UNIT_TYPES = { + AirDefence.AAA_8_8cm_Flak_18.id, + AirDefence.SPAAA_Vulcan_M163.id, + AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish.id, + } + + EWR_UNIT_TYPE = AirDefence.EWR_1L13.id + + ARMOR_GROUP_UNIT_TYPE = Armor.MBT_M1A2_Abrams.id + + FACTORY_UNIT_TYPE = Fortification.Workshop_A.id + + AMMUNITION_DEPOT_UNIT_TYPE = Warehouse.Ammunition_depot.id + + STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id def __init__(self, miz: Path, theater: ConflictTheater) -> None: self.theater = theater self.mission = Mission() - self.mission.load_file(str(miz)) + with logged_duration("Loading miz"): + self.mission.load_file(str(miz)) self.control_point_id = itertools.count(1000) # If there are no red carriers there usually aren't red units. Make sure @@ -197,62 +203,62 @@ class MizCampaignLoader: @property def ships(self) -> Iterator[ShipGroup]: - for group in self.blue.ship_group: + for group in self.red.ship_group: if group.units[0].type == self.SHIP_UNIT_TYPE: yield group - @property - def ewrs(self) -> Iterator[VehicleGroup]: - for group in self.blue.vehicle_group: - if group.units[0].type == self.EWR_UNIT_TYPE: - yield group - - @property - def sams(self) -> Iterator[VehicleGroup]: - for group in self.blue.vehicle_group: - if group.units[0].type == self.SAM_UNIT_TYPE: - yield group - - @property - def garrisons(self) -> Iterator[VehicleGroup]: - for group in self.blue.vehicle_group: - if group.units[0].type == self.GARRISON_UNIT_TYPE: - yield group - @property def offshore_strike_targets(self) -> Iterator[StaticGroup]: - for group in self.blue.static_group: + for group in self.red.static_group: if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE: yield group @property def missile_sites(self) -> Iterator[VehicleGroup]: - for group in self.blue.vehicle_group: + for group in self.red.vehicle_group: if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE: yield group @property def coastal_defenses(self) -> Iterator[VehicleGroup]: - for group in self.blue.vehicle_group: + for group in self.red.vehicle_group: if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE: yield group @property - def required_long_range_sams(self) -> Iterator[VehicleGroup]: + def long_range_sams(self) -> Iterator[VehicleGroup]: for group in self.red.vehicle_group: - if group.units[0].type in self.REQUIRED_LONG_RANGE_SAM_UNIT_TYPES: + if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES: yield group @property - def required_medium_range_sams(self) -> Iterator[VehicleGroup]: + def medium_range_sams(self) -> Iterator[VehicleGroup]: for group in self.red.vehicle_group: - if group.units[0].type in self.REQUIRED_MEDIUM_RANGE_SAM_UNIT_TYPES: + if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES: yield group @property - def required_ewrs(self) -> Iterator[VehicleGroup]: + def short_range_sams(self) -> Iterator[VehicleGroup]: for group in self.red.vehicle_group: - if group.units[0].type in self.REQUIRED_EWR_UNIT_TYPE: + if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES: + yield group + + @property + def aaa(self) -> Iterator[VehicleGroup]: + for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group): + if group.units[0].type in self.AAA_UNIT_TYPES: + yield group + + @property + def ewrs(self) -> Iterator[VehicleGroup]: + for group in self.red.vehicle_group: + if group.units[0].type in self.EWR_UNIT_TYPE: + yield group + + @property + def armor_groups(self) -> Iterator[VehicleGroup]: + for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group): + if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE: yield group @property @@ -261,6 +267,28 @@ class MizCampaignLoader: if group.units[0].type == self.FARP_HELIPAD: yield group + @property + def factories(self) -> Iterator[StaticGroup]: + for group in self.blue.static_group: + if group.units[0].type in self.FACTORY_UNIT_TYPE: + yield group + + @property + def ammunition_depots(self) -> Iterator[StaticGroup]: + for group in itertools.chain(self.blue.static_group, self.red.static_group): + if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE: + yield group + + @property + def strike_targets(self) -> Iterator[StaticGroup]: + for group in itertools.chain(self.blue.static_group, self.red.static_group): + if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE: + yield group + + @property + def scenery(self) -> List[SceneryGroup]: + return SceneryGroup.from_trigger_zones(self.mission.triggers._zones) + @cached_property def control_points(self) -> Dict[int, ControlPoint]: control_points = {} @@ -307,14 +335,17 @@ class MizCampaignLoader: if group.units[0].type == self.FRONT_LINE_UNIT_TYPE: yield group - @cached_property - def front_lines(self) -> Dict[str, ComplexFrontLine]: - # Dict of front line ID to a front line. - front_lines = {} + @property + def shipping_lane_groups(self) -> Iterator[ShipGroup]: + for group in self.country(blue=True).ship_group: + if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE: + yield group + + def add_supply_routes(self) -> None: for group in self.front_line_path_groups: - # The unit will have its first waypoint at the source CP and the - # final waypoint at the destination CP. Intermediate waypoints - # define the curve of the front line. + # The unit will have its first waypoint at the source CP and the final + # waypoint at the destination CP. Each waypoint defines the path of the + # cargo ship. waypoints = [p.position for p in group.points] origin = self.theater.closest_control_point(waypoints[0]) if origin is None: @@ -327,14 +358,32 @@ class MizCampaignLoader: f"No control point near the final waypoint of {group.name}" ) - # Snap the begin and end points to the control points. - waypoints[0] = origin.position - waypoints[-1] = destination.position - front_line_id = f"{origin.id}|{destination.id}" - front_lines[front_line_id] = ComplexFrontLine(origin, waypoints) - self.control_points[origin.id].connect(self.control_points[destination.id]) - self.control_points[destination.id].connect(self.control_points[origin.id]) - return front_lines + self.control_points[origin.id].create_convoy_route(destination, waypoints) + self.control_points[destination.id].create_convoy_route( + origin, list(reversed(waypoints)) + ) + + def add_shipping_lanes(self) -> None: + for group in self.shipping_lane_groups: + # The unit will have its first waypoint at the source CP and the final + # waypoint at the destination CP. Each waypoint defines the path of the + # cargo ship. + waypoints = [p.position for p in group.points] + origin = self.theater.closest_control_point(waypoints[0]) + if origin is None: + raise RuntimeError( + f"No control point near the first waypoint of {group.name}" + ) + destination = self.theater.closest_control_point(waypoints[-1]) + if destination is None: + raise RuntimeError( + f"No control point near the final waypoint of {group.name}" + ) + + self.control_points[origin.id].create_shipping_lane(destination, waypoints) + self.control_points[destination.id].create_shipping_lane( + origin, list(reversed(waypoints)) + ) def objective_info(self, group: Group) -> Tuple[ControlPoint, Distance]: closest = self.theater.closest_control_point(group.position) @@ -342,37 +391,6 @@ class MizCampaignLoader: return closest, distance def add_preset_locations(self) -> None: - for group in self.garrisons: - closest, distance = self.objective_info(group) - if distance < self.BASE_DEFENSE_RADIUS: - closest.preset_locations.base_garrisons.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - else: - logging.warning(f"Found garrison unit too far from base: {group.name}") - - for group in self.sams: - closest, distance = self.objective_info(group) - if distance < self.BASE_DEFENSE_RADIUS: - closest.preset_locations.base_air_defense.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - else: - closest.preset_locations.strike_locations.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - - for group in self.ewrs: - closest, distance = self.objective_info(group) - if distance < self.BASE_DEFENSE_RADIUS: - closest.preset_locations.base_ewrs.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - else: - closest.preset_locations.ewrs.append( - PointWithHeading.from_point(group.position, group.units[0].heading) - ) - for group in self.offshore_strike_targets: closest, distance = self.objective_info(group) closest.preset_locations.offshore_strike_locations.append( @@ -397,21 +415,39 @@ class MizCampaignLoader: PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_long_range_sams: + for group in self.long_range_sams: closest, distance = self.objective_info(group) - closest.preset_locations.required_long_range_sams.append( + closest.preset_locations.long_range_sams.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_medium_range_sams: + for group in self.medium_range_sams: closest, distance = self.objective_info(group) - closest.preset_locations.required_medium_range_sams.append( + closest.preset_locations.medium_range_sams.append( PointWithHeading.from_point(group.position, group.units[0].heading) ) - for group in self.required_ewrs: + for group in self.short_range_sams: closest, distance = self.objective_info(group) - closest.preset_locations.required_ewrs.append( + closest.preset_locations.short_range_sams.append( + PointWithHeading.from_point(group.position, 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) + ) + + 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) + ) + + 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) ) @@ -421,11 +457,34 @@ class MizCampaignLoader: PointWithHeading.from_point(group.position, group.units[0].heading) ) + for group in self.factories: + closest, distance = self.objective_info(group) + closest.preset_locations.factories.append( + PointWithHeading.from_point(group.position, group.units[0].heading) + ) + + for group in self.ammunition_depots: + closest, distance = self.objective_info(group) + closest.preset_locations.ammunition_depots.append( + PointWithHeading.from_point(group.position, group.units[0].heading) + ) + + for group in self.strike_targets: + closest, distance = self.objective_info(group) + closest.preset_locations.strike_locations.append( + PointWithHeading.from_point(group.position, group.units[0].heading) + ) + + for group in self.scenery: + closest, distance = self.objective_info(group) + closest.preset_locations.scenery.append(group) + def populate_theater(self) -> None: for control_point in self.control_points.values(): self.theater.add_controlpoint(control_point) self.add_preset_locations() - self.theater.set_frontline_data(self.front_lines) + self.add_supply_routes() + self.add_shipping_lanes() @dataclass @@ -444,43 +503,40 @@ class ConflictTheater: land_poly = None # type: Polygon """ daytime_map: Dict[str, Tuple[int, int]] - _frontline_data: Optional[Dict[str, ComplexFrontLine]] = None def __init__(self): self.controlpoints: List[ControlPoint] = [] - self._frontline_data: Optional[Dict[str, ComplexFrontLine]] = None + self.point_to_ll_transformer = Transformer.from_crs( + self.projection_parameters.to_crs(), CRS("WGS84") + ) + self.ll_to_point_transformer = Transformer.from_crs( + CRS("WGS84"), self.projection_parameters.to_crs() + ) """ self.land_poly = geometry.Polygon(self.landmap[0][0]) for x in self.landmap[1]: self.land_poly = self.land_poly.difference(geometry.Polygon(x)) """ - @property - def frontline_data(self) -> Optional[Dict[str, ComplexFrontLine]]: - if self._frontline_data is None: - self.load_frontline_data_from_file() - return self._frontline_data + 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["point_to_ll_transformer"] + del state["ll_to_point_transformer"] + return state - def load_frontline_data_from_file(self) -> None: - if self._frontline_data is not None: - logging.warning("Replacing existing frontline data from file") - self._frontline_data = FrontLine.load_json_frontlines(self) - if self._frontline_data is None: - self._frontline_data = {} - - def set_frontline_data(self, data: Dict[str, ComplexFrontLine]) -> None: - if self._frontline_data is not None: - logging.warning("Replacing existing frontline data") - self._frontline_data = data - - def add_controlpoint( - self, point: ControlPoint, connected_to: Optional[List[ControlPoint]] = None - ): - if connected_to is None: - connected_to = [] - for connected_point in connected_to: - point.connect(to=connected_point) + def __setstate__(self, state: Dict[str, Any]) -> None: + self.__dict__.update(state) + # Regenerate any state that was not persisted. + self.point_to_ll_transformer = Transformer.from_crs( + self.projection_parameters.to_crs(), CRS("WGS84") + ) + self.ll_to_point_transformer = Transformer.from_crs( + CRS("WGS84"), self.projection_parameters.to_crs() + ) + def add_controlpoint(self, point: ControlPoint): self.controlpoints.append(point) def find_ground_objects_by_obj_name(self, obj_name): @@ -561,12 +617,12 @@ class ConflictTheater: def player_points(self) -> List[ControlPoint]: return list(self.control_points_for(player=True)) - def conflicts(self, from_player=True) -> Iterator[FrontLine]: - for cp in [x for x in self.controlpoints if x.captured == from_player]: - for connected_point in [ - x for x in cp.connected_points if x.captured != from_player + def conflicts(self) -> Iterator[FrontLine]: + for player_cp in [x for x in self.controlpoints if x.captured]: + for enemy_cp in [ + x for x in player_cp.connected_points if not x.is_friendly_to(player_cp) ]: - yield FrontLine(cp, connected_point, self) + yield FrontLine(player_cp, enemy_cp) def enemy_points(self) -> List[ControlPoint]: return list(self.control_points_for(player=False)) @@ -606,71 +662,32 @@ class ConflictTheater: Returns a tuple of the two nearest opposing ControlPoints in theater. (player_cp, enemy_cp) """ - all_cp_min_distances = {} - for idx, control_point in enumerate(self.controlpoints): - distances = {} - closest_distance = None - for i, cp in enumerate(self.controlpoints): - if i != idx and cp.captured is not control_point.captured: - dist = cp.position.distance_to_point(control_point.position) - if not closest_distance: - closest_distance = dist - distances[cp.id] = dist - if dist < closest_distance: - distances[cp.id] = dist - closest_cp_id = min(distances, key=distances.get) # type: ignore + seen = set() + min_distance = math.inf + closest_blue = None + closest_red = None + for blue_cp in self.player_points(): + for red_cp in self.enemy_points(): + if (blue_cp, red_cp) in seen: + continue + seen.add((blue_cp, red_cp)) + seen.add((red_cp, blue_cp)) - all_cp_min_distances[(control_point.id, closest_cp_id)] = distances[ - closest_cp_id - ] - closest_opposing_cps = [ - self.find_control_point_by_id(i) - for i in min( - all_cp_min_distances, key=all_cp_min_distances.get - ) # type: ignore - ] # type: List[ControlPoint] - assert len(closest_opposing_cps) == 2 - if closest_opposing_cps[0].captured: - return cast(Tuple[ControlPoint, ControlPoint], tuple(closest_opposing_cps)) - else: - return cast( - Tuple[ControlPoint, ControlPoint], tuple(reversed(closest_opposing_cps)) - ) + dist = red_cp.position.distance_to_point(blue_cp.position) + if dist < min_distance: + closest_red = red_cp + closest_blue = blue_cp + min_distance = dist + + assert closest_blue is not None + assert closest_red is not None + return closest_blue, closest_red def find_control_point_by_id(self, id: int) -> ControlPoint: for i in self.controlpoints: if i.id == id: return i - raise RuntimeError(f"Cannot find ControlPoint with ID {id}") - - def add_json_cp(self, theater, p: dict) -> ControlPoint: - cp: ControlPoint - if p["type"] == "airbase": - - airbase = theater.terrain.airports[p["id"]] - - if "size" in p.keys(): - size = p["size"] - else: - size = SIZE_REGULAR - - if "importance" in p.keys(): - importance = p["importance"] - else: - importance = IMPORTANCE_MEDIUM - - cp = Airfield(airbase, size, importance) - elif p["type"] == "carrier": - cp = Carrier("carrier", Point(p["x"], p["y"]), p["id"]) - else: - cp = Lha("lha", Point(p["x"], p["y"]), p["id"]) - - if "captured_invert" in p.keys(): - cp.captured_invert = p["captured_invert"] - else: - cp.captured_invert = False - - return cp + raise KeyError(f"Cannot find ControlPoint with ID {id}") @staticmethod def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater: @@ -686,28 +703,27 @@ class ConflictTheater: t = theater() miz = data.get("miz", None) - if miz is not None: + if miz is None: + raise RuntimeError( + "Old format (non-miz) campaigns are no longer supported." + ) + + with logged_duration("Importing miz data"): MizCampaignLoader(directory / miz, t).populate_theater() - return t - - cps = {} - for p in data["player_points"]: - cp = t.add_json_cp(theater, p) - cp.captured = True - cps[p["id"]] = cp - t.add_controlpoint(cp) - - for p in data["enemy_points"]: - cp = t.add_json_cp(theater, p) - cps[p["id"]] = cp - t.add_controlpoint(cp) - - for l in data["links"]: - cps[l[0]].connect(cps[l[1]]) - cps[l[1]].connect(cps[l[0]]) - return t + @property + def projection_parameters(self) -> TransverseMercator: + raise NotImplementedError + + def point_to_ll(self, point: Point) -> LatLon: + lat, lon = self.point_to_ll_transformer.transform(point.x, point.y) + return LatLon(lat, lon) + + def ll_to_point(self, ll: LatLon) -> Point: + x, y = self.ll_to_point_transformer.transform(ll.latitude, ll.longitude) + return Point(x, y) + class CaucasusTheater(ConflictTheater): terrain = caucasus.Caucasus() @@ -725,6 +741,12 @@ class CaucasusTheater(ConflictTheater): "night": (0, 5), } + @property + def projection_parameters(self) -> TransverseMercator: + from .caucasus import PARAMETERS + + return PARAMETERS + class PersianGulfTheater(ConflictTheater): terrain = persiangulf.PersianGulf() @@ -741,6 +763,12 @@ class PersianGulfTheater(ConflictTheater): "night": (0, 5), } + @property + def projection_parameters(self) -> TransverseMercator: + from .persiangulf import PARAMETERS + + return PARAMETERS + class NevadaTheater(ConflictTheater): terrain = nevada.Nevada() @@ -757,6 +785,12 @@ class NevadaTheater(ConflictTheater): "night": (0, 5), } + @property + def projection_parameters(self) -> TransverseMercator: + from .nevada import PARAMETERS + + return PARAMETERS + class NormandyTheater(ConflictTheater): terrain = normandy.Normandy() @@ -773,6 +807,12 @@ class NormandyTheater(ConflictTheater): "night": (0, 5), } + @property + def projection_parameters(self) -> TransverseMercator: + from .normandy import PARAMETERS + + return PARAMETERS + class TheChannelTheater(ConflictTheater): terrain = thechannel.TheChannel() @@ -789,6 +829,12 @@ class TheChannelTheater(ConflictTheater): "night": (0, 5), } + @property + def projection_parameters(self) -> TransverseMercator: + from .thechannel import PARAMETERS + + return PARAMETERS + class SyriaTheater(ConflictTheater): terrain = syria.Syria() @@ -805,213 +851,8 @@ class SyriaTheater(ConflictTheater): "night": (0, 5), } - -@dataclass -class ComplexFrontLine: - """ - Stores data necessary for building a multi-segment frontline. - "points" should be ordered from closest to farthest distance originating from start_cp.position - """ - - start_cp: ControlPoint - points: List[Point] - - -@dataclass -class FrontLineSegment: - """ - Describes a line segment of a FrontLine - """ - - point_a: Point - point_b: Point - @property - def attack_heading(self) -> Numeric: - """The heading of the frontline segment from player to enemy control point""" - return self.point_a.heading_between_point(self.point_b) + def projection_parameters(self) -> TransverseMercator: + from .syria import PARAMETERS - @property - def attack_distance(self) -> Numeric: - """Length of the segment""" - return self.point_a.distance_to_point(self.point_b) - - -class FrontLine(MissionTarget): - """Defines a front line location between two control points. - Front lines are the area where ground combat happens. - Overwrites the entirety of MissionTarget __init__ method to allow for - dynamic position calculation. - """ - - def __init__( - self, - control_point_a: ControlPoint, - control_point_b: ControlPoint, - theater: ConflictTheater, - ) -> None: - self.control_point_a = control_point_a - self.control_point_b = control_point_b - self.segments: List[FrontLineSegment] = [] - self.theater = theater - self._build_segments() - self.name = f"Front line {control_point_a}/{control_point_b}" - - def is_friendly(self, to_player: bool) -> bool: - """Returns True if the objective is in friendly territory.""" - return False - - def mission_types(self, for_player: bool) -> Iterator[FlightType]: - yield from [ - FlightType.CAS, - FlightType.AEWC, - # TODO: FlightType.TROOP_TRANSPORT - # TODO: FlightType.EVAC - ] - yield from super().mission_types(for_player) - - @property - def position(self): - """ - 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 control_points(self) -> Tuple[ControlPoint, ControlPoint]: - """Returns a tuple of the two control points.""" - return self.control_point_a, self.control_point_b - - @property - def attack_distance(self): - """The total distance of all segments""" - return sum(i.attack_distance for i in self.segments) - - @property - def attack_heading(self): - """The heading of the active attack segment from player to enemy control point""" - return self.active_segment.attack_heading - - @property - def active_segment(self) -> FrontLineSegment: - """The FrontLine segment where there can be an active conflict""" - if self._position_distance <= self.segments[0].attack_distance: - return self.segments[0] - - remaining_dist = self._position_distance - for segment in self.segments: - if remaining_dist <= segment.attack_distance: - return segment - else: - remaining_dist -= segment.attack_distance - logging.error( - "Frontline attack distance is greater than the sum of its segments" - ) - return self.segments[0] - - def point_from_a(self, distance: Numeric) -> Point: - """ - Returns a point {distance} away from control_point_a along the frontline segments. - """ - if distance < self.segments[0].attack_distance: - return self.control_point_a.position.point_from_heading( - self.segments[0].attack_heading, 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 - ) - else: - remaining_dist -= segment.attack_distance - - @property - def _position_distance(self) -> float: - """ - The distance from point "a" where the conflict should occur - according to the current strength of each control point - """ - total_strength = ( - self.control_point_a.base.strength + self.control_point_b.base.strength - ) - if self.control_point_a.base.strength == 0: - return self._adjust_for_min_dist(0) - if self.control_point_b.base.strength == 0: - return self._adjust_for_min_dist(self.attack_distance) - strength_pct = self.control_point_a.base.strength / total_strength - return self._adjust_for_min_dist(strength_pct * self.attack_distance) - - def _adjust_for_min_dist(self, distance: Numeric) -> Numeric: - """ - Ensures the frontline conflict is never located within the minimum distance - constant of either end control point. - """ - if (distance > self.attack_distance / 2) and ( - distance + FRONTLINE_MIN_CP_DISTANCE > self.attack_distance - ): - distance = self.attack_distance - FRONTLINE_MIN_CP_DISTANCE - elif (distance < self.attack_distance / 2) and ( - distance < FRONTLINE_MIN_CP_DISTANCE - ): - distance = FRONTLINE_MIN_CP_DISTANCE - return distance - - def _build_segments(self) -> None: - """Create line segments for the frontline""" - control_point_ids = "|".join( - [str(self.control_point_a.id), str(self.control_point_b.id)] - ) # from_cp.id|to_cp.id - reversed_cp_ids = "|".join( - [str(self.control_point_b.id), str(self.control_point_a.id)] - ) - complex_frontlines = self.theater.frontline_data - if (complex_frontlines) and ( - (control_point_ids in complex_frontlines) - or (reversed_cp_ids in complex_frontlines) - ): - # The frontline segments must be stored in the correct order for the distance algorithms to work. - # The points in the frontline are ordered from the id before the | to the id after. - # First, check if control point id pair matches in order, and create segments if a match is found. - if control_point_ids in complex_frontlines: - point_pairs = pairwise(complex_frontlines[control_point_ids].points) - for i in point_pairs: - self.segments.append(FrontLineSegment(i[0], i[1])) - # Check the reverse order and build in reverse if found. - elif reversed_cp_ids in complex_frontlines: - point_pairs = pairwise( - reversed(complex_frontlines[reversed_cp_ids].points) - ) - for i in point_pairs: - self.segments.append(FrontLineSegment(i[0], i[1])) - # If no complex frontline has been configured, fall back to the old straight line method. - else: - self.segments.append( - FrontLineSegment( - self.control_point_a.position, self.control_point_b.position - ) - ) - - @staticmethod - def load_json_frontlines( - theater: ConflictTheater, - ) -> Optional[Dict[str, ComplexFrontLine]]: - """Load complex frontlines from json""" - try: - path = Path(f"resources/frontlines/{theater.terrain.name.lower()}.json") - with open(path, "r") as file: - logging.debug(f"Loading frontline from {path}...") - data = json.load(file) - return { - frontline: ComplexFrontLine( - data[frontline]["start_cp"], - [Point(i[0], i[1]) for i in data[frontline]["points"]], - ) - for frontline in data - } - except OSError: - logging.warning( - f"Unable to load preset frontlines for {theater.terrain.name}" - ) - return None + return PARAMETERS diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 4e4f3bbc..85afd44f 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -3,12 +3,25 @@ from __future__ import annotations import heapq import itertools import logging -import random from abc import ABC, abstractmethod +from collections import defaultdict from dataclasses import dataclass, field -from enum import Enum -from functools import total_ordering -from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Type +from enum import Enum, unique, auto, IntEnum +from functools import total_ordering, cached_property +from typing import ( + Any, + Dict, + Iterator, + List, + Optional, + Set, + TYPE_CHECKING, + Type, + Union, + Sequence, + Iterable, + Tuple, +) from dcs.mapping import Point from dcs.ships import ( @@ -18,23 +31,20 @@ from dcs.ships import ( Type_071_Amphibious_Transport_Dock, ) from dcs.terrain.terrain import Airport, ParkingSlot -from dcs.unittype import FlyingType +from dcs.unit import Unit +from dcs.unittype import FlyingType, VehicleType from game import db +from game.point_with_heading import PointWithHeading +from game.scenery_group import SceneryGroup from gen.flights.closestairfields import ObjectiveDistanceCache -from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD from gen.ground_forces.combat_stance import CombatStance from gen.runways import RunwayAssigner, RunwayData from .base import Base from .missiontarget import MissionTarget -from game.point_with_heading import PointWithHeading from .theatergroundobject import ( - BaseDefenseGroundObject, - EwrGroundObject, GenericCarrierGroundObject, - SamGroundObject, TheaterGroundObject, - VehicleGroupGroundObject, ) from ..db import PRICES from ..utils import nautical_miles @@ -43,6 +53,10 @@ from ..weather import Conditions if TYPE_CHECKING: from game import Game from gen.flights.flight import FlightType + from ..transfers import PendingTransfers + +FREE_FRONTLINE_UNIT_SUPPLY: int = 15 +AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION: int = 12 class ControlPointType(Enum): @@ -59,41 +73,15 @@ class ControlPointType(Enum): OFF_MAP = 6 -class LocationType(Enum): - BaseAirDefense = "base air defense" - Coastal = "coastal defense" - Ewr = "EWR" - BaseEwr = "Base EWR" - Garrison = "garrison" - MissileSite = "missile site" - OffshoreStrikeTarget = "offshore strike target" - Sam = "SAM" - Ship = "ship" - Shorad = "SHORAD" - StrikeTarget = "strike target" - - @dataclass class PresetLocations: """Defines the preset locations loaded from the campaign mission file.""" - #: Locations used for spawning ground defenses for bases. - base_garrisons: List[PointWithHeading] = field(default_factory=list) - - #: Locations used for spawning air defenses for bases. Used by SAMs, AAA, - #: and SHORADs. - base_air_defense: List[PointWithHeading] = field(default_factory=list) - - #: Locations used by EWRs. - ewrs: List[PointWithHeading] = field(default_factory=list) - - #: Locations used by Base EWRs. - base_ewrs: List[PointWithHeading] = field(default_factory=list) - - #: Locations used by non-carrier ships. Carriers and LHAs are not random. + #: Locations used by non-carrier ships that will be spawned unless the faction has + #: no navy or the player has disabled ship generation for the owning side. ships: List[PointWithHeading] = field(default_factory=list) - #: Locations used by coastal defenses. + #: Locations used by coastal defenses that are generated if the faction is capable. coastal_defenses: List[PointWithHeading] = field(default_factory=list) #: Locations used by ground based strike objectives. @@ -102,68 +90,116 @@ class PresetLocations: #: Locations used by offshore strike objectives. offshore_strike_locations: List[PointWithHeading] = field(default_factory=list) - #: Locations used by missile sites like scuds and V-2s. + #: Locations used by missile sites like scuds and V-2s that are generated if the + #: faction is capable. missile_sites: List[PointWithHeading] = field(default_factory=list) - #: Locations of long range SAMs which should always be spawned. - required_long_range_sams: List[PointWithHeading] = field(default_factory=list) + #: Locations of long range SAMs. + long_range_sams: List[PointWithHeading] = field(default_factory=list) - #: Locations of medium range SAMs which should always be spawned. - required_medium_range_sams: List[PointWithHeading] = field(default_factory=list) + #: Locations of medium range SAMs. + medium_range_sams: List[PointWithHeading] = field(default_factory=list) - #: Locations of EWRs which should always be spawned. - required_ewrs: List[PointWithHeading] = field(default_factory=list) + #: Locations of short range SAMs. + short_range_sams: List[PointWithHeading] = field(default_factory=list) - @staticmethod - def _random_from(points: List[PointWithHeading]) -> Optional[PointWithHeading]: - """Finds, removes, and returns a random position from the given list.""" - if not points: - return None - point = random.choice(points) - points.remove(point) - return point + #: Locations of AAA groups. + aaa: List[PointWithHeading] = field(default_factory=list) - def random_for(self, location_type: LocationType) -> Optional[PointWithHeading]: - """Returns a position suitable for the given location type. + #: Locations of EWRs. + ewrs: List[PointWithHeading] = field(default_factory=list) - The location, if found, will be claimed by the caller and not available - to subsequent calls. - """ - if location_type == LocationType.BaseAirDefense: - return self._random_from(self.base_air_defense) - if location_type == LocationType.Coastal: - return self._random_from(self.coastal_defenses) - if location_type == LocationType.Ewr: - return self._random_from(self.ewrs) - if location_type == LocationType.BaseEwr: - return self._random_from(self.base_ewrs) - if location_type == LocationType.Garrison: - return self._random_from(self.base_garrisons) - if location_type == LocationType.MissileSite: - return self._random_from(self.missile_sites) - if location_type == LocationType.OffshoreStrikeTarget: - return self._random_from(self.offshore_strike_locations) - if location_type == LocationType.Sam: - return self._random_from(self.strike_locations) - if location_type == LocationType.Ship: - return self._random_from(self.ships) - if location_type == LocationType.Shorad: - return self._random_from(self.base_garrisons) - if location_type == LocationType.StrikeTarget: - return self._random_from(self.strike_locations) - logging.error(f"Unknown location type: {location_type}") - return None + #: Locations of map scenery to create zones for. + scenery: List[SceneryGroup] = field(default_factory=list) + + #: Locations of factories for producing ground units. + factories: List[PointWithHeading] = field(default_factory=list) + + #: Locations of ammo depots for controlling number of units on the front line at a + #: control point. + ammunition_depots: List[PointWithHeading] = field(default_factory=list) + + #: Locations of stationary armor groups. + armor_groups: List[PointWithHeading] = field(default_factory=list) @dataclass(frozen=True) -class PendingOccupancy: - present: int - ordered: int - transferring: int +class AircraftAllocations: + present: dict[Type[FlyingType], int] + ordered: dict[Type[FlyingType], int] + transferring: dict[Type[FlyingType], int] + + @property + def total_value(self) -> int: + total: int = 0 + for unit_type, count in self.present.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.ordered.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.transferring.items(): + total += PRICES[unit_type] * count + + return total @property def total(self) -> int: - return self.present + self.ordered + self.transferring + return self.total_present + self.total_ordered + self.total_transferring + + @property + def total_present(self) -> int: + return sum(self.present.values()) + + @property + def total_ordered(self) -> int: + return sum(self.ordered.values()) + + @property + def total_transferring(self) -> int: + return sum(self.transferring.values()) + + +@dataclass(frozen=True) +class GroundUnitAllocations: + present: dict[Type[VehicleType], int] + ordered: dict[Type[VehicleType], int] + transferring: dict[Type[VehicleType], int] + + @property + def all(self) -> dict[Type[VehicleType], int]: + combined: dict[Type[VehicleType], int] = defaultdict(int) + for unit_type, count in itertools.chain( + self.present.items(), self.ordered.items(), self.transferring.items() + ): + combined[unit_type] += count + return dict(combined) + + @property + def total_value(self) -> int: + total: int = 0 + for unit_type, count in self.present.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.ordered.items(): + total += PRICES[unit_type] * count + for unit_type, count in self.transferring.items(): + total += PRICES[unit_type] * count + + return total + + @cached_property + def total(self) -> int: + return self.total_present + self.total_ordered + self.total_transferring + + @cached_property + def total_present(self) -> int: + return sum(self.present.values()) + + @cached_property + def total_ordered(self) -> int: + return sum(self.ordered.values()) + + @cached_property + def total_transferring(self) -> int: + return sum(self.transferring.values()) @dataclass @@ -227,6 +263,13 @@ class GroundUnitDestination: return self.total_value < other.total_value +@unique +class ControlPointStatus(IntEnum): + Functional = auto() + Damaged = auto() + Destroyed = auto() + + class ControlPoint(MissionTarget, ABC): position = None # type: Point @@ -257,7 +300,6 @@ class ControlPoint(MissionTarget, ABC): self.full_name = name self.at = at self.connected_objectives: List[TheaterGroundObject] = [] - self.base_defenses: List[BaseDefenseGroundObject] = [] self.preset_locations = PresetLocations() self.helipads: List[PointWithHeading] = [] @@ -269,13 +311,15 @@ class ControlPoint(MissionTarget, ABC): # TODO: Should be Airbase specific. self.has_frontline = has_frontline self.connected_points: List[ControlPoint] = [] + self.convoy_routes: Dict[ControlPoint, Tuple[Point, ...]] = {} + self.shipping_lanes: Dict[ControlPoint, Tuple[Point, ...]] = {} self.base: Base = Base() self.cptype = cptype # TODO: Should be Airbase specific. self.stances: Dict[int, CombatStance] = {} - from ..event import UnitsDeliveryEvent + from ..unitdelivery import PendingUnitDeliveries - self.pending_unit_deliveries = UnitsDeliveryEvent(self) + self.pending_unit_deliveries = PendingUnitDeliveries(self) self.target_position: Optional[Point] = None @@ -284,7 +328,7 @@ class ControlPoint(MissionTarget, ABC): @property def ground_objects(self) -> List[TheaterGroundObject]: - return list(itertools.chain(self.connected_objectives, self.base_defenses)) + return list(self.connected_objectives) @property @abstractmethod @@ -298,6 +342,69 @@ class ControlPoint(MissionTarget, ABC): def is_global(self): return not self.connected_points + def transitive_connected_friendly_points( + self, seen: Optional[Set[ControlPoint]] = None + ) -> List[ControlPoint]: + if seen is None: + seen = {self} + + connected = [] + for cp in self.connected_points: + if cp.captured != self.captured: + continue + if cp in seen: + continue + seen.add(cp) + connected.append(cp) + connected.extend(cp.transitive_connected_friendly_points(seen)) + return connected + + def transitive_friendly_shipping_destinations( + self, seen: Optional[Set[ControlPoint]] = None + ) -> List[ControlPoint]: + if seen is None: + seen = {self} + + connected = [] + for cp in self.shipping_lanes: + if cp.captured != self.captured: + continue + if cp in seen: + continue + seen.add(cp) + connected.append(cp) + connected.extend(cp.transitive_friendly_shipping_destinations(seen)) + return connected + + @property + def has_factory(self) -> bool: + for tgo in self.connected_objectives: + if tgo.is_factory and not tgo.is_dead: + return True + return False + + def can_recruit_ground_units(self, game: Game) -> bool: + """Returns True if this control point is capable of recruiting ground units.""" + if not self.can_deploy_ground_units: + return False + + if game.turn == 0: + # Allow units to be recruited anywhere on turn 0 to avoid long delays to get + # everyone to the front line. + return True + + return self.has_factory + + def has_ground_unit_source(self, game: Game) -> bool: + """Returns True if this control point has access to ground reinforcements.""" + if not self.can_deploy_ground_units: + return False + + for cp in game.theater.controlpoints: + if cp.is_friendly(self.captured) and cp.can_recruit_ground_units(game): + return True + return False + @property def is_carrier(self): """ @@ -340,10 +447,21 @@ class ControlPoint(MissionTarget, ABC): """ ... - # TODO: Should be Airbase specific. - def connect(self, to: ControlPoint) -> None: + def convoy_origin_for(self, destination: ControlPoint) -> Point: + return self.convoy_route_to(destination)[0] + + def convoy_route_to(self, destination: ControlPoint) -> Sequence[Point]: + return self.convoy_routes[destination] + + def create_convoy_route(self, to: ControlPoint, waypoints: Iterable[Point]) -> None: self.connected_points.append(to) self.stances[to.id] = CombatStance.DEFENSIVE + self.convoy_routes[to] = tuple(waypoints) + + def create_shipping_lane( + self, to: ControlPoint, waypoints: Iterable[Point] + ) -> None: + self.shipping_lanes[to] = tuple(waypoints) @abstractmethod def runway_is_operational(self) -> bool: @@ -393,23 +511,8 @@ class ControlPoint(MissionTarget, ABC): def is_friendly(self, to_player: bool) -> bool: return self.captured == to_player - # TODO: Should be Airbase specific. - def clear_base_defenses(self) -> None: - for base_defense in self.base_defenses: - p = PointWithHeading.from_point(base_defense.position, base_defense.heading) - if isinstance(base_defense, EwrGroundObject): - self.preset_locations.base_ewrs.append(p) - elif isinstance(base_defense, SamGroundObject): - self.preset_locations.base_air_defense.append(p) - elif isinstance(base_defense, VehicleGroupGroundObject): - self.preset_locations.base_garrisons.append(p) - else: - logging.error( - "Could not determine preset location type for " - f"{base_defense}. Assuming garrison type." - ) - self.preset_locations.base_garrisons.append(p) - self.base_defenses = [] + def is_friendly_to(self, control_point: ControlPoint) -> bool: + return control_point.is_friendly(self.captured) def capture_equipment(self, game: Game) -> None: total = self.base.total_armor_value @@ -465,7 +568,7 @@ class ControlPoint(MissionTarget, ABC): max_retreat_distance = nautical_miles(200) # Skip the first airbase because that's the airbase we're retreating # from. - airfields = list(closest.airfields_within(max_retreat_distance))[1:] + airfields = list(closest.operational_airfields_within(max_retreat_distance))[1:] for airbase in airfields: if not airbase.can_operate(airframe): continue @@ -495,11 +598,17 @@ class ControlPoint(MissionTarget, ABC): airframe, count = self.base.aircraft.popitem() self._retreat_air_units(game, airframe, count) + def depopulate_uncapturable_tgos(self) -> None: + for tgo in self.connected_objectives: + if not tgo.capturable: + tgo.clear() + # TODO: Should be Airbase specific. def capture(self, game: Game, for_player: bool) -> None: self.pending_unit_deliveries.refund_all(game) self.retreat_ground_units(game) self.retreat_air_units(game) + self.depopulate_uncapturable_tgos() if for_player: self.captured = True @@ -508,46 +617,29 @@ class ControlPoint(MissionTarget, ABC): self.base.set_strength_to_minimum() - self.clear_base_defenses() - from .start_generator import BaseDefenseGenerator - - BaseDefenseGenerator(game, self).generate() - @abstractmethod def can_operate(self, aircraft: Type[FlyingType]) -> bool: ... - def aircraft_transferring(self, game: Game) -> int: + def aircraft_transferring(self, game: Game) -> dict[Type[FlyingType], int]: if self.captured: ato = game.blue_ato else: ato = game.red_ato - total = 0 + transferring: defaultdict[Type[FlyingType], int] = defaultdict(int) for package in ato.packages: for flight in package.flights: if flight.departure == flight.arrival: continue if flight.departure == self: - total -= flight.count + transferring[flight.unit_type] -= flight.count elif flight.arrival == self: - total += flight.count - return total - - def expected_aircraft_next_turn(self, game: Game) -> PendingOccupancy: - on_order = 0 - for unit_bought in self.pending_unit_deliveries.units: - if issubclass(unit_bought, FlyingType): - on_order += self.pending_unit_deliveries.units[unit_bought] - - return PendingOccupancy( - self.base.total_aircraft, on_order, self.aircraft_transferring(game) - ) + transferring[flight.unit_type] += flight.count + return transferring def unclaimed_parking(self, game: Game) -> int: - return ( - self.total_aircraft_parking - self.expected_aircraft_next_turn(game).total - ) + return self.total_aircraft_parking - self.allocated_aircraft(game).total @abstractmethod def active_runway( @@ -597,66 +689,87 @@ class ControlPoint(MissionTarget, ABC): u.position.x = u.position.x + delta.x u.position.y = u.position.y + delta.y - @property - def pending_frontline_aa_deliveries_count(self): - """ - Get number of pending frontline aa units - """ - if self.pending_unit_deliveries: - return sum( - [ - v - for k, v in self.pending_unit_deliveries.units.items() - if k in TYPE_SHORAD - ] - ) - else: - return 0 - - @property - def pending_deliveries_count(self): - """ - Get number of pending units - """ - if self.pending_unit_deliveries: - return sum([v for k, v in self.pending_unit_deliveries.units.items()]) - else: - return 0 - - @property - def expected_ground_units_next_turn(self) -> PendingOccupancy: - on_order = 0 - for unit_bought in self.pending_unit_deliveries.units: + def allocated_aircraft(self, game: Game) -> AircraftAllocations: + on_order = {} + for unit_bought, count in self.pending_unit_deliveries.units.items(): if issubclass(unit_bought, FlyingType): - continue - if unit_bought in TYPE_SHORAD: - continue - on_order += self.pending_unit_deliveries.units[unit_bought] + on_order[unit_bought] = count - return PendingOccupancy( - self.base.total_armor, + return AircraftAllocations( + self.base.aircraft, on_order, self.aircraft_transferring(game) + ) + + def allocated_ground_units( + self, transfers: PendingTransfers + ) -> GroundUnitAllocations: + on_order = {} + for unit_bought, count in self.pending_unit_deliveries.units.items(): + if issubclass(unit_bought, VehicleType): + on_order[unit_bought] = count + + transferring: dict[Type[VehicleType], int] = defaultdict(int) + for transfer in transfers: + if transfer.destination == self: + for unit_type, count in transfer.units.items(): + transferring[unit_type] += count + + return GroundUnitAllocations( + self.base.armor, on_order, - # Ground unit transfers not yet implemented. - transferring=0, + transferring, ) @property def income_per_turn(self) -> int: return 0 - def mission_types(self, for_player: bool) -> Iterator[FlightType]: - from gen.flights.flight import FlightType - - if self.is_friendly(for_player): - yield from [ - FlightType.AEWC, - ] - yield from super().mission_types(for_player) - @property def has_active_frontline(self) -> bool: return any(not c.is_friendly(self.captured) for c in self.connected_points) + def front_is_active(self, other: ControlPoint) -> bool: + if other not in self.connected_points: + raise ValueError + + return self.captured != other.captured + + @property + def frontline_unit_count_limit(self) -> int: + return ( + FREE_FRONTLINE_UNIT_SUPPLY + + self.active_ammo_depots_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION + ) + + @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 + ] + ) + + @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"]) + + @property + def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + return [] + + @property + @abstractmethod + def category(self) -> str: + ... + + @property + @abstractmethod + def status(self) -> ControlPointStatus: + ... + class Airfield(ControlPoint): def __init__( @@ -686,18 +799,21 @@ class Airfield(ControlPoint): def mission_types(self, for_player: bool) -> Iterator[FlightType]: from gen.flights.flight import FlightType - if self.is_friendly(for_player): - yield from [ - # TODO: FlightType.INTERCEPTION - # TODO: FlightType.LOGISTICS - ] - else: + if not self.is_friendly(for_player): yield from [ FlightType.OCA_AIRCRAFT, FlightType.OCA_RUNWAY, ] + yield from super().mission_types(for_player) + if self.is_friendly(for_player): + yield from [ + FlightType.AEWC, + # TODO: FlightType.INTERCEPTION + # TODO: FlightType.LOGISTICS + ] + @property def total_aircraft_parking(self) -> int: return len(self.airport.parking_slots) @@ -734,6 +850,19 @@ class Airfield(ControlPoint): def income_per_turn(self) -> int: return 20 + @property + def category(self) -> str: + return "airfield" + + @property + def status(self) -> ControlPointStatus: + runway_staus = self.runway_status + if runway_staus.needs_repair: + return ControlPointStatus.Destroyed + elif runway_staus.damaged: + return ControlPointStatus.Damaged + return ControlPointStatus.Functional + class NavalControlPoint(ControlPoint, ABC): @property @@ -758,20 +887,24 @@ class NavalControlPoint(ControlPoint, ABC): def heading(self) -> int: return 0 # TODO compute heading + def find_main_tgo(self) -> TheaterGroundObject: + for g in self.ground_objects: + if g.dcs_identifier in ["CARRIER", "LHA"]: + return g + raise RuntimeError(f"Found no carrier/LHA group for {self.name}") + def runway_is_operational(self) -> bool: # Necessary because it's possible for the carrier itself to have sunk # while its escorts are still alive. - for g in self.ground_objects: - if g.dcs_identifier in ["CARRIER", "LHA"]: - for group in g.groups: - for u in group.units: - if db.unit_type_from_name(u.type) in [ - CVN_74_John_C__Stennis, - LHA_1_Tarawa, - CV_1143_5_Admiral_Kuznetsov, - Type_071_Amphibious_Transport_Dock, - ]: - return True + for group in self.find_main_tgo().groups: + for u in group.units: + if db.unit_type_from_name(u.type) in [ + CVN_74_John_C__Stennis, + LHA_1_Tarawa, + CV_1143_5_Admiral_Kuznetsov, + Type_071_Amphibious_Transport_Dock, + ]: + return True return False def active_runway( @@ -797,6 +930,14 @@ class NavalControlPoint(ControlPoint, ABC): def can_deploy_ground_units(self) -> bool: return False + @property + def status(self) -> ControlPointStatus: + if not self.runway_is_operational(): + return ControlPointStatus.Destroyed + if self.find_main_tgo().dead_units: + return ControlPointStatus.Damaged + return ControlPointStatus.Functional + class Carrier(NavalControlPoint): def __init__(self, name: str, at: Point, cp_id: int): @@ -813,6 +954,13 @@ class Carrier(NavalControlPoint): cptype=ControlPointType.AIRCRAFT_CARRIER_GROUP, ) + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + from gen.flights.flight import FlightType + + yield from super().mission_types(for_player) + if self.is_friendly(for_player): + yield FlightType.AEWC + def capture(self, game: Game, for_player: bool) -> None: raise RuntimeError("Carriers cannot be captured") @@ -827,6 +975,10 @@ class Carrier(NavalControlPoint): def total_aircraft_parking(self) -> int: return 90 + @property + def category(self) -> str: + return "cv" + class Lha(NavalControlPoint): def __init__(self, name: str, at: Point, cp_id: int): @@ -857,6 +1009,10 @@ class Lha(NavalControlPoint): def total_aircraft_parking(self) -> int: return 20 + @property + def category(self) -> str: + return "lha" + class OffMapSpawn(ControlPoint): def runway_is_operational(self) -> bool: @@ -907,6 +1063,14 @@ class OffMapSpawn(ControlPoint): def can_deploy_ground_units(self) -> bool: return False + @property + def category(self) -> str: + return "offmap" + + @property + def status(self) -> ControlPointStatus: + return ControlPointStatus.Functional + class Fob(ControlPoint): def __init__(self, name: str, at: Point, cp_id: int): @@ -940,18 +1104,10 @@ class Fob(ControlPoint): def mission_types(self, for_player: bool) -> Iterator[FlightType]: from gen.flights.flight import FlightType - if self.is_friendly(for_player): - yield from [ - FlightType.BARCAP, - # TODO: FlightType.LOGISTICS - ] - else: - yield from [ - FlightType.STRIKE, - FlightType.SWEEP, - FlightType.ESCORT, - FlightType.SEAD, - ] + if not self.is_friendly(for_player): + yield FlightType.STRIKE + + yield from super().mission_types(for_player) @property def total_aircraft_parking(self) -> int: @@ -971,3 +1127,11 @@ class Fob(ControlPoint): @property def income_per_turn(self) -> int: return 10 + + @property + def category(self) -> str: + return "fob" + + @property + def status(self) -> ControlPointStatus: + return ControlPointStatus.Functional diff --git a/game/theater/frontline.py b/game/theater/frontline.py new file mode 100644 index 00000000..225980d1 --- /dev/null +++ b/game/theater/frontline.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterator, List, Tuple + +from dcs.mapping import Point + +from gen.flights.flight import FlightType +from .controlpoint import ( + ControlPoint, + MissionTarget, +) +from ..utils import pairwise + + +FRONTLINE_MIN_CP_DISTANCE = 5000 + + +@dataclass +class FrontLineSegment: + """ + Describes a line segment of a FrontLine + """ + + point_a: Point + point_b: Point + + @property + def attack_heading(self) -> float: + """The heading of the frontline segment from player to enemy control point""" + return self.point_a.heading_between_point(self.point_b) + + @property + def attack_distance(self) -> float: + """Length of the segment""" + return self.point_a.distance_to_point(self.point_b) + + +class FrontLine(MissionTarget): + """Defines a front line location between two control points. + Front lines are the area where ground combat happens. + Overwrites the entirety of MissionTarget __init__ method to allow for + dynamic position calculation. + """ + + def __init__( + self, + blue_point: ControlPoint, + red_point: ControlPoint, + ) -> None: + self.blue_cp = blue_point + self.red_cp = red_point + try: + route = list(blue_point.convoy_route_to(red_point)) + except KeyError: + # Some campaigns are air only and the mission generator currently relies on + # *some* "front line" being drawn between these two. In this case there will + # be no supply route to follow. Just create an arbitrary route between the + # two points. + route = [blue_point.position, red_point.position] + # Snap the beginning and end points to the CPs rather than the convoy waypoints, + # which are on roads. + route[0] = blue_point.position + route[-1] = red_point.position + self.segments: List[FrontLineSegment] = [ + FrontLineSegment(a, b) for a, b in pairwise(route) + ] + self.name = f"Front line {blue_point}/{red_point}" + + def control_point_hostile_to(self, player: bool) -> ControlPoint: + if player: + return self.red_cp + return self.blue_cp + + def is_friendly(self, to_player: bool) -> bool: + """Returns True if the objective is in friendly territory.""" + return False + + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + yield from [ + FlightType.CAS, + FlightType.AEWC, + # TODO: FlightType.TROOP_TRANSPORT + # TODO: FlightType.EVAC + ] + yield from super().mission_types(for_player) + + @property + def position(self): + """ + 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 + for segment in self.segments: + yield segment.point_b + + @property + def control_points(self) -> Tuple[ControlPoint, ControlPoint]: + """Returns a tuple of the two control points.""" + return self.blue_cp, self.red_cp + + @property + def attack_distance(self): + """The total distance of all segments""" + return sum(i.attack_distance for i in self.segments) + + @property + def attack_heading(self): + """The heading of the active attack segment from player to enemy control point""" + return self.active_segment.attack_heading + + @property + def active_segment(self) -> FrontLineSegment: + """The FrontLine segment where there can be an active conflict""" + if self._position_distance <= self.segments[0].attack_distance: + return self.segments[0] + + remaining_dist = self._position_distance + for segment in self.segments: + if remaining_dist <= segment.attack_distance: + return segment + else: + remaining_dist -= segment.attack_distance + logging.error( + "Frontline attack distance is greater than the sum of its segments" + ) + return self.segments[0] + + def point_from_a(self, distance: float) -> Point: + """ + Returns a point {distance} away from control_point_a along the frontline segments. + """ + if distance < self.segments[0].attack_distance: + return self.blue_cp.position.point_from_heading( + self.segments[0].attack_heading, 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 + ) + else: + remaining_dist -= segment.attack_distance + + @property + def _position_distance(self) -> float: + """ + The distance from point "a" where the conflict should occur + according to the current strength of each control point + """ + total_strength = self.blue_cp.base.strength + self.red_cp.base.strength + if self.blue_cp.base.strength == 0: + return self._adjust_for_min_dist(0) + if self.red_cp.base.strength == 0: + return self._adjust_for_min_dist(self.attack_distance) + strength_pct = self.blue_cp.base.strength / total_strength + return self._adjust_for_min_dist(strength_pct * self.attack_distance) + + def _adjust_for_min_dist(self, distance: float) -> float: + """ + Ensures the frontline conflict is never located within the minimum distance + constant of either end control point. + """ + if (distance > self.attack_distance / 2) and ( + distance + FRONTLINE_MIN_CP_DISTANCE > self.attack_distance + ): + distance = self.attack_distance - FRONTLINE_MIN_CP_DISTANCE + elif (distance < self.attack_distance / 2) and ( + distance < FRONTLINE_MIN_CP_DISTANCE + ): + distance = FRONTLINE_MIN_CP_DISTANCE + return distance diff --git a/game/theater/latlon.py b/game/theater/latlon.py new file mode 100644 index 00000000..b819e30f --- /dev/null +++ b/game/theater/latlon.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from typing import List, Tuple + + +@dataclass(frozen=True) +class LatLon: + latitude: float + longitude: float + + def as_list(self) -> List[float]: + return [self.latitude, self.longitude] + + @staticmethod + def _components(dimension: float) -> Tuple[int, int, float]: + degrees = int(dimension) + minutes = int(dimension * 60 % 60) + seconds = dimension * 3600 % 60 + return degrees, minutes, seconds + + def _format_component( + self, dimension: float, hemispheres: Tuple[str, str], seconds_precision: int + ) -> str: + hemisphere = hemispheres[0] if dimension >= 0 else hemispheres[1] + degrees, minutes, seconds = self._components(dimension) + return f"{degrees}°{minutes:02}'{seconds:02.{seconds_precision}f}\"{hemisphere}" + + def format_dms(self, include_decimal_seconds: bool = False) -> str: + precision = 2 if include_decimal_seconds else 0 + return " ".join( + [ + self._format_component(self.latitude, ("N", "S"), precision), + self._format_component(self.longitude, ("E", "W"), precision), + ] + ) diff --git a/game/theater/missiontarget.py b/game/theater/missiontarget.py index 9a5fe8cd..ea426603 100644 --- a/game/theater/missiontarget.py +++ b/game/theater/missiontarget.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import Iterator, TYPE_CHECKING +from typing import Iterator, TYPE_CHECKING, List, Union from dcs.mapping import Point +from dcs.unit import Unit if TYPE_CHECKING: from gen.flights.flight import FlightType @@ -36,9 +37,13 @@ class MissionTarget: yield from [ FlightType.ESCORT, FlightType.TARCAP, - FlightType.SEAD, + FlightType.SEAD_ESCORT, FlightType.SWEEP, # TODO: FlightType.ELINT, # TODO: FlightType.EWAR, # TODO: FlightType.RECON, ] + + @property + def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + return [] diff --git a/game/theater/nevada.py b/game/theater/nevada.py new file mode 100644 index 00000000..ad245611 --- /dev/null +++ b/game/theater/nevada.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=-117, + false_easting=-193996.80999964548, + false_northing=-4410028.063999966, + scale_factor=0.9996, +) diff --git a/game/theater/normandy.py b/game/theater/normandy.py new file mode 100644 index 00000000..a74c692d --- /dev/null +++ b/game/theater/normandy.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=-3, + false_easting=-195526.00000000204, + false_northing=-5484812.999999951, + scale_factor=0.9996, +) diff --git a/game/theater/persiangulf.py b/game/theater/persiangulf.py new file mode 100644 index 00000000..600801dd --- /dev/null +++ b/game/theater/persiangulf.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=57, + false_easting=75755.99999999645, + false_northing=-2894933.0000000377, + scale_factor=0.9996, +) diff --git a/game/theater/projections.py b/game/theater/projections.py new file mode 100644 index 00000000..90f24fe2 --- /dev/null +++ b/game/theater/projections.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass + +from pyproj import CRS + + +@dataclass(frozen=True) +class TransverseMercator: + central_meridian: int + false_easting: float + false_northing: float + scale_factor: float + + def to_crs(self) -> CRS: + return CRS.from_proj4( + " ".join( + [ + "+proj=tmerc", + "+lat_0=0", + f"+lon_0={self.central_meridian}", + f"+k_0={self.scale_factor}", + f"+x_0={self.false_easting}", + f"+y_0={self.false_northing}", + "+towgs84=0,0,0,0,0,0,0", + "+units=m", + "+vunits=m", + "+ellps=WGS84", + "+no_defs", + "+axis=neu", + ] + ) + ) diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 9ac71a56..2f31fecf 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -5,7 +5,7 @@ import pickle import random from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, Iterable, List, Optional, Set +from typing import Any, Dict, Iterable, List, Set from dcs.mapping import Point from dcs.task import CAP, CAS, PinpointStrike @@ -13,15 +13,18 @@ from dcs.vehicles import AirDefence from game import Game, db from game.factions.faction import Faction -from game.theater import Carrier, Lha, LocationType, PointWithHeading +from game.scenery_group import SceneryGroup +from game.theater import Carrier, Lha, PointWithHeading from game.theater.theatergroundobject import ( BuildingGroundObject, CarrierGroundObject, EwrGroundObject, + FactoryGroundObject, LhaGroundObject, MissileSiteGroundObject, SamGroundObject, ShipGroundObject, + SceneryGroundObject, VehicleGroupGroundObject, CoastalSiteGroundObject, ) @@ -34,11 +37,10 @@ from gen.fleet.ship_group_generator import ( generate_lha_group, generate_ship_group, ) -from gen.locations.preset_location_finder import MizDataLocationFinder from gen.missiles.missiles_group_generator import generate_missile_group from gen.sam.airdefensegroupgenerator import AirDefenseRange -from gen.sam.sam_group_generator import generate_anti_air_group from gen.sam.ewr_group_generator import generate_ewr_group +from gen.sam.sam_group_generator import generate_anti_air_group from . import ( ConflictTheater, ControlPoint, @@ -46,6 +48,7 @@ from . import ( Fob, OffMapSpawn, ) +from ..profiling import logged_duration from ..settings import Settings GroundObjectTemplates = Dict[str, Dict[str, Any]] @@ -91,21 +94,23 @@ class GameGenerator: self.generator_settings = generator_settings def generate(self) -> Game: - # Reset name generator - namegen.reset() - self.prepare_theater() - game = Game( - player_name=self.player, - enemy_name=self.enemy, - theater=self.theater, - start_date=self.generator_settings.start_date, - settings=self.settings, - player_budget=self.generator_settings.player_budget, - enemy_budget=self.generator_settings.enemy_budget, - ) + with logged_duration("TGO population"): + # Reset name generator + namegen.reset() + self.prepare_theater() + game = Game( + player_name=self.player, + enemy_name=self.enemy, + theater=self.theater, + start_date=self.generator_settings.start_date, + settings=self.settings, + player_budget=self.generator_settings.player_budget, + enemy_budget=self.generator_settings.enemy_budget, + ) - GroundObjectGenerator(game, self.generator_settings).generate() + GroundObjectGenerator(game, self.generator_settings).generate() game.settings.version = VERSION + game.begin_turn_0() return game def prepare_theater(self) -> None: @@ -140,175 +145,6 @@ class GameGenerator: cp.captured = True -class LocationFinder: - def __init__(self, game: Game, control_point: ControlPoint) -> None: - self.game = game - self.control_point = control_point - self.miz_data = MizDataLocationFinder.compute_possible_locations( - game.theater.terrain.name, control_point.full_name - ) - - def location_for(self, location_type: LocationType) -> Optional[PointWithHeading]: - position = self.control_point.preset_locations.random_for(location_type) - if position is not None: - return position - - logging.warning( - f"No campaign location for %s Mat %s", - location_type.value, - self.control_point, - ) - position = self.random_from_miz_data( - location_type == LocationType.OffshoreStrikeTarget - ) - if position is not None: - return position - - logging.debug( - f"No mizdata location for %s at %s", location_type.value, self.control_point - ) - position = self.random_position(location_type) - if position is not None: - return position - - logging.error( - f"Could not find position for %s at %s", - location_type.value, - self.control_point, - ) - return None - - def random_from_miz_data(self, offshore: bool) -> Optional[PointWithHeading]: - if offshore: - locations = self.miz_data.offshore_locations - else: - locations = self.miz_data.ashore_locations - if self.miz_data.offshore_locations: - preset = random.choice(locations) - locations.remove(preset) - return PointWithHeading.from_point(preset.position, preset.heading) - return None - - def random_position( - self, location_type: LocationType - ) -> Optional[PointWithHeading]: - # TODO: Flesh out preset locations so we never hit this case. - - if location_type == LocationType.Coastal: - # No coastal locations generated randomly - return None - - logging.warning( - "Falling back to random location for %s at %s", - location_type.value, - self.control_point, - ) - - is_base_defense = location_type in { - LocationType.BaseAirDefense, - LocationType.Garrison, - LocationType.Shorad, - } - - on_land = location_type not in { - LocationType.OffshoreStrikeTarget, - LocationType.Ship, - } - - avoid_others = location_type not in { - LocationType.Garrison, - LocationType.MissileSite, - LocationType.Sam, - LocationType.Ship, - LocationType.Shorad, - } - - if is_base_defense: - min_range = 400 - max_range = 3200 - elif location_type == LocationType.Ship: - min_range = 5000 - max_range = 40000 - elif location_type == LocationType.MissileSite: - min_range = 2500 - max_range = 40000 - else: - min_range = 10000 - max_range = 40000 - - position = self._find_random_position( - min_range, max_range, on_land, is_base_defense, avoid_others - ) - - # Retry once, searching a bit further (On some big airbases, 3200 is too - # short (Ex : Incirlik)), but searching farther on every base would be - # problematic, as some base defense units would end up very far away - # from small airfields. - if position is None and is_base_defense: - position = self._find_random_position( - 3200, 4800, on_land, is_base_defense, avoid_others - ) - return position - - def _find_random_position( - self, - min_range: int, - max_range: int, - on_ground: bool, - is_base_defense: bool, - avoid_others: bool, - ) -> Optional[PointWithHeading]: - """ - Find a valid ground object location - :param on_ground: Whether it should be on ground or on sea (True = on - ground) - :param min_range: Minimal range from point - :param max_range: Max range from point - :param is_base_defense: True if the location is for base defense. - :return: - """ - near = self.control_point.position - others = self.control_point.ground_objects - - def is_valid(point: Optional[PointWithHeading]) -> bool: - if point is None: - return False - - if on_ground and not self.game.theater.is_on_land(point): - return False - elif not on_ground and not self.game.theater.is_in_sea(point): - return False - - if avoid_others: - for other in others: - if other.position.distance_to_point(point) < 10000: - return False - - if is_base_defense: - # If it's a base defense we don't care how close it is to other - # points. - return True - - # Else verify that it's not too close to another control point. - for control_point in self.game.theater.controlpoints: - if control_point != self.control_point: - if control_point.position.distance_to_point(point) < 30000: - return False - for ground_obj in control_point.ground_objects: - if ground_obj.position.distance_to_point(point) < 10000: - return False - return True - - for _ in range(300): - # Check if on land or sea - p = PointWithHeading.from_point( - near.random_point_within(max_range, min_range), random.randint(0, 360) - ) - if is_valid(p): - return p - return None - - class ControlPointGroundObjectGenerator: def __init__( self, @@ -319,7 +155,6 @@ class ControlPointGroundObjectGenerator: self.game = game self.generator_settings = generator_settings self.control_point = control_point - self.location_finder = LocationFinder(game, control_point) @property def faction_name(self) -> str: @@ -335,10 +170,7 @@ class ControlPointGroundObjectGenerator: def generate(self) -> bool: self.control_point.connected_objectives = [] if self.faction.navy_generators: - # Even airbases can generate navies if they are close enough to the - # water. This is not controlled by the control point definition, but - # rather by whether or not the generator can find a valid position - # for the ship. + # Even airbases can generate navies if they are close enough to the water. self.generate_navy() return True @@ -352,18 +184,14 @@ class ControlPointGroundObjectGenerator: if not self.control_point.captured and skip_enemy_navy: return - for _ in range(self.faction.navy_group_count): - self.generate_ship() - - def generate_ship(self) -> None: - point = self.location_finder.location_for(LocationType.OffshoreStrikeTarget) - if point is None: - return + for position in self.control_point.preset_locations.ships: + self.generate_ship_at(position) + def generate_ship_at(self, position: PointWithHeading) -> None: group_id = self.game.next_group_id() g = ShipGroundObject( - namegen.random_objective_name(), group_id, point, self.control_point + namegen.random_objective_name(), group_id, position, self.control_point ) group = generate_ship_group(self.game, g, self.faction_name) @@ -432,156 +260,6 @@ class LhaGroundObjectGenerator(ControlPointGroundObjectGenerator): return True -class BaseDefenseGenerator: - def __init__(self, game: Game, control_point: ControlPoint) -> None: - self.game = game - self.control_point = control_point - self.location_finder = LocationFinder(game, control_point) - - @property - def faction_name(self) -> str: - if self.control_point.captured: - return self.game.player_name - else: - return self.game.enemy_name - - @property - def faction(self) -> Faction: - return db.FACTIONS[self.faction_name] - - def generate(self) -> None: - self.generate_ewr() - self.generate_garrison() - self.generate_base_defenses() - - def generate_ewr(self) -> None: - position = self.location_finder.location_for(LocationType.BaseEwr) - if position is None: - return - - group_id = self.game.next_group_id() - - g = EwrGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - True, - ) - - group = generate_ewr_group(self.game, g, self.faction) - if group is None: - logging.error(f"Could not generate EWR at {self.control_point}") - return - - g.groups = [group] - self.control_point.base_defenses.append(g) - - def generate_base_defenses(self) -> None: - # First group has a 1/2 chance of being a SAM, 1/6 chance of SHORAD, - # and a 1/6 chance of a garrison. - # - # Further groups have a 1/3 chance of being SHORAD and 2/3 chance of - # being a garrison. - for i in range(random.randint(2, 5)): - if i == 0 and random.randint(0, 1) == 0: - self.generate_sam() - elif random.randint(0, 2) == 1: - self.generate_shorad() - else: - self.generate_garrison() - - def generate_garrison(self) -> None: - position = self.location_finder.location_for(LocationType.Garrison) - if position is None: - return - - group_id = self.game.next_group_id() - - g = VehicleGroupGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - for_airbase=True, - ) - - group = generate_armor_group(self.faction_name, self.game, g) - if group is None: - logging.error(f"Could not generate garrison at {self.control_point}") - return - g.groups.append(group) - self.control_point.base_defenses.append(g) - - def generate_sam(self) -> None: - position = self.location_finder.location_for(LocationType.BaseAirDefense) - if position is None: - return - - group_id = self.game.next_group_id() - - g = SamGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - for_airbase=True, - ) - - groups = generate_anti_air_group(self.game, g, self.faction) - if not groups: - logging.error(f"Could not generate SAM at {self.control_point}") - return - g.groups = groups - self.control_point.base_defenses.append(g) - - def generate_shorad(self) -> None: - position = self.location_finder.location_for(LocationType.BaseAirDefense) - if position is None: - return - - group_id = self.game.next_group_id() - - g = SamGroundObject( - namegen.random_objective_name(), - group_id, - position, - self.control_point, - for_airbase=True, - ) - - groups = generate_anti_air_group( - self.game, g, self.faction, ranges=[{AirDefenseRange.Short}] - ) - if not groups: - logging.error(f"Could not generate SHORAD group at {self.control_point}") - return - g.groups = groups - self.control_point.base_defenses.append(g) - - -class FobDefenseGenerator(BaseDefenseGenerator): - def generate(self) -> None: - self.generate_garrison() - self.generate_fob_defenses() - - def generate_fob_defenses(self): - # First group has a 1/2 chance of being a SHORAD, - # and a 1/2 chance of a garrison. - # - # Further groups have a 1/3 chance of being SHORAD and 2/3 chance of - # being a garrison. - for i in range(random.randint(2, 5)): - if i == 0 and random.randint(0, 1) == 0: - self.generate_shorad() - elif i == 0 and random.randint(0, 1) == 0: - self.generate_garrison() - elif random.randint(0, 2) == 1: - self.generate_shorad() - else: - self.generate_garrison() - - class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): def __init__( self, @@ -597,8 +275,19 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): if not super().generate(): return False - BaseDefenseGenerator(self.game, self.control_point).generate() self.generate_ground_points() + return True + + def generate_ground_points(self) -> None: + """Generate ground objects and AA sites for the control point.""" + self.generate_armor_groups() + self.generate_aa() + self.generate_ewrs() + self.generate_scenery_sites() + self.generate_strike_targets() + self.generate_offshore_strike_targets() + self.generate_factories() + self.generate_ammunition_depots() if self.faction.missiles: self.generate_missile_sites() @@ -606,96 +295,73 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): if self.faction.coastal_defenses: self.generate_coastal_sites() - return True + def generate_armor_groups(self) -> None: + for position in self.control_point.preset_locations.armor_groups: + self.generate_armor_at(position) - def generate_ground_points(self) -> None: - """Generate ground objects and AA sites for the control point.""" - skip_sams = self.generate_required_aa() - skip_ewrs = self.generate_required_ewr() + def generate_armor_at(self, position: PointWithHeading) -> None: + group_id = self.game.next_group_id() - if self.control_point.is_global: + g = VehicleGroupGroundObject( + namegen.random_objective_name(), + group_id, + position, + self.control_point, + ) + + group = generate_armor_group(self.faction_name, self.game, g) + if group is None: + logging.error( + "Could not generate armor group for %s at %s", + g.name, + self.control_point, + ) return + g.groups = [group] + self.control_point.connected_objectives.append(g) - # Always generate at least one AA point. - self.generate_aa_site() - - # And between 2 and 7 other objectives. - amount = random.randrange(2, 7) - for i in range(amount): - # 1 in 4 additional objectives are AA. - if random.randint(0, 3) == 0: - if skip_sams > 0: - skip_sams -= 1 - else: - self.generate_aa_site() - # 1 in 4 additional objectives are EWR. - elif random.randint(0, 3) == 0: - if skip_ewrs > 0: - skip_ewrs -= 1 - else: - self.generate_ewr_site() - else: - self.generate_ground_point() - - def generate_required_aa(self) -> int: - """Generates the AA sites that are required by the campaign. - - Returns: - The number of AA sites that were generated. - """ + def generate_aa(self) -> None: presets = self.control_point.preset_locations - for position in presets.required_long_range_sams: + for position in presets.long_range_sams: self.generate_aa_at( position, ranges=[ {AirDefenseRange.Long}, {AirDefenseRange.Medium}, {AirDefenseRange.Short}, + {AirDefenseRange.AAA}, ], ) - for position in presets.required_medium_range_sams: + for position in presets.medium_range_sams: self.generate_aa_at( position, ranges=[ {AirDefenseRange.Medium}, {AirDefenseRange.Short}, + {AirDefenseRange.AAA}, ], ) - return len(presets.required_long_range_sams) + len( - presets.required_medium_range_sams - ) + for position in presets.short_range_sams: + self.generate_aa_at( + position, + ranges=[{AirDefenseRange.Short}, {AirDefenseRange.AAA}], + ) + for position in presets.aaa: + self.generate_aa_at( + position, + ranges=[{AirDefenseRange.AAA}], + ) - def generate_required_ewr(self) -> int: - """Generates the EWR sites that are required by the campaign. - - Returns: - The number of EWR sites that were generated. - """ + def generate_ewrs(self) -> None: presets = self.control_point.preset_locations - for position in presets.required_ewrs: + for position in presets.ewrs: self.generate_ewr_at(position) - return len(presets.required_ewrs) - def generate_ground_point(self) -> None: - try: - category = random.choice(self.faction.building_set) - except IndexError: - logging.exception("Faction has no buildings defined") - return + def generate_strike_target_at(self, category: str, position: Point) -> None: obj_name = namegen.random_objective_name() template = random.choice(list(self.templates[category].values())) - if category == "oil": - location_type = LocationType.OffshoreStrikeTarget - else: - location_type = LocationType.StrikeTarget - - # Pick from preset locations - point = self.location_finder.location_for(location_type) - if point is None: - return - object_id = 0 group_id = self.game.next_group_id() @@ -709,7 +375,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): category, group_id, object_id, - point + template_point, + position + template_point, unit["heading"], self.control_point, unit["type"], @@ -717,19 +383,28 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.control_point.connected_objectives.append(g) - def generate_aa_site(self) -> None: - position = self.location_finder.location_for(LocationType.Sam) - if position is None: - return - self.generate_aa_at( - position, - ranges=[ - # Prefer to use proper SAMs, but fall back to SHORADs if needed. - {AirDefenseRange.Long, AirDefenseRange.Medium}, - {AirDefenseRange.Short}, - ], + def generate_ammunition_depots(self) -> None: + for position in self.control_point.preset_locations.ammunition_depots: + self.generate_strike_target_at(category="ammo", position=position) + + def generate_factories(self) -> None: + for position in self.control_point.preset_locations.factories: + self.generate_factory_at(position) + + def generate_factory_at(self, point: PointWithHeading) -> None: + obj_name = namegen.random_objective_name() + group_id = self.game.next_group_id() + + g = FactoryGroundObject( + obj_name, + group_id, + point, + point.heading, + self.control_point, ) + self.control_point.connected_objectives.append(g) + def generate_aa_at( self, position: Point, ranges: Iterable[Set[AirDefenseRange]] ) -> None: @@ -740,7 +415,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): group_id, position, self.control_point, - for_airbase=False, ) groups = generate_anti_air_group(self.game, g, self.faction, ranges) if not groups: @@ -753,13 +427,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): g.groups = groups self.control_point.connected_objectives.append(g) - def generate_ewr_site(self) -> None: - position = self.location_finder.location_for(LocationType.Ewr) - if position is None: - return - self.generate_ewr_at(position) - - def generate_ewr_at(self, position: Point) -> None: + def generate_ewr_at(self, position: PointWithHeading) -> None: group_id = self.game.next_group_id() g = EwrGroundObject( @@ -767,7 +435,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): group_id, position, self.control_point, - for_airbase=False, ) group = generate_ewr_group(self.game, g, self.faction) if group is None: @@ -780,15 +447,45 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): g.groups = [group] self.control_point.connected_objectives.append(g) + def generate_scenery_sites(self) -> None: + presets = self.control_point.preset_locations + for scenery_group in presets.scenery: + self.generate_tgo_for_scenery(scenery_group) + + def generate_tgo_for_scenery(self, scenery: SceneryGroup) -> None: + + obj_name = namegen.random_objective_name() + category = scenery.category + group_id = self.game.next_group_id() + object_id = 0 + + # Each nested trigger zone is a target/building/unit for an objective. + for zone in scenery.zones: + + object_id += 1 + local_position = zone.position + local_dcs_identifier = zone.name + + g = SceneryGroundObject( + obj_name, + category, + group_id, + object_id, + local_position, + self.control_point, + local_dcs_identifier, + zone, + ) + + self.control_point.connected_objectives.append(g) + + return + def generate_missile_sites(self) -> None: - for i in range(self.faction.missiles_group_count): - self.generate_missile_site() - - def generate_missile_site(self) -> None: - position = self.location_finder.location_for(LocationType.MissileSite) - if position is None: - return + for position in self.control_point.preset_locations.missile_sites: + self.generate_missile_site_at(position) + def generate_missile_site_at(self, position: PointWithHeading) -> None: group_id = self.game.next_group_id() g = MissileSiteGroundObject( @@ -802,14 +499,10 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): return def generate_coastal_sites(self) -> None: - for i in range(self.faction.coastal_group_count): - self.generate_coastal_site() - - def generate_coastal_site(self) -> None: - position = self.location_finder.location_for(LocationType.Coastal) - if position is None: - return + for position in self.control_point.preset_locations.coastal_defenses: + self.generate_coastal_site_at(position) + def generate_coastal_site_at(self, position: PointWithHeading) -> None: group_id = self.game.next_group_id() g = CoastalSiteGroundObject( @@ -826,21 +519,45 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): self.control_point.connected_objectives.append(g) return + def generate_strike_targets(self) -> None: + building_set = list(set(self.faction.building_set) - {"oil"}) + if not building_set: + logging.error("Faction has no buildings defined") + return + for position in self.control_point.preset_locations.strike_locations: + category = random.choice(building_set) + self.generate_strike_target_at(category, position) + + def generate_offshore_strike_targets(self) -> None: + if "oil" not in self.faction.building_set: + logging.error("Faction does not support offshore strike targets") + return + for position in self.control_point.preset_locations.offshore_strike_locations: + self.generate_strike_target_at("oil", position) + class FobGroundObjectGenerator(AirbaseGroundObjectGenerator): def generate(self) -> bool: self.generate_fob() - FobDefenseGenerator(self.game, self.control_point).generate() - self.generate_required_aa() + self.generate_armor_groups() + self.generate_factories() + self.generate_ammunition_depots() + self.generate_aa() + self.generate_ewrs() + self.generate_scenery_sites() + self.generate_strike_targets() + self.generate_offshore_strike_targets() + + if self.faction.missiles: + self.generate_missile_sites() + + if self.faction.coastal_defenses: + self.generate_coastal_sites() + return True def generate_fob(self) -> None: - try: - category = self.faction.building_set[self.faction.building_set.index("fob")] - except IndexError: - logging.exception("Faction has no fob buildings defined") - return - + category = "fob" obj_name = self.control_point.name template = random.choice(list(self.templates[category].values())) point = self.control_point.position @@ -862,7 +579,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator): unit["heading"], self.control_point, unit["type"], - airbase_group=True, + is_fob_structure=True, ) self.control_point.connected_objectives.append(g) diff --git a/game/theater/syria.py b/game/theater/syria.py new file mode 100644 index 00000000..7fe83db3 --- /dev/null +++ b/game/theater/syria.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=39, + false_easting=282801.00000003993, + false_northing=-3879865.9999999935, + scale_factor=0.9996, +) diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index c476a90b..e4ff3bca 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -2,14 +2,20 @@ from __future__ import annotations import itertools import logging -from typing import Iterator, List, TYPE_CHECKING +from typing import Iterator, List, TYPE_CHECKING, Union from dcs.mapping import Point +from dcs.triggers import TriggerZone from dcs.unit import Unit from dcs.unitgroup import Group +from dcs.unittype import VehicleType from .. import db -from ..data.radar_db import UNITS_WITH_RADAR +from ..data.radar_db import ( + TRACK_RADARS, + TELARS, + LAUNCHER_TRACKER_PAIRS, +) from ..utils import Distance, meters if TYPE_CHECKING: @@ -19,78 +25,25 @@ if TYPE_CHECKING: from .missiontarget import MissionTarget NAME_BY_CATEGORY = { - "power": "Power plant", - "ammo": "Ammo depot", - "fuel": "Fuel depot", + "ewr": "Early Warning Radar", "aa": "AA Defense Site", - "ware": "Warehouse", - "farp": "FARP", - "fob": "FOB", - "factory": "Factory", - "comms": "Comms. tower", - "oil": "Oil platform", - "derrick": "Derrick", - "ww2bunker": "Bunker", - "village": "Village", "allycamp": "Camp", - "EWR": "EWR", -} - -ABBREV_NAME = { - "power": "PLANT", - "ammo": "AMMO", - "fuel": "FUEL", - "aa": "AA", - "ware": "WARE", + "ammo": "Ammo depot", + "armor": "Armor group", + "coastal": "Coastal defense", + "comms": "Communications tower", + "derrick": "Derrick", + "factory": "Factory", "farp": "FARP", "fob": "FOB", - "factory": "FACTORY", - "comms": "COMMST", - "oil": "OILP", - "derrick": "DERK", - "ww2bunker": "BUNK", - "village": "VLG", - "allycamp": "CMP", -} - -CATEGORY_MAP = { - # Special cases - "CARRIER": ["CARRIER"], - "LHA": ["LHA"], - "aa": ["AA"], - # Buildings - "power": [ - "Workshop A", - "Electric power box", - "Garage small A", - "Farm B", - "Repair workshop", - "Garage B", - ], - "ware": ["Warehouse", "Hangar A"], - "fuel": ["Tank", "Tank 2", "Tank 3", "Fuel tank"], - "ammo": [".Ammunition depot", "Hangar B"], - "farp": [ - "FARP Tent", - "FARP Ammo Dump Coating", - "FARP Fuel Depot", - "FARP Command Post", - "FARP CP Blindage", - ], - "fob": ["Bunker 2", "Bunker 1", "Garage small B", ".Command Center", "Barracks 2"], - "factory": ["Tech combine", "Tech hangar A"], - "comms": ["TV tower", "Comms tower M"], - "oil": ["Oil platform"], - "derrick": ["Oil derrick", "Pump station", "Subsidiary structure 2"], - "ww2bunker": [ - "Siegfried Line", - "Fire Control Bunker", - "SK_C_28_naval_gun", - "Concertina Wire", - "Czech hedgehogs 1", - ], - "village": ["Small house 1B", "Small House 1A", "Small warehouse 1"], - "allycamp": [], + "fuel": "Fuel depot", + "missile": "Missile site", + "oil": "Oil platform", + "power": "Power plant", + "ship": "Ship", + "village": "Village", + "ware": "Warehouse", + "ww2bunker": "Bunker", } @@ -104,7 +57,6 @@ class TheaterGroundObject(MissionTarget): heading: int, control_point: ControlPoint, dcs_identifier: str, - airbase_group: bool, sea_object: bool, ) -> None: super().__init__(name, position) @@ -113,7 +65,6 @@ class TheaterGroundObject(MissionTarget): self.heading = heading self.control_point = control_point self.dcs_identifier = dcs_identifier - self.airbase_group = airbase_group self.sea_object = sea_object self.groups: List[Group] = [] @@ -128,6 +79,17 @@ class TheaterGroundObject(MissionTarget): """ return list(itertools.chain.from_iterable([g.units for g in self.groups])) + @property + def dead_units(self) -> List[Unit]: + """ + :return: all the dead units at this location + """ + return list( + itertools.chain.from_iterable( + [getattr(g, "units_losts", []) for g in self.groups] + ) + ) + @property def group_name(self) -> str: """The name of the unit group.""" @@ -178,12 +140,11 @@ class TheaterGroundObject(MissionTarget): return False @property - def has_radar(self) -> bool: - """Returns True if the ground object contains a unit with radar.""" + def has_live_radar_sam(self) -> bool: + """Returns True if the ground object contains a unit with working radar SAM.""" for group in self.groups: - for unit in group.units: - if db.unit_type_from_name(unit.type) in UNITS_WITH_RADAR: - return True + if self.threat_range(group, radar_only=True): + return True return False def _max_range_of_type(self, group: Group, range_type: str) -> Distance: @@ -204,19 +165,46 @@ class TheaterGroundObject(MissionTarget): max_range = max(max_range, meters(unit_range)) return max_range + def max_detection_range(self) -> Distance: + return max(self.detection_range(g) for g in self.groups) + def detection_range(self, group: Group) -> Distance: return self._max_range_of_type(group, "detection_range") - def threat_range(self, group: Group) -> Distance: - if not self.detection_range(group): - # For simple SAMs like shilkas, the unit has both a threat and - # detection range. For complex sites like SA-2s, the launcher has a - # threat range and the search/track radars have detection ranges. If - # the site has no detection range it has no radars and can't fire, - # so it's not actually a threat even if it still has launchers. - return meters(0) + 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: return self._max_range_of_type(group, "threat_range") + @property + def is_factory(self) -> bool: + return self.category == "factory" + + @property + def is_control_point(self) -> bool: + """True if this TGO is the group for the control point itself (CVs and FOBs).""" + return False + + @property + def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + return self.units + + @property + def mark_locations(self) -> Iterator[Point]: + yield self.position + + def clear(self) -> None: + self.groups = [] + + @property + def capturable(self) -> bool: + raise NotImplementedError + + @property + def purchasable(self) -> bool: + raise NotImplementedError + class BuildingGroundObject(TheaterGroundObject): def __init__( @@ -229,7 +217,7 @@ class BuildingGroundObject(TheaterGroundObject): heading: int, control_point: ControlPoint, dcs_identifier: str, - airbase_group=False, + is_fob_structure=False, ) -> None: super().__init__( name=name, @@ -239,9 +227,9 @@ class BuildingGroundObject(TheaterGroundObject): heading=heading, control_point=control_point, dcs_identifier=dcs_identifier, - airbase_group=airbase_group, sea_object=False, ) + self.is_fob_structure = is_fob_structure self.object_id = object_id # Other TGOs track deadness based on the number of alive units, but # buildings don't have groups assigned to the TGO. @@ -265,6 +253,90 @@ class BuildingGroundObject(TheaterGroundObject): def kill(self) -> None: self._dead = True + def iter_building_group(self) -> Iterator[TheaterGroundObject]: + for tgo in self.control_point.ground_objects: + if tgo.obj_name == self.obj_name and not tgo.is_dead: + yield tgo + + @property + def strike_targets(self) -> List[Union[MissionTarget, Unit]]: + return list(self.iter_building_group()) + + @property + def mark_locations(self) -> Iterator[Point]: + for building in self.iter_building_group(): + yield building.position + + @property + def is_control_point(self) -> bool: + return self.is_fob_structure + + @property + def capturable(self) -> bool: + return True + + @property + def purchasable(self) -> bool: + return False + + +class SceneryGroundObject(BuildingGroundObject): + def __init__( + self, + name: str, + category: str, + group_id: int, + object_id: int, + position: Point, + control_point: ControlPoint, + dcs_identifier: str, + zone: TriggerZone, + ) -> None: + super().__init__( + name=name, + category=category, + group_id=group_id, + object_id=object_id, + position=position, + heading=0, + control_point=control_point, + dcs_identifier=dcs_identifier, + is_fob_structure=False, + ) + self.zone = zone + try: + # In the default TriggerZone using "assign as..." in the DCS Mission Editor, + # property three has the scenery's object ID as its value. + self.map_object_id = self.zone.properties[3]["value"] + except (IndexError, KeyError): + logging.exception( + "Invalid TriggerZone for Scenery definition. The third property must " + "be the map object ID." + ) + raise + + +class FactoryGroundObject(BuildingGroundObject): + def __init__( + self, + name: str, + group_id: int, + position: Point, + heading: int, + control_point: ControlPoint, + ) -> None: + super().__init__( + name=name, + category="factory", + group_id=group_id, + object_id=0, + position=position, + heading=heading, + control_point=control_point, + dcs_identifier="Workshop A", + is_fob_structure=False, + ) + class NavalGroundObject(TheaterGroundObject): def mission_types(self, for_player: bool) -> Iterator[FlightType]: @@ -278,9 +350,19 @@ class NavalGroundObject(TheaterGroundObject): def might_have_aa(self) -> bool: return True + @property + def capturable(self) -> bool: + return False + + @property + def purchasable(self) -> bool: + return False + class GenericCarrierGroundObject(NavalGroundObject): - pass + @property + def is_control_point(self) -> bool: + return True # TODO: Why is this both a CP and a TGO? @@ -294,7 +376,6 @@ class CarrierGroundObject(GenericCarrierGroundObject): heading=0, control_point=control_point, dcs_identifier="CARRIER", - airbase_group=True, sea_object=True, ) @@ -316,7 +397,6 @@ class LhaGroundObject(GenericCarrierGroundObject): heading=0, control_point=control_point, dcs_identifier="LHA", - airbase_group=True, sea_object=True, ) @@ -333,16 +413,23 @@ class MissileSiteGroundObject(TheaterGroundObject): ) -> None: super().__init__( name=name, - category="aa", + category="missile", group_id=group_id, position=position, heading=0, control_point=control_point, dcs_identifier="AA", - airbase_group=False, sea_object=False, ) + @property + def capturable(self) -> bool: + return False + + @property + def purchasable(self) -> bool: + return False + class CoastalSiteGroundObject(TheaterGroundObject): def __init__( @@ -355,32 +442,34 @@ class CoastalSiteGroundObject(TheaterGroundObject): ) -> None: super().__init__( name=name, - category="aa", + category="coastal", group_id=group_id, position=position, heading=heading, control_point=control_point, dcs_identifier="AA", - airbase_group=False, sea_object=False, ) + @property + def capturable(self) -> bool: + return False -class BaseDefenseGroundObject(TheaterGroundObject): - """Base type for all base defenses.""" + @property + def purchasable(self) -> bool: + 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. -class SamGroundObject(BaseDefenseGroundObject): +class SamGroundObject(TheaterGroundObject): def __init__( self, name: str, group_id: int, position: Point, control_point: ControlPoint, - for_airbase: bool, ) -> None: super().__init__( name=name, @@ -390,7 +479,6 @@ class SamGroundObject(BaseDefenseGroundObject): heading=0, control_point=control_point, dcs_identifier="AA", - airbase_group=for_airbase, sea_object=False, ) # Set by the SAM unit generator if the generated group is compatible @@ -411,53 +499,98 @@ class SamGroundObject(BaseDefenseGroundObject): if not self.is_friendly(for_player): yield FlightType.DEAD + yield FlightType.SEAD yield from super().mission_types(for_player) @property def might_have_aa(self) -> bool: return True + def threat_range(self, group: Group, 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 + 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)) + ) + 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_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")) + ) + if radar_only: + return max(max_tel_range, max_telar_range) + else: + return max(max_tel_range, max_telar_range, max_non_radar) -class VehicleGroupGroundObject(BaseDefenseGroundObject): + @property + def capturable(self) -> bool: + return False + + @property + def purchasable(self) -> bool: + return True + + +class VehicleGroupGroundObject(TheaterGroundObject): def __init__( self, name: str, group_id: int, position: Point, control_point: ControlPoint, - for_airbase: bool, ) -> None: super().__init__( name=name, - category="aa", + category="armor", group_id=group_id, position=position, heading=0, control_point=control_point, dcs_identifier="AA", - airbase_group=for_airbase, sea_object=False, ) + @property + def capturable(self) -> bool: + return False -class EwrGroundObject(BaseDefenseGroundObject): + @property + def purchasable(self) -> bool: + return True + + +class EwrGroundObject(TheaterGroundObject): def __init__( self, name: str, group_id: int, position: Point, control_point: ControlPoint, - for_airbase: bool, ) -> None: super().__init__( name=name, - category="EWR", + category="ewr", group_id=group_id, position=position, heading=0, control_point=control_point, dcs_identifier="EWR", - airbase_group=for_airbase, sea_object=False, ) @@ -477,6 +610,14 @@ class EwrGroundObject(BaseDefenseGroundObject): def might_have_aa(self) -> bool: return True + @property + def capturable(self) -> bool: + return False + + @property + def purchasable(self) -> bool: + return True + class ShipGroundObject(NavalGroundObject): def __init__( @@ -484,13 +625,12 @@ class ShipGroundObject(NavalGroundObject): ) -> None: super().__init__( name=name, - category="aa", + category="ship", group_id=group_id, position=position, heading=0, control_point=control_point, dcs_identifier="AA", - airbase_group=False, sea_object=True, ) diff --git a/game/theater/thechannel.py b/game/theater/thechannel.py new file mode 100644 index 00000000..33137bd7 --- /dev/null +++ b/game/theater/thechannel.py @@ -0,0 +1,8 @@ +from game.theater.projections import TransverseMercator + +PARAMETERS = TransverseMercator( + central_meridian=3, + false_easting=99376.00000000288, + false_northing=-5636889.00000001, + scale_factor=0.9996, +) diff --git a/game/theater/transitnetwork.py b/game/theater/transitnetwork.py new file mode 100644 index 00000000..571d447b --- /dev/null +++ b/game/theater/transitnetwork.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import heapq +import math +from collections import defaultdict +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Dict, Iterator, List, Optional, Set, Tuple + +from game.theater import ConflictTheater +from game.theater.controlpoint import ControlPoint + + +class NoPathError(RuntimeError): + def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None: + super().__init__(f"Could not reconstruct path to {destination} from {origin}") + + +@dataclass(frozen=True, order=True) +class FrontierNode: + cost: float + point: ControlPoint = field(compare=False) + + +class Frontier: + def __init__(self) -> None: + self.nodes: List[FrontierNode] = [] + + def push(self, poly: ControlPoint, cost: float) -> None: + heapq.heappush(self.nodes, FrontierNode(cost, poly)) + + def pop(self) -> Optional[FrontierNode]: + try: + return heapq.heappop(self.nodes) + except IndexError: + return None + + def __bool__(self) -> bool: + return bool(self.nodes) + + +class TransitConnection(Enum): + Road = auto() + Shipping = auto() + Airlift = auto() + + +class TransitNetwork: + def __init__(self) -> None: + self.nodes: Dict[ + ControlPoint, Dict[ControlPoint, TransitConnection] + ] = defaultdict(dict) + + def has_destinations(self, control_point: ControlPoint) -> bool: + return bool(self.nodes[control_point]) + + def has_link(self, a: ControlPoint, b: ControlPoint) -> bool: + return b in self.nodes[a] + + def link_type(self, a: ControlPoint, b: ControlPoint) -> TransitConnection: + return self.nodes[a][b] + + def link_with( + self, a: ControlPoint, b: ControlPoint, link_type: TransitConnection + ) -> None: + self.nodes[a][b] = link_type + self.nodes[b][a] = link_type + + def link_road(self, a: ControlPoint, b: ControlPoint) -> None: + self.link_with(a, b, TransitConnection.Road) + + def link_shipping(self, a: ControlPoint, b: ControlPoint) -> None: + self.link_with(a, b, TransitConnection.Shipping) + + def link_airport(self, a: ControlPoint, b: ControlPoint) -> None: + self.link_with(a, b, TransitConnection.Airlift) + + def connections_from(self, control_point: ControlPoint) -> Iterator[ControlPoint]: + yield from self.nodes[control_point] + + def cost(self, a: ControlPoint, b: ControlPoint) -> float: + return { + TransitConnection.Road: 1, + TransitConnection.Shipping: 3, + # Set arbitrarily high so that other methods are preferred, but still scaled + # by distance so that when we do need it we still pick the closest airfield. + # The units of distance are meters so there's no risk of these + TransitConnection.Airlift: a.position.distance_to_point(b.position), + }[self.link_type(a, b)] + + def has_path_between( + self, + origin: ControlPoint, + destination: ControlPoint, + seen: Optional[set[ControlPoint]] = None, + ) -> bool: + if seen is None: + seen = set() + seen.add(origin) + for connection in self.connections_from(origin): + if connection in seen: + continue + if connection == destination: + return True + if self.has_path_between(connection, destination, seen): + return True + return False + + def shortest_path_between( + self, origin: ControlPoint, destination: ControlPoint + ) -> list[ControlPoint]: + return self.shortest_path_with_cost(origin, destination)[0] + + def shortest_path_with_cost( + self, origin: ControlPoint, destination: ControlPoint + ) -> Tuple[List[ControlPoint], float]: + if origin not in self.nodes: + raise ValueError(f"{origin} is not in the transit network.") + if destination not in self.nodes: + raise ValueError(f"{destination} is not in the transit network.") + + frontier = Frontier() + frontier.push(origin, 0) + + came_from: Dict[ControlPoint, Optional[ControlPoint]] = {origin: None} + + best_known: Dict[ControlPoint, float] = defaultdict(lambda: math.inf) + best_known[origin] = 0.0 + + while (node := frontier.pop()) is not None: + cost = node.cost + current = node.point + if cost > best_known[current]: + continue + + for neighbor in self.connections_from(current): + new_cost = cost + self.cost(node.point, neighbor) + if new_cost < best_known[neighbor]: + best_known[neighbor] = new_cost + frontier.push(neighbor, new_cost) + came_from[neighbor] = current + + # Reconstruct and reverse the path. + current = destination + path: List[ControlPoint] = [] + while current != origin: + path.append(current) + previous = came_from.get(current) + if previous is None: + raise NoPathError(origin, destination) + current = previous + path.reverse() + return path, best_known[destination] + + +class TransitNetworkBuilder: + def __init__(self, theater: ConflictTheater, for_player: bool) -> None: + self.control_points = list(theater.control_points_for(for_player)) + self.network = TransitNetwork() + self.airports: Set[ControlPoint] = { + cp + for cp in self.control_points + if cp.is_friendly(for_player) and cp.runway_is_operational() + } + + def build(self) -> TransitNetwork: + seen = set() + for control_point in self.control_points: + if control_point not in seen: + seen.add(control_point) + self.add_transit_links(control_point) + return self.network + + def add_transit_links(self, control_point: ControlPoint) -> None: + # Prefer road connections. + for road_connection in control_point.connected_points: + if road_connection.is_friendly_to(control_point): + self.network.link_road(control_point, road_connection) + + # Use sea connections if there's no road or rail connection. + for sea_connection in control_point.shipping_lanes: + if self.network.has_link(control_point, sea_connection): + continue + if sea_connection.is_friendly_to(control_point): + self.network.link_shipping(control_point, sea_connection) + + # And use airports as a last resort. + if control_point in self.airports: + for airport in self.airports: + if control_point == airport: + continue + if self.network.has_link(control_point, airport): + continue + if not airport.is_friendly_to(control_point): + continue + self.network.link_airport(control_point, airport) diff --git a/game/threatzones.py b/game/threatzones.py index e4ad0c39..4d29c6c3 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 +from typing import Optional, TYPE_CHECKING, Union, Iterable from dcs.mapping import Point as DcsPoint from shapely.geometry import ( @@ -13,11 +13,10 @@ from shapely.geometry import ( from shapely.geometry.base import BaseGeometry from shapely.ops import nearest_points, unary_union -from game.theater import ControlPoint +from game.theater import ControlPoint, MissionTarget from game.utils import Distance, meters, nautical_miles -from gen import Conflict from gen.flights.closestairfields import ObjectiveDistanceCache -from gen.flights.flight import Flight +from gen.flights.flight import Flight, FlightWaypoint if TYPE_CHECKING: from game import Game @@ -27,9 +26,12 @@ ThreatPoly = Union[MultiPolygon, Polygon] class ThreatZones: - def __init__(self, airbases: ThreatPoly, air_defenses: ThreatPoly) -> None: + def __init__( + self, airbases: ThreatPoly, air_defenses: ThreatPoly, radar_sam_threats + ) -> None: self.airbases = airbases self.air_defenses = air_defenses + self.radar_sam_threats = radar_sam_threats self.all = unary_union([airbases, air_defenses]) def closest_boundary(self, point: DcsPoint) -> DcsPoint: @@ -38,6 +40,10 @@ class ThreatZones: ) return DcsPoint(boundary.x, boundary.y) + def distance_to_threat(self, point: DcsPoint) -> Distance: + boundary = self.closest_boundary(point) + return meters(boundary.distance_to_point(point)) + @singledispatchmethod def threatened(self, position) -> bool: raise NotImplementedError @@ -69,6 +75,13 @@ class ThreatZones: LineString((self.dcs_to_shapely_point(p.position) for p in flight.points)) ) + def waypoints_threatened_by_aircraft( + self, waypoints: Iterable[FlightWaypoint] + ) -> bool: + return self.threatened_by_aircraft( + LineString((self.dcs_to_shapely_point(p.position) for p in waypoints)) + ) + @singledispatchmethod def threatened_by_air_defense(self, target) -> bool: raise NotImplementedError @@ -83,12 +96,39 @@ class ThreatZones: LineString((self.dcs_to_shapely_point(p.position) for p in flight.points)) ) + @threatened_by_air_defense.register + def _threatened_by_air_defense_mission_target(self, target: MissionTarget) -> bool: + return self.threatened_by_air_defense( + self.dcs_to_shapely_point(target.position) + ) + + @singledispatchmethod + def threatened_by_radar_sam(self, target) -> bool: + raise NotImplementedError + + @threatened_by_radar_sam.register + def _threatened_by_radar_sam_geom(self, position: BaseGeometry) -> bool: + return self.radar_sam_threats.intersects(position) + + @threatened_by_radar_sam.register + def _threatened_by_radar_sam_flight(self, flight: Flight) -> bool: + return self.threatened_by_radar_sam( + LineString((self.dcs_to_shapely_point(p.position) for p in flight.points)) + ) + + def waypoints_threatened_by_radar_sam( + self, waypoints: Iterable[FlightWaypoint] + ) -> bool: + return self.threatened_by_radar_sam( + LineString((self.dcs_to_shapely_point(p.position) for p in waypoints)) + ) + @classmethod def closest_enemy_airbase( cls, location: ControlPoint, max_distance: Distance ) -> Optional[ControlPoint]: airfields = ObjectiveDistanceCache.get_closest_airfields(location) - for airfield in airfields.airfields_within(max_distance): + for airfield in airfields.all_airfields_within(max_distance): if airfield.captured != location.captured: return airfield return None @@ -134,6 +174,7 @@ class ThreatZones: """ air_threats = [] air_defenses = [] + radar_sam_threats = [] for control_point in game.theater.controlpoints: if control_point.captured != player: continue @@ -151,9 +192,16 @@ class ThreatZones: 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( - airbases=unary_union(air_threats), air_defenses=unary_union(air_defenses) + airbases=unary_union(air_threats), + air_defenses=unary_union(air_defenses), + radar_sam_threats=unary_union(radar_sam_threats), ) @staticmethod diff --git a/game/transfers.py b/game/transfers.py new file mode 100644 index 00000000..988c1e84 --- /dev/null +++ b/game/transfers.py @@ -0,0 +1,619 @@ +from __future__ import annotations + +import logging +import math +from collections import defaultdict +from dataclasses import dataclass, field +from functools import singledispatchmethod +from typing import ( + Dict, + Generic, + Iterator, + List, + Optional, + TYPE_CHECKING, + Type, + TypeVar, + Sequence, +) + +from dcs.mapping import Point +from dcs.unittype import FlyingType, VehicleType + +from game.procurement import AircraftProcurementRequest +from game.squadrons import Squadron +from game.theater import ControlPoint, MissionTarget +from game.theater.transitnetwork import ( + TransitConnection, + TransitNetwork, +) +from game.utils import meters, nautical_miles +from gen.ato import Package +from gen.flights.ai_flight_planner_db import TRANSPORT_CAPABLE +from gen.flights.closestairfields import ObjectiveDistanceCache +from gen.flights.flight import Flight, FlightType +from gen.flights.flightplan import FlightPlanBuilder +from gen.naming import namegen + +if TYPE_CHECKING: + from game import Game + from game.inventory import ControlPointAircraftInventory + + +class Transport: + def __init__(self, destination: ControlPoint): + self.destination = destination + + def find_escape_route(self) -> Optional[ControlPoint]: + raise NotImplementedError + + def description(self) -> str: + raise NotImplementedError + + +@dataclass +class TransferOrder: + """The base type of all transfer orders. + + A transfer order can transfer multiple units of multiple types. + """ + + #: The location the units are transferring from. + origin: ControlPoint + + #: The location the units are transferring to. + destination: ControlPoint + + #: The current position of the group being transferred. Groups may make multiple + #: stops and can switch transport modes before reaching their destination. + position: ControlPoint = field(init=False) + + #: True if the transfer order belongs to the player. + player: bool = field(init=False) + + #: The units being transferred. + units: Dict[Type[VehicleType], int] + + transport: Optional[Transport] = field(default=None) + + def __post_init__(self) -> None: + self.position = self.origin + self.player = self.origin.is_friendly(to_player=True) + + @property + def description(self) -> str: + if self.transport is None: + return "No transports available" + return self.transport.description() + + def kill_all(self) -> None: + self.units.clear() + + def kill_unit(self, unit_type: Type[VehicleType]) -> None: + if unit_type not in self.units or not self.units[unit_type]: + raise KeyError(f"{self.destination} has no {unit_type} remaining") + self.units[unit_type] -= 1 + + @property + def size(self) -> int: + return sum(c for c in self.units.values()) + + def iter_units(self) -> Iterator[Type[VehicleType]]: + for unit_type, count in self.units.items(): + for _ in range(count): + yield unit_type + + @property + def completed(self) -> bool: + return self.destination == self.position or not self.units + + def disband_at(self, location: ControlPoint) -> None: + logging.info(f"Units halting at {location}.") + location.base.commision_units(self.units) + self.units.clear() + + @property + def next_stop(self) -> ControlPoint: + if self.transport is None: + raise RuntimeError( + "TransferOrder.next_stop called with no transport assigned" + ) + return self.transport.destination + + def proceed(self) -> None: + if self.transport is None: + return + + if not self.destination.is_friendly(self.player): + logging.info(f"Transfer destination {self.destination} was captured.") + if self.position.is_friendly(self.player): + self.disband_at(self.position) + elif (escape_route := self.transport.find_escape_route()) is not None: + self.disband_at(escape_route) + else: + logging.info( + f"No escape route available. Units were surrounded and destroyed " + "during transfer." + ) + self.kill_all() + return + + self.position = self.next_stop + self.transport = None + + if self.completed: + self.disband_at(self.position) + + +class Airlift(Transport): + """A transfer order that moves units by cargo planes and helicopters.""" + + def __init__( + self, transfer: TransferOrder, flight: Flight, next_stop: ControlPoint + ) -> None: + super().__init__(next_stop) + self.transfer = transfer + self.flight = flight + + @property + def units(self) -> Dict[Type[VehicleType], int]: + return self.transfer.units + + @property + def player_owned(self) -> bool: + return self.transfer.player + + def find_escape_route(self) -> Optional[ControlPoint]: + # TODO: Move units to closest base. + return None + + def description(self) -> str: + return ( + f"Being airlifted from {self.transfer.position} to {self.destination} by " + f"{self.flight}" + ) + + +class AirliftPlanner: + #: Maximum range from for any link in the route of takeoff, pickup, dropoff, and RTB + #: for a helicopter to be considered for airlift. Total route length is not + #: considered because the helicopter can refuel at each stop. Cargo planes have no + #: maximum range. + HELO_MAX_RANGE = nautical_miles(100) + + def __init__( + self, game: Game, transfer: TransferOrder, next_stop: ControlPoint + ) -> None: + self.game = game + self.transfer = transfer + self.next_stop = next_stop + self.for_player = transfer.destination.captured + self.package = Package(target=next_stop, auto_asap=True) + + def compatible_with_mission( + self, unit_type: Type[FlyingType], airfield: ControlPoint + ) -> bool: + if not unit_type in TRANSPORT_CAPABLE: + return False + if not self.transfer.origin.can_operate(unit_type): + return False + if not self.next_stop.can_operate(unit_type): + return False + + # Cargo planes have no maximum range. + if not unit_type.helicopter: + return True + + # A helicopter that is transport capable and able to operate at both bases. Need + # to check that no leg of the journey exceeds the maximum range. This doesn't + # account for any routing around threats that might take place, but it's close + # enough. + + home = airfield.position + pickup = self.transfer.position.position + drop_off = self.transfer.position.position + if meters(home.distance_to_point(pickup)) > self.HELO_MAX_RANGE: + return False + + if meters(pickup.distance_to_point(drop_off)) > self.HELO_MAX_RANGE: + return False + + if meters(drop_off.distance_to_point(home)) > self.HELO_MAX_RANGE: + return False + + return True + + def create_package_for_airlift(self) -> None: + distance_cache = ObjectiveDistanceCache.get_closest_airfields( + self.transfer.position + ) + for cp in distance_cache.closest_airfields: + if cp.captured != self.for_player: + continue + + inventory = self.game.aircraft_inventory.for_control_point(cp) + for unit_type, available in inventory.all_aircraft: + squadrons = [ + s + for s in self.game.air_wing_for(self.for_player).squadrons_for( + unit_type + ) + if FlightType.TRANSPORT in s.auto_assignable_mission_types + ] + if not squadrons: + continue + squadron = squadrons[0] + if self.compatible_with_mission(unit_type, cp): + while available and self.transfer.transport is None: + flight_size = self.create_airlift_flight(squadron, inventory) + available -= flight_size + if self.package.flights: + self.game.ato_for(self.for_player).add_package(self.package) + + def create_airlift_flight( + self, squadron: Squadron, inventory: ControlPointAircraftInventory + ) -> int: + available = inventory.available(squadron.aircraft) + capacity_each = 1 if squadron.aircraft.helicopter else 2 + required = math.ceil(self.transfer.size / capacity_each) + flight_size = min(required, available, squadron.aircraft.group_size_max) + capacity = flight_size * capacity_each + + if capacity < self.transfer.size: + transfer = self.game.transfers.split_transfer(self.transfer, capacity) + else: + transfer = self.transfer + + player = inventory.control_point.captured + flight = Flight( + self.package, + self.game.country_for(player), + squadron, + flight_size, + FlightType.TRANSPORT, + self.game.settings.default_start_type, + departure=inventory.control_point, + arrival=inventory.control_point, + divert=None, + cargo=transfer, + ) + + transport = Airlift(transfer, flight, self.next_stop) + transfer.transport = transport + + self.package.add_flight(flight) + planner = FlightPlanBuilder(self.game, self.package, self.for_player) + planner.populate_flight_plan(flight) + self.game.aircraft_inventory.claim_for_flight(flight) + return flight_size + + +class MultiGroupTransport(MissionTarget, Transport): + def __init__( + self, name: str, origin: ControlPoint, destination: ControlPoint + ) -> None: + MissionTarget.__init__(self, name, origin.position) + Transport.__init__(self, destination) + self.origin = origin + self.transfers: List[TransferOrder] = [] + + def is_friendly(self, to_player: bool) -> bool: + return self.origin.captured + + def add_units(self, transfer: TransferOrder) -> None: + self.transfers.append(transfer) + transfer.transport = self + + def remove_units(self, transfer: TransferOrder) -> None: + transfer.transport = None + self.transfers.remove(transfer) + + def kill_unit(self, unit_type: Type[VehicleType]) -> None: + for transfer in self.transfers: + try: + transfer.kill_unit(unit_type) + return + except KeyError: + pass + raise KeyError + + def kill_all(self) -> None: + for transfer in self.transfers: + transfer.kill_all() + + def disband(self) -> None: + for transfer in list(self.transfers): + self.remove_units(transfer) + self.transfers.clear() + + @property + def size(self) -> int: + return sum(sum(t.units.values()) for t in self.transfers) + + @property + def units(self) -> Dict[Type[VehicleType], int]: + units: Dict[Type[VehicleType], int] = defaultdict(int) + for transfer in self.transfers: + for unit_type, count in transfer.units.items(): + units[unit_type] += count + return units + + @property + def player_owned(self) -> bool: + return self.origin.captured + + def find_escape_route(self) -> Optional[ControlPoint]: + raise NotImplementedError + + def description(self) -> str: + raise NotImplementedError + + +class Convoy(MultiGroupTransport): + def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None: + super().__init__(namegen.next_convoy_name(), origin, destination) + + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + if self.is_friendly(for_player): + return + + yield FlightType.BAI + yield from super().mission_types(for_player) + + @property + def route_start(self) -> Point: + return self.origin.convoy_origin_for(self.destination) + + @property + def route_end(self) -> Point: + return self.destination.convoy_origin_for(self.origin) + + def description(self) -> str: + return f"In a convoy from {self.origin} to {self.destination}" + + def find_escape_route(self) -> Optional[ControlPoint]: + return None + + +class CargoShip(MultiGroupTransport): + def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None: + super().__init__(namegen.next_cargo_ship_name(), origin, destination) + + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + if self.is_friendly(for_player): + return + + yield FlightType.ANTISHIP + yield from super().mission_types(for_player) + + @property + def route(self) -> Sequence[Point]: + return self.origin.shipping_lanes[self.destination] + + def description(self) -> str: + return f"On a ship from {self.origin} to {self.destination}" + + def find_escape_route(self) -> Optional[ControlPoint]: + return None + + +TransportType = TypeVar("TransportType", bound=MultiGroupTransport) + + +class TransportMap(Generic[TransportType]): + def __init__(self) -> None: + # Dict of origin -> destination -> transport. + self.transports: Dict[ + ControlPoint, Dict[ControlPoint, TransportType] + ] = defaultdict(dict) + + def create_transport( + self, origin: ControlPoint, destination: ControlPoint + ) -> TransportType: + raise NotImplementedError + + def transport_exists(self, origin: ControlPoint, destination: ControlPoint) -> bool: + return destination in self.transports[origin] + + def find_transport( + self, origin: ControlPoint, destination: ControlPoint + ) -> Optional[TransportType]: + return self.transports[origin].get(destination) + + def find_or_create_transport( + self, origin: ControlPoint, destination: ControlPoint + ) -> TransportType: + transport = self.find_transport(origin, destination) + if transport is None: + transport = self.create_transport(origin, destination) + self.transports[origin][destination] = transport + return transport + + def departing_from(self, origin: ControlPoint) -> Iterator[TransportType]: + yield from self.transports[origin].values() + + def travelling_to(self, destination: ControlPoint) -> Iterator[TransportType]: + for destination_dict in self.transports.values(): + if destination in destination_dict: + yield destination_dict[destination] + + def disband_transport(self, transport: TransportType) -> None: + transport.disband() + del self.transports[transport.origin][transport.destination] + + def add(self, transfer: TransferOrder, next_stop: ControlPoint) -> None: + self.find_or_create_transport(transfer.position, next_stop).add_units(transfer) + + def remove(self, transport: TransportType, transfer: TransferOrder) -> None: + transport.remove_units(transfer) + if not transport.transfers: + self.disband_transport(transport) + + def disband_all(self) -> None: + for transport in list(self): + self.disband_transport(transport) + + def __iter__(self) -> Iterator[TransportType]: + for destination_dict in self.transports.values(): + yield from destination_dict.values() + + +class ConvoyMap(TransportMap): + def create_transport( + self, origin: ControlPoint, destination: ControlPoint + ) -> Convoy: + return Convoy(origin, destination) + + +class CargoShipMap(TransportMap): + def create_transport( + self, origin: ControlPoint, destination: ControlPoint + ) -> CargoShip: + return CargoShip(origin, destination) + + +class PendingTransfers: + def __init__(self, game: Game) -> None: + self.game = game + self.convoys = ConvoyMap() + self.cargo_ships = CargoShipMap() + self.pending_transfers: List[TransferOrder] = [] + + def __iter__(self) -> Iterator[TransferOrder]: + yield from self.pending_transfers + + @property + def pending_transfer_count(self) -> int: + return len(self.pending_transfers) + + def transfer_at_index(self, index: int) -> TransferOrder: + return self.pending_transfers[index] + + def index_of_transfer(self, transfer: TransferOrder) -> int: + return self.pending_transfers.index(transfer) + + def network_for(self, control_point: ControlPoint) -> TransitNetwork: + return self.game.transit_network_for(control_point.captured) + + def arrange_transport(self, transfer: TransferOrder) -> None: + network = self.network_for(transfer.position) + path = network.shortest_path_between(transfer.position, transfer.destination) + next_stop = path[0] + if network.link_type(transfer.position, next_stop) == TransitConnection.Road: + self.convoys.add(transfer, next_stop) + elif ( + network.link_type(transfer.position, next_stop) + == TransitConnection.Shipping + ): + self.cargo_ships.add(transfer, next_stop) + else: + AirliftPlanner(self.game, transfer, next_stop).create_package_for_airlift() + + def new_transfer(self, transfer: TransferOrder) -> None: + transfer.origin.base.commit_losses(transfer.units) + self.pending_transfers.append(transfer) + self.arrange_transport(transfer) + + def split_transfer(self, transfer: TransferOrder, size: int) -> TransferOrder: + """Creates a smaller transfer that is a subset of the original.""" + if transfer.size <= size: + raise ValueError + + units = {} + for unit_type, remaining in transfer.units.items(): + take = min(remaining, size) + size -= take + transfer.units[unit_type] -= take + units[unit_type] = take + if not size: + break + new_transfer = TransferOrder(transfer.origin, transfer.destination, units) + self.pending_transfers.append(new_transfer) + return new_transfer + + @singledispatchmethod + def cancel_transport(self, transport, transfer: TransferOrder) -> None: + pass + + @cancel_transport.register + def _cancel_transport_air( + self, transport: Airlift, _transfer: TransferOrder + ) -> None: + 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.aircraft_inventory.return_from_flight(flight) + flight.clear_roster() + + @cancel_transport.register + def _cancel_transport_convoy( + self, transport: Convoy, transfer: TransferOrder + ) -> None: + self.convoys.remove(transport, transfer) + + @cancel_transport.register + def _cancel_transport_cargo_ship( + self, transport: CargoShip, transfer: TransferOrder + ) -> None: + self.cargo_ships.remove(transport, transfer) + + def cancel_transfer(self, transfer: TransferOrder) -> None: + if transfer.transport is not None: + self.cancel_transport(transfer.transport, transfer) + self.pending_transfers.remove(transfer) + transfer.origin.base.commision_units(transfer.units) + + def perform_transfers(self) -> None: + incomplete = [] + for transfer in self.pending_transfers: + transfer.proceed() + if not transfer.completed: + incomplete.append(transfer) + self.pending_transfers = incomplete + self.convoys.disband_all() + self.cargo_ships.disband_all() + + def plan_transports(self) -> None: + for transfer in self.pending_transfers: + if transfer.transport is None: + self.arrange_transport(transfer) + + def order_airlift_assets(self) -> None: + for control_point in self.game.theater.controlpoints: + self.order_airlift_assets_at(control_point) + + @staticmethod + def desired_airlift_capacity(control_point: ControlPoint) -> int: + return 4 if control_point.has_factory else 0 + + def current_airlift_capacity(self, control_point: ControlPoint) -> int: + inventory = self.game.aircraft_inventory.for_control_point(control_point) + squadrons = self.game.air_wing_for(control_point.captured).squadrons_for_task( + FlightType.TRANSPORT + ) + unit_types = {s.aircraft for s in squadrons}.intersection(TRANSPORT_CAPABLE) + return sum( + count + for unit_type, count in inventory.all_aircraft + if unit_type in unit_types + ) + + def order_airlift_assets_at(self, control_point: ControlPoint) -> None: + gap = self.desired_airlift_capacity( + control_point + ) - self.current_airlift_capacity(control_point) + + if gap <= 0: + return + + if gap % 2: + # Always buy in pairs since we're not trying to fill odd squadrons. Purely + # aesthetic. + gap += 1 + + self.game.procurement_requests_for(player=control_point.captured).append( + AircraftProcurementRequest( + control_point, nautical_miles(200), FlightType.TRANSPORT, gap + ) + ) diff --git a/game/unitdelivery.py b/game/unitdelivery.py new file mode 100644 index 00000000..752d4719 --- /dev/null +++ b/game/unitdelivery.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from dataclasses import dataclass +from typing import Dict, Optional, TYPE_CHECKING, Type + +from dcs.unittype import UnitType, VehicleType + +from game.theater import ControlPoint +from .db import PRICES +from .theater.transitnetwork import ( + NoPathError, + TransitNetwork, +) +from .transfers import TransferOrder + +if TYPE_CHECKING: + from .game import Game + + +@dataclass(frozen=True) +class GroundUnitSource: + control_point: ControlPoint + + +class PendingUnitDeliveries: + def __init__(self, destination: ControlPoint) -> None: + self.destination = destination + + # Maps unit type to order quantity. + self.units: Dict[Type[UnitType], int] = defaultdict(int) + + def __str__(self) -> str: + return f"Pending delivery to {self.destination}" + + def order(self, units: Dict[Type[UnitType], int]) -> None: + for k, v in units.items(): + self.units[k] += v + + def sell(self, units: Dict[Type[UnitType], int]) -> None: + for k, v in units.items(): + self.units[k] -= v + + def refund_all(self, game: Game) -> None: + self.refund(game, self.units) + self.units = defaultdict(int) + + def refund(self, game: Game, units: Dict[Type[UnitType], int]) -> None: + for unit_type, count in units.items(): + try: + price = PRICES[unit_type] + except KeyError: + logging.error(f"Could not refund {unit_type.id}, price unknown") + continue + + logging.info(f"Refunding {count} {unit_type.id} at {self.destination.name}") + game.adjust_budget(price * count, player=self.destination.captured) + + def pending_orders(self, unit_type: Type[UnitType]) -> int: + pending_units = self.units.get(unit_type) + if pending_units is None: + pending_units = 0 + return pending_units + + def available_next_turn(self, unit_type: Type[UnitType]) -> int: + current_units = self.destination.base.total_units_of_type(unit_type) + return self.pending_orders(unit_type) + current_units + + def process(self, game: Game) -> None: + 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_all(game) + return + + bought_units: Dict[Type[UnitType], int] = {} + units_needing_transfer: Dict[Type[VehicleType], int] = {} + sold_units: Dict[Type[UnitType], int] = {} + for unit_type, count in self.units.items(): + coalition = "Ally" if self.destination.captured else "Enemy" + name = unit_type.id + + if ( + issubclass(unit_type, VehicleType) + and self.destination != ground_unit_source + ): + source = ground_unit_source + d = units_needing_transfer + else: + source = self.destination + d = bought_units + + if count >= 0: + d[unit_type] = count + game.message( + f"{coalition} reinforcements: {name} x {count} at {source}" + ) + else: + sold_units[unit_type] = -count + game.message(f"{coalition} sold: {name} x {-count} at {source}") + + self.units = defaultdict(int) + self.destination.base.commision_units(bought_units) + self.destination.base.commit_losses(sold_units) + + if units_needing_transfer: + ground_unit_source.base.commision_units(units_needing_transfer) + self.create_transfer(game, ground_unit_source, units_needing_transfer) + + def create_transfer( + self, game: Game, source: ControlPoint, units: Dict[Type[VehicleType], int] + ) -> None: + game.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 + # reaction to turn 0. On turn zero we allow units to be recruited anywhere for + # delivery on turn 1 so that turn 1 always starts with units on the front line. + if game.turn == 1: + return self.destination + + # Fast path if the destination is a valid source. + if self.destination.can_recruit_ground_units(game): + return self.destination + + try: + return self.find_ground_unit_source_in_network( + game.transit_network_for(self.destination.captured), game + ) + except NoPathError: + return None + + def find_ground_unit_source_in_network( + self, network: TransitNetwork, game: Game + ) -> Optional[ControlPoint]: + sources = [] + for control_point in game.theater.control_points_for(self.destination.captured): + if control_point.can_recruit_ground_units( + game + ) and network.has_path_between(self.destination, control_point): + sources.append(control_point) + + if not sources: + return None + + # Fast path to skip the distance calculation if we have only one option. + if len(sources) == 1: + return sources[0] + + closest = sources[0] + _, cost = network.shortest_path_with_cost(self.destination, closest) + for source in sources: + _, new_cost = network.shortest_path_with_cost(self.destination, source) + if new_cost < cost: + closest = source + cost = new_cost + return closest diff --git a/game/unitmap.py b/game/unitmap.py index 149cba40..c1778091 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -1,4 +1,6 @@ """Maps generated units back to their Liberation types.""" +import itertools +import math from dataclasses import dataclass from typing import Dict, Optional, Type @@ -7,11 +9,19 @@ from dcs.unitgroup import FlyingGroup, Group, VehicleGroup from dcs.unittype import VehicleType from game import db +from game.squadrons import Pilot from game.theater import Airfield, ControlPoint, TheaterGroundObject -from game.theater.theatergroundobject import BuildingGroundObject +from game.theater.theatergroundobject import BuildingGroundObject, SceneryGroundObject +from game.transfers import CargoShip, Convoy, TransferOrder from gen.flights.flight import Flight +@dataclass(frozen=True) +class FlyingUnit: + flight: Flight + pilot: Pilot + + @dataclass(frozen=True) class FrontLineUnit: unit_type: Type[VehicleType] @@ -25,6 +35,18 @@ class GroundObjectUnit: unit: Unit +@dataclass(frozen=True) +class ConvoyUnit: + unit_type: Type[VehicleType] + convoy: Convoy + + +@dataclass(frozen=True) +class AirliftUnits: + cargo: tuple[Type[VehicleType], ...] + transfer: TransferOrder + + @dataclass(frozen=True) class Building: ground_object: BuildingGroundObject @@ -32,22 +54,29 @@ class Building: class UnitMap: def __init__(self) -> None: - self.aircraft: Dict[str, Flight] = {} + 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.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: - for unit in group.units: + 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__. name = str(unit.name) if name in self.aircraft: raise RuntimeError(f"Duplicate unit name: {name}") - self.aircraft[name] = flight + if pilot is None: + raise ValueError(f"{name} has no pilot assigned") + self.aircraft[name] = FlyingUnit(flight, pilot) + if flight.cargo is not None: + self.add_airlift_units(group, flight.cargo) - def flight(self, unit_name: str) -> Optional[Flight]: + def flight(self, unit_name: str) -> Optional[FlyingUnit]: return self.aircraft.get(unit_name, None) def add_airfield(self, airfield: Airfield) -> None: @@ -113,6 +142,65 @@ class UnitMap: def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit]: return self.ground_object_units.get(name, None) + def add_convoy_units(self, group: Group, convoy: Convoy) -> None: + for unit in group.units: + # The actual name is a String (the pydcs translatable string), which + # doesn't define __eq__. + name = str(unit.name) + if name in self.convoys: + raise RuntimeError(f"Duplicate convoy unit: {name}") + unit_type = db.unit_type_from_name(unit.type) + if unit_type is None: + raise RuntimeError(f"Unknown unit type: {unit.type}") + if not issubclass(unit_type, VehicleType): + raise RuntimeError( + f"{name} is a {unit_type.__name__}, expected a VehicleType" + ) + self.convoys[name] = ConvoyUnit(unit_type, convoy) + + def convoy_unit(self, name: str) -> Optional[ConvoyUnit]: + return self.convoys.get(name, None) + + def add_cargo_ship(self, group: Group, 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 + # a convoy of ships that logic needs to change. + raise ValueError("Expected cargo ship to be a single unit group.") + unit = group.units[0] + # The actual name is a String (the pydcs translatable string), which + # doesn't define __eq__. + name = str(unit.name) + if name in self.cargo_ships: + raise RuntimeError(f"Duplicate cargo ship: {name}") + self.cargo_ships[name] = ship + + 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: + 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 + # assigned arbitrarily to units in the order of the group. The last unit in + # the group will receive a partial load if there is not enough cargo to fill + # every transport. + base_idx = idx * capacity_each + cargo = tuple( + itertools.islice( + transfer.iter_units(), base_idx, base_idx + capacity_each + ) + ) + # The actual name is a String (the pydcs translatable string), which + # doesn't define __eq__. + name = str(transport.name) + if name in self.airlifts: + raise RuntimeError(f"Duplicate airlift unit: {name}") + self.airlifts[name] = AirliftUnits(cargo, transfer) + + def airlift_unit(self, name: str) -> Optional[AirliftUnits]: + return self.airlifts.get(name, None) + def add_building(self, ground_object: BuildingGroundObject, group: Group) -> None: # The actual name is a String (the pydcs translatable string), which # doesn't define __eq__. @@ -136,5 +224,15 @@ class UnitMap: raise RuntimeError(f"Duplicate TGO unit: {name}") self.buildings[name] = Building(ground_object) + def add_scenery(self, ground_object: SceneryGroundObject) -> None: + name = str(ground_object.map_object_id) + if name in self.buildings: + raise RuntimeError( + f"Duplicate TGO unit: {name}. TriggerZone name: " + f"{ground_object.dcs_identifier}" + ) + + self.buildings[name] = Building(ground_object) + def building_or_fortification(self, name: str) -> Optional[Building]: return self.buildings.get(name, None) diff --git a/game/utils.py b/game/utils.py index 85fa85da..a35a41cd 100644 --- a/game/utils.py +++ b/game/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import math from dataclasses import dataclass from typing import Union @@ -178,3 +179,13 @@ def mach(value: float, altitude: Distance) -> Speed: SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5) + + +def pairwise(iterable): + """ + itertools recipe + s -> (s0,s1), (s1,s2), (s2, s3), ... + """ + a, b = itertools.tee(iterable) + next(b, None) + return zip(a, b) diff --git a/game/version.py b/game/version.py index 4d54004e..1a22c118 100644 --- a/game/version.py +++ b/game/version.py @@ -2,7 +2,7 @@ from pathlib import Path def _build_version_string() -> str: - components = ["2.5"] + components = ["3.0"] build_number_path = Path("resources/buildnumber") if build_number_path.exists(): with build_number_path.open("r") as build_number_file: @@ -16,3 +16,75 @@ def _build_version_string() -> str: #: Current version of Liberation. VERSION = _build_version_string() + +#: The latest version of the campaign format. Increment this version whenever all +#: existing campaigns should be flagged as incompatible in the UI. We will still attempt +#: to load old campaigns, but this provides a warning to the user that the campaign may +#: not work correctly. +#: +#: There is no verification that the campaign author updated their campaign correctly +#: this is just a UI hint. +#: +#: Version history: +#: +#: Version 0 +#: * Unknown compatibility. +#: +#: Version 1 +#: * Compatible with Liberation 2.5. +#: +#: Version 2 +#: * Front line endpoints now define convoy origin/destination waypoints. They should be +#: placed on or near roads. +#: * Factories (Workshop_A) define factory objectives. Only control points with +#: factories will be able to recruit ground units, so they should exist in sufficient +#: number and be protected by IADS. +#: +#: Version 3 +#: * Bulker Handy Winds define shipping lanes. They should be placed in port areas that +#: are navigable by ships and have a route to another port area. DCS ships *will not* +#: avoid driving into islands, so ensure that their waypoints plot a navigable route. +#: +#: Version 4 +#: * TriggerZones define map based building targets. White TriggerZones created by right +#: clicking an object and using "assign as..." define the buildings within an objective. +#: Blue circular TriggerZones created normally must surround groups of one or more +#: white TriggerZones to define an objective. If a white TriggerZone is not surrounded +#: by a blue circular TriggerZone, campaign creation will fail. Blue circular +#: TriggerZones must also have their first property's value field define the type of +#: objective (a valid value for a building TGO category, from `game.db.PRICES`). +#: +#: Version 4.1 +#: * All objective types may now be set as required generation (similar to the required +#: IADS generation). This includes: +#: * SHORADS +#: * Armor groups +#: * Strike targets +#: * Offshore strike targets +#: * Ships +#: * Missile sites +#: * Coastal defenses +#: +#: See the unit lists in MizCampaignLoader in conflicttheater.py for unit types. +#: +#: Version 4.2 +#: * Adds support for AAA objectives. Place with any of the following units (either red +#: or blue): +#: * AAA_8_8cm_Flak_18, +#: * SPAAA_Vulcan_M163, +#: * SPAAA_ZSU_23_4_Shilka_Gun_Dish, +#: +#: Version 5.0 +#: * Ammunition Depots objective locations are now predetermined using the "Ammunition +# Depot" Warehouse object, and through trigger zone based scenery objects. +#: * The number of alive Ammunition Depot objective buildings connected to a control +#: point directly influences how many ground units can be supported on the front +#: line. +#: * The number of supported ground units at any control point is artificially +#: capped at 50, even if the number of alive Ammunition Depot objectives can +#: support more. +#: +#: Version 6.0 +#: * Random objective generation no is longer supported. Fixed objective locations were +#: added in 4.1. +CAMPAIGN_FORMAT_VERSION = (6, 0) diff --git a/gen/aircraft.py b/gen/aircraft.py index 024a49c4..bae04e7d 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -2,10 +2,10 @@ from __future__ import annotations import logging import random -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import timedelta from functools import cached_property -from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union +from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union, Iterable from dcs import helicopters from dcs.action import AITaskPush, ActivateGroup @@ -42,6 +42,7 @@ from dcs.planes import ( from dcs.point import MovingPoint, PointAction from dcs.task import ( AWACS, + AWACSTaskAction, AntishipStrike, AttackGroup, Bombing, @@ -60,26 +61,26 @@ from dcs.task import ( OptReactOnThreat, OptRestrictJettison, OrbitAction, - PinpointStrike, RunwayAttack, - SEAD, StartCommand, Targets, - Task, + Transport, WeaponType, - AWACSTaskAction, - SetFrequencyCommand, + TargetType, ) from dcs.terrain.terrain import Airport, NoParkingSlotError from dcs.triggers import Event, TriggerOnce, TriggerRule +from dcs.unit import Unit, Skill from dcs.unitgroup import FlyingGroup, ShipGroup, StaticGroup from dcs.unittype import FlyingType, UnitType from game import db from game.data.cap_capabilities_db import GUNFIGHTERS -from game.data.weapons import Pylon, Weapon +from game.data.weapons import Pylon +from game.db import GUN_RELIANT_AIRFRAMES from game.factions.faction import Faction from game.settings import Settings +from game.squadrons import Pilot, Squadron from game.theater.controlpoint import ( Airfield, ControlPoint, @@ -87,7 +88,9 @@ from game.theater.controlpoint import ( NavalControlPoint, OffMapSpawn, ) +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 gen.ato import AirTaskingOrder, Package @@ -100,17 +103,17 @@ from gen.flights.flight import ( ) from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio from gen.runways import RunwayData +from .airsupportgen import AirSupport, AwacsInfo +from .callsigns import callsign_for_support_unit from .flights.flightplan import ( + AwacsFlightPlan, CasFlightPlan, LoiterFlightPlan, PatrollingFlightPlan, SweepFlightPlan, - AwacsFlightPlan, ) from .flights.traveltime import GroundSpeed, TotEstimator from .naming import namegen -from .airsupportgen import AirSupport, AwacsInfo -from .callsigns import callsign_for_support_unit if TYPE_CHECKING: from game import Game @@ -662,10 +665,16 @@ AIRCRAFT_DATA: Dict[str, AircraftData] = { channel_allocator=None, channel_namer=SCR522ChannelNamer, ), + "JAS39Gripen": AircraftData( + inter_flight_radio=get_radio("R&S Series 6000"), + intra_flight_radio=get_radio("R&S Series 6000"), + channel_allocator=None, + ), } AIRCRAFT_DATA["A-10C_2"] = AIRCRAFT_DATA["A-10C"] AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"] AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"] +AIRCRAFT_DATA["JAS39Gripen_AG"] = AIRCRAFT_DATA["JAS39Gripen"] class AircraftConflictGenerator: @@ -724,74 +733,90 @@ class AircraftConflictGenerator: return StartType.Cold return StartType.Warm + def skill_level_for( + self, unit: FlyingUnit, pilot: Optional[Pilot], blue: bool + ) -> Skill: + if blue: + base_skill = Skill(self.game.settings.player_skill) + else: + base_skill = Skill(self.game.settings.enemy_skill) + + if pilot is None: + logging.error(f"Cannot determine skill level: {unit.name} has not pilot") + return base_skill + + levels = [ + Skill.Average, + Skill.Good, + Skill.High, + Skill.Excellent, + ] + current_level = levels.index(base_skill) + missions_for_skill_increase = 4 + increase = pilot.record.missions_flown // missions_for_skill_increase + new_level = min(current_level + increase, len(levels) - 1) + return levels[new_level] + + def set_skill(self, unit: FlyingUnit, pilot: Optional[Pilot], blue: bool) -> None: + if pilot is None or not pilot.player: + unit.skill = self.skill_level_for(unit, pilot, blue) + return + + if self.use_client: + unit.set_client() + else: + unit.set_player() + + @staticmethod + def livery_from_db(flight: Flight) -> Optional[str]: + return db.PLANE_LIVERY_OVERRIDES.get(flight.unit_type) + + def livery_from_faction(self, flight: Flight) -> Optional[str]: + faction = self.game.faction_for(player=flight.departure.captured) + if (choices := faction.liveries_overrides.get(flight.unit_type)) is not None: + return random.choice(choices) + return None + + @staticmethod + def livery_from_squadron(flight: Flight) -> Optional[str]: + return flight.squadron.livery + + def livery_for(self, flight: Flight) -> Optional[str]: + if (livery := self.livery_from_squadron(flight)) is not None: + return livery + if (livery := self.livery_from_faction(flight)) is not None: + return livery + if (livery := self.livery_from_db(flight)) is not None: + return livery + return None + + def _setup_livery(self, flight: Flight, group: FlyingGroup) -> None: + livery = self.livery_for(flight) + if livery is None: + return + for unit in group.units: + unit.livery_id = livery + def _setup_group( self, group: FlyingGroup, - for_task: Type[Task], package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData], ) -> None: - did_load_loadout = False unit_type = group.units[0].unit_type - if unit_type in db.PLANE_PAYLOAD_OVERRIDES: - # Clear pylons - for p in group.units: - p.pylons.clear() - - # Now load loadout - if for_task in db.PLANE_PAYLOAD_OVERRIDES[unit_type]: - payload_name = db.PLANE_PAYLOAD_OVERRIDES[unit_type][for_task] - group.load_loadout(payload_name) - if not group.units[0].pylons and for_task == RunwayAttack: - if PinpointStrike in db.PLANE_PAYLOAD_OVERRIDES[unit_type]: - logging.warning( - 'No loadout for "Runway Attack" for the {}, defaulting to Strike loadout'.format( - str(unit_type) - ) - ) - payload_name = db.PLANE_PAYLOAD_OVERRIDES[unit_type][ - PinpointStrike - ] - group.load_loadout(payload_name) - did_load_loadout = True - logging.info( - "Loaded overridden payload for {} - {} for task {}".format( - unit_type, payload_name, for_task - ) - ) - - if not did_load_loadout: - group.load_task_default_loadout(for_task) - - if unit_type in db.PLANE_LIVERY_OVERRIDES: - for unit_instance in group.units: - unit_instance.livery_id = db.PLANE_LIVERY_OVERRIDES[unit_type] - - # Override livery by faction file data - if flight.from_cp.captured: - faction = self.game.player_faction - else: - faction = self.game.enemy_faction - - if unit_type in faction.liveries_overrides: - livery = random.choice(faction.liveries_overrides[unit_type]) - for unit_instance in group.units: - unit_instance.livery_id = livery - - for idx in range(0, min(len(group.units), flight.client_count)): - unit = group.units[idx] - if self.use_client: - unit.set_client() - else: - unit.set_player() + self._setup_payload(flight, group) + self._setup_livery(flight, group) + for unit, pilot in zip(group.units, flight.roster.pilots): + player = pilot is not None and pilot.player + self.set_skill(unit, pilot, blue=flight.departure.captured) # Do not generate player group with late activation. - if group.late_activation: + if player and group.late_activation: group.late_activation = False - # Set up F-14 Client to have pre-stored alignement + # Set up F-14 Client to have pre-stored alignment if unit_type is F_14B: unit.set_property(F_14B.Properties.INSAlignmentStored.id, True) @@ -812,7 +837,7 @@ class AircraftConflictGenerator: self.flights.append( FlightData( package=package, - country=faction.country, + country=self.game.faction_for(player=flight.departure.captured).country, flight_type=flight.flight_type, units=group.units, size=len(group.units), @@ -844,12 +869,13 @@ class AircraftConflictGenerator: self.air_support.awacs.append( AwacsInfo( - dcsGroupName=str(group.name), + group_name=str(group.name), callsign=callsign, freq=channel, depature_location=flight.departure.name, end_time=flight.flight_plan.mission_departure_time, start_time=flight.flight_plan.mission_start_time, + blue=flight.departure.captured, ) ) @@ -975,39 +1001,20 @@ class AircraftConflictGenerator: else: assert False - @staticmethod - def _setup_custom_payload(flight: Flight, group: FlyingGroup) -> None: - if not flight.use_custom_loadout: - return - - logging.info("Custom loadout for flight : " + flight.__repr__()) + def _setup_payload(self, flight: Flight, group: FlyingGroup) -> None: for p in group.units: p.pylons.clear() - for pylon_number, weapon in flight.loadout.items(): + loadout = flight.loadout + if self.game.settings.restrict_weapons_by_date: + loadout = loadout.degrade_for_date(flight.unit_type, self.game.date) + + for pylon_number, weapon in loadout.pylons.items(): if weapon is None: continue pylon = Pylon.for_aircraft(flight.unit_type, pylon_number) pylon.equip(group, weapon) - def _degrade_payload_to_era(self, flight: Flight, group: FlyingGroup) -> None: - loadout = dict(group.units[0].pylons) - for pylon_number, clsid in loadout.items(): - weapon = Weapon.from_clsid(clsid["CLSID"]) - if weapon is None: - logging.error(f"Could not find weapon for clsid {clsid}") - continue - - if not weapon.available_on(self.game.date): - pylon = Pylon.for_aircraft(flight.unit_type, pylon_number) - for fallback in weapon.fallbacks: - if not pylon.can_equip(fallback): - continue - if not fallback.available_on(self.game.date): - continue - pylon.equip(group, fallback) - break - def clear_parking_slots(self) -> None: for cp in self.game.theater.controlpoints: for parking_slot in cp.parking_slots: @@ -1066,7 +1073,7 @@ class AircraftConflictGenerator: flight = Flight( Package(control_point), faction.country, - aircraft, + self.game.air_wing_for(control_point.captured).squadron_for(aircraft), 1, FlightType.BARCAP, "Cold", @@ -1201,12 +1208,23 @@ class AircraftConflictGenerator: raise RuntimeError(f"No reduced fuel case for type {unit_type}") @staticmethod + def flight_always_keeps_gun(flight: Flight) -> bool: + # Never take bullets from players. They're smart enough to know when to use it + # and when to RTB. + if flight.client_count > 0: + return True + + return flight.unit_type in GUN_RELIANT_AIRFRAMES + def configure_behavior( + self, + flight: Flight, group: FlyingGroup, react_on_threat: Optional[OptReactOnThreat.Values] = None, roe: Optional[OptROE.Values] = None, rtb_winchester: Optional[OptRTBOnOutOfAmmo.Values] = None, restrict_jettison: Optional[bool] = None, + mission_uses_gun: bool = True, ) -> None: group.points[0].tasks.clear() if react_on_threat is not None: @@ -1218,6 +1236,17 @@ class AircraftConflictGenerator: if rtb_winchester is not None: group.points[0].tasks.append(OptRTBOnOutOfAmmo(rtb_winchester)) + # Confiscate the bullets of AI missions that do not rely on the gun. There is no + # "all but gun" RTB winchester option, so air to ground missions with mixed + # weapon types will insist on using all of their bullets after running out of + # missiles and bombs. Take away their bullets so they don't strafe a Tor. + # + # Exceptions are made for player flights and for airframes where the gun is + # essential like the A-10 or warbirds. + if not mission_uses_gun and not self.flight_always_keeps_gun(flight): + for unit in group.units: + unit.gun = 0 + group.points[0].tasks.append(OptRTBOnBingoFuel(True)) # Do not restrict afterburner. # 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 @@ -1236,14 +1265,14 @@ class AircraftConflictGenerator: dynamic_runways: Dict[str, RunwayData], ) -> None: group.task = CAP.name - self._setup_group(group, CAP, package, flight, dynamic_runways) + self._setup_group(group, package, flight, dynamic_runways) if flight.unit_type not in GUNFIGHTERS: ammo_type = OptRTBOnOutOfAmmo.Values.AAM else: ammo_type = OptRTBOnOutOfAmmo.Values.Cannon - self.configure_behavior(group, rtb_winchester=ammo_type) + self.configure_behavior(flight, group, rtb_winchester=ammo_type) def configure_sweep( self, @@ -1253,14 +1282,14 @@ class AircraftConflictGenerator: dynamic_runways: Dict[str, RunwayData], ) -> None: group.task = FighterSweep.name - self._setup_group(group, FighterSweep, package, flight, dynamic_runways) + self._setup_group(group, package, flight, dynamic_runways) if flight.unit_type not in GUNFIGHTERS: ammo_type = OptRTBOnOutOfAmmo.Values.AAM else: ammo_type = OptRTBOnOutOfAmmo.Values.Cannon - self.configure_behavior(group, rtb_winchester=ammo_type) + self.configure_behavior(flight, group, rtb_winchester=ammo_type) def configure_cas( self, @@ -1270,8 +1299,9 @@ class AircraftConflictGenerator: dynamic_runways: Dict[str, RunwayData], ) -> None: group.task = CAS.name - self._setup_group(group, CAS, package, flight, dynamic_runways) + self._setup_group(group, package, flight, dynamic_runways) self.configure_behavior( + flight, group, react_on_threat=OptReactOnThreat.Values.EvadeFire, roe=OptROE.Values.OpenFire, @@ -1286,14 +1316,22 @@ class AircraftConflictGenerator: flight: Flight, dynamic_runways: Dict[str, RunwayData], ) -> None: - group.task = SEAD.name - self._setup_group(group, SEAD, package, flight, dynamic_runways) + # Only CAS and SEAD are capable of the Attack Group task. SEAD is arguably more + # appropriate but it has an extremely limited list of capable aircraft, whereas + # CAS has a much wider selection of units. + # + # Note that the only effect that the DCS task type has is in determining which + # waypoint actions the group may perform. + group.task = CAS.name + self._setup_group(group, package, flight, dynamic_runways) self.configure_behavior( + flight, group, react_on_threat=OptReactOnThreat.Values.EvadeFire, roe=OptROE.Values.OpenFire, - rtb_winchester=OptRTBOnOutOfAmmo.Values.ASM, + rtb_winchester=OptRTBOnOutOfAmmo.Values.All, restrict_jettison=True, + mission_uses_gun=False, ) def configure_sead( @@ -1303,14 +1341,21 @@ class AircraftConflictGenerator: flight: Flight, dynamic_runways: Dict[str, RunwayData], ) -> None: - group.task = SEAD.name - self._setup_group(group, SEAD, package, flight, dynamic_runways) + # CAS is able to perform all the same tasks as SEAD using a superset of the + # available aircraft, and F-14s are not able to be SEAD despite having TALDs. + # https://forums.eagle.ru/topic/272112-cannot-assign-f-14-to-sead/ + group.task = CAS.name + self._setup_group(group, package, flight, dynamic_runways) self.configure_behavior( + flight, group, react_on_threat=OptReactOnThreat.Values.EvadeFire, roe=OptROE.Values.OpenFire, + # ASM includes ARMs and TALDs (among other things, but those are the useful + # weapons for SEAD). rtb_winchester=OptRTBOnOutOfAmmo.Values.ASM, restrict_jettison=True, + mission_uses_gun=False, ) def configure_strike( @@ -1321,12 +1366,14 @@ class AircraftConflictGenerator: dynamic_runways: Dict[str, RunwayData], ) -> None: group.task = GroundAttack.name - self._setup_group(group, GroundAttack, package, flight, dynamic_runways) + self._setup_group(group, package, flight, dynamic_runways) self.configure_behavior( + flight, group, react_on_threat=OptReactOnThreat.Values.EvadeFire, roe=OptROE.Values.OpenFire, restrict_jettison=True, + mission_uses_gun=False, ) def configure_anti_ship( @@ -1337,12 +1384,14 @@ class AircraftConflictGenerator: dynamic_runways: Dict[str, RunwayData], ) -> None: group.task = AntishipStrike.name - self._setup_group(group, AntishipStrike, package, flight, dynamic_runways) + self._setup_group(group, package, flight, dynamic_runways) self.configure_behavior( + flight, group, react_on_threat=OptReactOnThreat.Values.EvadeFire, roe=OptROE.Values.OpenFire, restrict_jettison=True, + mission_uses_gun=False, ) def configure_runway_attack( @@ -1353,12 +1402,14 @@ class AircraftConflictGenerator: dynamic_runways: Dict[str, RunwayData], ) -> None: group.task = RunwayAttack.name - self._setup_group(group, RunwayAttack, package, flight, dynamic_runways) + self._setup_group(group, package, flight, dynamic_runways) self.configure_behavior( + flight, group, react_on_threat=OptReactOnThreat.Values.EvadeFire, roe=OptROE.Values.OpenFire, restrict_jettison=True, + mission_uses_gun=False, ) def configure_oca_strike( @@ -1369,8 +1420,9 @@ class AircraftConflictGenerator: dynamic_runways: Dict[str, RunwayData], ) -> None: group.task = CAS.name - self._setup_group(group, CAS, package, flight, dynamic_runways) + self._setup_group(group, package, flight, dynamic_runways) self.configure_behavior( + flight, group, react_on_threat=OptReactOnThreat.Values.EvadeFire, roe=OptROE.Values.OpenFire, @@ -1392,10 +1444,11 @@ class AircraftConflictGenerator: ) return - self._setup_group(group, AWACS, package, flight, dynamic_runways) + self._setup_group(group, package, flight, dynamic_runways) # Awacs task action self.configure_behavior( + flight, group, react_on_threat=OptReactOnThreat.Values.EvadeFire, roe=OptROE.Values.WeaponHold, @@ -1415,14 +1468,54 @@ class AircraftConflictGenerator: # Search Then Engage task, which we have to use instead of the Escort # task for the reasons explained in JoinPointBuilder. group.task = CAP.name - self._setup_group(group, CAP, package, flight, dynamic_runways) + self._setup_group(group, package, flight, dynamic_runways) self.configure_behavior( - group, roe=OptROE.Values.OpenFire, restrict_jettison=True + flight, group, roe=OptROE.Values.OpenFire, restrict_jettison=True + ) + + def configure_sead_escort( + self, + group: FlyingGroup, + package: Package, + flight: Flight, + dynamic_runways: Dict[str, RunwayData], + ) -> None: + # CAS is able to perform all the same tasks as SEAD using a superset of the + # available aircraft, and F-14s are not able to be SEAD despite having TALDs. + # https://forums.eagle.ru/topic/272112-cannot-assign-f-14-to-sead/ + group.task = CAS.name + self._setup_group(group, package, flight, dynamic_runways) + self.configure_behavior( + flight, + group, + roe=OptROE.Values.OpenFire, + # ASM includes ARMs and TALDs (among other things, but those are the useful + # weapons for SEAD). + rtb_winchester=OptRTBOnOutOfAmmo.Values.ASM, + restrict_jettison=True, + mission_uses_gun=False, + ) + + def configure_transport( + self, + group: FlyingGroup, + package: Package, + flight: Flight, + dynamic_runways: Dict[str, RunwayData], + ) -> None: + group.task = Transport.name + self._setup_group(group, package, flight, dynamic_runways) + self.configure_behavior( + flight, + group, + react_on_threat=OptReactOnThreat.Values.EvadeFire, + roe=OptROE.Values.WeaponHold, + restrict_jettison=True, ) def configure_unknown_task(self, group: FlyingGroup, flight: Flight) -> None: logging.error(f"Unhandled flight type: {flight.flight_type}") - self.configure_behavior(group) + self.configure_behavior(flight, group) def setup_flight_group( self, @@ -1448,6 +1541,8 @@ class AircraftConflictGenerator: self.configure_dead(group, package, flight, dynamic_runways) elif flight_type == FlightType.SEAD: self.configure_sead(group, package, flight, dynamic_runways) + elif flight_type == FlightType.SEAD_ESCORT: + self.configure_sead_escort(group, package, flight, dynamic_runways) elif flight_type == FlightType.STRIKE: self.configure_strike(group, package, flight, dynamic_runways) elif flight_type == FlightType.ANTISHIP: @@ -1458,6 +1553,8 @@ class AircraftConflictGenerator: self.configure_runway_attack(group, package, flight, dynamic_runways) elif flight_type == FlightType.OCA_AIRCRAFT: self.configure_oca_strike(group, package, flight, dynamic_runways) + elif flight_type == FlightType.TRANSPORT: + self.configure_transport(group, package, flight, dynamic_runways) else: self.configure_unknown_task(group, flight) @@ -1511,9 +1608,6 @@ class AircraftConflictGenerator: # Set here rather than when the FlightData is created so they waypoints # have their TOTs set. self.flights[-1].waypoints = [takeoff_point] + flight.points - self._setup_custom_payload(flight, group) - if self.game.settings.restrict_weapons_by_date: - self._degrade_payload_to_era(flight, group) def should_delay_flight(self, flight: Flight, start_time: timedelta) -> bool: if start_time.total_seconds() <= 0: @@ -1593,7 +1687,7 @@ class PydcsWaypointBuilder: waypoint = self.group.add_waypoint( Point(self.waypoint.x, self.waypoint.y), self.waypoint.alt.meters, - name=self.mission.string(self.waypoint.name), + name=self.waypoint.name, ) if self.waypoint.flyover: @@ -1625,6 +1719,7 @@ class PydcsWaypointBuilder: mission: Mission, ) -> PydcsWaypointBuilder: builders = { + FlightWaypointType.DROP_OFF: CargoStopBuilder, FlightWaypointType.INGRESS_BAI: BaiIngressBuilder, FlightWaypointType.INGRESS_CAS: CasIngressBuilder, FlightWaypointType.INGRESS_DEAD: DeadIngressBuilder, @@ -1638,6 +1733,7 @@ class PydcsWaypointBuilder: FlightWaypointType.LOITER: HoldPointBuilder, FlightWaypointType.PATROL: RaceTrackEndBuilder, FlightWaypointType.PATROL_TRACK: RaceTrackBuilder, + FlightWaypointType.PICKUP: CargoStopBuilder, } builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder) return builder(waypoint, group, package, flight, mission) @@ -1653,7 +1749,9 @@ class PydcsWaypointBuilder: else: return False - def register_special_waypoints(self, targets) -> None: + def register_special_waypoints( + self, targets: Iterable[Union[MissionTarget, Unit]] + ) -> None: """Create special target waypoints for various aircraft""" for i, t in enumerate(targets): if self.group.units[0].unit_type == JF_17 and i < 4: @@ -1691,25 +1789,33 @@ class BaiIngressBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() - target_group = self.package.target - if isinstance(target_group, TheaterGroundObject): - tgroup = self.mission.find_group(target_group.group_name) - if tgroup is not None: - task = AttackGroup(tgroup.id, weapon_type=WeaponType.Auto) - task.params["attackQtyLimit"] = False - task.params["directionEnabled"] = False - task.params["altitudeEnabled"] = False - task.params["groupAttack"] = True - waypoint.tasks.append(task) - else: - logging.error( - "Could not find group for BAI mission %s", target_group.group_name - ) + # TODO: Add common "UnitGroupTarget" base type. + group_names = [] + target = self.package.target + if isinstance(target, TheaterGroundObject): + for group in target.groups: + group_names.append(group.name) + elif isinstance(target, MultiGroupTransport): + group_names.append(target.name) else: logging.error( "Unexpected target type for BAI mission: %s", - target_group.__class__.__name__, + target.__class__.__name__, ) + return waypoint + + for group_name in group_names: + group = self.mission.find_group(group_name) + if group is None: + logging.error("Could not find group for BAI mission %s", group_name) + continue + + task = AttackGroup(group.id, weapon_type=WeaponType.Auto) + task.params["attackQtyLimit"] = False + task.params["directionEnabled"] = False + task.params["altitudeEnabled"] = False + task.params["groupAttack"] = True + waypoint.tasks.append(task) return waypoint @@ -1746,23 +1852,29 @@ class CasIngressBuilder(PydcsWaypointBuilder): class DeadIngressBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() - - target_group = self.package.target - if isinstance(target_group, TheaterGroundObject): - tgroup = self.mission.find_group(target_group.group_name) - if tgroup is not None: - task = AttackGroup(tgroup.id, weapon_type=WeaponType.Guided) - task.params["expend"] = "All" - task.params["attackQtyLimit"] = False - task.params["directionEnabled"] = False - task.params["altitudeEnabled"] = False - task.params["groupAttack"] = True - waypoint.tasks.append(task) - else: - logging.error( - f"Could not find group for DEAD mission {target_group.group_name}" - ) self.register_special_waypoints(self.waypoint.targets) + + target = self.package.target + if not isinstance(target, TheaterGroundObject): + logging.error( + "Unexpected target type for DEAD mission: %s", + target.__class__.__name__, + ) + return waypoint + + for group in target.groups: + miz_group = self.mission.find_group(group.name) + if miz_group is None: + logging.error(f"Could not find group for DEAD mission {group.name}") + continue + + task = AttackGroup(miz_group.id, weapon_type=WeaponType.Auto) + task.params["expend"] = "All" + task.params["attackQtyLimit"] = False + task.params["directionEnabled"] = False + task.params["altitudeEnabled"] = False + task.params["groupAttack"] = True + waypoint.tasks.append(task) return waypoint @@ -1814,25 +1926,29 @@ class OcaRunwayIngressBuilder(PydcsWaypointBuilder): class SeadIngressBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() - - target_group = self.package.target - if isinstance(target_group, TheaterGroundObject): - tgroup = self.mission.find_group(target_group.group_name) - if tgroup is not None: - waypoint.add_task( - EngageTargetsInZone( - position=tgroup.position, - radius=int(nautical_miles(30).meters), - targets=[ - Targets.All.GroundUnits.AirDefence, - ], - ) - ) - else: - logging.error( - f"Could not find group for DEAD mission {target_group.group_name}" - ) self.register_special_waypoints(self.waypoint.targets) + + target = self.package.target + if not isinstance(target, TheaterGroundObject): + logging.error( + "Unexpected target type for SEAD mission: %s", + target.__class__.__name__, + ) + return waypoint + + for group in target.groups: + miz_group = self.mission.find_group(group.name) + if miz_group is None: + logging.error(f"Could not find group for SEAD mission {group.name}") + continue + + task = AttackGroup(miz_group.id, weapon_type=WeaponType.Guided) + task.params["expend"] = "All" + task.params["attackQtyLimit"] = False + task.params["directionEnabled"] = False + task.params["altitudeEnabled"] = False + task.params["groupAttack"] = True + waypoint.tasks.append(task) return waypoint @@ -1856,12 +1972,11 @@ class StrikeIngressBuilder(PydcsWaypointBuilder): center.y += target.position.y center.x /= len(targets) center.y /= len(targets) - bombing = Bombing(center) + bombing = Bombing(center, weapon_type=WeaponType.Bombs) bombing.params["expend"] = "All" bombing.params["attackQtyLimit"] = False bombing.params["directionEnabled"] = False bombing.params["altitudeEnabled"] = False - bombing.params["weaponType"] = WeaponType.Bombs.value bombing.params["groupAttack"] = True waypoint.tasks.append(bombing) return waypoint @@ -1869,29 +1984,15 @@ class StrikeIngressBuilder(PydcsWaypointBuilder): def build_strike(self) -> MovingPoint: waypoint = super().build() for target in self.waypoint.targets: + bombing = Bombing(target.position, weapon_type=WeaponType.Auto) + # If there is only one target, drop all ordnance in one pass. + if len(self.waypoint.targets) == 1: + bombing.params["expend"] = "All" + bombing.params["groupAttack"] = True + waypoint.tasks.append(bombing) - targets = [target] - # If the target type is a group of units, - # then target each unit in the group with a Bombing task on their position - # (It is not perfect, we should have an engage Group task instead, - # but we don't have the group ref in the model there) - # TODO : for building group, engage all the buildings as well - if isinstance(target, TheaterGroundObject): - if len(target.units) > 0: - targets = target.units - - for t in targets: - bombing = Bombing(t.position) - # If there is only one target, drop all ordnance in one pass - if len(self.waypoint.targets) == 1 and len(targets) == 1: - bombing.params["expend"] = "All" - bombing.params["weaponType"] = WeaponType.Auto.value - bombing.params["groupAttack"] = True - waypoint.tasks.append(bombing) - print(bombing) - - # Register special waypoints - self.register_special_waypoints(targets) + # Register special waypoints + self.register_special_waypoints(self.waypoint.targets) return waypoint @@ -1910,7 +2011,10 @@ class SweepIngressBuilder(PydcsWaypointBuilder): waypoint.tasks.append( EngageTargets( max_distance=int(nautical_miles(50).meters), - targets=[Targets.All.Air.Planes.Fighters], + targets=[ + Targets.All.Air.Planes.Fighters, + Targets.All.Air.Planes.MultiroleFighters, + ], ) ) @@ -1921,11 +2025,23 @@ class JoinPointBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() if self.flight.flight_type == FlightType.ESCORT: - self.configure_escort_tasks(waypoint) + self.configure_escort_tasks( + waypoint, + [ + Targets.All.Air.Planes.Fighters, + Targets.All.Air.Planes.MultiroleFighters, + ], + ) + elif self.flight.flight_type == FlightType.SEAD_ESCORT: + self.configure_escort_tasks( + waypoint, [Targets.All.GroundUnits.AirDefence.AAA.SAMRelated] + ) return waypoint @staticmethod - def configure_escort_tasks(waypoint: MovingPoint) -> None: + def configure_escort_tasks( + waypoint: MovingPoint, target_types: List[Type[TargetType]] + ) -> None: # Ideally we would use the escort mission type and escort task to have # the AI automatically but the AI only escorts AI flights while they are # traveling between waypoints. When an AI flight performs an attack @@ -1951,13 +2067,13 @@ class JoinPointBuilder(PydcsWaypointBuilder): # for the target area that is set to end on a flag flip that occurs when # the strike aircraft finish their attack task. # - # https://forums.eagle.ru/forum/english/digital-combat-simulator/dcs-world-2-5/bugs-and-problems-ai/ai-ad/250183-task-follow-and-escort-temporarily-aborted + # https://forums.eagle.ru/topic/251798-options-for-alternate-ai-escort-behavior waypoint.add_task( ControlledTask( EngageTargets( # TODO: From doctrine. max_distance=int(nautical_miles(30).meters), - targets=[Targets.All.Air.Planes.Fighters], + targets=target_types, ) ) ) @@ -1975,6 +2091,15 @@ class LandingPointBuilder(PydcsWaypointBuilder): return waypoint +class CargoStopBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + waypoint.type = "LandingReFuAr" + waypoint.action = PointAction.LandingReFuAr + waypoint.landing_refuel_rearm_time = 2 # Minutes. + return waypoint + + class RaceTrackBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() diff --git a/gen/airfields.py b/gen/airfields.py index 2903e72d..52ee18ff 100644 --- a/gen/airfields.py +++ b/gen/airfields.py @@ -451,7 +451,7 @@ AIRFIELD_DATA = { "12": ("IMA", MHz(111, 750)), }, ), - "Al Minhad Intl": AirfieldData( + "Al Minhad AFB": AirfieldData( theater="Persian Gulf", icao="OMDM", elevation=190, @@ -620,9 +620,9 @@ AIRFIELD_DATA = { tacan=TacanChannel(78, TacanBand.X), tacan_callsign="BND", vor=("BND", MHz(117, 200)), - atc=AtcData(MHz(4, 250), MHz(39, 401), MHz(118, 100), MHz(251, 0)), + atc=AtcData(MHz(4, 250), MHz(39, 400), MHz(118, 100), MHz(251, 0)), ils={ - "21": ("IBND", MHz(333, 800)), + "21": ("IBND", MHz(109, 900)), }, ), "Jiroft": AirfieldData( @@ -674,7 +674,7 @@ AIRFIELD_DATA = { vor=("DAN", MHz(108, 400)), atc=AtcData(MHz(3, 900), MHz(38, 700), MHz(122, 100), MHz(360, 100)), ils={ - "50": ("IDAN", MHz(109, 300)), + "05": ("IDAN", MHz(109, 300)), "23": ("DANM", MHz(111, 700)), }, ), diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index a0d9f75e..3b39a37b 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -35,23 +35,25 @@ AWACS_ALT = 13000 class AwacsInfo: """AWACS information for the kneeboard.""" - dcsGroupName: str + group_name: str callsign: str freq: RadioFrequency depature_location: Optional[str] start_time: Optional[timedelta] end_time: Optional[timedelta] + blue: bool @dataclass class TankerInfo: """Tanker information for the kneeboard.""" - dcsGroupName: str + group_name: str callsign: str variant: str freq: RadioFrequency tacan: TacanChannel + blue: bool @dataclass @@ -92,9 +94,9 @@ class AirSupportConflictGenerator: def generate(self): player_cp = ( - self.conflict.from_cp - if self.conflict.from_cp.captured - else self.conflict.to_cp + self.conflict.blue_cp + if self.conflict.blue_cp.captured + else self.conflict.red_cp ) fallback_tanker_number = 0 @@ -107,8 +109,8 @@ class AirSupportConflictGenerator: freq = self.radio_registry.alloc_uhf() tacan = self.tacan_registry.alloc_for_band(TacanBand.Y) tanker_heading = ( - self.conflict.to_cp.position.heading_between_point( - self.conflict.from_cp.position + self.conflict.red_cp.position.heading_between_point( + self.conflict.blue_cp.position ) + TANKER_HEADING_OFFSET * i ) @@ -165,7 +167,14 @@ class AirSupportConflictGenerator: tanker_group.points[0].tasks.append(SetImmortalCommand(True)) self.air_support.tankers.append( - TankerInfo(str(tanker_group.name), callsign, variant, freq, tacan) + TankerInfo( + str(tanker_group.name), + callsign, + variant, + freq, + tacan, + blue=True, + ) ) if not self.game.settings.disable_legacy_aewc: @@ -196,12 +205,13 @@ class AirSupportConflictGenerator: self.air_support.awacs.append( AwacsInfo( - dcsGroupName=str(awacs_flight.name), + group_name=str(awacs_flight.name), callsign=callsign_for_support_unit(awacs_flight), freq=freq, depature_location=None, start_time=None, end_time=None, + blue=True, ) ) else: diff --git a/gen/armor.py b/gen/armor.py index 8f075d38..3b9f93ee 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -68,11 +68,12 @@ INFANTRY_GROUP_SIZE = 5 class JtacInfo: """JTAC information.""" - dcsGroupName: str + group_name: str unit_name: str callsign: str region: str code: str + blue: bool # TODO: Radio info? Type? @@ -134,10 +135,10 @@ class GroundConflictGenerator: def generate(self): position = Conflict.frontline_position( - self.conflict.from_cp, self.conflict.to_cp, self.game.theater + self.conflict.front_line, self.game.theater ) frontline_vector = Conflict.frontline_vector( - self.conflict.from_cp, self.conflict.to_cp, self.game.theater + self.conflict.front_line, self.game.theater ) # Create player groups at random position @@ -156,21 +157,21 @@ class GroundConflictGenerator: player_groups, enemy_groups, self.conflict.heading + 90, - self.conflict.from_cp, - self.conflict.to_cp, + self.conflict.blue_cp, + self.conflict.red_cp, ) self.plan_action_for_groups( self.enemy_stance, enemy_groups, player_groups, self.conflict.heading - 90, - self.conflict.to_cp, - self.conflict.from_cp, + self.conflict.red_cp, + self.conflict.blue_cp, ) # Add JTAC if self.game.player_faction.has_jtac: - n = "JTAC" + str(self.conflict.from_cp.id) + str(self.conflict.to_cp.id) + n = "JTAC" + str(self.conflict.blue_cp.id) + str(self.conflict.red_cp.id) code = 1688 - len(self.jtacs) utype = MQ_9_Reaper @@ -191,12 +192,19 @@ class GroundConflictGenerator: OrbitAction(5000, 300, OrbitAction.OrbitPattern.Circle) ) frontline = ( - f"Frontline {self.conflict.from_cp.name}/{self.conflict.to_cp.name}" + f"Frontline {self.conflict.blue_cp.name}/{self.conflict.red_cp.name}" ) # Note: Will need to change if we ever add ground based JTAC. callsign = callsign_for_support_unit(jtac) self.jtacs.append( - JtacInfo(str(jtac.name), n, callsign, frontline, str(code)) + JtacInfo( + str(jtac.name), + n, + callsign, + frontline, + str(code), + blue=True, + ) ) def gen_infantry_group_for_group( @@ -213,9 +221,9 @@ class GroundConflictGenerator: logging.warning("Could not find infantry position") return if side == self.conflict.attackers_country: - cp = self.conflict.from_cp + cp = self.conflict.blue_cp else: - cp = self.conflict.to_cp + cp = self.conflict.red_cp if is_player: faction = self.game.player_name @@ -782,9 +790,9 @@ class GroundConflictGenerator: ) -> VehicleGroup: if side == self.conflict.attackers_country: - cp = self.conflict.from_cp + cp = self.conflict.blue_cp else: - cp = self.conflict.to_cp + cp = self.conflict.red_cp logging.info("armorgen: {} for {}".format(unit, side.id)) group = self.mission.vehicle_group( diff --git a/gen/ato.py b/gen/ato.py index da58430f..d3e991b2 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -8,6 +8,8 @@ example, the package to strike an enemy airfield may contain an escort flight, a SEAD flight, and the strike aircraft themselves. CAP packages may contain only the single CAP flight. """ +from __future__ import annotations + import logging from collections import defaultdict from dataclasses import dataclass, field @@ -65,6 +67,10 @@ class Package: waypoints: Optional[PackageWaypoints] = field(default=None) + @property + def has_players(self) -> bool: + return any(flight.client_count for flight in self.flights) + @property def formation_speed(self) -> Optional[Speed]: """The speed of the package when in formation. @@ -172,6 +178,7 @@ class Package: FlightType.OCA_RUNWAY, FlightType.BAI, FlightType.DEAD, + FlightType.TRANSPORT, FlightType.SEAD, FlightType.TARCAP, FlightType.BARCAP, diff --git a/gen/briefinggen.py b/gen/briefinggen.py index 017c4e4e..87029d7b 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -36,8 +36,8 @@ class CommInfo: class FrontLineInfo: def __init__(self, front_line: FrontLine): self.front_line: FrontLine = front_line - self.player_base: ControlPoint = front_line.control_point_a - self.enemy_base: ControlPoint = front_line.control_point_b + self.player_base: ControlPoint = front_line.blue_cp + self.enemy_base: ControlPoint = front_line.red_cp self.player_zero: bool = self.player_base.base.total_armor == 0 self.enemy_zero: bool = self.enemy_base.base.total_armor == 0 self.advantage: bool = ( @@ -164,7 +164,7 @@ class BriefingGenerator(MissionInfoGenerator): def _generate_frontline_info(self) -> None: """Build FrontLineInfo objects from FrontLine type and append to briefing.""" - for front_line in self.game.theater.conflicts(from_player=True): + for front_line in self.game.theater.conflicts(): self.add_frontline(FrontLineInfo(front_line)) # TODO: This should determine if runway is friendly through a method more robust than the existing string match diff --git a/gen/cargoshipgen.py b/gen/cargoshipgen.py new file mode 100644 index 00000000..e3161a42 --- /dev/null +++ b/gen/cargoshipgen.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import itertools +from typing import TYPE_CHECKING + +from dcs import Mission +from dcs.ships import Bulker_Handy_Wind +from dcs.unitgroup import ShipGroup + +from game.transfers import CargoShip +from game.unitmap import UnitMap +from game.utils import knots + +if TYPE_CHECKING: + from game import Game + + +class CargoShipGenerator: + def __init__(self, mission: Mission, game: Game, unit_map: UnitMap) -> None: + self.mission = mission + self.game = game + self.unit_map = unit_map + self.count = itertools.count() + + def generate(self) -> None: + # Reset the count to make generation deterministic. + for ship in self.game.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 + ) + waypoints = ship.route + group = self.mission.ship_group( + country, + ship.name, + Bulker_Handy_Wind, + position=waypoints[0], + group_size=1, + ) + for waypoint in waypoints[1:]: + # 12 knots is very slow but it's also nearly the max allowed by DCS for this + # type of ship. + group.add_waypoint(waypoint, speed=knots(12).kph) + self.unit_map.add_cargo_ship(group, ship) + return group diff --git a/gen/conflictgen.py b/gen/conflictgen.py index 25e1fed3..d4b145d4 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -17,8 +17,7 @@ class Conflict: def __init__( self, theater: ConflictTheater, - from_cp: ControlPoint, - to_cp: ControlPoint, + front_line: FrontLine, attackers_side: str, defenders_side: str, attackers_country: Country, @@ -33,22 +32,28 @@ class Conflict: self.attackers_country = attackers_country self.defenders_country = defenders_country - self.from_cp = from_cp - self.to_cp = to_cp + self.front_line = front_line self.theater = theater self.position = position self.heading = heading self.size = size + @property + def blue_cp(self) -> ControlPoint: + return self.front_line.blue_cp + + @property + def red_cp(self) -> ControlPoint: + return self.front_line.red_cp + @classmethod def has_frontline_between(cls, from_cp: ControlPoint, to_cp: ControlPoint) -> bool: return from_cp.has_frontline and to_cp.has_frontline @classmethod def frontline_position( - cls, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater + cls, frontline: FrontLine, theater: ConflictTheater ) -> Tuple[Point, int]: - frontline = FrontLine(from_cp, to_cp, theater) attack_heading = frontline.attack_heading position = cls.find_ground_position( frontline.position, @@ -60,12 +65,12 @@ class Conflict: @classmethod def frontline_vector( - cls, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater + cls, front_line: FrontLine, theater: ConflictTheater ) -> Tuple[Point, int, int]: """ Returns a vector for a valid frontline location avoiding exclusion zones. """ - center_position, heading = cls.frontline_position(from_cp, to_cp, theater) + center_position, heading = cls.frontline_position(front_line, theater) left_heading = heading_sum(heading, -90) right_heading = heading_sum(heading, 90) left_position = cls.extend_ground_position( @@ -84,18 +89,16 @@ class Conflict: defender_name: str, attacker: Country, defender: Country, - from_cp: ControlPoint, - to_cp: ControlPoint, + front_line: FrontLine, theater: ConflictTheater, ): - assert cls.has_frontline_between(from_cp, to_cp) - position, heading, distance = cls.frontline_vector(from_cp, to_cp, theater) + assert cls.has_frontline_between(front_line.blue_cp, front_line.red_cp) + position, heading, distance = cls.frontline_vector(front_line, theater) conflict = cls( position=position, heading=heading, theater=theater, - from_cp=from_cp, - to_cp=to_cp, + front_line=front_line, attackers_side=attacker_name, defenders_side=defender_name, attackers_country=attacker, diff --git a/gen/convoygen.py b/gen/convoygen.py new file mode 100644 index 00000000..9c904009 --- /dev/null +++ b/gen/convoygen.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import itertools +from typing import Dict, TYPE_CHECKING, Type + +from dcs import Mission +from dcs.mapping import Point +from dcs.point import PointAction +from dcs.unit import Vehicle +from dcs.unitgroup import VehicleGroup +from dcs.unittype import VehicleType + +from game.transfers import Convoy +from game.unitmap import UnitMap +from game.utils import kph + +if TYPE_CHECKING: + from game import Game + + +class ConvoyGenerator: + def __init__(self, mission: Mission, game: Game, unit_map: UnitMap) -> None: + self.mission = mission + self.game = game + self.unit_map = unit_map + self.count = itertools.count() + + def generate(self) -> None: + # Reset the count to make generation deterministic. + for convoy in self.game.transfers.convoys: + self.generate_convoy(convoy) + + def generate_convoy(self, convoy: Convoy) -> VehicleGroup: + group = self._create_mixed_unit_group( + convoy.name, + convoy.route_start, + convoy.units, + convoy.player_owned, + ) + group.add_waypoint( + convoy.route_end, + speed=kph(40).kph, + move_formation=PointAction.OnRoad, + ) + self.make_drivable(group) + self.unit_map.add_convoy_units(group, convoy) + return group + + def _create_mixed_unit_group( + self, + name: str, + position: Point, + units: Dict[Type[VehicleType], int], + for_player: bool, + ) -> VehicleGroup: + country = self.mission.country( + self.game.player_country if for_player else self.game.enemy_country + ) + + unit_types = list(units.items()) + main_unit_type, main_unit_count = unit_types[0] + + group = self.mission.vehicle_group( + country, + name, + main_unit_type, + position=position, + group_size=main_unit_count, + move_formation=PointAction.OnRoad, + ) + + unit_name_counter = itertools.count(main_unit_count + 1) + # pydcs spreads units out by 20 in the Y axis by default. Pick up where it left + # off. + y = itertools.count(position.y + main_unit_count * 20, 20) + for unit_type, count in unit_types[1:]: + for i in range(count): + v = self.mission.vehicle( + f"{name} Unit #{next(unit_name_counter)}", unit_type + ) + v.position.x = position.x + v.position.y = next(y) + v.heading = 0 + group.add_unit(v) + + return group + + @staticmethod + def make_drivable(group: VehicleGroup) -> None: + for v in group.units: + if isinstance(v, Vehicle): + v.player_can_drive = True diff --git a/gen/fleet/lacombattanteII.py b/gen/fleet/lacombattanteII.py new file mode 100644 index 00000000..9d1ba6e1 --- /dev/null +++ b/gen/fleet/lacombattanteII.py @@ -0,0 +1,12 @@ +from dcs.ships import FAC_La_Combattante_IIa + +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): + super(LaCombattanteIIGroupGenerator, self).__init__( + game, ground_object, faction, FAC_La_Combattante_IIa + ) diff --git a/gen/fleet/ru_dd_group.py b/gen/fleet/ru_dd_group.py index be98274c..bbd7b176 100644 --- a/gen/fleet/ru_dd_group.py +++ b/gen/fleet/ru_dd_group.py @@ -74,7 +74,7 @@ class RussianNavyGroupGenerator(ShipGroupGenerator): if include_cc: # Only include the Moskva for now, the Pyotry Velikiy is an unkillable monster. - # See https://github.com/Khopa/dcs_liberation/issues/567 + # See https://github.com/dcs-liberation/dcs_liberation/issues/567 self.add_unit( Cruiser_1164_Moskva, "CC1", diff --git a/gen/fleet/ship_group_generator.py b/gen/fleet/ship_group_generator.py index 024d6e03..03ab852f 100644 --- a/gen/fleet/ship_group_generator.py +++ b/gen/fleet/ship_group_generator.py @@ -8,6 +8,7 @@ from gen.fleet.dd_group import ( ArleighBurkeGroupGenerator, OliverHazardPerryGroupGenerator, ) +from gen.fleet.lacombattanteII import LaCombattanteIIGroupGenerator from gen.fleet.lha_group import LHAGroupGenerator from gen.fleet.ru_dd_group import ( RussianNavyGroupGenerator, @@ -34,6 +35,7 @@ SHIP_MAP = { "KiloSubGroupGenerator": KiloSubGroupGenerator, "TangoSubGroupGenerator": TangoSubGroupGenerator, "Type54GroupGenerator": Type54GroupGenerator, + "LaCombattanteIIGroupGenerator": LaCombattanteIIGroupGenerator, } diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 0f79e8d9..58260dfd 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -17,12 +17,17 @@ from typing import ( TYPE_CHECKING, Tuple, Type, + TypeVar, + Union, ) from dcs.unittype import FlyingType +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, @@ -39,8 +44,8 @@ from game.theater.theatergroundobject import ( NavalGroundObject, VehicleGroupGroundObject, ) -from game.utils import Distance, nautical_miles -from gen import Conflict +from game.transfers import CargoShip, Convoy +from game.utils import Distance, nautical_miles, meters from gen.ato import Package from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.closestairfields import ( @@ -108,6 +113,8 @@ class ProposedMission: #: 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}" @@ -118,17 +125,19 @@ class AircraftAllocator: 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_aircraft_for_flight( + def find_squadron_for_flight( self, flight: ProposedFlight - ) -> Optional[Tuple[ControlPoint, Type[FlyingType]]]: + ) -> Optional[Tuple[ControlPoint, Squadron]]: """Finds aircraft suitable for the given mission. Searches for aircraft capable of performing the given mission within the @@ -147,28 +156,47 @@ 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_of_type(flight, aircraft_for_task(flight.task)) + return self.find_aircraft_for_task(flight, flight.task) - def find_aircraft_of_type( - self, - flight: ProposedFlight, - types: List[Type[FlyingType]], - ) -> Optional[Tuple[ControlPoint, Type[FlyingType]]]: - airfields_in_range = self.closest_airfields.airfields_within( + 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 ) + + # Prefer using squadrons with pilots first + best_understaffed: Optional[Tuple[ControlPoint, Squadron]] = None 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, available in inventory.all_aircraft: + for aircraft in types: if not airfield.can_operate(aircraft): continue - if aircraft in types and available >= flight.num_aircraft: - inventory.remove_aircraft(aircraft, flight.num_aircraft) - return airfield, aircraft + if inventory.available(aircraft) < flight.num_aircraft: + continue + # Valid location with enough aircraft available. Find a squadron to fit + # the role. + for squadron in self.air_wing.squadrons_for(aircraft): + if task not in squadron.auto_assignable_mission_types: + continue + if len(squadron.available_pilots) >= flight.num_aircraft: + inventory.remove_aircraft(aircraft, flight.num_aircraft) + return airfield, squadron - return None + # A compatible squadron that doesn't have enough pilots. Remember it + # as a fallback in case we find no better choices. + if best_understaffed is None: + best_understaffed = airfield, squadron + + if best_understaffed is not None: + airfield, squadron = best_understaffed + self.global_inventory.for_control_point(airfield).remove_aircraft( + squadron.aircraft, flight.num_aircraft + ) + return best_understaffed class PackageBuilder: @@ -179,16 +207,18 @@ class PackageBuilder: 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) + self.package = Package(location, auto_asap=asap) self.allocator = AircraftAllocator( - closest_airfields, global_inventory, is_player + air_wing, closest_airfields, global_inventory, is_player ) self.global_inventory = global_inventory self.start_type = start_type @@ -201,10 +231,10 @@ class PackageBuilder: caller should return any previously planned flights to the inventory using release_planned_aircraft. """ - assignment = self.allocator.find_aircraft_for_flight(plan) + assignment = self.allocator.find_squadron_for_flight(plan) if assignment is None: return False - airfield, aircraft = assignment + airfield, squadron = assignment if isinstance(airfield, OffMapSpawn): start_type = "In Flight" else: @@ -213,13 +243,13 @@ class PackageBuilder: flight = Flight( self.package, self.package_country, - aircraft, + squadron, plan.num_aircraft, plan.task, start_type, departure=airfield, arrival=airfield, - divert=self.find_divert_field(aircraft, airfield), + divert=self.find_divert_field(squadron.aircraft, airfield), ) self.package.add_flight(flight) return True @@ -228,7 +258,9 @@ class PackageBuilder: self, aircraft: Type[FlyingType], arrival: ControlPoint ) -> Optional[ControlPoint]: divert_limit = nautical_miles(150) - for airfield in self.closest_airfields.airfields_within(divert_limit): + for airfield in self.closest_airfields.operational_airfields_within( + divert_limit + ): if airfield.captured != self.is_player: continue if airfield == arrival: @@ -249,9 +281,13 @@ class PackageBuilder: flights = list(self.package.flights) for flight in flights: self.global_inventory.return_from_flight(flight) + flight.clear_roster() self.package.remove_flight(flight) +MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget) + + class ObjectiveFinder: """Identifies potential objectives for the mission planner.""" @@ -263,41 +299,53 @@ class ObjectiveFinder: self.game = game self.is_player = is_player - def enemy_sams(self) -> Iterator[TheaterGroundObject]: + def enemy_air_defenses(self) -> Iterator[tuple[TheaterGroundObject, Distance]]: """Iterates over all enemy SAM sites.""" - # Control points might have the same ground object several times, for - # some reason. - found_targets: Set[str] = set() + 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: - is_ewr = isinstance(ground_object, EwrGroundObject) - is_sam = isinstance(ground_object, SamGroundObject) - if not is_ewr and not is_sam: - continue - if ground_object.is_dead: continue - if ground_object.name in found_targets: + 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 - if not ground_object.has_radar: - continue + yield ground_object, target_range - # TODO: Yield in order of most threatening. - # Need to sort in order of how close their defensive range comes - # to friendly assets. To do that we need to add effective range - # information to the database. - yield ground_object - found_targets.add(ground_object.name) - - def threatening_sams(self) -> Iterator[MissionTarget]: + def threatening_air_defenses(self) -> Iterator[TheaterGroundObject]: """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). """ - return self._targets_by_range(self.enemy_sams()) + + target_ranges: list[tuple[TheaterGroundObject, 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.""" @@ -339,9 +387,9 @@ class ObjectiveFinder: return self._targets_by_range(self.enemy_ships()) def _targets_by_range( - self, targets: Iterable[MissionTarget] - ) -> Iterator[MissionTarget]: - target_ranges: List[Tuple[MissionTarget, int]] = [] + self, targets: Iterable[MissionTargetType] + ) -> Iterator[MissionTargetType]: + target_ranges: List[Tuple[MissionTargetType, int]] = [] for target in targets: ranges: List[int] = [] for cp in self.friendly_control_points(): @@ -387,7 +435,7 @@ class ObjectiveFinder: is_building = isinstance(ground_object, BuildingGroundObject) is_fob = isinstance(enemy_cp, Fob) - if is_building and is_fob and ground_object.airbase_group: + 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. @@ -408,13 +456,7 @@ class ObjectiveFinder: def front_lines(self) -> Iterator[FrontLine]: """Iterates over all active front lines in the theater.""" - for cp in self.friendly_control_points(): - for connected in cp.connected_points: - if connected.is_friendly(self.is_player): - continue - - if Conflict.has_frontline_between(cp, connected): - yield FrontLine(cp, connected, self.game.theater) + yield from self.game.theater.conflicts() def vulnerable_control_points(self) -> Iterator[ControlPoint]: """Iterates over friendly CPs that are vulnerable to enemy CPs. @@ -427,8 +469,10 @@ class ObjectiveFinder: # Off-map spawn locations don't need protection. continue airfields_in_proximity = self.closest_airfields_to(cp) - airfields_in_threat_range = airfields_in_proximity.airfields_within( - self.AIRFIELD_THREAT_RANGE + 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): @@ -444,37 +488,41 @@ class ObjectiveFinder: 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) -> Optional[ControlPoint]: - """ - Iterates over all friendly control points and find the one farthest away from the frontline - BUT! prefer Cvs. Everybody likes CVs! - """ - from_frontline = 0 - cp = None - first_friendly_cp = None + 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) - for c in self.game.theater.controlpoints: - if c.is_friendly(self.is_player): - if first_friendly_cp is None: - first_friendly_cp = c - if c.is_carrier: - return c - if c.has_active_frontline: - if c.distance_to(self.front_lines().__next__()) > from_frontline: - from_frontline = c.distance_to(self.front_lines().__next__()) - cp = c + 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 no frontlines on the map, return the first friendly cp - if cp is None: - return first_friendly_cp - else: - return cp + if farthest is None: + raise RuntimeError("Found no friendly control points. You probably lost.") + return farthest def enemy_control_points(self) -> Iterator[ControlPoint]: """Iterates over all enemy control points.""" @@ -541,7 +589,25 @@ class CoalitionMissionPlanner: self.objective_finder = ObjectiveFinder(self.game, self.is_player) 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: List[AircraftProcurementRequest] = [] + self.procurement_requests = self.game.procurement_requests_for(self.is_player) + self.faction = self.game.faction_for(self.is_player) + + 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. + """ + all_compatible = aircraft_for_task(mission_type) + for squadron in self.game.air_wing_for(self.is_player).iter_squadrons(): + if ( + squadron.aircraft in all_compatible + and mission_type in squadron.auto_assignable_mission_types + ): + return True + return False def critical_missions(self) -> Iterator[ProposedMission]: """Identifies the most important missions to plan this turn. @@ -554,35 +620,28 @@ class CoalitionMissionPlanner: eliminated this turn. """ - # Find farthest, friendly CP for AEWC - cp = self.objective_finder.farthest_friendly_control_point() - if cp is not None: - yield ProposedMission( - cp, [ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)] - ) + # 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, + ) # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP. for cp in self.objective_finder.vulnerable_control_points(): - # Plan three rounds of CAP to give ~90 minutes coverage. Spacing - # these out appropriately is done in stagger_missions. - yield ProposedMission( - cp, - [ - ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), - ], - ) - yield ProposedMission( - cp, - [ - ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), - ], - ) - yield ProposedMission( - cp, - [ - ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), - ], - ) + # 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(): @@ -618,14 +677,76 @@ class CoalitionMissionPlanner: # 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_sams(): + 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( - sam, + convoy, [ - ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE), + ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE), # TODO: Max escort range. ProposedFlight( - FlightType.ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.AirToAir + 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 ), ], ) @@ -655,7 +776,7 @@ class CoalitionMissionPlanner: FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir ), ProposedFlight( - FlightType.SEAD, 2, self.MAX_OCA_RANGE, EscortType.Sead + FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead ), ], ) @@ -677,7 +798,7 @@ class CoalitionMissionPlanner: FlightType.ESCORT, 2, self.MAX_OCA_RANGE, EscortType.AirToAir ), ProposedFlight( - FlightType.SEAD, 2, self.MAX_OCA_RANGE, EscortType.Sead + FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead ), ] ) @@ -694,20 +815,29 @@ class CoalitionMissionPlanner: FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE, EscortType.AirToAir ), ProposedFlight( - FlightType.SEAD, 2, self.MAX_STRIKE_RANGE, EscortType.Sead + FlightType.SEAD_ESCORT, + 2, + self.MAX_STRIKE_RANGE, + EscortType.Sead, ), ], ) def plan_missions(self) -> None: """Identifies and plans mission for the turn.""" - for proposed_mission in self.propose_missions(): - self.plan_mission(proposed_mission) + 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) - for critical_mission in self.critical_missions(): - self.plan_mission(critical_mission, reserves=True) + 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) - self.stagger_missions() + 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) @@ -762,27 +892,29 @@ class CoalitionMissionPlanner: def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]: threats = defaultdict(bool) for flight in builder.package.flights: - if self.threat_zones.threatened_by_aircraft(flight): + if self.threat_zones.waypoints_threatened_by_aircraft( + flight.flight_plan.escorted_waypoints() + ): threats[EscortType.AirToAir] = True - if self.threat_zones.threatened_by_air_defense(flight): + 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, reserves: bool = False) -> None: + def plan_mission( + self, mission: ProposedMission, tracer: MultiEventTracer, reserves: bool = False + ) -> None: """Allocates aircraft for a proposed mission and adds it to the ATO.""" - - if self.is_player: - package_country = self.game.player_country - else: - package_country = self.game.enemy_country - builder = PackageBuilder( mission.location, self.objective_finder.closest_airfields_to(mission.location), self.game.aircraft_inventory, + self.game.air_wing_for(self.is_player), self.is_player, - package_country, + self.game.country_for(self.is_player), self.game.settings.default_start_type, + mission.asap, ) # Attempt to plan all the main elements of the mission first. Escorts @@ -791,12 +923,20 @@ class CoalitionMissionPlanner: 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 - self.plan_flight(mission, proposed_flight, builder, missing_types, reserves) + with tracer.trace("Flight planning"): + self.plan_flight( + mission, proposed_flight, builder, missing_types, reserves + ) if missing_types: self.scrub_mission_missing_aircraft( @@ -804,6 +944,12 @@ class CoalitionMissionPlanner: ) 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 @@ -814,7 +960,8 @@ class CoalitionMissionPlanner: self.game, builder.package, self.is_player ) for flight in builder.package.flights: - flight_plan_builder.populate_flight_plan(flight) + with tracer.trace("Flight plan population"): + flight_plan_builder.populate_flight_plan(flight) needed_escorts = self.check_needed_escorts(builder) for escort in escorts: @@ -822,7 +969,8 @@ class CoalitionMissionPlanner: # impossible. assert escort.escort_type is not None if needed_escorts[escort.escort_type]: - self.plan_flight(mission, escort, builder, missing_types, reserves) + 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. @@ -842,7 +990,13 @@ class CoalitionMissionPlanner: # Add flight plans for escorts. for flight in package.flights: if not flight.flight_plan.waypoints: - flight_plan_builder.populate_flight_plan(flight) + 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: + package.auto_asap = True + package.set_tot_asap() + self.ato.add_package(package) def stagger_missions(self) -> None: @@ -852,7 +1006,7 @@ class CoalitionMissionPlanner: interval = (latest - earliest) // count for time in range(earliest, latest, interval): error = random.randint(-margin, margin) - yield timedelta(minutes=max(0, time + error)) + yield timedelta(seconds=max(0, time + error)) dca_types = { FlightType.BARCAP, @@ -865,7 +1019,12 @@ class CoalitionMissionPlanner: ] start_time = start_time_generator( - count=len(non_dca_packages), earliest=5, latest=90, margin=5 + 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() @@ -885,6 +1044,8 @@ class CoalitionMissionPlanner: 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 diff --git a/gen/flights/ai_flight_planner_db.py b/gen/flights/ai_flight_planner_db.py index d717bc86..7b982a2a 100644 --- a/gen/flights/ai_flight_planner_db.py +++ b/gen/flights/ai_flight_planner_db.py @@ -5,8 +5,11 @@ from dcs.helicopters import ( AH_1W, AH_64A, AH_64D, + CH_47D, + CH_53E, Ka_50, Mi_24V, + Mi_26, Mi_28N, Mi_8MT, OH_58D, @@ -14,6 +17,7 @@ from dcs.helicopters import ( SA342M, SH_60B, UH_1H, + UH_60A, ) from dcs.planes import ( AJS37, @@ -23,11 +27,14 @@ from dcs.planes import ( A_10C_2, A_20G, A_50, + An_26B, B_17G, B_1B, B_52H, Bf_109K_4, C_101CC, + C_130, + C_17A, E_2C, E_3A, FA_18C_hornet, @@ -43,6 +50,7 @@ from dcs.planes import ( F_4E, F_5E_3, F_86F_Sabre, + IL_76MD, I_16, JF_17, J_11A, @@ -88,12 +96,14 @@ from dcs.planes import ( Tu_95MS, WingLoong_I, I_16, + Yak_40, ) from dcs.unittype import FlyingType from gen.flights.flight import FlightType from pydcs_extensions.a4ec.a4ec import A_4E_C from pydcs_extensions.f22a.f22a import F_22A +from pydcs_extensions.jas39.jas39 import JAS39Gripen, JAS39Gripen_AG from pydcs_extensions.hercules.hercules import Hercules from pydcs_extensions.mb339.mb339 import MB_339PAN from pydcs_extensions.su57.su57 import Su_57 @@ -123,14 +133,15 @@ CAP_CAPABLE = [ MiG_29A, F_16C_50, FA_18C_hornet, - F_15E, F_16A, F_4E, + JAS39Gripen, JF_17, MiG_23MLD, MiG_21Bis, Mirage_2000_5, M_2000C, + F_15E, F_5E_3, MiG_19P, A_4E_C, @@ -155,10 +166,8 @@ CAP_CAPABLE = [ # Used for CAS (Close air support) and BAI (Battlefield Interdiction) CAS_CAPABLE = [ A_10C_2, - B_1B, A_10C, - F_14B, - F_14A_135_GR, + Hercules, Su_25TM, Su_25T, Su_25, @@ -167,14 +176,19 @@ CAS_CAPABLE = [ FA_18C_hornet, Tornado_GR4, Tornado_IDS, + JAS39Gripen_AG, JF_17, + AV8BNA, A_10A, + B_1B, A_4E_C, + F_14B, + F_14A_135_GR, AJS37, Su_24MR, Su_24M, Su_17M4, - AV8BNA, + F_4E, S_3B, Su_34, Su_30, @@ -201,6 +215,7 @@ CAS_CAPABLE = [ MB_339PAN, L_39ZA, A_20G, + Ju_88A4, P_47D_40, P_47D_30bl1, P_47D_30, @@ -218,7 +233,7 @@ CAS_CAPABLE = [ ] -# Aircraft used for SEAD tasks +# Aircraft used for SEAD and SEAD Escort tasks. Must be capable of the CAS DCS task. SEAD_CAPABLE = [ JF_17, F_16C_50, @@ -228,6 +243,9 @@ SEAD_CAPABLE = [ Su_25TM, F_4E, A_4E_C, + F_14B, + F_14A_135_GR, + JAS39Gripen_AG, AV8BNA, Su_24M, Su_17M4, @@ -235,9 +253,21 @@ SEAD_CAPABLE = [ Su_30, MiG_27K, Tornado_GR4, - F_117A, - B_17G, +] + + +# Aircraft used for DEAD tasks. Must be capable of the CAS DCS task. +DEAD_CAPABLE = [ + AJS37, + F_14B, + F_14A_135_GR, + JAS39Gripen_AG, + B_1B, + B_52H, + Tu_160, + Tu_95MS, A_20G, + Ju_88A4, P_47D_40, P_47D_30bl1, P_47D_30, @@ -248,18 +278,6 @@ SEAD_CAPABLE = [ Bf_109K_4, FW_190D9, FW_190A8, -] - - -# Aircraft used for DEAD tasks -DEAD_CAPABLE = [ - AJS37, - F_14B, - F_14A_135_GR, - B_1B, - B_52H, - Tu_160, - Tu_95MS, ] + SEAD_CAPABLE @@ -279,6 +297,7 @@ STRIKE_CAPABLE = [ F_16A, F_14B, F_14A_135_GR, + JAS39Gripen_AG, Tornado_IDS, Su_17M4, Su_24MR, @@ -294,6 +313,7 @@ STRIKE_CAPABLE = [ MiG_29G, MiG_29A, JF_17, + F_4E, A_10C_2, A_10C, AV8BNA, @@ -310,6 +330,7 @@ STRIKE_CAPABLE = [ L_39ZA, B_17G, A_20G, + Ju_88A4, P_47D_40, P_47D_30bl1, P_47D_30, @@ -327,6 +348,7 @@ ANTISHIP_CAPABLE = [ AJS37, Tu_22M3, FA_18C_hornet, + JAS39Gripen_AG, Su_24M, Su_17M4, JF_17, @@ -354,9 +376,20 @@ RUNWAY_ATTACK_CAPABLE = [ # For any aircraft that isn't necessarily directly involved in strike # missions in a direct combat sense, but can transport objects and infantry. TRANSPORT_CAPABLE = [ + C_17A, Hercules, - Mi_8MT, + C_130, + IL_76MD, + An_26B, + Yak_40, + CH_53E, + CH_47D, + SH_60B, + UH_60A, UH_1H, + Mi_8MT, + Mi_8MT, + Mi_26, ] DRONES = [MQ_9_Reaper, RQ_1A_Predator, WingLoong_I] @@ -370,7 +403,7 @@ AEWC_CAPABLE = [ def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: - cap_missions = (FlightType.BARCAP, FlightType.TARCAP) + cap_missions = (FlightType.BARCAP, FlightType.TARCAP, FlightType.SWEEP) if task in cap_missions: return CAP_CAPABLE elif task == FlightType.ANTISHIP: @@ -381,6 +414,8 @@ def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: return CAS_CAPABLE elif task == FlightType.SEAD: return SEAD_CAPABLE + elif task == FlightType.SEAD_ESCORT: + return SEAD_CAPABLE elif task == FlightType.DEAD: return DEAD_CAPABLE elif task == FlightType.OCA_AIRCRAFT: @@ -393,6 +428,16 @@ def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: return CAP_CAPABLE elif task == FlightType.AEWC: return AEWC_CAPABLE + elif task == FlightType.TRANSPORT: + return TRANSPORT_CAPABLE else: logging.error(f"Unplannable flight type: {task}") return [] + + +def tasks_for_aircraft(aircraft: Type[FlyingType]) -> list[FlightType]: + tasks = [] + for task in FlightType: + if aircraft in aircraft_for_task(task): + tasks.append(task) + return tasks diff --git a/gen/flights/closestairfields.py b/gen/flights/closestairfields.py index 76662c81..4dd0032e 100644 --- a/gen/flights/closestairfields.py +++ b/gen/flights/closestairfields.py @@ -18,7 +18,7 @@ class ClosestAirfields: self.target = target # This cache is configured once on load, so it's important that it is # complete and deterministic to avoid different behaviors across loads. - # E.g. https://github.com/Khopa/dcs_liberation/issues/819 + # E.g. https://github.com/dcs-liberation/dcs_liberation/issues/819 self.closest_airfields: List[ControlPoint] = sorted( all_control_points, key=lambda c: self.target.distance_to(c) ) @@ -27,17 +27,35 @@ class ClosestAirfields: def operational_airfields(self) -> Iterator[ControlPoint]: return (c for c in self.closest_airfields if c.runway_is_operational()) - def airfields_within(self, distance: Distance) -> Iterator[ControlPoint]: + def _airfields_within( + self, distance: Distance, operational: bool + ) -> Iterator[ControlPoint]: + airfields = ( + self.operational_airfields if operational else self.closest_airfields + ) + for cp in airfields: + if cp.distance_to(self.target) < distance.meters: + yield cp + else: + break + + def operational_airfields_within( + self, distance: Distance + ) -> Iterator[ControlPoint]: """Iterates over all airfields within the given range of the target. Note that this iterates over *all* airfields, not just friendly airfields. """ - for cp in self.closest_airfields: - if cp.distance_to(self.target) < distance.meters: - yield cp - else: - break + return self._airfields_within(distance, operational=True) + + def all_airfields_within(self, distance: Distance) -> Iterator[ControlPoint]: + """Iterates over all airfields within the given range of the target. + + Note that this iterates over *all* airfields, not just friendly + airfields. + """ + return self._airfields_within(distance, operational=False) class ObjectiveDistanceCache: diff --git a/gen/flights/flight.py b/gen/flights/flight.py index bfcf9f28..fa826a30 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -1,20 +1,23 @@ from __future__ import annotations -from collections import defaultdict +from dataclasses import dataclass from datetime import timedelta from enum import Enum -from typing import Dict, List, Optional, TYPE_CHECKING, Type +from typing import List, Optional, TYPE_CHECKING, Type, Union from dcs.mapping import Point from dcs.point import MovingPoint, PointAction +from dcs.unit import Unit from dcs.unittype import FlyingType from game import db -from game.data.weapons import Weapon +from game.squadrons import Pilot, Squadron from game.theater.controlpoint import ControlPoint, MissionTarget from game.utils import Distance, meters +from gen.flights.loadouts import Loadout if TYPE_CHECKING: + from game.transfers import TransferOrder from gen.ato import Package from gen.flights.flightplan import FlightPlan @@ -27,6 +30,27 @@ class FlightType(Enum): These values are persisted to the save game as well since they are a part of each flight and thus a part of the ATO, so changing these values will break save compat. + + When adding new mission types to this list, you will also need to update: + + * flightplan.py: Add waypoint population in generate_flight_plan. Add a new flight + plan type if necessary, though most are a subclass of StrikeFlightPlan. + * aircraft.py: Add a configuration method and call it in setup_flight_group. This is + responsible for configuring waypoint 0 actions like setting ROE, threat reaction, + and mission abort parameters (winchester, bingo, etc). + * Implementations of MissionTarget.mission_types: A mission type can only be planned + against compatible targets. The mission_types method of each target class defines + which missions may target it. + * ai_flight_planner_db.py: Add the new mission type to aircraft_for_task that + returns the list of compatible aircraft in order of preference. + + You may also need to update: + + * flight.py: Add a new waypoint type if necessary. Most mission types will need + these, as aircraft.py uses the ingress point type to specialize AI tasks, and non- + strike-like missions will need more specialized control. + * ai_flight_planner.py: Use the new mission type in propose_missions so the AI will + plan the new mission type. """ TARCAP = "TARCAP" @@ -43,12 +67,35 @@ class FlightType(Enum): OCA_RUNWAY = "OCA/Runway" OCA_AIRCRAFT = "OCA/Aircraft" AEWC = "AEW&C" + TRANSPORT = "Transport" + SEAD_ESCORT = "SEAD Escort" def __str__(self) -> str: return self.value + @classmethod + def from_name(cls, name: str) -> FlightType: + for entry in cls: + if name == entry.value: + return entry + raise KeyError(f"No FlightType with name {name}") + class FlightWaypointType(Enum): + """Enumeration of waypoint types. + + The value of the enum has no meaning but should remain stable to prevent breaking + save game compatibility. + + When adding a new waypoint type, you will also need to update: + + * waypointbuilder.py: Add a builder to simplify construction of the new waypoint + type unless the new waypoint type will be a parameter to an existing builder + method (such as how escort ingress waypoints work). + * aircraft.py: Associate AI actions with the new waypoint type by subclassing + PydcsWaypointBuilder and using it in PydcsWaypointBuilder.for_waypoint. + """ + TAKEOFF = 0 # Take off point ASCEND_POINT = 1 # Ascension point after take off PATROL = 2 # Patrol point @@ -63,7 +110,7 @@ class FlightWaypointType(Enum): LANDING_POINT = 11 # Should land there TARGET_POINT = 12 # A target building or static object, position TARGET_GROUP_LOC = 13 # A target group approximate location - TARGET_SHIP = 14 # A target ship known location + TARGET_SHIP = 14 # Unused. CUSTOM = 15 # User waypoint (no specific behaviour) JOIN = 16 SPLIT = 17 @@ -75,6 +122,9 @@ class FlightWaypointType(Enum): DIVERT = 23 INGRESS_OCA_RUNWAY = 24 INGRESS_OCA_AIRCRAFT = 25 + PICKUP = 26 + DROP_OFF = 27 + BULLSEYE = 28 class FlightWaypoint: @@ -104,7 +154,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[MissionTarget] = [] + self.targets: List[Union[MissionTarget, Unit]] = [] self.obj_name = "" self.pretty_name = "" self.only_for_player = False @@ -151,12 +201,55 @@ class FlightWaypoint: return waypoint +class FlightRoster: + def __init__(self, squadron: Squadron, initial_size: int = 0) -> None: + self.squadron = squadron + self.pilots: list[Optional[Pilot]] = [] + self.resize(initial_size) + + @property + def max_size(self) -> int: + return len(self.pilots) + + @property + def player_count(self) -> int: + return len([p for p in self.pilots if p is not None and p.player]) + + @property + def missing_pilots(self) -> int: + return len([p for p in self.pilots if p is None]) + + def resize(self, new_size: int) -> None: + if self.max_size > new_size: + self.squadron.return_pilots( + [p for p in self.pilots[new_size:] if p is not None] + ) + self.pilots = self.pilots[:new_size] + return + self.pilots.extend( + [ + self.squadron.claim_available_pilot() + for _ in range(new_size - self.max_size) + ] + ) + + def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: + if pilot is not None: + self.squadron.claim_pilot(pilot) + if (current_pilot := self.pilots[index]) is not None: + self.squadron.return_pilot(current_pilot) + self.pilots[index] = pilot + + def clear(self) -> None: + self.squadron.return_pilots([p for p in self.pilots if p is not None]) + + class Flight: def __init__( self, package: Package, country: str, - unit_type: Type[FlyingType], + squadron: Squadron, count: int, flight_type: FlightType, start_type: str, @@ -164,23 +257,30 @@ class Flight: arrival: ControlPoint, divert: Optional[ControlPoint], custom_name: Optional[str] = None, + cargo: Optional[TransferOrder] = None, + roster: Optional[FlightRoster] = None, ) -> None: self.package = package self.country = country - self.unit_type = unit_type - self.count = count + self.squadron = squadron + if roster is None: + self.roster = FlightRoster(self.squadron, initial_size=count) + else: + self.roster = roster self.departure = departure self.arrival = arrival self.divert = divert self.flight_type = flight_type # TODO: Replace with FlightPlan. self.targets: List[MissionTarget] = [] - self.loadout: Dict[int, Optional[Weapon]] = {} + self.loadout = Loadout.default_for(self) self.start_type = start_type self.use_custom_loadout = False - self.client_count = 0 self.custom_name = custom_name + # Only used by transport missions. + self.cargo = cargo + # Will be replaced with a more appropriate FlightPlan by # FlightPlanBuilder, but an empty flight plan the flight begins with an # empty flight plan. @@ -190,6 +290,18 @@ class Flight: package=package, flight=self, custom_waypoints=[] ) + @property + def count(self) -> int: + return self.roster.max_size + + @property + def client_count(self) -> int: + return self.roster.player_count + + @property + def unit_type(self) -> Type[FlyingType]: + return self.squadron.aircraft + @property def from_cp(self) -> ControlPoint: return self.departure @@ -198,6 +310,19 @@ class Flight: def points(self) -> List[FlightWaypoint]: return self.flight_plan.waypoints[1:] + def resize(self, new_size: int) -> None: + self.roster.resize(new_size) + + def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: + self.roster.set_pilot(index, pilot) + + @property + def missing_pilots(self) -> int: + return self.roster.missing_pilots + + def clear_roster(self) -> None: + self.roster.clear() + def __repr__(self): name = db.unit_type_name(self.unit_type) if self.custom_name: diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 2b929387..90fa276c 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -10,18 +10,18 @@ from __future__ import annotations import logging import math import random -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import timedelta from functools import cached_property from typing import Iterator, List, Optional, Set, TYPE_CHECKING, Tuple -from dcs.planes import E_3A, E_2C, A_50, KJ_2000 - from dcs.mapping import Point +from dcs.planes import E_3A, E_2C, A_50, KJ_2000 from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine +from game.squadrons import Pilot from game.theater import ( Airfield, ControlPoint, @@ -41,6 +41,7 @@ from ..conflictgen import Conflict, FRONTLINE_LENGTH if TYPE_CHECKING: from game import Game from gen.ato import Package + from game.transfers import Convoy INGRESS_TYPES = { FlightWaypointType.INGRESS_CAS, @@ -124,6 +125,10 @@ class FlightPlan: """ raise NotImplementedError + @property + def tot(self) -> timedelta: + return self.package.time_over_target + self.tot_offset + @cached_property def bingo_fuel(self) -> int: """Bingo fuel value for the FlightPlan""" @@ -197,15 +202,28 @@ class FlightPlan: def dismiss_escort_at(self) -> Optional[FlightWaypoint]: return None + def escorted_waypoints(self) -> Iterator[FlightWaypoint]: + begin = self.request_escort_at() + end = self.dismiss_escort_at() + if begin is None or end is None: + return + escorting = False + for waypoint in self.waypoints: + if waypoint == begin: + escorting = True + if escorting: + yield waypoint + if waypoint == end: + return + def takeoff_time(self) -> Optional[timedelta]: tot_waypoint = self.tot_waypoint if tot_waypoint is None: return None - time = self.tot_for_waypoint(tot_waypoint) + time = self.tot if time is None: return None - time += self.tot_offset return time - self._travel_time_to_waypoint(tot_waypoint) def startup_time(self) -> Optional[timedelta]: @@ -242,7 +260,7 @@ class FlightPlan: if self.flight.from_cp.is_fleet: return timedelta(minutes=2) else: - return timedelta(minutes=5) + return timedelta(minutes=8) @property def mission_departure_time(self) -> timedelta: @@ -425,6 +443,7 @@ class BarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint land: FlightWaypoint divert: Optional[FlightWaypoint] + bullseye: FlightWaypoint def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.takeoff @@ -437,6 +456,7 @@ class BarCapFlightPlan(PatrollingFlightPlan): yield self.land if self.divert is not None: yield self.divert + yield self.bullseye @dataclass(frozen=True) @@ -445,6 +465,7 @@ class CasFlightPlan(PatrollingFlightPlan): target: FlightWaypoint land: FlightWaypoint divert: Optional[FlightWaypoint] + bullseye: FlightWaypoint def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.takeoff @@ -458,6 +479,7 @@ class CasFlightPlan(PatrollingFlightPlan): yield self.land if self.divert is not None: yield self.divert + yield self.bullseye def request_escort_at(self) -> Optional[FlightWaypoint]: return self.patrol_start @@ -471,6 +493,7 @@ class TarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint land: FlightWaypoint divert: Optional[FlightWaypoint] + bullseye: FlightWaypoint lead_time: timedelta def iter_waypoints(self) -> Iterator[FlightWaypoint]: @@ -484,6 +507,7 @@ class TarCapFlightPlan(PatrollingFlightPlan): yield self.land if self.divert is not None: yield self.divert + yield self.bullseye @property def tot_offset(self) -> timedelta: @@ -499,7 +523,7 @@ class TarCapFlightPlan(PatrollingFlightPlan): start = self.package.escort_start_time if start is not None: return start + self.tot_offset - return super().patrol_start_time + self.tot_offset + return self.tot @property def patrol_end_time(self) -> timedelta: @@ -522,6 +546,8 @@ class StrikeFlightPlan(FormationFlightPlan): nav_from: List[FlightWaypoint] land: FlightWaypoint divert: Optional[FlightWaypoint] + bullseye: FlightWaypoint + lead_time: timedelta = field(default_factory=timedelta) def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.takeoff @@ -536,6 +562,7 @@ class StrikeFlightPlan(FormationFlightPlan): yield self.land if self.divert is not None: yield self.divert + yield self.bullseye @property def package_speed_waypoints(self) -> Set[FlightWaypoint]: @@ -559,6 +586,13 @@ class StrikeFlightPlan(FormationFlightPlan): def tot_waypoint(self) -> FlightWaypoint: return self.targets[0] + @property + def tot_offset(self) -> timedelta: + try: + return -self.lead_time + except AttributeError: + return timedelta() + @property def target_area_waypoint(self) -> FlightWaypoint: return FlightWaypoint( @@ -591,10 +625,6 @@ class StrikeFlightPlan(FormationFlightPlan): ) return total - @property - def mission_speed(self) -> Speed: - return GroundSpeed.for_flight(self.flight, self.ingress.alt) - @property def join_time(self) -> timedelta: travel_time = self.travel_time_between_waypoints(self.join, self.ingress) @@ -607,7 +637,7 @@ class StrikeFlightPlan(FormationFlightPlan): @property def ingress_time(self) -> timedelta: - tot = self.package.time_over_target + tot = self.tot travel_time = self.travel_time_between_waypoints( self.ingress, self.target_area_waypoint ) @@ -615,7 +645,7 @@ class StrikeFlightPlan(FormationFlightPlan): @property def egress_time(self) -> timedelta: - tot = self.package.time_over_target + tot = self.tot travel_time = self.travel_time_between_waypoints( self.target_area_waypoint, self.egress ) @@ -627,7 +657,7 @@ class StrikeFlightPlan(FormationFlightPlan): elif waypoint == self.egress: return self.egress_time elif waypoint in self.targets: - return self.package.time_over_target + return self.tot return super().tot_for_waypoint(waypoint) @@ -640,6 +670,7 @@ class SweepFlightPlan(LoiterFlightPlan): nav_from: List[FlightWaypoint] land: FlightWaypoint divert: Optional[FlightWaypoint] + bullseye: FlightWaypoint lead_time: timedelta def iter_waypoints(self) -> Iterator[FlightWaypoint]: @@ -652,6 +683,7 @@ class SweepFlightPlan(LoiterFlightPlan): yield self.land if self.divert is not None: yield self.divert + yield self.bullseye @property def tot_waypoint(self) -> Optional[FlightWaypoint]: @@ -670,7 +702,7 @@ class SweepFlightPlan(LoiterFlightPlan): @property def sweep_end_time(self) -> timedelta: - return self.package.time_over_target + self.tot_offset + return self.tot def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: if waypoint == self.sweep_start: @@ -703,6 +735,7 @@ class AwacsFlightPlan(LoiterFlightPlan): nav_from: List[FlightWaypoint] land: FlightWaypoint divert: Optional[FlightWaypoint] + bullseye: FlightWaypoint def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.takeoff @@ -712,6 +745,7 @@ class AwacsFlightPlan(LoiterFlightPlan): yield self.land if self.divert is not None: yield self.divert + yield self.bullseye @property def mission_start_time(self) -> Optional[timedelta]: @@ -735,6 +769,48 @@ class AwacsFlightPlan(LoiterFlightPlan): return self.push_time +@dataclass(frozen=True) +class AirliftFlightPlan(FlightPlan): + takeoff: FlightWaypoint + nav_to_pickup: List[FlightWaypoint] + pickup: Optional[FlightWaypoint] + nav_to_drop_off: List[FlightWaypoint] + drop_off: FlightWaypoint + nav_to_home: List[FlightWaypoint] + land: FlightWaypoint + divert: Optional[FlightWaypoint] + bullseye: FlightWaypoint + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.takeoff + yield from self.nav_to_pickup + if self.pickup: + yield self.pickup + yield from self.nav_to_drop_off + yield self.drop_off + yield from self.nav_to_home + yield self.land + if self.divert is not None: + yield self.divert + yield self.bullseye + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + return self.drop_off + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + # TOT planning isn't really useful for transports. They're behind the front + # lines so no need to wait for escorts or for other missions to complete. + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + return None + + @property + def mission_departure_time(self) -> timedelta: + return self.package.time_over_target + + @dataclass(frozen=True) class CustomFlightPlan(FlightPlan): custom_waypoints: List[FlightWaypoint] @@ -782,11 +858,7 @@ class FlightPlanBuilder: self.game = game self.package = package self.is_player = is_player - if is_player: - faction = self.game.player_faction - else: - faction = self.game.enemy_faction - self.doctrine: Doctrine = faction.doctrine + self.doctrine: Doctrine = self.game.faction_for(self.is_player).doctrine self.threat_zones = self.game.threat_zone_for(not self.is_player) def populate_flight_plan( @@ -798,12 +870,12 @@ class FlightPlanBuilder: """Creates a default flight plan for the given mission.""" if flight not in self.package.flights: raise RuntimeError("Flight must be a part of the package") - if self.package.waypoints is None: - self.regenerate_package_waypoints() from game.navmesh import NavMeshError try: + if self.package.waypoints is None: + self.regenerate_package_waypoints() flight.flight_plan = self.generate_flight_plan(flight, custom_targets) except NavMeshError as ex: color = "blue" if self.is_player else "red" @@ -835,6 +907,8 @@ class FlightPlanBuilder: return self.generate_runway_attack(flight) elif task == FlightType.SEAD: return self.generate_sead(flight, custom_targets) + elif task == FlightType.SEAD_ESCORT: + return self.generate_escort(flight) elif task == FlightType.STRIKE: return self.generate_strike(flight) elif task == FlightType.SWEEP: @@ -843,6 +917,8 @@ class FlightPlanBuilder: return self.generate_tarcap(flight) elif task == FlightType.AEWC: return self.generate_aewc(flight) + elif task == FlightType.TRANSPORT: + return self.generate_transport(flight) raise PlanningError(f"{task} flight plan generation not implemented") def regenerate_package_waypoints(self) -> None: @@ -981,7 +1057,7 @@ class FlightPlanBuilder: """ location = self.package.target - start = self.aewc_orbit(location) + orbit_location = self.aewc_orbit(location) # As high as possible to maximize detection and on-station time. if flight.unit_type == E_2C: @@ -996,25 +1072,26 @@ class FlightPlanBuilder: patrol_alt = feet(25000) builder = WaypointBuilder(flight, self.game, self.is_player) - start = builder.orbit(start, patrol_alt) + orbit_location = 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, start.position, patrol_alt + flight.departure.position, orbit_location.position, patrol_alt ), nav_from=builder.nav_path( - start.position, flight.arrival.position, patrol_alt + orbit_location.position, flight.arrival.position, patrol_alt ), land=builder.land(flight.arrival), divert=builder.divert(flight.divert), - hold=start, + bullseye=builder.bullseye(), + hold=orbit_location, hold_duration=timedelta(hours=4), ) - def generate_bai(self, flight: Flight) -> StrikeFlightPlan: + def generate_bai(self, flight: Flight) -> FlightPlan: """Generates a BAI flight plan. Args: @@ -1022,18 +1099,28 @@ class FlightPlanBuilder: """ location = self.package.target - if not isinstance(location, TheaterGroundObject): - raise InvalidObjectiveLocation(flight.flight_type, location) + from game.transfers import Convoy targets: List[StrikeTarget] = [] - for group in location.groups: - if group.units: - targets.append(StrikeTarget(f"{group.name} at {location.name}", group)) + if isinstance(location, TheaterGroundObject): + for group in location.groups: + if group.units: + targets.append( + StrikeTarget(f"{group.name} at {location.name}", group) + ) + elif isinstance(location, Convoy): + targets.append(StrikeTarget(location.name, location)) + else: + raise InvalidObjectiveLocation(flight.flight_type, location) return self.strike_flightplan( flight, location, FlightWaypointType.INGRESS_BAI, targets ) + @staticmethod + def anti_ship_targets_for_tgo(tgo: TheaterGroundObject) -> List[StrikeTarget]: + return [StrikeTarget(f"{g.name} at {tgo.name}", g) for g in tgo.groups] + def generate_anti_ship(self, flight: Flight) -> StrikeFlightPlan: """Generates an anti-ship flight plan. @@ -1042,20 +1129,20 @@ class FlightPlanBuilder: """ location = self.package.target + from game.transfers import CargoShip + if isinstance(location, ControlPoint): - if location.is_fleet: - # The first group generated will be the carrier group itself. - location = location.ground_objects[0] - else: + if not location.is_fleet: raise InvalidObjectiveLocation(flight.flight_type, location) - - if not isinstance(location, TheaterGroundObject): + # The first group generated will be the carrier group itself. + targets = self.anti_ship_targets_for_tgo(location.ground_objects[0]) + elif isinstance(location, TheaterGroundObject): + targets = self.anti_ship_targets_for_tgo(location) + elif isinstance(location, CargoShip): + targets = [StrikeTarget(location.name, location)] + else: raise InvalidObjectiveLocation(flight.flight_type, location) - targets: List[StrikeTarget] = [] - for group in location.groups: - targets.append(StrikeTarget(f"{group.name} at {location.name}", group)) - return self.strike_flightplan( flight, location, FlightWaypointType.INGRESS_BAI, targets ) @@ -1098,6 +1185,7 @@ class FlightPlanBuilder: patrol_end=end, land=builder.land(flight.arrival), divert=builder.divert(flight.divert), + bullseye=builder.bullseye(), ) def generate_sweep(self, flight: Flight) -> SweepFlightPlan: @@ -1134,6 +1222,59 @@ class FlightPlanBuilder: sweep_end=end, land=builder.land(flight.arrival), divert=builder.divert(flight.divert), + bullseye=builder.bullseye(), + ) + + def generate_transport(self, flight: Flight) -> AirliftFlightPlan: + """Generate an airlift flight at a given location. + + Args: + flight: The flight to generate the flight plan for. + """ + cargo = flight.cargo + if cargo is None: + raise PlanningError( + "Cannot plan transport mission for flight with no cargo." + ) + + altitude = feet(1500) + altitude_is_agl = True + + builder = WaypointBuilder(flight, self.game, self.is_player) + + pickup = None + nav_to_pickup = [] + if cargo.origin != flight.departure: + pickup = builder.pickup(cargo.origin) + nav_to_pickup = builder.nav_path( + flight.departure.position, + cargo.origin.position, + altitude, + altitude_is_agl, + ) + + return AirliftFlightPlan( + package=self.package, + flight=flight, + takeoff=builder.takeoff(flight.departure), + nav_to_pickup=nav_to_pickup, + pickup=pickup, + nav_to_drop_off=builder.nav_path( + cargo.origin.position, + cargo.next_stop.position, + altitude, + altitude_is_agl, + ), + drop_off=builder.drop_off(cargo.next_stop), + nav_to_home=builder.nav_path( + cargo.origin.position, + flight.arrival.position, + altitude, + altitude_is_agl, + ), + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert), + bullseye=builder.bullseye(), ) def racetrack_for_objective( @@ -1198,29 +1339,31 @@ class FlightPlanBuilder: return start, end def aewc_orbit(self, location: MissionTarget) -> Point: - # in threat zone + closest_boundary = self.threat_zones.closest_boundary(location.position) + heading_to_threat_boundary = location.position.heading_between_point( + closest_boundary + ) + distance_to_threat = meters( + location.position.distance_to_point(closest_boundary) + ) + orbit_heading = heading_to_threat_boundary + # Station 100nm outside the threat zone. + threat_buffer = nautical_miles(100) if self.threat_zones.threatened(location.position): - # Borderpoint - closest_boundary = self.threat_zones.closest_boundary(location.position) - - # Heading + Distance to border point - heading = location.position.heading_between_point(closest_boundary) - distance = location.position.distance_to_point(closest_boundary) - - return location.position.point_from_heading(heading, distance) - - # this Part is fine. No threat zone, just use our point + orbit_distance = distance_to_threat + threat_buffer else: - return location.position + orbit_distance = distance_to_threat - threat_buffer + + return location.position.point_from_heading( + orbit_heading, orbit_distance.meters + ) def racetrack_for_frontline( self, origin: Point, front_line: FrontLine ) -> Tuple[Point, Point]: - ally_cp, enemy_cp = front_line.control_points - # Find targets waypoints ingress, heading, distance = Conflict.frontline_vector( - ally_cp, enemy_cp, self.game.theater + front_line, self.game.theater ) center = ingress.point_from_heading(heading, distance / 2) orbit_center = center.point_from_heading( @@ -1287,6 +1430,7 @@ class FlightPlanBuilder: patrol_end=end, land=builder.land(flight.arrival), divert=builder.divert(flight.divert), + bullseye=builder.bullseye(), ) def generate_dead( @@ -1380,7 +1524,11 @@ class FlightPlanBuilder: targets.append(StrikeTarget(location.name, target)) return self.strike_flightplan( - flight, location, FlightWaypointType.INGRESS_SEAD, targets + flight, + location, + FlightWaypointType.INGRESS_SEAD, + targets, + lead_time=timedelta(minutes=1), ) def generate_escort(self, flight: Flight) -> StrikeFlightPlan: @@ -1415,6 +1563,7 @@ class FlightPlanBuilder: ), land=builder.land(flight.arrival), divert=builder.divert(flight.divert), + bullseye=builder.bullseye(), ) def generate_cas(self, flight: Flight) -> CasFlightPlan: @@ -1429,7 +1578,7 @@ class FlightPlanBuilder: raise InvalidObjectiveLocation(flight.flight_type, location) ingress, heading, distance = Conflict.frontline_vector( - location.control_points[0], location.control_points[1], self.game.theater + location, self.game.theater ) center = ingress.point_from_heading(heading, distance / 2) egress = ingress.point_from_heading(heading, distance) @@ -1460,6 +1609,7 @@ class FlightPlanBuilder: patrol_end=builder.egress(egress, location), land=builder.land(flight.arrival), divert=builder.divert(flight.divert), + bullseye=builder.bullseye(), ) @staticmethod @@ -1556,6 +1706,7 @@ class FlightPlanBuilder: location: MissionTarget, ingress_type: FlightWaypointType, targets: Optional[List[StrikeTarget]] = None, + lead_time: timedelta = timedelta(), ) -> StrikeFlightPlan: assert self.package.waypoints is not None builder = WaypointBuilder(flight, self.game, self.is_player, targets) @@ -1594,6 +1745,8 @@ class FlightPlanBuilder: ), land=builder.land(flight.arrival), divert=builder.divert(flight.divert), + bullseye=builder.bullseye(), + lead_time=lead_time, ) def _retreating_rendezvous_point(self, attack_transition: Point) -> Point: @@ -1658,7 +1811,7 @@ class FlightPlanBuilder: # We'll always have a package, but if this is being planned via the UI # it could be the first flight in the package. if not self.package.flights: - raise RuntimeError( + raise PlanningError( "Cannot determine source airfield for package with no flights" ) @@ -1670,4 +1823,4 @@ class FlightPlanBuilder: for flight in self.package.flights: if flight.departure == airfield: return airfield - raise RuntimeError("Could not find any airfield assigned to this package") + raise PlanningError("Could not find any airfield assigned to this package") diff --git a/gen/flights/loadouts.py b/gen/flights/loadouts.py new file mode 100644 index 00000000..845f7751 --- /dev/null +++ b/gen/flights/loadouts.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import datetime +from typing import Optional, List, Iterator, Type, TYPE_CHECKING, Mapping + +from dcs.unittype import FlyingType + +from game.data.weapons import Weapon, Pylon + +if TYPE_CHECKING: + from gen.flights.flight import Flight + + +class Loadout: + def __init__( + self, + name: str, + pylons: Mapping[int, Optional[Weapon]], + date: Optional[datetime.date], + is_custom: bool = False, + ) -> None: + self.name = name + self.pylons = {k: v for k, v in pylons.items() if v is not None} + self.date = date + self.is_custom = is_custom + + def derive_custom(self, name: str) -> Loadout: + return Loadout(name, self.pylons, self.date, is_custom=True) + + def degrade_for_date( + self, unit_type: Type[FlyingType], date: datetime.date + ) -> Loadout: + if self.date is not None and self.date <= date: + return Loadout(self.name, self.pylons, self.date) + + new_pylons = dict(self.pylons) + for pylon_number, weapon in self.pylons.items(): + if weapon is None: + del new_pylons[pylon_number] + 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: + del new_pylons[pylon_number] + return Loadout(f"{self.name} ({date.year})", new_pylons, date) + + @classmethod + def iter_for(cls, flight: Flight) -> Iterator[Loadout]: + # Dict of payload ID (numeric) to: + # + # { + # "name": The name the user set in the ME + # "pylons": List (as a dict) of dicts of: + # {"CLSID": class ID, "num": pylon number} + # "tasks": List (as a dict) of task IDs the payload is used by. + # } + payloads = flight.unit_type.load_payloads() + for payload in payloads.values(): + name = payload["name"] + pylons = payload["pylons"] + yield Loadout( + name, + {p["num"]: Weapon.from_clsid(p["CLSID"]) for p in pylons.values()}, + 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 + + # This is a list of mappings from the FlightType of a Flight to the type of + # payload defined in the resources/payloads/UNIT_TYPE.lua file. A Flight has no + # concept of a PyDCS task, so COMMON_OVERRIDE cannot be used here. This is used + # in the payload editor, for setting the default loadout of an object. The left + # element is the FlightType name, and the right element is a tuple containing + # what is used in the lua file. Some aircraft differ from the standard loadout + # names, so those have been included here too. The priority goes from first to + # last - the first element in the tuple will be tried first, then the second, + # etc. + loadout_names = {t: [f"Liberation {t.value}"] for t in FlightType} + legacy_names = { + FlightType.TARCAP: ("CAP HEAVY", "CAP", "Liberation BARCAP"), + FlightType.BARCAP: ("CAP HEAVY", "CAP", "Liberation TARCAP"), + FlightType.CAS: ("CAS MAVERICK F", "CAS"), + FlightType.STRIKE: ("STRIKE",), + FlightType.ANTISHIP: ("ANTISHIP",), + FlightType.SEAD: ("SEAD",), + FlightType.BAI: ("BAI",), + FlightType.OCA_RUNWAY: ("RUNWAY_ATTACK", "RUNWAY_STRIKE"), + FlightType.OCA_AIRCRAFT: ("OCA",), + } + for flight_type, names in legacy_names.items(): + loadout_names[flight_type].extend(names) + # A SEAD escort typically does not need a different loadout than a regular + # SEAD flight, so fall back to SEAD if needed. + loadout_names[FlightType.SEAD_ESCORT].extend(loadout_names[FlightType.SEAD]) + # Sweep and escort can fall back to TARCAP. + loadout_names[FlightType.ESCORT].extend(loadout_names[FlightType.TARCAP]) + loadout_names[FlightType.SWEEP].extend(loadout_names[FlightType.TARCAP]) + # Intercept can fall back to BARCAP. + loadout_names[FlightType.INTERCEPTION].extend(loadout_names[FlightType.BARCAP]) + # OCA/Aircraft falls back to BAI, which falls back to CAS. + loadout_names[FlightType.BAI].extend(loadout_names[FlightType.CAS]) + loadout_names[FlightType.OCA_AIRCRAFT].extend(loadout_names[FlightType.BAI]) + # DEAD also falls back to BAI. + loadout_names[FlightType.DEAD].extend(loadout_names[FlightType.BAI]) + # OCA/Runway falls back to Strike + loadout_names[FlightType.OCA_RUNWAY].extend(loadout_names[FlightType.STRIKE]) + yield from loadout_names[flight.flight_type] + + @classmethod + def default_for(cls, flight: Flight) -> Loadout: + # Iterate through each possible payload type for a given aircraft. + # Some aircraft have custom loadouts that in aren't the standard set. + for name in cls.default_loadout_names_for(flight): + # This operation is cached, but must be called before load_by_name will + # work. + flight.unit_type.load_payloads() + payload = flight.unit_type.loadout_by_name(name) + if payload is not None: + return Loadout( + name, + {i: Weapon.from_clsid(d["clsid"]) for i, d in payload}, + date=None, + ) + + # TODO: Try group.load_task_default_loadout(loadout_for_task) + return Loadout("Empty", {}, date=None) diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 98e74fac..6b787f95 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -36,23 +36,23 @@ class GroundSpeed: # DCS's max speed is in kph at 0 MSL. max_speed = kph(flight.unit_type.max_speed) if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL: - # Aircraft is supersonic. Limit to mach 0.8 to conserve fuel and + # Aircraft is supersonic. Limit to mach 0.85 to conserve fuel and # account for heavily loaded jets. - return mach(0.8, altitude) + return mach(0.85, altitude) # For subsonic aircraft, assume the aircraft can reasonably perform at # 80% of its maximum, and that it can maintain the same mach at altitude # as it can at sea level. This probably isn't great assumption, but # might. be sufficient given the wiggle room. We can come up with # another heuristic if needed. - cruise_mach = max_speed.mach() * 0.8 + cruise_mach = max_speed.mach() * 0.85 return mach(cruise_mach, altitude) class TravelTime: @staticmethod def between_points(a: Point, b: Point, speed: Speed) -> timedelta: - error_factor = 1.1 + error_factor = 1.05 distance = meters(a.distance_to_point(b)) return timedelta(hours=distance.nautical_miles / speed.knots * error_factor) @@ -72,6 +72,9 @@ class TotEstimator: return startup_time def earliest_tot(self) -> timedelta: + if not self.package.flights: + return timedelta(0) + earliest_tot = max( (self.earliest_tot_for_flight(f) for f in self.package.flights) ) diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 53fad47c..f8380897 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -18,6 +18,7 @@ from dcs.unitgroup import Group, VehicleGroup if TYPE_CHECKING: from game import Game + from game.transfers import MultiGroupTransport from game.theater import ( ControlPoint, @@ -32,7 +33,7 @@ from .flight import Flight, FlightWaypoint, FlightWaypointType @dataclass(frozen=True) class StrikeTarget: name: str - target: Union[VehicleGroup, TheaterGroundObject, Unit, Group] + target: Union[VehicleGroup, TheaterGroundObject, Unit, Group, MultiGroupTransport] class WaypointBuilder: @@ -49,6 +50,7 @@ class WaypointBuilder: self.threat_zones = game.threat_zone_for(not player) self.navmesh = game.navmesh_for(player) self.targets = targets + self._bullseye = game.bullseye_for(player) @property def is_helo(self) -> bool: @@ -144,6 +146,19 @@ class WaypointBuilder: waypoint.only_for_player = True return waypoint + def bullseye(self) -> FlightWaypoint: + waypoint = FlightWaypoint( + FlightWaypointType.BULLSEYE, + self._bullseye.position.x, + self._bullseye.position.y, + meters(0), + ) + waypoint.pretty_name = "Bullseye" + waypoint.description = "Bullseye" + waypoint.name = "BULLSEYE" + waypoint.only_for_player = True + return waypoint + def hold(self, position: Point) -> FlightWaypoint: waypoint = FlightWaypoint( FlightWaypointType.LOITER, @@ -201,8 +216,7 @@ class WaypointBuilder: waypoint.pretty_name = "INGRESS on " + objective.name waypoint.description = "INGRESS on " + objective.name waypoint.name = "INGRESS" - # TODO: This seems wrong, but it's what was there before. - waypoint.targets.append(objective) + waypoint.targets = objective.strike_targets return waypoint def egress(self, position: Point, target: MissionTarget) -> FlightWaypoint: @@ -406,10 +420,13 @@ class WaypointBuilder: end: The end of the sweep. altitude: The sweep altitude. """ - return (self.sweep_start(start, altitude), self.sweep_end(end, altitude)) + return self.sweep_start(start, altitude), self.sweep_end(end, altitude) def escort( - self, ingress: Point, target: MissionTarget, egress: Point + self, + ingress: Point, + target: MissionTarget, + egress: Point, ) -> Tuple[FlightWaypoint, FlightWaypoint, FlightWaypoint]: """Creates the waypoints needed to escort the package. @@ -442,24 +459,69 @@ class WaypointBuilder: return ingress, waypoint, egress @staticmethod - def nav(position: Point, altitude: Distance) -> FlightWaypoint: + def pickup(control_point: ControlPoint) -> FlightWaypoint: + """Creates a cargo pickup waypoint. + + Args: + control_point: Pick up location. + """ + waypoint = FlightWaypoint( + FlightWaypointType.PICKUP, + control_point.position.x, + control_point.position.y, + meters(0), + ) + waypoint.alt_type = "RADIO" + waypoint.name = "PICKUP" + waypoint.description = f"Pick up cargo from {control_point}" + waypoint.pretty_name = "Pick up location" + return waypoint + + @staticmethod + def drop_off(control_point: ControlPoint) -> FlightWaypoint: + """Creates a cargo drop-off waypoint. + + Args: + control_point: Drop-off location. + """ + waypoint = FlightWaypoint( + FlightWaypointType.PICKUP, + control_point.position.x, + control_point.position.y, + meters(0), + ) + waypoint.alt_type = "RADIO" + waypoint.name = "DROP OFF" + waypoint.description = f"Drop off cargo at {control_point}" + waypoint.pretty_name = "Drop off location" + return waypoint + + @staticmethod + def nav( + position: Point, altitude: Distance, altitude_is_agl: bool = False + ) -> FlightWaypoint: """Creates a navigation point. Args: position: Position of the waypoint. altitude: Altitude of the waypoint. + altitude_is_agl: True for altitude is AGL. False if altitude is MSL. """ waypoint = FlightWaypoint( FlightWaypointType.NAV, position.x, position.y, altitude ) + if altitude_is_agl: + waypoint.alt_type = "RADIO" waypoint.name = "NAV" waypoint.description = "NAV" waypoint.pretty_name = "Nav" return waypoint - def nav_path(self, a: Point, b: Point, altitude: Distance) -> List[FlightWaypoint]: + def nav_path( + self, a: Point, b: Point, altitude: Distance, altitude_is_agl: bool = False + ) -> List[FlightWaypoint]: path = self.clean_nav_points(self.navmesh.shortest_path(a, b)) - return [self.nav(self.perturb(p), altitude) for p in path] + return [self.nav(self.perturb(p), altitude, altitude_is_agl) for p in path] def clean_nav_points(self, points: Iterable[Point]) -> Iterator[Point]: # Examine a sliding window of three waypoints. `current` is the waypoint diff --git a/gen/ground_forces/ai_ground_planner.py b/gen/ground_forces/ai_ground_planner.py index 777efd01..75fb1bea 100644 --- a/gen/ground_forces/ai_ground_planner.py +++ b/gen/ground_forces/ai_ground_planner.py @@ -1,3 +1,4 @@ +import logging import random from enum import Enum from typing import Dict, List @@ -5,7 +6,8 @@ from typing import Dict, List from dcs.unittype import VehicleType from game.theater import ControlPoint -from gen.ground_forces.ai_ground_planner_db import * + +from game.data.groundunitclass import GroundUnitClass from gen.ground_forces.combat_stance import CombatStance MAX_COMBAT_GROUP_PER_CP = 10 @@ -20,17 +22,19 @@ class CombatGroupRole(Enum): LOGI = 6 INFANTRY = 7 ATGM = 8 + RECON = 9 DISTANCE_FROM_FRONTLINE = { CombatGroupRole.TANK: (2200, 3200), - CombatGroupRole.APC: (7500, 8500), + CombatGroupRole.APC: (2700, 3700), CombatGroupRole.IFV: (2700, 3700), CombatGroupRole.ARTILLERY: (16000, 18000), - CombatGroupRole.SHORAD: (12000, 13000), + CombatGroupRole.SHORAD: (5000, 8000), CombatGroupRole.LOGI: (18000, 20000), CombatGroupRole.INFANTRY: (2800, 3300), CombatGroupRole.ATGM: (5200, 6200), + CombatGroupRole.RECON: (2000, 3000), } GROUP_SIZES_BY_COMBAT_STANCE = { @@ -72,6 +76,7 @@ class GroundPlanner: self.atgm_group: List[CombatGroup] = [] self.logi_groups: List[CombatGroup] = [] self.shorad_groups: List[CombatGroup] = [] + self.recon_groups: List[CombatGroup] = [] self.units_per_cp: Dict[int, List[CombatGroup]] = {} for cp in self.connected_enemy_cp: @@ -80,6 +85,10 @@ class GroundPlanner: def plan_groundwar(self): + ground_unit_limit = self.cp.frontline_unit_count_limit + + remaining_available_frontline_units = ground_unit_limit + if hasattr(self.cp, "stance"): group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[self.cp.stance] else: @@ -87,37 +96,44 @@ class GroundPlanner: group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE] # Create combat groups and assign them randomly to each enemy CP - for key in self.cp.base.armor.keys(): - - role = None - collection = None - if key in TYPE_TANKS: + for unit_type in self.cp.base.armor: + if unit_type in GroundUnitClass.Tank: collection = self.tank_groups role = CombatGroupRole.TANK - elif key in TYPE_APC: + elif unit_type in GroundUnitClass.Apc: collection = self.apc_group role = CombatGroupRole.APC - elif key in TYPE_ARTILLERY: + elif unit_type in GroundUnitClass.Artillery: collection = self.art_group role = CombatGroupRole.ARTILLERY - elif key in TYPE_IFV: + elif unit_type in GroundUnitClass.Ifv: collection = self.ifv_group role = CombatGroupRole.IFV - elif key in TYPE_LOGI: + elif unit_type in GroundUnitClass.Logistics: collection = self.logi_groups role = CombatGroupRole.LOGI - elif key in TYPE_ATGM: + elif unit_type in GroundUnitClass.Atgm: collection = self.atgm_group role = CombatGroupRole.ATGM - elif key in TYPE_SHORAD: + elif unit_type in GroundUnitClass.Shorads: collection = self.shorad_groups role = CombatGroupRole.SHORAD + elif unit_type in GroundUnitClass.Recon: + collection = self.recon_groups + role = CombatGroupRole.RECON else: - print("Warning unit type not handled by ground generator") - print(key) + logging.warning( + f"Unused front line vehicle at base {unit_type}: unknown unit class" + ) continue - available = self.cp.base.armor[key] + available = self.cp.base.armor[unit_type] + + if available > remaining_available_frontline_units: + available = remaining_available_frontline_units + + remaining_available_frontline_units -= available + while available > 0: if role == CombatGroupRole.SHORAD: @@ -141,14 +157,17 @@ class GroundPlanner: group.assigned_enemy_cp = "__reserve__" for i in range(n): - group.units.append(key) + group.units.append(unit_type) collection.append(group) + if remaining_available_frontline_units == 0: + break + print("------------------") print("Ground Planner : ") print(self.cp.name) print("------------------") - for key in self.units_per_cp.keys(): - print("For : #" + str(key)) - for group in self.units_per_cp[key]: + 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/ground_forces/ai_ground_planner_db.py b/gen/ground_forces/ai_ground_planner_db.py deleted file mode 100644 index 36c775de..00000000 --- a/gen/ground_forces/ai_ground_planner_db.py +++ /dev/null @@ -1,182 +0,0 @@ -from dcs.vehicles import AirDefence, Infantry, Unarmed, Artillery, Armor - -from pydcs_extensions.frenchpack import frenchpack - -TYPE_TANKS = [ - Armor.MBT_T_55, - Armor.MBT_T_72B, - Armor.MBT_T_72B3, - Armor.MBT_T_80U, - Armor.MBT_T_90, - Armor.MBT_Leopard_2, - Armor.MBT_Leopard_1A3, - Armor.MBT_Leclerc, - Armor.MBT_Challenger_II, - Armor.MBT_M1A2_Abrams, - Armor.MBT_M60A3_Patton, - Armor.MBT_Merkava_IV, - Armor.ZTZ_96B, - # WW2 - Armor.MT_Pz_Kpfw_V_Panther_Ausf_G, - Armor.MT_PzIV_H, - Armor.HT_Pz_Kpfw_VI_Tiger_I, - Armor.HT_Pz_Kpfw_VI_Ausf__B_Tiger_II, - Armor.MT_M4_Sherman, - Armor.MT_M4A4_Sherman_Firefly, - Armor.SPG_StuG_IV, - Armor.CT_Centaur_IV, - Armor.CT_Cromwell_IV, - Armor.HIT_Churchill_VII, - Armor.LT_Mk_VII_Tetrarch, - # Mods - frenchpack.DIM__TOYOTA_BLUE, - frenchpack.DIM__TOYOTA_GREEN, - frenchpack.DIM__TOYOTA_DESERT, - frenchpack.DIM__KAMIKAZE, - frenchpack.AMX_10RCR, - frenchpack.AMX_10RCR_SEPAR, - frenchpack.AMX_30B2, - frenchpack.Leclerc_Serie_XXI, -] - -TYPE_ATGM = [ - Armor.ATGM_HMMWV, - Armor.ATGM_Stryker, - Armor.IFV_BMP_2, - # WW2 (Tank Destroyers) - Unarmed.Carrier_M30_Cargo, - Armor.SPG_Jagdpanzer_IV, - Armor.SPG_Jagdpanther_G1, - Armor.SPG_M10_GMC, - # Mods - frenchpack.VBAE_CRAB_MMP, - frenchpack.VAB_MEPHISTO, - frenchpack.TRM_2000_PAMELA, -] - -TYPE_IFV = [ - Armor.IFV_BMP_3, - Armor.IFV_BMP_2, - Armor.IFV_BMP_1, - Armor.IFV_Marder, - Armor.IFV_Warrior, - Armor.IFV_LAV_25, - Armor.SPG_Stryker_MGS, - Armor.IFV_Sd_Kfz_234_2_Puma, - Armor.IFV_M2A2_Bradley, - Armor.IFV_BMD_1, - Armor.ZBD_04A, - # WW2 - Armor.IFV_Sd_Kfz_234_2_Puma, - Armor.Car_M8_Greyhound_Armored, - Armor.Car_Daimler_Armored, - # Mods - frenchpack.ERC_90, - frenchpack.VBAE_CRAB, - frenchpack.VAB_T20_13, -] - -TYPE_APC = [ - Armor.APC_HMMWV__Scout, - Armor.IFV_M1126_Stryker_ICV, - Armor.APC_M113, - Armor.APC_BTR_80, - Armor.APC_BTR_82A, - Armor.APC_MTLB, - Armor.APC_M2A1_Halftrack, - Armor.APC_Cobra__Scout, - Armor.APC_Sd_Kfz_251_Halftrack, - Armor.APC_AAV_7_Amphibious, - Armor.APC_TPz_Fuchs, - Armor.IFV_BRDM_2, - Armor.APC_BTR_RD, - Artillery.Grad_MRL_FDDM__FC, - # WW2 - Armor.APC_M2A1_Halftrack, - Armor.APC_Sd_Kfz_251_Halftrack, - # Mods - frenchpack.VAB__50, - frenchpack.VBL__50, - frenchpack.VBL_AANF1, -] - -TYPE_ARTILLERY = [ - Artillery.MLRS_9A52_Smerch_HE_300mm, - Artillery.SPH_2S1_Gvozdika_122mm, - Artillery.SPH_2S3_Akatsia_152mm, - Artillery.MLRS_BM_21_Grad_122mm, - Artillery.MLRS_BM_27_Uragan_220mm, - Artillery.SPH_M109_Paladin_155mm, - Artillery.MLRS_M270_227mm, - Artillery.SPH_2S9_Nona_120mm_M, - Artillery.SPH_Dana_vz77_152mm, - Artillery.PLZ_05, - Artillery.SPH_2S19_Msta_152mm, - Artillery.MLRS_9A52_Smerch_CM_300mm, - # WW2 - Artillery.SPG_Sturmpanzer_IV_Brummbar, - Artillery.SPG_M12_GMC_155mm, -] - -TYPE_LOGI = [ - Unarmed.Truck_M818_6x6, - Unarmed.Truck_KAMAZ_43101, - Unarmed.Truck_Ural_375, - Unarmed.Truck_GAZ_66, - Unarmed.Truck_GAZ_3307, - Unarmed.Truck_GAZ_3308, - Unarmed.Truck_Ural_4320_31_Arm_d, - Unarmed.Truck_Ural_4320T, - Unarmed.Truck_Opel_Blitz, - Unarmed.LUV_Kubelwagen_82, - Unarmed.Carrier_Sd_Kfz_7_Tractor, - Unarmed.LUV_Kettenrad, - Unarmed.Car_Willys_Jeep, - Unarmed.LUV_Land_Rover_109, - Unarmed.Truck_Land_Rover_101_FC, - # Mods - frenchpack.VBL, - frenchpack.VAB, -] - -TYPE_INFANTRY = [ - Infantry.Insurgent_AK_74, - Infantry.Infantry_AK_74, - Infantry.Infantry_M1_Garand, - Infantry.Infantry_Mauser_98, - Infantry.Infantry_SMLE_No_4_Mk_1, - Infantry.Infantry_M4_Georgia, - Infantry.Infantry_AK_74_Rus, - Infantry.Paratrooper_AKS, - Infantry.Paratrooper_RPG_16, - Infantry.Infantry_M249, - Infantry.Infantry_M4, - Infantry.Infantry_RPG, -] - -TYPE_SHORAD = [ - AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375, - AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375, - AirDefence.SPAAA_ZSU_57_2, - AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish, - AirDefence.SAM_SA_8_Osa_Gecko_TEL, - AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL, - AirDefence.SAM_SA_13_Strela_10M3_Gopher_TEL, - AirDefence.SAM_SA_15_Tor_Gauntlet, - AirDefence.SAM_SA_19_Tunguska_Grison, - AirDefence.SPAAA_Gepard, - AirDefence.SPAAA_Vulcan_M163, - AirDefence.SAM_Linebacker___Bradley_M6, - AirDefence.SAM_Chaparral_M48, - AirDefence.SAM_Avenger__Stinger, - AirDefence.SAM_Roland_ADS, - AirDefence.HQ_7_Self_Propelled_LN, - AirDefence.AAA_8_8cm_Flak_18, - AirDefence.AAA_8_8cm_Flak_36, - AirDefence.AAA_8_8cm_Flak_37, - AirDefence.AAA_8_8cm_Flak_41, - AirDefence.AAA_40mm_Bofors, - AirDefence.AAA_S_60_57mm, - AirDefence.AAA_M1_37mm, - AirDefence.AAA_QF_3_7, -] diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index 589bd960..f3cf94f3 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -12,9 +12,10 @@ import random from typing import Dict, Iterator, Optional, TYPE_CHECKING, Type, List from dcs import Mission, Point, unitgroup +from dcs.action import SceneryDestructionZone from dcs.country import Country from dcs.point import StaticPoint -from dcs.statics import fortification_map, warehouse_map, Warehouse +from dcs.statics import Fortification, fortification_map, warehouse_map from dcs.task import ( ActivateBeaconCommand, ActivateICLSCommand, @@ -22,7 +23,8 @@ from dcs.task import ( OptAlarmState, FireAtPoint, ) -from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad, Static +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.vehicles import vehicle_map @@ -34,13 +36,15 @@ from game.theater import ControlPoint, TheaterGroundObject from game.theater.theatergroundobject import ( BuildingGroundObject, CarrierGroundObject, + FactoryGroundObject, GenericCarrierGroundObject, LhaGroundObject, ShipGroundObject, MissileSiteGroundObject, + SceneryGroundObject, ) from game.unitmap import UnitMap -from game.utils import knots, mps +from game.utils import feet, knots, mps from .radios import RadioFrequency, RadioRegistry from .runways import RunwayData from .tacan import TacanBand, TacanChannel, TacanRegistry @@ -72,8 +76,12 @@ class GenericGroundObjectGenerator: self.m = mission self.unit_map = unit_map + @property + def culled(self) -> bool: + return self.game.position_culled(self.ground_object.position) + def generate(self) -> None: - if self.game.position_culled(self.ground_object.position): + if self.culled: return for group in self.ground_object.groups: @@ -92,13 +100,11 @@ class GenericGroundObjectGenerator: position=group.position, heading=group.units[0].heading, ) - vg.units[0].name = self.m.string(group.units[0].name) + vg.units[0].name = group.units[0].name vg.units[0].player_can_drive = True for i, u in enumerate(group.units): if i > 0: - vehicle = Vehicle( - self.m.next_unit_id(), self.m.string(u.name), u.type - ) + vehicle = Vehicle(self.m.next_unit_id(), u.name, u.type) vehicle.position.x = u.position.x vehicle.position.y = u.position.y vehicle.heading = u.heading @@ -128,6 +134,12 @@ class GenericGroundObjectGenerator: class MissileSiteGenerator(GenericGroundObjectGenerator): + @property + def culled(self) -> bool: + # Don't cull missile sites - their range is long enough to make them easily + # culled despite being a threat. + return False + def generate(self) -> None: super(MissileSiteGenerator, self).generate() # Note : Only the SCUD missiles group can fire (V1 site cannot fire in game right now) @@ -213,7 +225,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator): f"{self.ground_object.dcs_identifier} not found in static maps" ) - def generate_vehicle_group(self, unit_type: UnitType) -> None: + def generate_vehicle_group(self, unit_type: Type[UnitType]) -> None: if not self.ground_object.is_dead: group = self.m.vehicle_group( country=self.country, @@ -224,7 +236,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator): ) self._register_fortification(group) - def generate_static(self, static_type: StaticType) -> None: + def generate_static(self, static_type: Type[StaticType]) -> None: group = self.m.static_group( country=self.country, name=self.ground_object.group_name, @@ -244,6 +256,74 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator): self.unit_map.add_building(self.ground_object, building) +class FactoryGenerator(BuildingSiteGenerator): + """Generator for factory sites. + + Factory sites are the buildings that allow the recruitment of ground units. + Destroying these prevents the owner from recruiting ground units at the connected + control point. + """ + + def generate(self) -> None: + if self.game.position_culled(self.ground_object.position): + return + + # TODO: Faction specific? + self.generate_static(Fortification.Workshop_A) + + +class SceneryGenerator(BuildingSiteGenerator): + def generate(self) -> None: + assert isinstance(self.ground_object, SceneryGroundObject) + + trigger_zone = self.generate_trigger_zone(self.ground_object) + + # DCS only visually shows a scenery object is dead when + # this trigger rule is applied. Otherwise you can kill a + # structure twice. + if self.ground_object.is_dead: + self.generate_dead_trigger_rule(trigger_zone) + + # Tell Liberation to manage this groundobjectsgen as part of the campaign. + self.register_scenery() + + def generate_trigger_zone(self, scenery: SceneryGroundObject) -> TriggerZone: + + zone = scenery.zone + + # Align the trigger zones to the faction color on the DCS briefing/F10 map. + if scenery.is_friendly(to_player=True): + color = {1: 0.2, 2: 0.7, 3: 1, 4: 0.15} + else: + color = {1: 1, 2: 0.2, 3: 0.2, 4: 0.15} + + # Create the smallest valid size trigger zone (16 feet) so that risk of overlap is minimized. + # As long as the triggerzone is over the scenery object, we're ok. + smallest_valid_radius = feet(16).meters + + return self.m.triggers.add_triggerzone( + zone.position, + smallest_valid_radius, + zone.hidden, + zone.name, + color, + zone.properties, + ) + + def generate_dead_trigger_rule(self, trigger_zone: TriggerZone) -> None: + # Add destruction zone trigger + t = TriggerStart(comment="Destruction") + t.actions.append( + SceneryDestructionZone(destruction_level=100, zone=trigger_zone.id) + ) + self.m.triggerrules.triggers.append(t) + + def register_scenery(self) -> None: + scenery = self.ground_object + assert isinstance(scenery, SceneryGroundObject) + self.unit_map.add_scenery(scenery) + + class GenericCarrierGenerator(GenericGroundObjectGenerator): """Base type for carrier group generation. @@ -313,13 +393,13 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator): heading=group.units[0].heading, ) ship_group.set_frequency(atc_channel.hertz) - ship_group.units[0].name = self.m.string(group.units[0].name) + ship_group.units[0].name = group.units[0].name return ship_group def create_ship(self, unit: Unit, atc_channel: RadioFrequency) -> Ship: ship = Ship( self.m.next_unit_id(), - self.m.string(unit.name), + unit.name, unit_type_from_name(unit.type), ) ship.position.x = unit.position.x @@ -464,11 +544,11 @@ class ShipObjectGenerator(GenericGroundObjectGenerator): position=group_def.position, heading=group_def.units[0].heading, ) - group.units[0].name = self.m.string(group_def.units[0].name) + group.units[0].name = group_def.units[0].name # TODO: Skipping the first unit looks like copy pasta from the carrier. for unit in group_def.units[1:]: unit_type = unit_type_from_name(unit.type) - ship = Ship(self.m.next_unit_id(), self.m.string(unit.name), unit_type) + ship = Ship(self.m.next_unit_id(), unit.name, unit_type) ship.position.x = unit.position.x ship.position.y = unit.position.y ship.heading = unit.heading @@ -507,11 +587,11 @@ class HelipadGenerator: for i, helipad in enumerate(self.cp.helipads): name = self.cp.name + "_helipad_" + str(i) logging.info("Generating helipad : " + name) - pad = SingleHeliPad(name=self.m.string(name + "_unit")) + pad = SingleHeliPad(name=(name + "_unit")) pad.position = Point(helipad.x, helipad.y) pad.heading = helipad.heading # pad.heliport_frequency = self.radio_registry.alloc_uhf() TODO : alloc radio & callsign - sg = unitgroup.StaticGroup(self.m.next_group_id(), self.m.string(name)) + sg = unitgroup.StaticGroup(self.m.next_group_id(), name) sg.add_unit(pad) sp = StaticPoint() sp.position = pad.position @@ -557,7 +637,15 @@ class GroundObjectsGenerator: ).generate() for ground_object in cp.ground_objects: - if isinstance(ground_object, BuildingGroundObject): + if isinstance(ground_object, FactoryGroundObject): + generator = FactoryGenerator( + ground_object, country, self.game, self.m, self.unit_map + ) + elif isinstance(ground_object, SceneryGroundObject): + generator = SceneryGenerator( + ground_object, country, self.game, self.m, self.unit_map + ) + elif isinstance(ground_object, BuildingGroundObject): generator = BuildingSiteGenerator( ground_object, country, self.game, self.m, self.unit_map ) diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 8f809148..71544a26 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -23,25 +23,30 @@ 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 textwrap from collections import defaultdict from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional, TYPE_CHECKING, Tuple +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple, Iterator from PIL import Image, ImageDraw, ImageFont from dcs.mission import Mission +from dcs.unit import Unit from dcs.unittype import FlyingType from tabulate import tabulate +from game.data.alic import AlicCodes +from game.db import unit_type_from_name +from game.theater import ConflictTheater, TheaterGroundObject, LatLon +from game.theater.bullseye import Bullseye from game.utils import meters from .aircraft import AIRCRAFT_DATA, FlightData from .airsupportgen import AwacsInfo, TankerInfo from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator -from .flights.flight import FlightWaypoint, FlightWaypointType +from .flights.flight import FlightWaypoint, FlightWaypointType, FlightType from .radios import RadioFrequency from .runways import RunwayData - if TYPE_CHECKING: from game import Game @@ -137,6 +142,11 @@ class KneeboardPage: """Writes the kneeboard page to the given path.""" raise NotImplementedError + def format_ll(self, ll: LatLon) -> str: + ns = "N" if ll.latitude >= 0 else "S" + ew = "E" if ll.longitude >= 0 else "W" + return f"{ll.latitude:.4}°{ns} {ll.longitude:.4}°{ew}" + @dataclass(frozen=True) class NumberedWaypoint: @@ -249,21 +259,16 @@ class BriefingPage(KneeboardPage): def __init__( self, flight: FlightData, - comms: List[CommInfo], - awacs: List[AwacsInfo], - tankers: List[TankerInfo], - jtacs: List[JtacInfo], + bullseye: Bullseye, + theater: ConflictTheater, start_time: datetime.datetime, dark_kneeboard: bool, ) -> None: self.flight = flight - self.comms = list(comms) - self.awacs = awacs - self.tankers = tankers - self.jtacs = jtacs + self.bullseye = bullseye + self.theater = theater self.start_time = start_time self.dark_kneeboard = dark_kneeboard - self.comms.append(CommInfo("Flight", self.flight.intra_flight_channel)) def write(self, path: Path) -> None: writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) @@ -293,7 +298,8 @@ class BriefingPage(KneeboardPage): headers=["#", "Action", "Alt", "Dist", "GSPD", "Time", "Departure"], ) - flight_plan_builder + writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}") + writer.table( [ [ @@ -304,6 +310,86 @@ class BriefingPage(KneeboardPage): ["Bingo", "Joker"], ) + writer.write(path) + + def airfield_info_row( + self, row_title: str, runway: Optional[RunwayData] + ) -> List[str]: + """Creates a table row for a given airfield. + + Args: + row_title: Purpose of the airfield. e.g. "Departure", "Arrival" or + "Divert". + runway: The runway described by this row. + + Returns: + A list of strings to be used as a row of the airfield table. + """ + if runway is None: + return [row_title, "", "", "", "", ""] + + atc = "" + if runway.atc is not None: + atc = self.format_frequency(runway.atc) + if runway.tacan is None: + tacan = "" + else: + tacan = str(runway.tacan) + if runway.ils is not None: + ils = str(runway.ils) + elif runway.icls is not None: + ils = str(runway.icls) + else: + ils = "" + return [ + row_title, + "\n".join(textwrap.wrap(runway.airfield_name, width=24)), + atc, + tacan, + ils, + runway.runway_name, + ] + + def format_frequency(self, frequency: RadioFrequency) -> str: + channel = self.flight.channel_for(frequency) + if channel is None: + return str(frequency) + + namer = AIRCRAFT_DATA[self.flight.aircraft_type.id].channel_namer + channel_name = namer.channel_name(channel.radio_id, channel.channel) + return f"{channel_name}\n{frequency}" + + +class SupportPage(KneeboardPage): + """A kneeboard page containing information about support units.""" + + def __init__( + self, + flight: FlightData, + comms: List[CommInfo], + awacs: List[AwacsInfo], + tankers: List[TankerInfo], + jtacs: List[JtacInfo], + start_time: datetime.datetime, + dark_kneeboard: bool, + ) -> None: + self.flight = flight + self.comms = list(comms) + self.awacs = awacs + self.tankers = tankers + self.jtacs = jtacs + self.start_time = start_time + self.dark_kneeboard = dark_kneeboard + self.comms.append(CommInfo("Flight", self.flight.intra_flight_channel)) + + def write(self, path: Path) -> None: + writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) + if self.flight.custom_name is not None: + custom_name_title = ' ("{}")'.format(self.flight.custom_name) + else: + custom_name_title = "" + writer.title(f"{self.flight.callsign} Support Info{custom_name_title}") + # AEW&C writer.heading("AEW&C") aewc_ladder = [] @@ -361,44 +447,6 @@ class BriefingPage(KneeboardPage): writer.write(path) - def airfield_info_row( - self, row_title: str, runway: Optional[RunwayData] - ) -> List[str]: - """Creates a table row for a given airfield. - - Args: - row_title: Purpose of the airfield. e.g. "Departure", "Arrival" or - "Divert". - runway: The runway described by this row. - - Returns: - A list of strings to be used as a row of the airfield table. - """ - if runway is None: - return [row_title, "", "", "", "", ""] - - atc = "" - if runway.atc is not None: - atc = self.format_frequency(runway.atc) - if runway.tacan is None: - tacan = "" - else: - tacan = str(runway.tacan) - if runway.ils is not None: - ils = str(runway.ils) - elif runway.icls is not None: - ils = str(runway.icls) - else: - ils = "" - return [ - row_title, - runway.airfield_name, - atc, - tacan, - ils, - runway.runway_name, - ] - def format_frequency(self, frequency: RadioFrequency) -> str: channel = self.flight.channel_for(frequency) if channel is None: @@ -415,6 +463,94 @@ class BriefingPage(KneeboardPage): return local_time.strftime(f"%H:%M:%S") +class SeadTaskPage(KneeboardPage): + """A kneeboard page containing SEAD/DEAD target information.""" + + def __init__( + self, flight: FlightData, dark_kneeboard: bool, theater: ConflictTheater + ) -> None: + self.flight = flight + self.dark_kneeboard = dark_kneeboard + self.theater = theater + + @property + def target_units(self) -> Iterator[Unit]: + if isinstance(self.flight.package.target, TheaterGroundObject): + yield from self.flight.package.target.units + + @staticmethod + def alic_for(unit: Unit) -> str: + try: + return str(AlicCodes.code_for(unit)) + except KeyError: + return "" + + def write(self, path: Path) -> None: + writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) + if self.flight.custom_name is not None: + custom_name_title = ' ("{}")'.format(self.flight.custom_name) + else: + custom_name_title = "" + task = "DEAD" if self.flight.flight_type == FlightType.DEAD else "SEAD" + writer.title(f"{self.flight.callsign} {task} Target Info{custom_name_title}") + + writer.table( + [self.target_info_row(t) for t in self.target_units], + headers=["Description", "ALIC", "Location"], + ) + + writer.write(path) + + def target_info_row(self, unit: Unit) -> List[str]: + ll = self.theater.point_to_ll(unit.position) + unit_type = unit_type_from_name(unit.type) + name = unit.name if unit_type is None else unit_type.name + return [name, self.alic_for(unit), ll.format_dms(include_decimal_seconds=True)] + + +class StrikeTaskPage(KneeboardPage): + """A kneeboard page containing strike target information.""" + + def __init__( + self, + flight: FlightData, + dark_kneeboard: bool, + theater: ConflictTheater, + ) -> None: + self.flight = flight + self.dark_kneeboard = dark_kneeboard + self.theater = theater + + @property + def targets(self) -> Iterator[NumberedWaypoint]: + for idx, waypoint in enumerate(self.flight.waypoints): + if waypoint.waypoint_type == FlightWaypointType.TARGET_POINT: + yield NumberedWaypoint(idx, waypoint) + + def write(self, path: Path) -> None: + writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) + if self.flight.custom_name is not None: + custom_name_title = ' ("{}")'.format(self.flight.custom_name) + else: + custom_name_title = "" + writer.title(f"{self.flight.callsign} Strike Task Info{custom_name_title}") + + writer.table( + [self.target_info_row(t) for t in self.targets], + headers=["Steerpoint", "Description", "Location"], + ) + + writer.write(path) + + def target_info_row(self, target: NumberedWaypoint) -> List[str]: + ll = self.theater.point_to_ll(target.waypoint.position) + return [ + str(target.number), + target.waypoint.pretty_name, + ll.format_dms(include_decimal_seconds=True), + ] + + class KneeboardGenerator(MissionInfoGenerator): """Creates kneeboard pages for each client flight in the mission.""" @@ -456,10 +592,24 @@ class KneeboardGenerator(MissionInfoGenerator): ) return all_flights + def generate_task_page(self, flight: FlightData) -> Optional[KneeboardPage]: + if flight.flight_type in (FlightType.DEAD, FlightType.SEAD): + return SeadTaskPage(flight, self.dark_kneeboard, self.game.theater) + elif flight.flight_type is FlightType.STRIKE: + return StrikeTaskPage(flight, self.dark_kneeboard, self.game.theater) + return None + def generate_flight_kneeboard(self, flight: FlightData) -> List[KneeboardPage]: """Returns a list of kneeboard pages for the given flight.""" - return [ + pages: List[KneeboardPage] = [ BriefingPage( + flight, + self.game.bullseye_for(flight.friendly), + self.game.theater, + self.mission.start_time, + self.dark_kneeboard, + ), + SupportPage( flight, self.comms, self.awacs, @@ -469,3 +619,8 @@ class KneeboardGenerator(MissionInfoGenerator): self.dark_kneeboard, ), ] + + if (target_page := self.generate_task_page(flight)) is not None: + pages.append(target_page) + + return pages diff --git a/gen/locations/preset_location_finder.py b/gen/locations/preset_location_finder.py deleted file mode 100644 index eb591ff9..00000000 --- a/gen/locations/preset_location_finder.py +++ /dev/null @@ -1,87 +0,0 @@ -from pathlib import Path -from typing import List - -from dcs import Mission, ships -from dcs.vehicles import MissilesSS - -from gen.locations.preset_control_point_locations import PresetControlPointLocations -from gen.locations.preset_locations import PresetLocation - - -class MizDataLocationFinder: - @staticmethod - def compute_possible_locations( - terrain_name: str, cp_name: str - ) -> PresetControlPointLocations: - """ - Extract the list of preset locations from miz data - :param terrain_name: Terrain/Map name - :param cp_name: Control Point / Airbase name - :return: - """ - - miz_file = Path("./resources/mizdata/", terrain_name.lower(), cp_name + ".miz") - - offshore_locations: List[PresetLocation] = [] - ashore_locations: List[PresetLocation] = [] - powerplants_locations: List[PresetLocation] = [] - antiship_locations: List[PresetLocation] = [] - - if miz_file.exists(): - m = Mission() - m.load_file(miz_file.absolute()) - - for vehicle_group in m.country("USA").vehicle_group: - if len(vehicle_group.units) > 0: - ashore_locations.append( - PresetLocation( - vehicle_group.position, - vehicle_group.units[0].heading, - vehicle_group.name, - ) - ) - - for ship_group in m.country("USA").ship_group: - if ( - len(ship_group.units) > 0 - and ship_group.units[0].type == ships.FFG_Oliver_Hazzard_Perry.id - ): - offshore_locations.append( - PresetLocation( - ship_group.position, - ship_group.units[0].heading, - ship_group.name, - ) - ) - - for static_group in m.country("USA").static_group: - if len(static_group.units) > 0: - powerplants_locations.append( - PresetLocation( - static_group.position, - static_group.units[0].heading, - static_group.name, - ) - ) - - if m.country("Iran") is not None: - for vehicle_group in m.country("Iran").vehicle_group: - if ( - len(vehicle_group.units) > 0 - and vehicle_group.units[0].type - == MissilesSS.AShM_SS_N_2_Silkworm.id - ): - antiship_locations.append( - PresetLocation( - vehicle_group.position, - vehicle_group.units[0].heading, - vehicle_group.name, - ) - ) - - return PresetControlPointLocations( - ashore_locations, - offshore_locations, - antiship_locations, - powerplants_locations, - ) diff --git a/gen/naming.py b/gen/naming.py index f1b14114..dad364dc 100644 --- a/gen/naming.py +++ b/gen/naming.py @@ -39,7 +39,7 @@ ALPHA_MILITARY = [ "Zero", ] -ANIMALS = [ +ANIMALS: tuple[str, ...] = ( "SHARK", "TORTOISE", "BAT", @@ -243,22 +243,26 @@ ANIMALS = [ "CANARY", "WOODCHUCK", "ANACONDA", -] +) class NameGenerator: number = 0 infantry_number = 0 aircraft_number = 0 + convoy_number = 0 + cargo_ship_number = 0 - ANIMALS = ANIMALS + animals: list[str] = list(ANIMALS) existing_alphas: List[str] = [] @classmethod def reset(cls): cls.number = 0 cls.infantry_number = 0 - cls.ANIMALS = ANIMALS + cls.convoy_number = 0 + cls.cargo_ship_number = 0 + cls.animals = list(ANIMALS) cls.existing_alphas = [] @classmethod @@ -266,6 +270,8 @@ class NameGenerator: cls.number = 0 cls.infantry_number = 0 cls.aircraft_number = 0 + cls.convoy_number = 0 + cls.cargo_ship_number = 0 @classmethod def next_aircraft_name(cls, country: Country, parent_base_id: int, flight: Flight): @@ -306,10 +312,6 @@ class NameGenerator: db.unit_type_name(unit_type), ) - @staticmethod - def next_basedefense_name(): - return "basedefense_aa|0|0|" - @classmethod def next_awacs_name(cls, country: Country): cls.number += 1 @@ -328,31 +330,36 @@ class NameGenerator: return "carrier|{}|{}|0|".format(country.id, cls.number) @classmethod - def random_objective_name(cls): - if len(cls.ANIMALS) == 0: - for i in range(10): - new_name_generated = True - alpha_mil_name = ( - random.choice(ALPHA_MILITARY).upper() - + "#" - + str(random.randint(0, 100)) - ) - for existing_name in cls.existing_alphas: - if existing_name == alpha_mil_name: - new_name_generated = False - if new_name_generated: - cls.existing_alphas.append(alpha_mil_name) - return alpha_mil_name + def next_convoy_name(cls) -> str: + cls.convoy_number += 1 + return f"Convoy {cls.convoy_number:03}" - # At this point, give up trying - something has gone wrong and we haven't been able to make a new name in 10 tries. - # We'll just make a longer name using the current unix epoch in nanoseconds. That should be unique... right? - last_chance_name = alpha_mil_name + str(time.time_ns()) - cls.existing_alphas.append(last_chance_name) - return last_chance_name - else: - animal = random.choice(cls.ANIMALS) - cls.ANIMALS.remove(animal) + @classmethod + def next_cargo_ship_name(cls) -> str: + cls.cargo_ship_number += 1 + return f"Cargo Ship {cls.cargo_ship_number:03}" + + @classmethod + def random_objective_name(cls): + if cls.animals: + animal = random.choice(cls.animals) + cls.animals.remove(animal) return animal + for _ in range(10): + alpha = random.choice(ALPHA_MILITARY).upper() + number = random.randint(0, 100) + alpha_mil_name = f"{alpha} #{number:02}" + if alpha_mil_name not in cls.existing_alphas: + cls.existing_alphas.append(alpha_mil_name) + return alpha_mil_name + + # At this point, give up trying - something has gone wrong and we haven't been + # able to make a new name in 10 tries. We'll just make a longer name using the + # current unix epoch in nanoseconds. That should be unique... right? + last_chance_name = alpha_mil_name + str(time.time_ns()) + cls.existing_alphas.append(last_chance_name) + return last_chance_name + namegen = NameGenerator diff --git a/gen/radios.py b/gen/radios.py index ae72922b..333647df 100644 --- a/gen/radios.py +++ b/gen/radios.py @@ -134,6 +134,7 @@ RADIOS: List[Radio] = [ Radio("AN/ARC-51BX", MHz(225), MHz(400), step=kHz(50)), Radio("AN/ARC-131", MHz(30), MHz(76), step=kHz(50)), Radio("AN/ARC-134", MHz(116), MHz(150), step=kHz(25)), + Radio("R&S Series 6000", MHz(100), MHz(156), step=kHz(25)), ] @@ -206,7 +207,7 @@ class RadioRegistry: except StopIteration: # In the event of too many channel users, fail gracefully by reusing # the last channel. - # https://github.com/Khopa/dcs_liberation/issues/598 + # https://github.com/dcs-liberation/dcs_liberation/issues/598 channel = radio.last_channel logging.warning( f"No more free channels for {radio.name}. Reusing {channel}." diff --git a/gen/sam/aaa_bofors.py b/gen/sam/aaa_bofors.py index 54e06f63..6af41922 100644 --- a/gen/sam/aaa_bofors.py +++ b/gen/sam/aaa_bofors.py @@ -27,7 +27,7 @@ class BoforsGenerator(AirDefenseGroupGenerator): for j in range(grid_y): index = index + 1 self.add_unit( - AirDefence.AAA_40mm_Bofors, + AirDefence.AAA_Bofors_40mm, "AAA#" + str(index), self.position.x + spacing * i, self.position.y + spacing * j, @@ -36,4 +36,4 @@ class BoforsGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/aaa_flak.py b/gen/sam/aaa_flak.py index bec67196..25b9e274 100644 --- a/gen/sam/aaa_flak.py +++ b/gen/sam/aaa_flak.py @@ -98,4 +98,4 @@ class FlakGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/aaa_flak18.py b/gen/sam/aaa_flak18.py index 5dec254a..26b44f82 100644 --- a/gen/sam/aaa_flak18.py +++ b/gen/sam/aaa_flak18.py @@ -43,4 +43,4 @@ class Flak18Generator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/aaa_ks19.py b/gen/sam/aaa_ks19.py index c733b468..1e3de4ca 100644 --- a/gen/sam/aaa_ks19.py +++ b/gen/sam/aaa_ks19.py @@ -41,4 +41,4 @@ class KS19Generator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/aaa_ww2_ally_flak.py b/gen/sam/aaa_ww2_ally_flak.py index 3dd6b715..c9ace380 100644 --- a/gen/sam/aaa_ww2_ally_flak.py +++ b/gen/sam/aaa_ww2_ally_flak.py @@ -80,4 +80,4 @@ class AllyWW2FlakGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/aaa_zsu57.py b/gen/sam/aaa_zsu57.py index ad3c5eea..c87bf63d 100644 --- a/gen/sam/aaa_zsu57.py +++ b/gen/sam/aaa_zsu57.py @@ -30,4 +30,4 @@ class ZSU57Generator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/aaa_zu23_insurgent.py b/gen/sam/aaa_zu23_insurgent.py index 00e2f41f..96cd1d71 100644 --- a/gen/sam/aaa_zu23_insurgent.py +++ b/gen/sam/aaa_zu23_insurgent.py @@ -27,7 +27,7 @@ class ZU23InsurgentGenerator(AirDefenseGroupGenerator): for j in range(grid_y): index = index + 1 self.add_unit( - AirDefence.AAA_ZU_23_Closed_Emplacement_Insurgent, + AirDefence.AAA_ZU_23_Insurgent_Closed_Emplacement, "AAA#" + str(index), self.position.x + spacing * i, self.position.y + spacing * j, @@ -36,4 +36,4 @@ class ZU23InsurgentGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py index 39d61e8f..a62a5f11 100644 --- a/gen/sam/airdefensegroupgenerator.py +++ b/gen/sam/airdefensegroupgenerator.py @@ -1,4 +1,3 @@ -import logging from abc import ABC, abstractmethod from enum import Enum from typing import Iterator, List @@ -6,11 +5,12 @@ from typing import Iterator, List from dcs.unitgroup import VehicleGroup from game import Game -from gen.sam.group_generator import GroupGenerator from game.theater.theatergroundobject import SamGroundObject +from gen.sam.group_generator import GroupGenerator class AirDefenseRange(Enum): + AAA = "AAA" Short = "short" Medium = "medium" Long = "long" @@ -21,6 +21,8 @@ class AirDefenseGroupGenerator(GroupGenerator, ABC): This is the base for all SAM group generators """ + price: int + def __init__(self, game: Game, ground_object: SamGroundObject) -> None: ground_object.skynet_capable = True super().__init__(game, ground_object) diff --git a/gen/sam/cold_war_flak.py b/gen/sam/cold_war_flak.py index 7b11f618..1ea3a724 100644 --- a/gen/sam/cold_war_flak.py +++ b/gen/sam/cold_war_flak.py @@ -6,7 +6,6 @@ from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, ) -from gen.sam.group_generator import GroupGenerator class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator): @@ -80,7 +79,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA class ColdWarFlakGenerator(AirDefenseGroupGenerator): @@ -153,4 +152,4 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/ewr_group_generator.py b/gen/sam/ewr_group_generator.py index e576f5a8..81ede492 100644 --- a/gen/sam/ewr_group_generator.py +++ b/gen/sam/ewr_group_generator.py @@ -17,8 +17,8 @@ from gen.sam.ewrs import ( SnowDriftGenerator, StraightFlushGenerator, TallRackGenerator, + EwrGenerator, ) -from gen.sam.group_generator import GroupGenerator EWR_MAP = { "BoxSpringGenerator": BoxSpringGenerator, @@ -36,7 +36,7 @@ EWR_MAP = { def get_faction_possible_ewrs_generator( faction: Faction, -) -> List[Type[GroupGenerator]]: +) -> List[Type[EwrGenerator]]: """ Return the list of possible EWR generators for the given faction :param faction: Faction name to search units for diff --git a/gen/sam/ewrs.py b/gen/sam/ewrs.py index 7164a09e..0c529cf8 100644 --- a/gen/sam/ewrs.py +++ b/gen/sam/ewrs.py @@ -5,9 +5,16 @@ from gen.sam.group_generator import GroupGenerator class EwrGenerator(GroupGenerator): - @property - def unit_type(self) -> VehicleType: - raise NotImplementedError + unit_type: VehicleType + + @classmethod + 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( @@ -87,7 +94,7 @@ class StraightFlushGenerator(EwrGenerator): This is the SA-6 search/track radar, but used as an early warning radar. """ - unit_type = AirDefence.SAM_SA_6_Kub_Long_Track_STR + unit_type = AirDefence.SAM_SA_6_Kub_Straight_Flush_STR class HawkEwrGenerator(EwrGenerator): diff --git a/gen/sam/sam_group_generator.py b/gen/sam/sam_group_generator.py index e13f0f2c..a4968830 100644 --- a/gen/sam/sam_group_generator.py +++ b/gen/sam/sam_group_generator.py @@ -105,13 +105,13 @@ SAM_MAP: Dict[str, Type[AirDefenseGroupGenerator]] = { SAM_PRICES = { - AirDefence.SAM_Hawk_Generator__PCP: 35, + AirDefence.SAM_Hawk_Platoon_Command_Post__PCP: 35, AirDefence.AAA_ZU_23_Emplacement: 10, AirDefence.AAA_ZU_23_Closed_Emplacement: 10, AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375: 10, AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375: 10, - AirDefence.AAA_ZU_23_Closed_Emplacement_Insurgent: 10, - AirDefence.AAA_ZU_23_Insurgent: 10, + AirDefence.AAA_ZU_23_Insurgent_Closed_Emplacement: 10, + AirDefence.AAA_ZU_23_Insurgent_Emplacement: 10, AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish: 10, AirDefence.SPAAA_Vulcan_M163: 15, AirDefence.SAM_Linebacker___Bradley_M6: 20, @@ -122,7 +122,7 @@ SAM_PRICES = { AirDefence.SAM_Patriot_LN: 85, AirDefence.SAM_Patriot_EPP_III: 85, AirDefence.SAM_Chaparral_M48: 25, - AirDefence.AAA_40mm_Bofors: 15, + AirDefence.AAA_Bofors_40mm: 15, AirDefence.AAA_8_8cm_Flak_36: 15, AirDefence.SAM_SA_2_S_75_Guideline_LN: 30, AirDefence.SAM_SA_3_S_125_Goa_LN: 35, diff --git a/gen/sam/sam_hawk.py b/gen/sam/sam_hawk.py index ab3332cd..f07f8872 100644 --- a/gen/sam/sam_hawk.py +++ b/gen/sam/sam_hawk.py @@ -26,7 +26,7 @@ class HawkGenerator(AirDefenseGroupGenerator): self.heading, ) self.add_unit( - AirDefence.SAM_Hawk_Generator__PCP, + AirDefence.SAM_Hawk_Platoon_Command_Post__PCP, "PCP", self.position.x, self.position.y, diff --git a/gen/sam/sam_sa6.py b/gen/sam/sam_sa6.py index a1def06d..25746651 100644 --- a/gen/sam/sam_sa6.py +++ b/gen/sam/sam_sa6.py @@ -18,7 +18,7 @@ class SA6Generator(AirDefenseGroupGenerator): def generate(self): self.add_unit( - AirDefence.SAM_SA_6_Kub_Long_Track_STR, + AirDefence.SAM_SA_6_Kub_Straight_Flush_STR, "STR", self.position.x, self.position.y, diff --git a/gen/sam/sam_vulcan.py b/gen/sam/sam_vulcan.py index 9ce2c04a..24998c4f 100644 --- a/gen/sam/sam_vulcan.py +++ b/gen/sam/sam_vulcan.py @@ -42,4 +42,4 @@ class VulcanGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/sam_zsu23.py b/gen/sam/sam_zsu23.py index 9caa1f01..5be3de8d 100644 --- a/gen/sam/sam_zsu23.py +++ b/gen/sam/sam_zsu23.py @@ -33,4 +33,4 @@ class ZSU23Generator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/sam_zu23.py b/gen/sam/sam_zu23.py index 0cd169a1..9cb43ab2 100644 --- a/gen/sam/sam_zu23.py +++ b/gen/sam/sam_zu23.py @@ -36,4 +36,4 @@ class ZU23Generator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/sam_zu23_ural.py b/gen/sam/sam_zu23_ural.py index 50e57a84..af5f85db 100644 --- a/gen/sam/sam_zu23_ural.py +++ b/gen/sam/sam_zu23_ural.py @@ -33,4 +33,4 @@ class ZU23UralGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/sam/sam_zu23_ural_insurgent.py b/gen/sam/sam_zu23_ural_insurgent.py index 6c6f09ff..91ca4a66 100644 --- a/gen/sam/sam_zu23_ural_insurgent.py +++ b/gen/sam/sam_zu23_ural_insurgent.py @@ -33,4 +33,4 @@ class ZU23UralInsurgentGenerator(AirDefenseGroupGenerator): @classmethod def range(cls) -> AirDefenseRange: - return AirDefenseRange.Short + return AirDefenseRange.AAA diff --git a/gen/triggergen.py b/gen/triggergen.py index 7df4838f..a8e29a42 100644 --- a/gen/triggergen.py +++ b/gen/triggergen.py @@ -10,14 +10,12 @@ from dcs.condition import ( FlagIsFalse, FlagIsTrue, ) -from dcs.unitgroup import FlyingGroup from dcs.mission import Mission from dcs.task import Option from dcs.translation import String from dcs.triggers import ( Event, TriggerOnce, - TriggerZone, TriggerCondition, ) from dcs.unit import Skill @@ -25,7 +23,6 @@ from dcs.unit import Skill from game.theater import Airfield from game.theater.controlpoint import Fob - if TYPE_CHECKING: from game.game import Game @@ -96,32 +93,15 @@ class TriggersGenerator: """ for coalition_name, coalition in self.mission.coalition.items(): if coalition_name == player_coalition: - skill_level = ( - self.game.settings.player_skill, - self.game.settings.player_skill, - ) + skill_level = Skill(self.game.settings.player_skill) elif coalition_name == enemy_coalition: - skill_level = ( - self.game.settings.enemy_skill, - self.game.settings.enemy_vehicle_skill, - ) + skill_level = Skill(self.game.settings.enemy_vehicle_skill) else: continue for country in coalition.countries.values(): - flying_groups = ( - country.plane_group + country.helicopter_group - ) # type: FlyingGroup - for flying_group in flying_groups: - for plane_unit in flying_group.units: - if ( - plane_unit.skill != Skill.Client - and plane_unit.skill != Skill.Player - ): - plane_unit.skill = Skill(skill_level[0]) - for vehicle_group in country.vehicle_group: - vehicle_group.set_skill(Skill(skill_level[1])) + vehicle_group.set_skill(skill_level) def _gen_markers(self): """ @@ -132,19 +112,22 @@ class TriggersGenerator: mark_trigger.add_condition(TimeAfter(1)) v = 10 for cp in self.game.theater.controlpoints: - added = [] + seen = set() for ground_object in cp.ground_objects: - if ground_object.obj_name not in added: + if ground_object.obj_name in seen: + continue + + seen.add(ground_object.obj_name) + for location in ground_object.mark_locations: zone = self.mission.triggers.add_triggerzone( - ground_object.position, radius=10, hidden=True, name="MARK" + location, radius=10, hidden=True, name="MARK" ) if cp.captured: name = ground_object.obj_name + " [ALLY]" else: name = ground_object.obj_name + " [ENEMY]" mark_trigger.add_action(MarkToAll(v, zone.id, String(name))) - v = v + 1 - added.append(ground_object.obj_name) + v += 1 self.mission.triggerrules.triggers.append(mark_trigger) def _generate_capture_triggers( @@ -209,16 +192,6 @@ class TriggersGenerator: player_coalition = "blue" enemy_coalition = "red" - player_cp, enemy_cp = self.game.theater.closest_opposing_control_points() - self.mission.coalition["blue"].bullseye = { - "x": enemy_cp.position.x, - "y": enemy_cp.position.y, - } - self.mission.coalition["red"].bullseye = { - "x": player_cp.position.x, - "y": player_cp.position.y, - } - self._set_skill(player_coalition, enemy_coalition) self._set_allegiances(player_coalition, enemy_coalition) self._gen_markers() diff --git a/gen/visualgen.py b/gen/visualgen.py index 8c1982fa..0fa9c335 100644 --- a/gen/visualgen.py +++ b/gen/visualgen.py @@ -98,18 +98,18 @@ class VisualGenerator: def _generate_frontline_smokes(self): for front_line in self.game.theater.conflicts(): - from_cp = front_line.control_point_a - to_cp = front_line.control_point_b + from_cp = front_line.blue_cp + to_cp = front_line.red_cp if from_cp.is_global or to_cp.is_global: continue plane_start, heading, distance = Conflict.frontline_vector( - from_cp, to_cp, self.game.theater + front_line, self.game.theater ) if not plane_start: continue - for offset in range(0, distance, FRONT_SMOKE_SPACING): + for offset in range(0, distance, self.game.settings.perf_smoke_spacing): position = plane_start.point_from_heading(heading, offset) for k, v in FRONT_SMOKE_TYPE_CHANCES.items(): @@ -162,6 +162,7 @@ class VisualGenerator: "", _type=v, position=position, + hidden=True, ) break diff --git a/installer/dcs_liberation.iss b/installer/dcs_liberation.iss index cd53dda3..c20edb05 100644 --- a/installer/dcs_liberation.iss +++ b/installer/dcs_liberation.iss @@ -4,7 +4,7 @@ #define MyAppName "DCS Liberation" #define MyAppVersion "{{version}}" #define MyAppPublisher "Khopa" -#define MyAppURL "https://github.com/Khopa/dcs_liberation/wiki" +#define MyAppURL "https://github.com/dcs-liberation/dcs_liberation/wiki" #define MyAppExeName "liberation_main.exe" [Setup] diff --git a/mypy.ini b/mypy.ini index e397c985..b8bb3a89 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5,6 +5,9 @@ namespace_packages = True follow_imports=silent ignore_missing_imports = True +[mypy-faker.*] +ignore_missing_imports = True + [mypy-PIL.*] ignore_missing_imports = True diff --git a/pydcs b/pydcs index cd14f0a0..53632aa7 160000 --- a/pydcs +++ b/pydcs @@ -1 +1 @@ -Subproject commit cd14f0a049d2bfdf1fa2e4e71f6258cd49040819 +Subproject commit 53632aa7a8749c67eba371aaea95bfef73f43cdc diff --git a/pydcs_extensions/a4ec/a4ec.py b/pydcs_extensions/a4ec/a4ec.py index 6582ae94..e835ebf3 100644 --- a/pydcs_extensions/a4ec/a4ec.py +++ b/pydcs_extensions/a4ec/a4ec.py @@ -4,6 +4,8 @@ from dcs import task from dcs.planes import PlaneType from dcs.weapons_data import Weapons +from pydcs_extensions.weapon_injector import inject_weapons + class WeaponsA4EC: AN_M57__2__TER_ = { @@ -432,6 +434,9 @@ class WeaponsA4EC: } +inject_weapons(WeaponsA4EC) + + class A_4E_C(PlaneType): id = "A-4E-C" flyable = True diff --git a/pydcs_extensions/f22a/f22a.py b/pydcs_extensions/f22a/f22a.py index 97e331f1..33eea7ea 100644 --- a/pydcs_extensions/f22a/f22a.py +++ b/pydcs_extensions/f22a/f22a.py @@ -4,12 +4,17 @@ from dcs import task from dcs.planes import PlaneType from dcs.weapons_data import Weapons +from pydcs_extensions.weapon_injector import inject_weapons + class F22AWeapons: AIM_9XX = {"clsid": "{AIM-9XX}", "name": "AIM-9XX", "weight": 85} AIM_120D = {"clsid": "{AIM-120D}", "name": "AIM-120D", "weight": 152} +inject_weapons(F22AWeapons) + + class F_22A(PlaneType): id = "F-22A" flyable = True diff --git a/pydcs_extensions/hercules/hercules.py b/pydcs_extensions/hercules/hercules.py index 611b0eb0..012a2ad0 100644 --- a/pydcs_extensions/hercules/hercules.py +++ b/pydcs_extensions/hercules/hercules.py @@ -4,6 +4,8 @@ from dcs import task from dcs.planes import PlaneType from dcs.weapons_data import Weapons +from pydcs_extensions.weapon_injector import inject_weapons + class HerculesWeapons: GAU_23A_Chain_Gun__30mm_ = { @@ -679,6 +681,9 @@ class HerculesWeapons: } +inject_weapons(HerculesWeapons) + + class Hercules(PlaneType): id = "Hercules" flyable = True diff --git a/pydcs_extensions/jas39/jas39.py b/pydcs_extensions/jas39/jas39.py new file mode 100644 index 00000000..a9940b2d --- /dev/null +++ b/pydcs_extensions/jas39/jas39.py @@ -0,0 +1,476 @@ +from dcs import task +from dcs.planes import PlaneType +from dcs.weapons_data import Weapons + +from pydcs_extensions.weapon_injector import inject_weapons + + +class JAS39GripenWeapons: + JAS_ARAKM70BAP = { + "clsid": "JAS_ARAKM70BAP", + "name": "ARAK M70B AP", + "weight": 372.2, + } + JAS_ARAKM70BHE = { + "clsid": "JAS_ARAKM70BHE", + "name": "ARAK M70B HE", + "weight": 372.2, + } + JAS_BK90 = { + "clsid": "JAS_BK90", + "name": "BK-90 Unguided Cluster Munition", + "weight": 605, + } + JAS_BRIMSTONE = { + "clsid": "JAS_BRIMSTONE", + "name": "Brimstone Laser Guided Missile", + "weight": 195.5, + } + JAS_GBU10_TV = { + "clsid": "JAS_GBU10_TV", + "name": "GBU-10 2000 lb TV-guided Bomb", + "weight": 934, + } + JAS_GBU12 = { + "clsid": "JAS_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, + } + JAS_GBU31 = { + "clsid": "JAS_GBU31", + "name": "GBU-31 2000lb TV Guided Glide-Bomb", + "weight": 934, + } + JAS_GBU49_TV = { + "clsid": "JAS_GBU49_TV", + "name": "GBU-49 500lb TV Guided Bomb", + "weight": 275, + } + JAS_IRIS_T = { + "clsid": "JAS_IRIS-T", + "name": "Rb98 IRIS-T Sidewinder IR AAM", + "weight": 88.4, + } + JAS_Litening = { + "clsid": "JAS_Litening", + "name": "Litening III POD (LLTV)", + "weight": 295, + } + JAS_MAR_1 = { + "clsid": "JAS_MAR-1", + "name": "MAR-1 High Speed Anti-Radiation Missile", + "weight": 350, + } + JAS_Meteor = { + "clsid": "JAS_Meteor", + "name": "Rb101 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, + } + JAS_RB75T = { + "clsid": "JAS_RB75T", + "name": "Rb-75T (AGM-65E Maverick) (Laser ASM Lg Whd)", + "weight": 210, + } + JAS_Rb74 = { + "clsid": "JAS_Rb74", + "name": "Rb74 AIM-9L Sidewinder IR AAM", + "weight": 90, + } + JAS_Rb99 = { + "clsid": "JAS_Rb99", + "name": "Rb99 AIM-120B AMRAAM Active Rdr AAM", + "weight": 157, + } + JAS_Rb99_DUAL = { + "clsid": "JAS_Rb99_DUAL", + "name": "Rb99 AIM-120B AMRAAM Active Rdr AAM x 2", + "weight": 313, + } + JAS_Stormshadow = { + "clsid": "JAS_Stormshadow", + "name": "Storm Shadow Long Range Anti-Radiation Cruise-missile", + "weight": None, + } + JAS_TANK1100 = { + "clsid": "JAS_TANK1100", + "name": "External drop tank 1100 litre", + "weight": 1019, + } + JAS_TANK1700 = { + "clsid": "JAS_TANK1700", + "name": "External drop tank 1700 litre", + "weight": 1533, + } + + +inject_weapons(JAS39GripenWeapons) + + +class JAS39Gripen(PlaneType): + id = "JAS39Gripen" + flyable = True + height = 4.5 + width = 8.4 + length = 14.1 + fuel_max = 2550 + max_speed = 2649.996 + chaff = 90 + flare = 45 + charge_total = 180 + chaff_charge_size = 1 + flare_charge_size = 2 + 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) + 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) + Smokewinder___blue = (1, Weapons.Smokewinder___blue) + Smokewinder___white = (1, Weapons.Smokewinder___white) + Smokewinder___yellow = (1, Weapons.Smokewinder___yellow) + 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 + + 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 + + class Pylon4: + L_081_Fantasmagoria_ELINT_pod = (4, Weapons.L_081_Fantasmagoria_ELINT_pod) + + 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 + + class Pylon6: + L005_Sorbtsiya_ECM_pod__left_ = (6, Weapons.L005_Sorbtsiya_ECM_pod__left_) + + class Pylon7: + JAS_Litening = (7, JAS39GripenWeapons.JAS_Litening) + + # ERRR + + 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 + + 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 = ( + 9, + Weapons.AIM_120C_5_AMRAAM___Active_Rdr_AAM, + ) + + # 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) + + pylons = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + tasks = [ + task.Intercept, + task.CAP, + task.Reconnaissance, + task.Escort, + task.FighterSweep, + ] + task_default = task.FighterSweep + + +class JAS39Gripen_AG(PlaneType): + id = "JAS39Gripen_AG" + flyable = True + height = 4.5 + width = 8.4 + length = 14.1 + fuel_max = 2550 + max_speed = 2649.996 + chaff = 90 + flare = 45 + charge_total = 180 + 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) + 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) + Smokewinder___blue = (1, Weapons.Smokewinder___blue) + Smokewinder___white = (1, Weapons.Smokewinder___white) + Smokewinder___yellow = (1, Weapons.Smokewinder___yellow) + 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 + 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 = ( + 2, + Weapons._4x_SB_M_71_120kg_GP_Bomb_Low_drag, + ) + JAS_ARAKM70BHE = (2, JAS39GripenWeapons.JAS_ARAKM70BHE) + JAS_ARAKM70BAP = (2, JAS39GripenWeapons.JAS_ARAKM70BAP) + JAS_BRIMSTONE = (2, JAS39GripenWeapons.JAS_BRIMSTONE) + + # ERRR + + class Pylon3: + JAS_RB75T = (3, JAS39GripenWeapons.JAS_RB75T) + 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, + ) + 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) + BRU_33_with_2_x_Mk_82___500lb_GP_Bomb_LD_ = ( + 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 + + class Pylon4: + L_081_Fantasmagoria_ELINT_pod = (4, Weapons.L_081_Fantasmagoria_ELINT_pod) + + 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 + + class Pylon6: + L005_Sorbtsiya_ECM_pod__left_ = (6, Weapons.L005_Sorbtsiya_ECM_pod__left_) + + class Pylon7: + JAS_Litening = (7, JAS39GripenWeapons.JAS_Litening) + + # ERRR + + 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 + + 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_ = ( + 9, + Weapons.AGM_65K___Maverick_K__CCD_Imp_ASM_, + ) + 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) + + pylons = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + tasks = [ + task.SEAD, + task.AntishipStrike, + task.CAS, + task.GroundAttack, + task.PinpointStrike, + task.RunwayAttack, + ] + task_default = task.CAS diff --git a/pydcs_extensions/mb339/mb339.py b/pydcs_extensions/mb339/mb339.py index 896a33b0..77fe9691 100644 --- a/pydcs_extensions/mb339/mb339.py +++ b/pydcs_extensions/mb339/mb339.py @@ -4,6 +4,8 @@ from dcs import task from dcs.planes import PlaneType from dcs.weapons_data import Weapons +from pydcs_extensions.weapon_injector import inject_weapons + class MB_339PAN_Weapons: ARF8M3_TP = {"clsid": "{ARF8M3_TP}", "name": "ARF8M3 TP", "weight": None} @@ -107,6 +109,9 @@ class MB_339PAN_Weapons: } +inject_weapons(MB_339PAN_Weapons) + + class MB_339PAN(PlaneType): id = "MB-339PAN" flyable = True diff --git a/pydcs_extensions/mod_units.py b/pydcs_extensions/mod_units.py index df011480..31c26829 100644 --- a/pydcs_extensions/mod_units.py +++ b/pydcs_extensions/mod_units.py @@ -2,6 +2,7 @@ from pydcs_extensions.a4ec.a4ec import A_4E_C from pydcs_extensions.f22a.f22a import F_22A from pydcs_extensions.hercules.hercules import Hercules from pydcs_extensions.highdigitsams import highdigitsams +from pydcs_extensions.jas39.jas39 import JAS39Gripen, JAS39Gripen_AG from pydcs_extensions.mb339.mb339 import MB_339PAN from pydcs_extensions.su57.su57 import Su_57 import pydcs_extensions.frenchpack.frenchpack as frenchpack @@ -12,6 +13,8 @@ MODDED_AIRPLANES = [ Su_57, F_22A, Hercules, + JAS39Gripen, + JAS39Gripen_AG, ] MODDED_VEHICLES = [ frenchpack._FIELD_HIDE, diff --git a/pydcs_extensions/su57/su57.py b/pydcs_extensions/su57/su57.py index fa3ce0a2..5892dc33 100644 --- a/pydcs_extensions/su57/su57.py +++ b/pydcs_extensions/su57/su57.py @@ -4,6 +4,8 @@ from dcs import task from dcs.planes import PlaneType from dcs.weapons_data import Weapons +from pydcs_extensions.weapon_injector import inject_weapons + class Su57Weapons: Kh_59MK2 = {"clsid": "{KH_59MK2}", "name": "Kh-59MK2", "weight": None} @@ -18,6 +20,9 @@ class Su57Weapons: } +inject_weapons(Su57Weapons) + + class Su_57(PlaneType): id = "Su-57" flyable = True diff --git a/pydcs_extensions/weapon_injector.py b/pydcs_extensions/weapon_injector.py new file mode 100644 index 00000000..6341c594 --- /dev/null +++ b/pydcs_extensions/weapon_injector.py @@ -0,0 +1,17 @@ +from typing import List, Any + +from dcs.weapons_data import Weapons, weapon_ids + + +def inject_weapons(weapon_class: Any) -> None: + """ + Inject custom weapons from mods into pydcs weapons databases via introspection + :param weapon_class: The custom weapons class containing dictionaries with weapon info + :return: None + """ + for key, value in weapon_class.__dict__.items(): + if key.startswith("__"): + continue + if isinstance(value, dict) and value.get("clsid"): + setattr(Weapons, key, value) + weapon_ids[value["clsid"]] = value diff --git a/qt_ui/delegates.py b/qt_ui/delegates.py new file mode 100644 index 00000000..050983f9 --- /dev/null +++ b/qt_ui/delegates.py @@ -0,0 +1,122 @@ +from contextlib import contextmanager +from typing import ContextManager, Optional + +from PySide2.QtCore import QModelIndex, Qt, QSize +from PySide2.QtGui import QPainter, QFont, QFontMetrics, QIcon +from PySide2.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, QStyle + + +@contextmanager +def painter_context(painter: QPainter) -> ContextManager[None]: + try: + painter.save() + yield + finally: + painter.restore() + + +class TwoColumnRowDelegate(QStyledItemDelegate): + HMARGIN = 4 + VMARGIN = 4 + + def __init__(self, rows: int, columns: int, font_size: int = 12) -> None: + if columns not in (1, 2): + raise ValueError(f"Only one or two columns may be used, not {columns}") + super().__init__() + self.font_size = font_size + self.rows = rows + self.columns = columns + + def get_font(self, option: QStyleOptionViewItem) -> QFont: + font = QFont(option.font) + font.setPointSize(self.font_size) + return font + + def text_for(self, index: QModelIndex, row: int, column: int) -> str: + raise NotImplementedError + + def paint( + self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex + ) -> None: + # Draw the list item with all the default selection styling, but with an + # invalid index so text formatting is left to us. + super().paint(painter, option, QModelIndex()) + + rect = option.rect.adjusted( + self.HMARGIN, self.VMARGIN, -self.HMARGIN, -self.VMARGIN + ) + + with painter_context(painter): + painter.setFont(self.get_font(option)) + + icon: Optional[QIcon] = index.data(Qt.DecorationRole) + + if icon is not None: + icon.paint( + painter, + rect, + Qt.AlignLeft | Qt.AlignVCenter, + self.icon_mode(option), + self.icon_state(option), + ) + rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, 0, 0, 0) + + row_height = rect.height() / self.rows + for row in range(self.rows): + y = row_height * row + location = rect.adjusted(0, y, 0, y) + painter.drawText(location, Qt.AlignLeft, self.text_for(index, row, 0)) + if self.columns == 2: + painter.drawText( + location, Qt.AlignRight, self.text_for(index, row, 1) + ) + + @staticmethod + def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode: + if not (option.state & QStyle.State_Enabled): + return QIcon.Disabled + elif option.state & QStyle.State_Selected: + return QIcon.Selected + elif option.state & QStyle.State_Active: + return QIcon.Active + return QIcon.Normal + + @staticmethod + def icon_state(option: QStyleOptionViewItem) -> QIcon.State: + return QIcon.On if option.state & QStyle.State_Open else QIcon.Off + + @staticmethod + def icon_size(option: QStyleOptionViewItem) -> QSize: + icon_size: Optional[QSize] = option.decorationSize + if icon_size is None: + return QSize(0, 0) + else: + return icon_size + + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: + metrics = QFontMetrics(self.get_font(option)) + widths = [] + heights = [] + + icon_size = self.icon_size(option) + icon_width = 0 + icon_height = 0 + if icon_size.width(): + icon_width = icon_size.width() + self.HMARGIN + if icon_size.height(): + icon_height = icon_size.height() + self.VMARGIN + + for row in range(self.rows): + width = 0 + height = 0 + for column in range(self.columns): + size = metrics.size(0, self.text_for(index, row, column)) + width += size.width() + height = max(height, size.height()) + widths.append(width) + heights.append(height) + + return QSize( + icon_width + max(widths) + 2 * self.HMARGIN, + max(icon_height, sum(heights)) + 2 * self.VMARGIN, + ) diff --git a/qt_ui/displayoptions.py b/qt_ui/displayoptions.py deleted file mode 100644 index 2abf369c..00000000 --- a/qt_ui/displayoptions.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Visibility options for the game map.""" -from dataclasses import dataclass, field -from typing import Iterator, Optional, Union - - -@dataclass -class DisplayRule: - name: str - _value: bool - debug_only: bool = field(default=False) - - @property - def menu_text(self) -> str: - return self.name - - @property - def value(self) -> bool: - return self._value - - @value.setter - def value(self, value: bool) -> None: - from qt_ui.widgets.map.QLiberationMap import QLiberationMap - - self._value = value - if QLiberationMap.instance is not None: - QLiberationMap.instance.reload_scene() - QLiberationMap.instance.update() - - def __bool__(self) -> bool: - return self.value - - -class DisplayGroup: - def __init__(self, name: Optional[str], debug_only: bool = False) -> None: - self.name = name - self.debug_only = debug_only - - def __iter__(self) -> Iterator[DisplayRule]: - # Python 3.6 enforces that __dict__ is order preserving by default. - for value in self.__dict__.values(): - if isinstance(value, DisplayRule): - yield value - - -class FlightPathOptions(DisplayGroup): - def __init__(self) -> None: - super().__init__("Flight Paths") - self.hide = DisplayRule("Hide Flight Paths", False) - self.only_selected = DisplayRule("Show Selected Flight Path", False) - self.all = DisplayRule("Show All Flight Paths", True) - - -class ThreatZoneOptions(DisplayGroup): - def __init__(self, coalition_name: str) -> None: - super().__init__(f"{coalition_name} Threat Zones") - self.none = DisplayRule(f"Hide {coalition_name.lower()} threat zones", True) - self.all = DisplayRule( - f"Show full {coalition_name.lower()} threat zones", False - ) - self.aircraft = DisplayRule( - f"Show {coalition_name.lower()} aircraft threat tones", False - ) - self.air_defenses = DisplayRule( - f"Show {coalition_name.lower()} air defenses threat zones", False - ) - - -class NavMeshOptions(DisplayGroup): - def __init__(self) -> None: - super().__init__("Navmeshes", debug_only=True) - self.hide = DisplayRule("DEBUG Hide Navmeshes", True) - self.blue_navmesh = DisplayRule("DEBUG Show blue navmesh", False) - self.red_navmesh = DisplayRule("DEBUG Show red navmesh", False) - - -class PathDebugFactionOptions(DisplayGroup): - def __init__(self) -> None: - super().__init__("Faction for path debugging", debug_only=True) - self.blue = DisplayRule("Debug blue paths", True) - self.red = DisplayRule("Debug red paths", False) - - -class PathDebugOptions(DisplayGroup): - def __init__(self) -> None: - super().__init__("Shortest paths", debug_only=True) - self.hide = DisplayRule("DEBUG Hide paths", True) - self.shortest_path = DisplayRule("DEBUG Show shortest path", False) - self.barcap = DisplayRule("DEBUG Show BARCAP plan", False) - self.cas = DisplayRule("DEBUG Show CAS plan", False) - self.sweep = DisplayRule("DEBUG Show fighter sweep plan", False) - self.strike = DisplayRule("DEBUG Show strike plan", False) - self.tarcap = DisplayRule("DEBUG Show TARCAP plan", False) - - -class DisplayOptions: - ground_objects = DisplayRule("Ground Objects", True) - control_points = DisplayRule("Control Points", True) - lines = DisplayRule("Lines", True) - sam_ranges = DisplayRule("Ally SAM Threat Range", False) - enemy_sam_ranges = DisplayRule("Enemy SAM Threat Range", True) - detection_range = DisplayRule("SAM Detection Range", False) - map_poly = DisplayRule("Map Polygon Debug Mode", False) - waypoint_info = DisplayRule("Waypoint Information", True) - culling = DisplayRule("Display Culling Zones", False) - actual_frontline_pos = DisplayRule("Display Actual Frontline Location", False) - patrol_engagement_range = DisplayRule( - "Display selected patrol engagement range", True - ) - flight_paths = FlightPathOptions() - blue_threat_zones = ThreatZoneOptions("Blue") - red_threat_zones = ThreatZoneOptions("Red") - navmeshes = NavMeshOptions() - path_debug_faction = PathDebugFactionOptions() - path_debug = PathDebugOptions() - - @classmethod - def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]: - debug = False # Set to True to enable debug options. - # Python 3.6 enforces that __dict__ is order preserving by default. - for value in cls.__dict__.values(): - if isinstance(value, DisplayRule): - if value.debug_only and not debug: - continue - yield value - elif isinstance(value, DisplayGroup): - if value.debug_only and not debug: - continue - yield value diff --git a/qt_ui/liberation_install.py b/qt_ui/liberation_install.py index 164c9323..0bbcb0b0 100644 --- a/qt_ui/liberation_install.py +++ b/qt_ui/liberation_install.py @@ -1,5 +1,7 @@ import json +import logging import os +from pathlib import Path from shutil import copyfile import dcs @@ -10,7 +12,10 @@ global __dcs_saved_game_directory global __dcs_installation_directory global __last_save_file -PREFERENCES_FILE_PATH = "liberation_preferences.json" + +USER_PATH = Path(os.environ["LOCALAPPDATA"]) / "DCSLiberation" + +PREFERENCES_PATH = USER_PATH / "liberation_preferences.json" def init(): @@ -18,18 +23,16 @@ def init(): global __dcs_installation_directory global __last_save_file - if os.path.isfile(PREFERENCES_FILE_PATH): + if PREFERENCES_PATH.exists(): try: - with (open(PREFERENCES_FILE_PATH)) as prefs: - pref_data = json.loads(prefs.read()) - __dcs_saved_game_directory = pref_data["saved_game_dir"] - __dcs_installation_directory = pref_data["dcs_install_dir"] - if "last_save_file" in pref_data: - __last_save_file = pref_data["last_save_file"] - else: - __last_save_file = "" + logging.debug("Loading Liberation preferences from %s", PREFERENCES_PATH) + with PREFERENCES_PATH.open() as prefs: + pref_data = json.load(prefs) + __dcs_saved_game_directory = pref_data["saved_game_dir"] + __dcs_installation_directory = pref_data["dcs_install_dir"] + __last_save_file = pref_data.get("last_save_file", "") is_first_start = False - except: + except KeyError: __dcs_saved_game_directory = "" __dcs_installation_directory = "" __last_save_file = "" @@ -78,8 +81,9 @@ def save_config(): "dcs_install_dir": __dcs_installation_directory, "last_save_file": __last_save_file, } - with (open(PREFERENCES_FILE_PATH, "w")) as prefs: - prefs.write(json.dumps(pref_data)) + PREFERENCES_PATH.parent.mkdir(exist_ok=True, parents=True) + with PREFERENCES_PATH.open("w") as prefs: + json.dump(pref_data, prefs, indent=" ") def get_dcs_install_directory(): diff --git a/qt_ui/main.py b/qt_ui/main.py index 95b07f3d..46b7fc1c 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -6,19 +6,19 @@ from datetime import datetime from pathlib import Path from typing import Optional -import dcs -from dcs.weapons_data import weapon_ids - from PySide2 import QtWidgets 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, db, persistency, VERSION +from game import Game, VERSION, persistency from game.data.weapons import ( WEAPON_FALLBACK_MAP, WEAPON_INTRODUCTION_YEARS, Weapon, ) +from game.profiling import logged_duration from game.settings import Settings from game.theater.start_generator import GameGenerator, GeneratorSettings from qt_ui import ( @@ -35,8 +35,29 @@ from qt_ui.windows.preferences.QLiberationFirstStartWindow import ( QLiberationFirstStartWindow, ) +THIS_DIR = Path(__file__).parent -def run_ui(game: Optional[Game] = None) -> None: + +def inject_custom_payloads(user_path: Path) -> None: + dev_payloads = THIS_DIR.parent / "resources/customized_payloads" + # The packaged release rearranges the file locations, so the release has the + # customized payloads in a different location. + release_payloads = THIS_DIR / "resources/customized_payloads" + if dev_payloads.exists(): + payloads = dev_payloads + elif release_payloads.exists(): + payloads = release_payloads + else: + raise RuntimeError( + f"Could not find customized payloads at {release_payloads} or " + f"{dev_payloads}. Aircraft will have no payloads." + ) + # We configure these as fallbacks so that the user's payloads override ours. + PayloadDirectories.set_fallback(payloads) + PayloadDirectories.set_preferred(user_path / "MissionEditor" / "UnitPayloads") + + +def run_ui(game: Optional[Game]) -> None: os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens app = QApplication(sys.argv) @@ -48,22 +69,6 @@ def run_ui(game: Optional[Game] = None) -> None: logging.info("Loading stylesheet: %s", liberation_theme.get_theme_css_file()) app.setStyleSheet(stylesheet.read()) - # Inject custom payload in pydcs framework - custom_payloads = os.path.join( - os.path.dirname(os.path.realpath(__file__)), - "..\\resources\\customized_payloads", - ) - if os.path.exists(custom_payloads): - dcs.unittype.FlyingType.payload_dirs.append(custom_payloads) - else: - # For release version the path is different. - custom_payloads = os.path.join( - os.path.dirname(os.path.realpath(__file__)), - "resources\\customized_payloads", - ) - if os.path.exists(custom_payloads): - dcs.unittype.FlyingType.payload_dirs.append(custom_payloads) - first_start = liberation_install.init() if first_start: window = QLiberationFirstStartWindow() @@ -76,6 +81,8 @@ def run_ui(game: Optional[Game] = None) -> None: ) ) + inject_custom_payloads(Path(persistency.base_path())) + # Splash screen setup pixmap = QPixmap("./resources/ui/splash_screen.png") splash = QSplashScreen(pixmap) @@ -132,6 +139,9 @@ def parse_args() -> argparse.Namespace: help="Emits a warning for weapons without date or fallback information.", ) + parser.add_argument("--new-map", help="Deprecated. Does nothing.") + parser.add_argument("--old-map", help="Deprecated. Does nothing.") + new_game = subparsers.add_parser("new-game") new_game.add_argument( @@ -158,6 +168,8 @@ def parse_args() -> argparse.Namespace: "--inverted", action="store_true", help="Invert the campaign." ) + new_game.add_argument("--cheats", action="store_true", help="Enable cheats.") + return parser.parse_args() @@ -168,7 +180,23 @@ def create_game( supercarrier: bool, auto_procurement: bool, inverted: bool, + cheats: bool, ) -> Game: + first_start = liberation_install.init() + if first_start: + sys.exit( + "Cannot generate campaign without configuring DCS Liberation. Start the UI " + "for the first run configuration." + ) + + # This needs to run before the pydcs payload cache is created, which happens + # extremely early. It's not a problem that we inject these paths twice because we'll + # get the same answers each time. + # + # Without this, it is not possible to use next turn (or anything that needs to check + # for loadouts) without saving the generated campaign and reloading it the normal + # way. + inject_custom_payloads(Path(persistency.base_path())) campaign = Campaign.from_json(campaign_path) generator = GameGenerator( blue, @@ -179,6 +207,8 @@ def create_game( automate_runway_repair=auto_procurement, automate_front_line_reinforcements=auto_procurement, automate_aircraft_reinforcements=auto_procurement, + enable_frontline_cheats=cheats, + enable_base_capture_cheat=cheats, ), GeneratorSettings( start_date=datetime.today(), @@ -207,8 +237,7 @@ def lint_weapon_data() -> None: def main(): logging_config.init_logging(VERSION) - # Load eagerly to catch errors early. - db.FACTIONS.initialize() + logging.debug("Python version %s", sys.version) game: Optional[Game] = None @@ -219,14 +248,16 @@ def main(): lint_weapon_data() if args.subcommand == "new-game": - game = create_game( - args.campaign, - args.blue, - args.red, - args.supercarrier, - args.auto_procurement, - args.inverted, - ) + with logged_duration("New game creation"): + game = create_game( + args.campaign, + args.blue, + args.red, + args.supercarrier, + args.auto_procurement, + args.inverted, + args.cheats, + ) run_ui(game) diff --git a/qt_ui/models.py b/qt_ui/models.py index cdc594d6..0bf5920f 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -1,4 +1,6 @@ """Qt data models for game objects.""" +from __future__ import annotations + import datetime from typing import Any, Callable, Dict, Iterator, Optional, TypeVar @@ -12,11 +14,13 @@ 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 gen.ato import AirTaskingOrder, Package -from gen.flights.flight import Flight +from gen.flights.flight import Flight, FlightType from gen.flights.traveltime import TotEstimator from qt_ui.uiconstants import AIRCRAFT_ICONS -from game.theater.missiontarget import MissionTarget class DeletableChildModelManager: @@ -49,8 +53,13 @@ class DeletableChildModelManager: ModelDict = Dict[DataType, ModelType] - def __init__(self, create_model: Callable[[DataType], ModelType]) -> None: + def __init__( + self, + create_model: Callable[[DataType, GameModel], ModelType], + game_model: GameModel, + ) -> None: self.create_model = create_model + self.game_model = game_model self.models: DeletableChildModelManager.ModelDict = {} def acquire(self, data: DataType) -> ModelType: @@ -61,7 +70,7 @@ class DeletableChildModelManager: """ if data in self.models: return self.models[data] - model = self.create_model(data) + model = self.create_model(data, self.game_model) self.models[data] = model return model @@ -102,9 +111,10 @@ class PackageModel(QAbstractListModel): tot_changed = Signal() - def __init__(self, package: Package) -> None: + def __init__(self, package: Package, game_model: GameModel) -> None: super().__init__() self.package = package + self.game_model = game_model def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: return len(self.package.flights) @@ -151,13 +161,13 @@ class PackageModel(QAbstractListModel): self.delete_flight(self.flight_at_index(index)) def delete_flight(self, flight: Flight) -> None: - """Removes the given flight from the package. - - If the flight is using claimed inventory, the caller is responsible for - returning that inventory. - """ + """Removes the given flight from the package.""" index = self.package.flights.index(flight) self.beginRemoveRows(QModelIndex(), index, index) + if flight.cargo is not None: + flight.cargo.transport = None + self.game_model.game.aircraft_inventory.return_from_flight(flight) + flight.clear_roster() self.package.remove_flight(flight) self.endRemoveRows() self.update_tot() @@ -169,8 +179,6 @@ class PackageModel(QAbstractListModel): def set_tot(self, tot: datetime.timedelta) -> None: self.package.time_over_target = tot self.update_tot() - # For some reason this is needed to make the UI update quickly. - self.layoutChanged.emit() def set_asap(self, asap: bool) -> None: self.package.auto_asap = asap @@ -180,6 +188,8 @@ class PackageModel(QAbstractListModel): if self.package.auto_asap: self.package.set_tot_asap() self.tot_changed.emit() + # For some reason this is needed to make the UI update quickly. + self.layoutChanged.emit() @property def mission_target(self) -> MissionTarget: @@ -207,11 +217,15 @@ class AtoModel(QAbstractListModel): client_slots_changed = Signal() - def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None: + def __init__(self, game_model: GameModel, ato: AirTaskingOrder) -> None: super().__init__() - self.game = game + self.game_model = game_model self.ato = ato - self.package_models = DeletableChildModelManager(PackageModel) + self.package_models = DeletableChildModelManager(PackageModel, game_model) + + @property + def game(self) -> Optional[Game]: + return self.game_model.game def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: return len(self.ato.packages) @@ -246,6 +260,9 @@ class AtoModel(QAbstractListModel): self.ato.remove_package(package) for flight in package.flights: self.game.aircraft_inventory.return_from_flight(flight) + flight.clear_roster() + if flight.cargo is not None: + flight.cargo.transport = None self.endRemoveRows() # noinspection PyUnresolvedReferences self.client_slots_changed.emit() @@ -254,20 +271,19 @@ class AtoModel(QAbstractListModel): """Returns the package at the given index.""" return self.ato.packages[index.row()] - def replace_from_game(self, game: Optional[Game], player: bool) -> None: + def replace_from_game(self, player: bool) -> None: """Updates the ATO object to match the updated game object. If the game is None (as is the case when no game has been loaded), an empty ATO will be used. """ self.beginResetModel() - self.game = game self.package_models.clear() if self.game is not None: if player: - self.ato = game.blue_ato + self.ato = self.game.blue_ato else: - self.ato = game.red_ato + self.ato = self.game.red_ato else: self.ato = AirTaskingOrder() self.endResetModel() @@ -278,6 +294,12 @@ class AtoModel(QAbstractListModel): """Returns a model for the package at the given index.""" return self.package_models.acquire(self.package_at_index(index)) + def find_matching_package_model(self, package: Package) -> Optional[PackageModel]: + for model in self.packages: + if model.package == package: + return model + return None + @property def packages(self) -> Iterator[PackageModel]: """Iterates over all the packages in the ATO.""" @@ -285,6 +307,176 @@ class AtoModel(QAbstractListModel): yield self.package_models.acquire(package) +class TransferModel(QAbstractListModel): + """The model for a ground unit transfer.""" + + TransferRole = Qt.UserRole + + def __init__(self, game_model: GameModel) -> None: + super().__init__() + self.game_model = game_model + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return self.game_model.game.transfers.pending_transfer_count + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: + if not index.isValid(): + return None + transfer = self.transfer_at_index(index) + if role == Qt.DisplayRole: + return self.text_for_transfer(transfer) + if role == Qt.DecorationRole: + return self.icon_for_transfer(transfer) + elif role == TransferModel.TransferRole: + return transfer + return None + + @staticmethod + def text_for_transfer(transfer: TransferOrder) -> str: + """Returns the text that should be displayed for the transfer.""" + count = sum(transfer.units.values()) + origin = transfer.origin.name + destination = transfer.destination.name + description = "Transfer" if transfer.player else "Enemy transfer" + return f"{description} of {count} units from {origin} to {destination}" + + @staticmethod + def icon_for_transfer(_transfer: TransferOrder) -> Optional[QIcon]: + """Returns the icon that should be displayed for the transfer.""" + return None + + def new_transfer(self, transfer: TransferOrder) -> None: + """Updates the game with the new unit transfer.""" + self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) + # TODO: Needs to regenerate base inventory tab. + self.game_model.game.transfers.new_transfer(transfer) + self.endInsertRows() + + def cancel_transfer_at_index(self, index: QModelIndex) -> None: + """Cancels the planned unit transfer at the given index.""" + self.cancel_transfer(self.transfer_at_index(index)) + + 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) + self.beginRemoveRows(QModelIndex(), index, index) + # TODO: Needs to regenerate base inventory tab. + self.game_model.game.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()) + + +class AirWingModel(QAbstractListModel): + """The model for an air wing.""" + + SquadronRole = Qt.UserRole + + def __init__(self, game_model: GameModel, player: bool) -> None: + super().__init__() + self.game_model = game_model + self.player = player + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return self.game_model.game.air_wing_for(self.player).size + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: + if not index.isValid(): + return None + squadron = self.squadron_at_index(index) + if role == Qt.DisplayRole: + return self.text_for_squadron(squadron) + if role == Qt.DecorationRole: + return self.icon_for_squadron(squadron) + elif role == AirWingModel.SquadronRole: + return squadron + return None + + @staticmethod + def text_for_squadron(squadron: Squadron) -> str: + """Returns the text that should be displayed for the squadron.""" + return str(squadron) + + @staticmethod + def icon_for_squadron(squadron: Squadron) -> Optional[QIcon]: + """Returns the icon that should be displayed for the squadron.""" + name = db.unit_type_name(squadron.aircraft) + if name in AIRCRAFT_ICONS: + return QIcon(AIRCRAFT_ICONS[name]) + return None + + def squadron_at_index(self, index: QModelIndex) -> Squadron: + """Returns the squadron located at the given index.""" + return self.game_model.game.air_wing_for(self.player).squadron_at_index( + index.row() + ) + + +class SquadronModel(QAbstractListModel): + """The model for a squadron.""" + + PilotRole = Qt.UserRole + + def __init__(self, squadron: Squadron) -> None: + super().__init__() + self.squadron = squadron + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return self.squadron.number_of_pilots_including_dead + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: + if not index.isValid(): + return None + pilot = self.pilot_at_index(index) + if role == Qt.DisplayRole: + return self.text_for_pilot(pilot) + if role == Qt.DecorationRole: + return self.icon_for_pilot(pilot) + elif role == SquadronModel.PilotRole: + return pilot + return None + + @staticmethod + def text_for_pilot(pilot: Pilot) -> str: + """Returns the text that should be displayed for the pilot.""" + return pilot.name + + @staticmethod + def icon_for_pilot(_pilot: Pilot) -> Optional[QIcon]: + """Returns the icon that should be displayed for the pilot.""" + return None + + def pilot_at_index(self, index: QModelIndex) -> Pilot: + """Returns the pilot located at the given index.""" + return self.squadron.pilot_at_index(index.row()) + + def toggle_ai_state(self, index: QModelIndex) -> None: + pilot = self.pilot_at_index(index) + self.beginResetModel() + pilot.player = not pilot.player + self.endResetModel() + + def toggle_leave_state(self, index: QModelIndex) -> None: + pilot = self.pilot_at_index(index) + self.beginResetModel() + if pilot.on_leave: + pilot.return_from_leave() + else: + pilot.send_on_leave() + self.endResetModel() + + def is_auto_assignable(self, task: FlightType) -> bool: + return task in self.squadron.auto_assignable_mission_types + + def set_auto_assignable(self, task: FlightType, auto_assignable: bool) -> None: + if auto_assignable: + self.squadron.auto_assignable_mission_types.add(task) + else: + self.squadron.auto_assignable_mission_types.remove(task) + + class GameModel: """A model for the Game object. @@ -294,12 +486,19 @@ class GameModel: def __init__(self, game: Optional[Game]) -> None: self.game: Optional[Game] = game + self.transfer_model = TransferModel(self) + self.blue_air_wing_model = AirWingModel(self, player=True) if self.game is None: - self.ato_model = AtoModel(self.game, AirTaskingOrder()) - self.red_ato_model = AtoModel(self.game, AirTaskingOrder()) + self.ato_model = AtoModel(self, AirTaskingOrder()) + self.red_ato_model = AtoModel(self, AirTaskingOrder()) else: - self.ato_model = AtoModel(self.game, self.game.blue_ato) - self.red_ato_model = AtoModel(self.game, 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: + return self.ato_model + return self.red_ato_model def set(self, game: Optional[Game]) -> None: """Updates the managed Game object. @@ -310,5 +509,5 @@ class GameModel: loaded. """ self.game = game - self.ato_model.replace_from_game(self.game, player=True) - self.red_ato_model.replace_from_game(self.game, player=False) + self.ato_model.replace_from_game(player=True) + self.red_ato_model.replace_from_game(player=False) diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index e34515e0..19bc1945 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -1,67 +1,21 @@ import os from typing import Dict -from PySide2.QtGui import QColor, QFont, QPixmap +from PySide2.QtGui import QPixmap -from game.theater.theatergroundobject import CATEGORY_MAP +from game.theater.theatergroundobject import NAME_BY_CATEGORY from .liberation_theme import get_theme_icons - URLS: Dict[str, str] = { - "Manual": "https://github.com/khopa/dcs_liberation/wiki", - "Repository": "https://github.com/khopa/dcs_liberation", + "Manual": "https://github.com/dcs-liberation/dcs_liberation/wiki", + "Repository": "https://github.com/dcs-liberation/dcs_liberation", "ForumThread": "https://forums.eagle.ru/showthread.php?t=214834", - "Issues": "https://github.com/khopa/dcs_liberation/issues", + "Issues": "https://github.com/dcs-liberation/dcs_liberation/issues", } LABELS_OPTIONS = ["Full", "Abbreviated", "Dot Only", "Off"] SKILL_OPTIONS = ["Average", "Good", "High", "Excellent"] -FONT_SIZE = 8 -FONT_NAME = "Arial" -# FONT = QFont("Arial", 12, weight=5, italic=True) -FONT_PRIMARY = QFont(FONT_NAME, FONT_SIZE, weight=5, italic=False) -FONT_PRIMARY_I = QFont(FONT_NAME, FONT_SIZE, weight=5, italic=True) -FONT_PRIMARY_B = QFont(FONT_NAME, FONT_SIZE, weight=75, italic=False) -FONT_MAP = QFont(FONT_NAME, 10, weight=75, italic=False) - -COLORS: Dict[str, QColor] = { - "white": QColor(255, 255, 255), - "white_transparent": QColor(255, 255, 255, 35), - "light_red": QColor(231, 92, 83, 90), - "red": QColor(200, 80, 80), - "dark_red": QColor(140, 20, 20), - "red_transparent": QColor(227, 32, 0, 20), - "transparent": QColor(255, 255, 255, 0), - "light_blue": QColor(105, 182, 240, 90), - "blue": QColor(0, 132, 255), - "dark_blue": QColor(45, 62, 80), - "sea_blue": QColor(52, 68, 85), - "sea_blue_transparent": QColor(52, 68, 85, 150), - "blue_transparent": QColor(0, 132, 255, 20), - "purple": QColor(187, 137, 255), - "yellow": QColor(238, 225, 123), - "bright_red": QColor(150, 80, 80), - "super_red": QColor(227, 32, 0), - "green": QColor(128, 186, 128), - "light_green": QColor(223, 255, 173), - "light_green_transparent": QColor(180, 255, 140, 50), - "bright_green": QColor(64, 200, 64), - "black": QColor(0, 0, 0), - "black_transparent": QColor(0, 0, 0, 5), - "orange": QColor(254, 125, 10), - "night_overlay": QColor(12, 20, 69), - "dawn_dust_overlay": QColor(46, 38, 85), - "grey": QColor(150, 150, 150), - "grey_transparent": QColor(150, 150, 150, 150), - "dark_grey": QColor(75, 75, 75), - "dark_grey_transparent": QColor(75, 75, 75, 150), - "dark_dark_grey": QColor(48, 48, 48), - "dark_dark_grey_transparent": QColor(48, 48, 48, 150), -} - -CP_SIZE = 12 - AIRCRAFT_BANNERS: Dict[str, QPixmap] = {} AIRCRAFT_ICONS: Dict[str, QPixmap] = {} VEHICLE_BANNERS: Dict[str, QPixmap] = {} @@ -139,25 +93,6 @@ def load_icons(): "./resources/ui/misc/" + get_theme_icons() + "/ordnance_icon.png" ) - ICONS["target"] = QPixmap("./resources/ui/ground_assets/target.png") - ICONS["cleared"] = QPixmap("./resources/ui/ground_assets/cleared.png") - for category in CATEGORY_MAP.keys(): - ICONS[category] = QPixmap("./resources/ui/ground_assets/" + category + ".png") - ICONS[category + "_blue"] = QPixmap( - "./resources/ui/ground_assets/" + category + "_blue.png" - ) - ICONS["destroyed"] = QPixmap("./resources/ui/ground_assets/destroyed.png") - ICONS["EWR"] = QPixmap("./resources/ui/ground_assets/ewr.png") - ICONS["EWR_blue"] = QPixmap("./resources/ui/ground_assets/ewr_blue.png") - ICONS["ship"] = QPixmap("./resources/ui/ground_assets/ship.png") - ICONS["ship_blue"] = QPixmap("./resources/ui/ground_assets/ship_blue.png") - ICONS["missile"] = QPixmap("./resources/ui/ground_assets/missile.png") - ICONS["missile_blue"] = QPixmap("./resources/ui/ground_assets/missile_blue.png") - ICONS["nothreat"] = QPixmap("./resources/ui/ground_assets/nothreat.png") - ICONS["nothreat_blue"] = QPixmap("./resources/ui/ground_assets/nothreat_blue.png") - ICONS["coastal"] = QPixmap("./resources/ui/ground_assets/coastal.png") - ICONS["coastal_blue"] = QPixmap("./resources/ui/ground_assets/coastal_blue.png") - ICONS["Generator"] = QPixmap( "./resources/ui/misc/" + get_theme_icons() + "/generator.png" ) diff --git a/qt_ui/widgets/QBudgetBox.py b/qt_ui/widgets/QBudgetBox.py index fe20ab44..e44713a8 100644 --- a/qt_ui/widgets/QBudgetBox.py +++ b/qt_ui/widgets/QBudgetBox.py @@ -14,18 +14,14 @@ class QBudgetBox(QGroupBox): super(QBudgetBox, self).__init__("Budget") self.game = game - self.money_icon = QLabel() - self.money_icon.setPixmap(CONST.ICONS["Money"]) - self.money_amount = QLabel() - self.finances = QPushButton("Details") + self.finances = QPushButton() self.finances.setDisabled(True) self.finances.setProperty("style", "btn-primary") + self.finances.setIcon(CONST.ICONS["Money"]) self.finances.clicked.connect(self.openFinances) self.layout = QHBoxLayout() - self.layout.addWidget(self.money_icon) - self.layout.addWidget(self.money_amount) self.layout.addWidget(self.finances) self.setLayout(self.layout) @@ -35,7 +31,7 @@ class QBudgetBox(QGroupBox): :param budget: Current money available :param reward: Planned reward for next turn """ - self.money_amount.setText( + self.finances.setText( str(round(budget, 2)) + "M (+" + str(round(reward, 2)) + "M)" ) diff --git a/qt_ui/widgets/QConditionsWidget.py b/qt_ui/widgets/QConditionsWidget.py index 23abedb0..b0e9f630 100644 --- a/qt_ui/widgets/QConditionsWidget.py +++ b/qt_ui/widgets/QConditionsWidget.py @@ -220,7 +220,7 @@ class QWeatherWidget(QGroupBox): precipitation = self.conditions.weather.clouds.precipitation if not cloud_density: - self.forecastClouds.setText("Sunny") + self.forecastClouds.setText("Clear") weather_type = "clear" elif cloud_density < 3: self.forecastClouds.setText("Partly Cloudy") diff --git a/qt_ui/widgets/QIntelBox.py b/qt_ui/widgets/QIntelBox.py index 2e3eb7e8..a31e8448 100644 --- a/qt_ui/widgets/QIntelBox.py +++ b/qt_ui/widgets/QIntelBox.py @@ -24,23 +24,39 @@ class QIntelBox(QGroupBox): self.setLayout(columns) summary = QGridLayout() - columns.addLayout(summary) + summary.setContentsMargins(5, 5, 5, 5) - summary.addWidget(QLabel("Air superiority:"), 0, 0) + air_superiority = QLabel("Air superiority:") + summary.addWidget(air_superiority, 0, 0) self.air_strength = QLabel() summary.addWidget(self.air_strength, 0, 1) - summary.addWidget(QLabel("Front line:"), 1, 0) + front_line = QLabel("Front line:") + summary.addWidget(front_line, 1, 0) self.ground_strength = QLabel() summary.addWidget(self.ground_strength, 1, 1) - summary.addWidget(QLabel("Economic strength:"), 2, 0) + economy = QLabel("Economic strength:") + summary.addWidget(economy, 2, 0) self.economic_strength = QLabel() summary.addWidget(self.economic_strength, 2, 1) - details = QPushButton("Details") - columns.addWidget(details) - details.clicked.connect(self.open_details_window) + # some dirty styling to make the labels show up well on the button + button_text_style = "background-color: rgba(0,0,0,0%); color: white;" + air_superiority.setStyleSheet(button_text_style) + front_line.setStyleSheet(button_text_style) + economy.setStyleSheet(button_text_style) + self.air_strength.setStyleSheet(button_text_style) + self.ground_strength.setStyleSheet(button_text_style) + self.economic_strength.setStyleSheet(button_text_style) + + self.details = QPushButton() + self.details.setMinimumHeight(50) + self.details.setMinimumWidth(210) + self.details.setLayout(summary) + columns.addWidget(self.details) + self.details.clicked.connect(self.open_details_window) + self.details.setEnabled(False) self.update_summary() @@ -48,6 +64,7 @@ class QIntelBox(QGroupBox): def set_game(self, game: Optional[Game]) -> None: self.game = game + self.details.setEnabled(True) self.update_summary() @staticmethod diff --git a/qt_ui/widgets/QLiberationCalendar.py b/qt_ui/widgets/QLiberationCalendar.py index 43e0d469..c33d8810 100644 --- a/qt_ui/widgets/QLiberationCalendar.py +++ b/qt_ui/widgets/QLiberationCalendar.py @@ -1,8 +1,6 @@ -from PySide2 import QtCore, QtGui, QtWidgets +from PySide2 import QtCore, QtGui from PySide2.QtWidgets import QCalendarWidget -from qt_ui.uiconstants import COLORS - class QLiberationCalendar(QCalendarWidget): def __init__(self, parent=None): @@ -29,7 +27,7 @@ class QLiberationCalendar(QCalendarWidget): painter.save() painter.fillRect(rect, QtGui.QColor("#D3D3D3")) painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(QtGui.QColor(COLORS["sea_blue"])) + painter.setBrush(QtGui.QColor(52, 68, 85)) r = QtCore.QRect( QtCore.QPoint(), min(rect.width(), rect.height()) * QtCore.QSize(1, 1) ) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 68834d28..7292cec4 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -1,9 +1,7 @@ -import logging -import timeit -from datetime import timedelta from typing import List, Optional from PySide2.QtWidgets import ( + QDialog, QFrame, QGroupBox, QHBoxLayout, @@ -14,24 +12,27 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game from game.event.airwar import AirWarEvent +from game.profiling import logged_duration from gen.ato import Package from gen.flights.traveltime import TotEstimator from qt_ui.models import GameModel from qt_ui.widgets.QBudgetBox import QBudgetBox +from qt_ui.widgets.QConditionsWidget import QConditionsWidget from qt_ui.widgets.QFactionsInfos import QFactionsInfos from qt_ui.widgets.QIntelBox import QIntelBox from qt_ui.widgets.clientslots import MaxPlayerCount +from qt_ui.windows.AirWingDialog import AirWingDialog from qt_ui.windows.GameUpdateSignal import GameUpdateSignal +from qt_ui.windows.PendingTransfersDialog import PendingTransfersDialog from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow -from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow -from qt_ui.windows.stats.QStatsWindow import QStatsWindow -from qt_ui.widgets.QConditionsWidget import QConditionsWidget class QTopPanel(QFrame): def __init__(self, game_model: GameModel): super(QTopPanel, self).__init__() self.game_model = game_model + self.dialog: Optional[QDialog] = None + self.setMaximumHeight(70) self.init_ui() GameUpdateSignal.get_instance().gameupdated.connect(self.setGame) @@ -61,24 +62,22 @@ class QTopPanel(QFrame): self.factionsInfos = QFactionsInfos(self.game) - self.settings = QPushButton("Settings") - self.settings.setDisabled(True) - self.settings.setIcon(CONST.ICONS["Settings"]) - self.settings.setProperty("style", "btn-primary") - self.settings.clicked.connect(self.openSettings) + self.air_wing = QPushButton("Air Wing") + self.air_wing.setDisabled(True) + self.air_wing.setProperty("style", "btn-primary") + self.air_wing.clicked.connect(self.open_air_wing) - self.statistics = QPushButton("Statistics") - self.statistics.setDisabled(True) - self.statistics.setIcon(CONST.ICONS["Statistics"]) - self.statistics.setProperty("style", "btn-primary") - self.statistics.clicked.connect(self.openStatisticsWindow) + self.transfers = QPushButton("Transfers") + self.transfers.setDisabled(True) + self.transfers.setProperty("style", "btn-primary") + self.transfers.clicked.connect(self.open_transfers) self.intel_box = QIntelBox(self.game) self.buttonBox = QGroupBox("Misc") self.buttonBoxLayout = QHBoxLayout() - self.buttonBoxLayout.addWidget(self.settings) - self.buttonBoxLayout.addWidget(self.statistics) + self.buttonBoxLayout.addWidget(self.air_wing) + self.buttonBoxLayout.addWidget(self.transfers) self.buttonBox.setLayout(self.buttonBoxLayout) self.proceedBox = QGroupBox("Proceed") @@ -106,8 +105,8 @@ class QTopPanel(QFrame): if game is None: return - self.settings.setEnabled(True) - self.statistics.setEnabled(True) + self.air_wing.setEnabled(True) + self.transfers.setEnabled(True) self.conditionsWidget.setCurrentTurn(game.turn, game.conditions) self.intel_box.set_game(game) @@ -121,21 +120,19 @@ class QTopPanel(QFrame): else: self.proceedButton.setEnabled(True) - def openSettings(self): - self.subwindow = QSettingsWindow(self.game) - self.subwindow.show() + def open_air_wing(self): + self.dialog = AirWingDialog(self.game_model, self.window()) + self.dialog.show() - def openStatisticsWindow(self): - self.subwindow = QStatsWindow(self.game) - self.subwindow.show() + def open_transfers(self): + self.dialog = PendingTransfersDialog(self.game_model) + self.dialog.show() def passTurn(self): - start = timeit.default_timer() - self.game.pass_turn(no_action=True) - GameUpdateSignal.get_instance().updateGame(self.game) - self.proceedButton.setEnabled(True) - end = timeit.default_timer() - logging.info("Skipping turn took %s", timedelta(seconds=end - start)) + with logged_duration("Skipping turn"): + self.game.pass_turn(no_action=True) + GameUpdateSignal.get_instance().updateGame(self.game) + self.proceedButton.setEnabled(True) def negative_start_packages(self) -> List[Package]: packages = [] @@ -165,17 +162,18 @@ class QTopPanel(QFrame): def confirm_no_client_launch(self) -> bool: result = QMessageBox.question( self, - "Continue without client slots?", + "Continue without player pilots?", ( - "No client slots have been created for players. Continuing will " - "allow the AI to perform the mission, but players will be unable " - "to participate.
" + "No player pilots have been assigned to flights. Continuing will allow " + "the AI to perform the mission, but players will be unable to " + "participate.
" "
" - "To add client slots for players, select a package from the " - "Packages panel on the left of the main window, and then a flight " - "from the Flights panel below the Packages panel. The edit button " - "below the Flights panel will allow you to edit the number of " - "client slots in the flight. Each client slot allows one player.
" + "To assign player pilots to a flight, select a package from the " + "Packages panel on the left of the main window, and then a flight from " + "the Flights panel below the Packages panel. The edit button below the " + "Flights panel will allow you to assign specific pilots to the flight. " + "If you have no player pilots available, the checkbox next to the " + "name will convert them to a player.
" "
Click 'Yes' to continue with an AI only mission" "
Click 'No' if you'd like to make more changes." ), @@ -221,11 +219,44 @@ class QTopPanel(QFrame): return True return False + def check_no_missing_pilots(self) -> bool: + missing_pilots = [] + for package in self.game.blue_ato.packages: + for flight in package.flights: + if flight.missing_pilots > 0: + missing_pilots.append((package, flight)) + + if not missing_pilots: + return False + + formatted = "
".join( + [f"{p.primary_task} {p.target}: {f}" for p, f in missing_pilots] + ) + mbox = QMessageBox( + QMessageBox.Critical, + "Flights are missing pilots", + ( + "The following flights are missing one or more pilots:
" + "
" + f"{formatted}
" + "
" + "You must either assign pilots to those flights or cancel those " + "missions." + ), + parent=self, + ) + mbox.setEscapeButton(mbox.addButton(QMessageBox.Close)) + mbox.exec_() + return True + def launch_mission(self): """Finishes planning and waits for mission completion.""" if not self.ato_has_clients() and not self.confirm_no_client_launch(): return + if self.check_no_missing_pilots(): + return + negative_starts = self.negative_start_packages() if negative_starts: if not self.confirm_negative_start_time(negative_starts): diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index fa5e7072..b7cdbdec 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -1,7 +1,6 @@ """Widgets for displaying air tasking orders.""" import logging -from contextlib import contextmanager -from typing import ContextManager, Optional +from typing import Optional from PySide2.QtCore import ( QItemSelectionModel, @@ -11,10 +10,6 @@ from PySide2.QtCore import ( ) from PySide2.QtGui import ( QContextMenuEvent, - QFont, - QFontMetrics, - QIcon, - QPainter, ) from PySide2.QtWidgets import ( QAbstractItemView, @@ -26,121 +21,49 @@ from PySide2.QtWidgets import ( QMenu, QPushButton, QSplitter, - QStyle, - QStyleOptionViewItem, - QStyledItemDelegate, QVBoxLayout, ) -from game import db from gen.ato import Package from gen.flights.flight import Flight from gen.flights.traveltime import TotEstimator from qt_ui.windows.GameUpdateSignal import GameUpdateSignal +from ..delegates import TwoColumnRowDelegate from ..models import AtoModel, GameModel, NullListModel, PackageModel -class FlightDelegate(QStyledItemDelegate): - FONT_SIZE = 10 - HMARGIN = 4 - VMARGIN = 4 - +class FlightDelegate(TwoColumnRowDelegate): def __init__(self, package: Package) -> None: - super().__init__() + super().__init__(rows=2, columns=2, font_size=10) self.package = package - def get_font(self, option: QStyleOptionViewItem) -> QFont: - font = QFont(option.font) - font.setPointSize(self.FONT_SIZE) - return font - @staticmethod def flight(index: QModelIndex) -> Flight: return index.data(PackageModel.FlightRole) - def first_row_text(self, index: QModelIndex) -> str: + def text_for(self, index: QModelIndex, row: int, column: int) -> str: flight = self.flight(index) - estimator = TotEstimator(self.package) - delay = estimator.mission_start_time(flight) - return f"{flight} in {delay}" - - def second_row_text(self, index: QModelIndex) -> str: - flight = self.flight(index) - origin = flight.from_cp.name - if flight.arrival != flight.departure: - return f"From {origin} to {flight.arrival.name}" - return f"From {origin}" - - def paint( - self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex - ) -> None: - # Draw the list item with all the default selection styling, but with an - # invalid index so text formatting is left to us. - super().paint(painter, option, QModelIndex()) - - rect = option.rect.adjusted( - self.HMARGIN, self.VMARGIN, -self.HMARGIN, -self.VMARGIN - ) - - with painter_context(painter): - painter.setFont(self.get_font(option)) - - icon: Optional[QIcon] = index.data(Qt.DecorationRole) - if icon is not None: - icon.paint( - painter, - rect, - Qt.AlignLeft | Qt.AlignVCenter, - self.icon_mode(option), - self.icon_state(option), - ) - - rect = rect.adjusted(self.icon_size(option).width() + self.HMARGIN, 0, 0, 0) - painter.drawText(rect, Qt.AlignLeft, self.first_row_text(index)) - line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2) - painter.drawText(line2, Qt.AlignLeft, self.second_row_text(index)) - + if (row, column) == (0, 0): + estimator = TotEstimator(self.package) + delay = estimator.mission_start_time(flight) + return f"{flight} in {delay}" + elif (row, column) == (0, 1): clients = self.num_clients(index) - if clients: - painter.drawText(rect, Qt.AlignRight, f"Player Slots: {clients}") + return f"Player Slots: {clients}" if clients else "" + elif (row, column) == (1, 0): + origin = flight.from_cp.name + if flight.arrival != flight.departure: + return f"From {origin} to {flight.arrival.name}" + return f"From {origin}" + elif (row, column) == (1, 1): + missing_pilots = flight.missing_pilots + return f"Missing pilots: {flight.missing_pilots}" if missing_pilots else "" + return "" def num_clients(self, index: QModelIndex) -> int: flight = self.flight(index) return flight.client_count - @staticmethod - def icon_mode(option: QStyleOptionViewItem) -> QIcon.Mode: - if not (option.state & QStyle.State_Enabled): - return QIcon.Disabled - elif option.state & QStyle.State_Selected: - return QIcon.Selected - elif option.state & QStyle.State_Active: - return QIcon.Active - return QIcon.Normal - - @staticmethod - def icon_state(option: QStyleOptionViewItem) -> QIcon.State: - return QIcon.On if option.state & QStyle.State_Open else QIcon.Off - - @staticmethod - def icon_size(option: QStyleOptionViewItem) -> QSize: - icon_size: Optional[QSize] = option.decorationSize - if icon_size is None: - return QSize(0, 0) - else: - return icon_size - - def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: - left = self.icon_size(option).width() + self.HMARGIN - metrics = QFontMetrics(self.get_font(option)) - first = metrics.size(0, self.first_row_text(index)) - second = metrics.size(0, self.second_row_text(index)) - text_width = max(first.width(), second.width()) - return QSize( - left + text_width + 2 * self.HMARGIN, - first.height() + second.height() + 2 * self.VMARGIN, - ) - class QFlightList(QListView): """List view for displaying the flights of a package.""" @@ -205,7 +128,6 @@ class QFlightList(QListView): ) def delete_flight(self, index: QModelIndex) -> None: - self.game_model.game.aircraft_inventory.return_from_flight(self.selected_item) self.package_model.delete_flight_at_index(index) GameUpdateSignal.get_instance().redraw_flight_paths() @@ -312,71 +234,35 @@ class QFlightPanel(QGroupBox): self.flight_list.delete_flight(index) -@contextmanager -def painter_context(painter: QPainter) -> ContextManager[None]: - try: - painter.save() - yield - finally: - painter.restore() - - -class PackageDelegate(QStyledItemDelegate): - FONT_SIZE = 12 - HMARGIN = 4 - VMARGIN = 4 - - def get_font(self, option: QStyleOptionViewItem) -> QFont: - font = QFont(option.font) - font.setPointSize(self.FONT_SIZE) - return font +class PackageDelegate(TwoColumnRowDelegate): + def __init__(self) -> None: + super().__init__(rows=2, columns=2) @staticmethod def package(index: QModelIndex) -> Package: return index.data(AtoModel.PackageRole) - def left_text(self, index: QModelIndex) -> str: + def text_for(self, index: QModelIndex, row: int, column: int) -> str: package = self.package(index) - return f"{package.package_description} {package.target.name}" - - def right_text(self, index: QModelIndex) -> str: - package = self.package(index) - return f"TOT T+{package.time_over_target}" - - def paint( - self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex - ) -> None: - # Draw the list item with all the default selection styling, but with an - # invalid index so text formatting is left to us. - super().paint(painter, option, QModelIndex()) - - rect = option.rect.adjusted( - self.HMARGIN, self.VMARGIN, -self.HMARGIN, -self.VMARGIN - ) - - with painter_context(painter): - painter.setFont(self.get_font(option)) - - painter.drawText(rect, Qt.AlignLeft, self.left_text(index)) - line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2) - painter.drawText(line2, Qt.AlignLeft, self.right_text(index)) - + if (row, column) == (0, 0): + return f"{package.package_description} {package.target.name}" + elif (row, column) == (0, 1): clients = self.num_clients(index) - if clients: - painter.drawText(rect, Qt.AlignRight, f"Player Slots: {clients}") + return f"Player Slots: {clients}" if clients else "" + elif (row, column) == (1, 0): + return f"TOT T+{package.time_over_target}" + elif (row, column) == (1, 1): + unassigned_pilots = self.missing_pilots(index) + return f"Missing pilots: {unassigned_pilots}" if unassigned_pilots else "" + return "" def num_clients(self, index: QModelIndex) -> int: package = self.package(index) return sum(f.client_count for f in package.flights) - def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: - metrics = QFontMetrics(self.get_font(option)) - left = metrics.size(0, self.left_text(index)) - right = metrics.size(0, self.right_text(index)) - return QSize( - max(left.width(), right.width()) + 2 * self.HMARGIN, - left.height() + right.height() + 2 * self.VMARGIN, - ) + def missing_pilots(self, index: QModelIndex) -> int: + package = self.package(index) + return sum(f.missing_pilots for f in package.flights) class QPackageList(QListView): @@ -387,7 +273,7 @@ class QPackageList(QListView): self.ato_model = model self.setModel(model) self.setItemDelegate(PackageDelegate()) - self.setIconSize(QSize(91, 24)) + self.setIconSize(QSize(0, 0)) self.setSelectionBehavior(QAbstractItemView.SelectItems) self.model().rowsInserted.connect(self.on_new_packages) self.doubleClicked.connect(self.on_double_click) diff --git a/qt_ui/widgets/combos/QAircraftTypeSelector.py b/qt_ui/widgets/combos/QAircraftTypeSelector.py index 5afa3761..b7949aa2 100644 --- a/qt_ui/widgets/combos/QAircraftTypeSelector.py +++ b/qt_ui/widgets/combos/QAircraftTypeSelector.py @@ -2,15 +2,12 @@ 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 -import gen.flights.ai_flight_planner_db - -from game import Game, db - class QAircraftTypeSelector(QComboBox): """Combo box for selecting among the given aircraft types.""" @@ -19,83 +16,24 @@ class QAircraftTypeSelector(QComboBox): self, aircraft_types: Iterable[Type[FlyingType]], country: str, - mission_type: str, + mission_type: FlightType, ) -> None: super().__init__() self.model().sort(0) self.setSizeAdjustPolicy(self.AdjustToContents) self.country = country - self.updateItems(mission_type, aircraft_types) + self.update_items(mission_type, aircraft_types) - def updateItems(self, mission_type: str, aircraft_types): + def update_items(self, mission_type: FlightType, aircraft_types): current_aircraft = self.currentData() self.clear() for aircraft in aircraft_types: - if mission_type in [ - FlightType.BARCAP, - FlightType.ESCORT, - FlightType.INTERCEPTION, - FlightType.SWEEP, - FlightType.TARCAP, - ]: - if aircraft in gen.flights.ai_flight_planner_db.CAP_CAPABLE: - self.addItem( - f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", - userData=aircraft, - ) - elif mission_type in [ - FlightType.CAS, - FlightType.BAI, - FlightType.OCA_AIRCRAFT, - ]: - if ( - aircraft in gen.flights.ai_flight_planner_db.CAS_CAPABLE - or aircraft in gen.flights.ai_flight_planner_db.TRANSPORT_CAPABLE - ): - self.addItem( - f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", - userData=aircraft, - ) - elif mission_type in [FlightType.SEAD]: - if aircraft in gen.flights.ai_flight_planner_db.SEAD_CAPABLE: - self.addItem( - f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", - userData=aircraft, - ) - elif mission_type in [FlightType.DEAD]: - if aircraft in gen.flights.ai_flight_planner_db.DEAD_CAPABLE: - self.addItem( - f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", - userData=aircraft, - ) - elif mission_type in [FlightType.STRIKE]: - if ( - aircraft in gen.flights.ai_flight_planner_db.STRIKE_CAPABLE - or aircraft in gen.flights.ai_flight_planner_db.TRANSPORT_CAPABLE - ): - self.addItem( - f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", - userData=aircraft, - ) - elif mission_type in [FlightType.ANTISHIP]: - if aircraft in gen.flights.ai_flight_planner_db.ANTISHIP_CAPABLE: - self.addItem( - f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", - userData=aircraft, - ) - elif mission_type in [FlightType.OCA_RUNWAY]: - if aircraft in gen.flights.ai_flight_planner_db.RUNWAY_ATTACK_CAPABLE: - self.addItem( - f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", - userData=aircraft, - ) - elif mission_type in [FlightType.AEWC]: - if aircraft in gen.flights.ai_flight_planner_db.AEWC_CAPABLE: - self.addItem( - f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", - userData=aircraft, - ) + if aircraft in aircraft_for_task(mission_type): + self.addItem( + f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", + userData=aircraft, + ) current_aircraft_index = self.findData(current_aircraft) if current_aircraft_index != -1: self.setCurrentIndex(current_aircraft_index) diff --git a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py index 5de3527e..12e3d55e 100644 --- a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py +++ b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py @@ -63,26 +63,19 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox): return i + 1 if self.include_frontlines: - for cp in self.game.theater.controlpoints: - if cp.captured: - enemy_cp = [ - ecp - for ecp in cp.connected_points - if ecp.captured != cp.captured - ] - for ecp in enemy_cp: - pos = Conflict.frontline_position(cp, ecp, self.game.theater)[0] - wpt = FlightWaypoint( - FlightWaypointType.CUSTOM, - pos.x, - pos.y, - Distance.from_meters(800), - ) - wpt.name = "Frontline " + cp.name + "/" + ecp.name + " [CAS]" - wpt.alt_type = "RADIO" - wpt.pretty_name = wpt.name - wpt.description = "Frontline" - i = add_model_item(i, model, wpt.pretty_name, wpt) + for front_line in self.game.theater.conflicts(): + pos = Conflict.frontline_position(front_line, self.game.theater)[0] + wpt = FlightWaypoint( + FlightWaypointType.CUSTOM, + pos.x, + pos.y, + Distance.from_meters(800), + ) + wpt.name = f"Frontline {front_line.name} [CAS]" + wpt.alt_type = "RADIO" + wpt.pretty_name = wpt.name + wpt.description = "Frontline" + i = add_model_item(i, model, wpt.pretty_name, wpt) if self.include_targets: for cp in self.game.theater.controlpoints: diff --git a/qt_ui/widgets/combos/QSEADTargetSelectionComboBox.py b/qt_ui/widgets/combos/QSEADTargetSelectionComboBox.py deleted file mode 100644 index 3cd46bf9..00000000 --- a/qt_ui/widgets/combos/QSEADTargetSelectionComboBox.py +++ /dev/null @@ -1,87 +0,0 @@ -from PySide2.QtGui import QStandardItem, QStandardItemModel - -from game import Game -from game.data.radar_db import UNITS_WITH_RADAR -from gen import db -from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox - - -class SEADTargetInfo: - def __init__(self): - self.name = "" - self.location = None - self.radars = [] - self.threat_range = 0 - self.detection_range = 0 - - -class QSEADTargetSelectionComboBox(QFilteredComboBox): - def __init__(self, game: Game, parent=None): - super(QSEADTargetSelectionComboBox, self).__init__(parent) - self.game = game - self.find_possible_sead_targets() - - def get_selected_target(self) -> SEADTargetInfo: - n = self.currentText() - for target in self.targets: - if target.name == n: - return target - - def find_possible_sead_targets(self): - - self.targets = [] - i = 0 - model = QStandardItemModel() - - def add_model_item(i, model, target): - item = QStandardItem(target.name) - model.setItem(i, 0, item) - self.targets.append(target) - return i + 1 - - for cp in self.game.theater.controlpoints: - if cp.captured: - continue - for g in cp.ground_objects: - - radars = [] - detection_range = 0 - threat_range = 0 - if g.dcs_identifier == "AA": - for group in g.groups: - for u in group.units: - utype = db.unit_type_from_name(u.type) - - if utype in UNITS_WITH_RADAR: - if ( - hasattr(utype, "detection_range") - and utype.detection_range > 1000 - ): - if utype.detection_range > detection_range: - detection_range = utype.detection_range - radars.append(u) - - if hasattr(utype, "threat_range"): - if utype.threat_range > threat_range: - threat_range = utype.threat_range - if len(radars) > 0: - tgt_info = SEADTargetInfo() - tgt_info.name = ( - g.obj_name - + " [" - + ",".join( - [db.unit_type_from_name(u.type).id for u in radars] - ) - + " ]" - ) - if len(tgt_info.name) > 25: - tgt_info.name = ( - g.obj_name + " [" + str(len(radars)) + " units]" - ) - tgt_info.radars = radars - tgt_info.location = g - tgt_info.threat_range = threat_range - tgt_info.detection_range = detection_range - i = add_model_item(i, model, tgt_info) - - self.setModel(model) diff --git a/qt_ui/widgets/map/QFrontLine.py b/qt_ui/widgets/map/QFrontLine.py deleted file mode 100644 index 41edcaea..00000000 --- a/qt_ui/widgets/map/QFrontLine.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Common base for objects drawn on the game map.""" -from typing import Optional - -from PySide2.QtCore import Qt -from PySide2.QtGui import QPen -from PySide2.QtWidgets import ( - QAction, - QGraphicsLineItem, - QGraphicsSceneContextMenuEvent, - QGraphicsSceneHoverEvent, - QGraphicsSceneMouseEvent, - QMenu, -) - -import qt_ui.uiconstants as const -from game.theater import FrontLine -from qt_ui.dialogs import Dialog -from qt_ui.models import GameModel -from qt_ui.windows.GameUpdateSignal import GameUpdateSignal -from qt_ui.windows.mission.QPackageDialog import QNewPackageDialog - - -class QFrontLine(QGraphicsLineItem): - """Base class for objects drawn on the game map. - - Game map objects have an on_click behavior that triggers on left click, and - change the mouse cursor on hover. - """ - - def __init__( - self, - x1: float, - y1: float, - x2: float, - y2: float, - mission_target: FrontLine, - game_model: GameModel, - ) -> None: - super().__init__(x1, y1, x2, y2) - self.mission_target = mission_target - self.game_model = game_model - self.new_package_dialog: Optional[QNewPackageDialog] = None - self.setAcceptHoverEvents(True) - - pen = QPen(brush=const.COLORS["bright_red"]) - pen.setColor(const.COLORS["orange"]) - pen.setWidth(8) - self.setPen(pen) - - def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): - self.setCursor(Qt.PointingHandCursor) - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - if event.button() == Qt.LeftButton: - self.on_click() - - def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: - menu = QMenu("Menu") - - object_details_action = QAction(self.object_dialog_text) - object_details_action.triggered.connect(self.on_click) - menu.addAction(object_details_action) - - new_package_action = QAction(f"New package") - new_package_action.triggered.connect(self.open_new_package_dialog) - menu.addAction(new_package_action) - - if self.game_model.game.settings.enable_frontline_cheats: - cheat_forward = QAction(f"CHEAT: Advance Frontline") - cheat_forward.triggered.connect(self.cheat_forward) - menu.addAction(cheat_forward) - - cheat_backward = QAction(f"CHEAT: Retreat Frontline") - cheat_backward.triggered.connect(self.cheat_backward) - menu.addAction(cheat_backward) - - menu.exec_(event.screenPos()) - - @property - def object_dialog_text(self) -> str: - """Text to for the object's dialog in the context menu. - - Right clicking a map object will open a context menu and the first item - will open the details dialog for this object. This menu action has the - same behavior as the on_click event. - - Return: - The text that should be displayed for the menu item. - """ - return "Details" - - def on_click(self) -> None: - """The action to take when this map object is left-clicked. - - Typically this should open a details view of the object. - """ - raise NotImplementedError - - def open_new_package_dialog(self) -> None: - """Opens the dialog for planning a new mission package.""" - Dialog.open_new_package_dialog(self.mission_target) - - def cheat_forward(self) -> None: - self.mission_target.control_point_a.base.affect_strength(0.1) - self.mission_target.control_point_b.base.affect_strength(-0.1) - self.game_model.game.initialize_turn() - GameUpdateSignal.get_instance().updateGame(self.game_model.game) - - def cheat_backward(self) -> None: - self.mission_target.control_point_a.base.affect_strength(-0.1) - self.mission_target.control_point_b.base.affect_strength(0.1) - self.game_model.game.initialize_turn() - GameUpdateSignal.get_instance().updateGame(self.game_model.game) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 11705746..5d7f2d7d 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -1,1333 +1,58 @@ from __future__ import annotations -import datetime import logging -import math -from functools import singledispatchmethod -from typing import Iterable, Iterator, List, Optional, Tuple - -from PySide2 import QtCore, QtWidgets -from PySide2.QtCore import QLineF, QPointF, QRectF, Qt -from PySide2.QtGui import ( - QBrush, - QColor, - QFont, - QPen, - QPixmap, - QPolygonF, - QWheelEvent, -) -from PySide2.QtWidgets import ( - QFrame, - QGraphicsItem, - QGraphicsOpacityEffect, - QGraphicsScene, - QGraphicsSceneMouseEvent, - QGraphicsView, -) -from dcs import Point -from dcs.planes import F_16C_50 -from dcs.mapping import point_from_heading -from dcs.unitgroup import Group -from shapely.geometry import ( - LineString, - MultiPolygon, - Point as ShapelyPoint, - Polygon, +from pathlib import Path +from typing import ( + Optional, +) + +from PySide2.QtCore import QUrl +from PySide2.QtWebChannel import QWebChannel +from PySide2.QtWebEngineWidgets import ( + QWebEnginePage, + QWebEngineView, ) -import qt_ui.uiconstants as CONST from game import Game -from game.navmesh import NavMesh -from game.theater import ControlPoint, Enum -from game.theater.conflicttheater import FrontLine, ReferencePoint -from game.theater.theatergroundobject import ( - TheaterGroundObject, -) -from game.utils import Distance, meters, nautical_miles -from game.weather import TimeOfDay -from gen import Conflict, Package -from gen.flights.flight import ( - Flight, - FlightType, - FlightWaypoint, - FlightWaypointType, -) -from gen.flights.flightplan import ( - BarCapFlightPlan, - FlightPlan, - FlightPlanBuilder, - InvalidObjectiveLocation, - PatrollingFlightPlan, - TarCapFlightPlan, -) -from gen.flights.traveltime import TotEstimator -from qt_ui.displayoptions import DisplayOptions, ThreatZoneOptions from qt_ui.models import GameModel -from qt_ui.widgets.map.QFrontLine import QFrontLine -from qt_ui.widgets.map.QLiberationScene import QLiberationScene -from qt_ui.widgets.map.QMapControlPoint import QMapControlPoint -from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject -from qt_ui.windows.GameUpdateSignal import GameUpdateSignal - -MAX_SHIP_DISTANCE = nautical_miles(80) +from qt_ui.widgets.map.mapmodel import MapModel -def binomial(i: int, n: int) -> float: - """Binomial coefficient""" - return math.factorial(n) / float(math.factorial(i) * math.factorial(n - i)) +class LoggingWebPage(QWebEnginePage): + def javaScriptConsoleMessage( + self, + level: QWebEnginePage.JavaScriptConsoleMessageLevel, + message: str, + line_number: int, + source: str, + ) -> None: + if level == QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: + logging.error(message) + elif level == QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: + logging.warning(message) + else: + logging.info(message) -def bernstein(t: float, i: int, n: int) -> float: - """Bernstein polynom""" - return binomial(i, n) * (t ** i) * ((1 - t) ** (n - i)) - - -def bezier(t: float, points: Iterable[Tuple[float, float]]) -> Tuple[float, float]: - """Calculate coordinate of a point in the bezier curve""" - n = len(points) - 1 - x = y = 0 - for i, pos in enumerate(points): - bern = bernstein(t, i, n) - x += pos[0] * bern - y += pos[1] * bern - return x, y - - -def bezier_curve_range( - n: int, points: Iterable[Tuple[float, float]] -) -> Iterator[Tuple[float, float]]: - """Range of points in a curve bezier""" - for i in range(n): - t = i / float(n - 1) - yield bezier(t, points) - - -class QLiberationMapState(Enum): - NORMAL = 0 - MOVING_UNIT = 1 - - -class QLiberationMap(QGraphicsView): - - WAYPOINT_SIZE = 4 - reference_point_setup_mode = False - instance: Optional[QLiberationMap] = None - - def __init__(self, game_model: GameModel): - super(QLiberationMap, self).__init__() - QLiberationMap.instance = self +class QLiberationMap(QWebEngineView): + def __init__(self, game_model: GameModel, parent) -> None: + super().__init__(parent) self.game_model = game_model - self.game: Optional[Game] = game_model.game - self.state = QLiberationMapState.NORMAL - - self.waypoint_info_font = QFont() - self.waypoint_info_font.setPointSize(12) - - self.flight_path_items: List[QGraphicsItem] = [] - # A tuple of (package index, flight index), or none. - self.selected_flight: Optional[Tuple[int, int]] = None - self.setMinimumSize(800, 600) - self.setMaximumHeight(2160) - self._zoom = 0 - self.factor = 1 - self.factorized = 1 - self.init_scene() - self.setGame(game_model.game) + self.map_model = MapModel(game_model) - # Object displayed when unit is selected - self.movement_line = QtWidgets.QGraphicsLineItem( - QtCore.QLineF(QPointF(0, 0), QPointF(0, 0)) + self.channel = QWebChannel() + self.channel.registerObject("game", self.map_model) + + self.page = LoggingWebPage(self) + self.page.setWebChannel(self.channel) + self.page.load( + QUrl.fromLocalFile(str(Path("resources/ui/map/canvas.html").resolve())) ) - self.movement_line.setPen(QPen(CONST.COLORS["orange"], width=10.0)) - self.selected_cp: QMapControlPoint = None + self.setPage(self.page) - GameUpdateSignal.get_instance().flight_paths_changed.connect( - lambda: self.draw_flight_plans(self.scene()) - ) - - def update_package_selection(index: int) -> None: - # Optional[int] isn't a valid type for a Qt signal. None will be - # converted to zero automatically. We use -1 to indicate no - # selection. - if index == -1: - self.selected_flight = None - else: - self.selected_flight = index, 0 - self.draw_flight_plans(self.scene()) - - GameUpdateSignal.get_instance().package_selection_changed.connect( - update_package_selection - ) - - def update_flight_selection(index: int) -> None: - if self.selected_flight is None: - if index != -1: - # We don't know what order update_package_selection and - # update_flight_selection will be called in when the last - # package is removed. If no flight is selected, it's not a - # problem to also have no package selected. - logging.error("Flight was selected with no package selected") - return - - # Optional[int] isn't a valid type for a Qt signal. None will be - # converted to zero automatically. We use -1 to indicate no - # selection. - if index == -1: - self.selected_flight = self.selected_flight[0], None - self.selected_flight = self.selected_flight[0], index - self.draw_flight_plans(self.scene()) - - GameUpdateSignal.get_instance().flight_selection_changed.connect( - update_flight_selection - ) - - self.nm_to_pixel_ratio: int = 0 - - self.navmesh_highlight: Optional[QPolygonF] = None - self.shortest_path_segments: List[QLineF] = [] - - def init_scene(self): - scene = QLiberationScene(self) - self.setScene(scene) - self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) - self.setResizeAnchor(QGraphicsView.AnchorUnderMouse) - self.setBackgroundBrush(QBrush(QColor(30, 30, 30))) - self.setFrameShape(QFrame.NoFrame) - self.setDragMode(QGraphicsView.ScrollHandDrag) - - def setGame(self, game: Optional[Game]): - self.game = game - if self.game is not None: - logging.debug("Reloading Map Canvas") - self.nm_to_pixel_ratio = self.distance_to_pixels(nautical_miles(1)) - self.reload_scene() - - """ - - Uncomment to set up theather reference points""" - - def keyPressEvent(self, event): - modifiers = QtWidgets.QApplication.keyboardModifiers() - if not self.reference_point_setup_mode: - if modifiers == QtCore.Qt.ShiftModifier and event.key() == QtCore.Qt.Key_R: - self.reference_point_setup_mode = True - self.reload_scene() - else: - super(QLiberationMap, self).keyPressEvent(event) + def set_game(self, game: Optional[Game]) -> None: + if game is None: + self.map_model.clear() else: - if modifiers == QtCore.Qt.ShiftModifier and event.key() == QtCore.Qt.Key_R: - self.reference_point_setup_mode = False - self.reload_scene() - else: - distance = 1 - modifiers = int(event.modifiers()) - if modifiers & QtCore.Qt.ShiftModifier: - distance *= 10 - elif modifiers & QtCore.Qt.ControlModifier: - distance *= 100 - - if event.key() == QtCore.Qt.Key_Down: - self.update_reference_point( - self.game.theater.reference_points[0], Point(0, distance) - ) - if event.key() == QtCore.Qt.Key_Up: - self.update_reference_point( - self.game.theater.reference_points[0], Point(0, -distance) - ) - if event.key() == QtCore.Qt.Key_Left: - self.update_reference_point( - self.game.theater.reference_points[0], Point(-distance, 0) - ) - if event.key() == QtCore.Qt.Key_Right: - self.update_reference_point( - self.game.theater.reference_points[0], Point(distance, 0) - ) - - if event.key() == QtCore.Qt.Key_S: - self.update_reference_point( - self.game.theater.reference_points[1], Point(0, distance) - ) - if event.key() == QtCore.Qt.Key_W: - self.update_reference_point( - self.game.theater.reference_points[1], Point(0, -distance) - ) - if event.key() == QtCore.Qt.Key_A: - self.update_reference_point( - self.game.theater.reference_points[1], Point(-distance, 0) - ) - if event.key() == QtCore.Qt.Key_D: - self.update_reference_point( - self.game.theater.reference_points[1], Point(distance, 0) - ) - - logging.debug(f"Reference points: {self.game.theater.reference_points}") - self.reload_scene() - - @staticmethod - def update_reference_point(point: ReferencePoint, change: Point) -> None: - point.image_coordinates += change - - def display_culling(self, scene: QGraphicsScene) -> None: - """Draws the culling distance rings on the map""" - culling_points = self.game_model.game.get_culling_points() - culling_zones = self.game_model.game.get_culling_zones() - culling_distance = self.game_model.game.settings.perf_culling_distance - for point in culling_points: - culling_distance_point = Point(point.x + 2500, point.y + 2500) - distance_point = self._transform_point(culling_distance_point) - transformed = self._transform_point(point) - radius = distance_point[0] - transformed[0] - scene.addEllipse( - transformed[0] - radius, - transformed[1] - radius, - 2 * radius, - 2 * radius, - CONST.COLORS["transparent"], - CONST.COLORS["light_green_transparent"], - ) - for zone in culling_zones: - culling_distance_zone = Point( - zone.x + culling_distance * 1000, zone.y + culling_distance * 1000 - ) - distance_zone = self._transform_point(culling_distance_zone) - transformed = self._transform_point(zone) - radius = distance_zone[0] - transformed[0] - scene.addEllipse( - transformed[0] - radius, - transformed[1] - radius, - 2 * radius, - 2 * radius, - CONST.COLORS["transparent"], - CONST.COLORS["light_green_transparent"], - ) - - def draw_shapely_poly( - self, scene: QGraphicsScene, poly: Polygon, pen: QPen, brush: QBrush - ) -> Optional[QPolygonF]: - if poly.is_empty: - return None - points = [] - for x, y in poly.exterior.coords: - x, y = self._transform_point(Point(x, y)) - points.append(QPointF(x, y)) - return scene.addPolygon(QPolygonF(points), pen, brush) - - def draw_threat_zone( - self, scene: QGraphicsScene, poly: Polygon, player: bool - ) -> None: - if player: - brush = QColor(0, 132, 255, 100) - else: - brush = QColor(227, 32, 0, 100) - self.draw_shapely_poly(scene, poly, CONST.COLORS["transparent"], brush) - - def display_threat_zones( - self, scene: QGraphicsScene, options: ThreatZoneOptions, player: bool - ) -> None: - """Draws the threat zones on the map.""" - threat_zones = self.game.threat_zone_for(player) - if options.all: - threat_poly = threat_zones.all - elif options.aircraft: - threat_poly = threat_zones.airbases - elif options.air_defenses: - threat_poly = threat_zones.air_defenses - else: - return - - if isinstance(threat_poly, MultiPolygon): - polys = threat_poly.geoms - else: - polys = [threat_poly] - for poly in polys: - self.draw_threat_zone(scene, poly, player) - - def draw_navmesh_neighbor_line( - self, scene: QGraphicsScene, poly: Polygon, begin: ShapelyPoint - ) -> None: - vertex = Point(begin.x, begin.y) - centroid = poly.centroid - direction = Point(centroid.x, centroid.y) - end = vertex.point_from_heading( - vertex.heading_between_point(direction), nautical_miles(2).meters - ) - - scene.addLine( - QLineF( - QPointF(*self._transform_point(vertex)), - QPointF(*self._transform_point(end)), - ), - CONST.COLORS["yellow"], - ) - - @singledispatchmethod - def draw_navmesh_border( - self, intersection, scene: QGraphicsScene, poly: Polygon - ) -> None: - raise NotImplementedError( - "draw_navmesh_border not implemented for %s", - intersection.__class__.__name__, - ) - - @draw_navmesh_border.register - def draw_navmesh_point_border( - self, intersection: ShapelyPoint, scene: QGraphicsScene, poly: Polygon - ) -> None: - # Draw a line from the vertex toward the center of the polygon. - self.draw_navmesh_neighbor_line(scene, poly, intersection) - - @draw_navmesh_border.register - def draw_navmesh_edge_border( - self, intersection: LineString, scene: QGraphicsScene, poly: Polygon - ) -> None: - # Draw a line from the center of the edge toward the center of the - # polygon. - edge_center = intersection.interpolate(0.5, normalized=True) - self.draw_navmesh_neighbor_line(scene, poly, edge_center) - - def display_navmesh(self, scene: QGraphicsScene, player: bool) -> None: - for navpoly in self.game.navmesh_for(player).polys: - self.draw_shapely_poly( - scene, navpoly.poly, CONST.COLORS["black"], CONST.COLORS["transparent"] - ) - - position = self._transform_point( - Point(navpoly.poly.centroid.x, navpoly.poly.centroid.y) - ) - text = scene.addSimpleText( - f"Navmesh {navpoly.ident}", self.waypoint_info_font - ) - text.setBrush(QColor(255, 255, 255)) - text.setPen(QColor(255, 255, 255)) - text.moveBy(position[0] + 8, position[1]) - text.setZValue(2) - - for border in navpoly.neighbors.values(): - self.draw_navmesh_border(border, scene, navpoly.poly) - - def highlight_mouse_navmesh( - self, scene: QGraphicsScene, navmesh: NavMesh, mouse_position: Point - ) -> None: - if self.navmesh_highlight is not None: - try: - scene.removeItem(self.navmesh_highlight) - except RuntimeError: - pass - navpoly = navmesh.localize(mouse_position) - if navpoly is None: - return - self.navmesh_highlight = self.draw_shapely_poly( - scene, - navpoly.poly, - CONST.COLORS["transparent"], - CONST.COLORS["light_green_transparent"], - ) - - def draw_shortest_path( - self, scene: QGraphicsScene, navmesh: NavMesh, destination: Point, player: bool - ) -> None: - for line in self.shortest_path_segments: - try: - scene.removeItem(line) - except RuntimeError: - pass - - if player: - origin = self.game.theater.player_points()[0] - else: - origin = self.game.theater.enemy_points()[0] - - prev_pos = self._transform_point(origin.position) - try: - path = navmesh.shortest_path(origin.position, destination) - except ValueError: - return - for waypoint in path[1:]: - new_pos = self._transform_point(waypoint) - flight_path_pen = self.flight_path_pen(player, selected=True) - # Draw the line to the *middle* of the waypoint. - offset = self.WAYPOINT_SIZE // 2 - self.shortest_path_segments.append( - scene.addLine( - prev_pos[0] + offset, - prev_pos[1] + offset, - new_pos[0] + offset, - new_pos[1] + offset, - flight_path_pen, - ) - ) - - self.shortest_path_segments.append( - scene.addEllipse( - new_pos[0], - new_pos[1], - self.WAYPOINT_SIZE, - self.WAYPOINT_SIZE, - flight_path_pen, - flight_path_pen, - ) - ) - - prev_pos = new_pos - - def draw_test_flight_plan( - self, - scene: QGraphicsScene, - task: FlightType, - point_near_target: Point, - player: bool, - ) -> None: - for line in self.shortest_path_segments: - try: - scene.removeItem(line) - except RuntimeError: - pass - - self.clear_flight_paths(scene) - - target = self.game.theater.closest_target(point_near_target) - - if player: - origin = self.game.theater.player_points()[0] - else: - origin = self.game.theater.enemy_points()[0] - - package = Package(target) - flight = Flight( - package, - self.game.player_country if player else self.game.enemy_country, - F_16C_50, - 2, - task, - start_type="Warm", - departure=origin, - arrival=origin, - divert=None, - ) - package.add_flight(flight) - planner = FlightPlanBuilder(self.game, package, is_player=player) - try: - planner.populate_flight_plan(flight) - except InvalidObjectiveLocation: - return - - package.time_over_target = TotEstimator(package).earliest_tot() - self.draw_flight_plan(scene, flight, selected=True) - - @staticmethod - def should_display_ground_objects_at(cp: ControlPoint) -> bool: - return (DisplayOptions.sam_ranges and cp.captured) or ( - DisplayOptions.enemy_sam_ranges and not cp.captured - ) - - def draw_threat_range( - self, - scene: QGraphicsScene, - group: Group, - ground_object: TheaterGroundObject, - cp: ControlPoint, - ) -> None: - go_pos = self._transform_point(ground_object.position) - detection_range = ground_object.detection_range(group) - threat_range = ground_object.threat_range(group) - if threat_range: - threat_pos = self._transform_point( - ground_object.position + Point(threat_range.meters, threat_range.meters) - ) - threat_radius = Point(*go_pos).distance_to_point(Point(*threat_pos)) - - # Add threat range circle - scene.addEllipse( - go_pos[0] - threat_radius / 2 + 7, - go_pos[1] - threat_radius / 2 + 6, - threat_radius, - threat_radius, - self.threat_pen(cp.captured), - ) - - if detection_range and DisplayOptions.detection_range: - # Add detection range circle - detection_pos = self._transform_point( - ground_object.position - + Point(detection_range.meters, detection_range.meters) - ) - detection_radius = Point(*go_pos).distance_to_point(Point(*detection_pos)) - scene.addEllipse( - go_pos[0] - detection_radius / 2 + 7, - go_pos[1] - detection_radius / 2 + 6, - detection_radius, - detection_radius, - self.detection_pen(cp.captured), - ) - - def draw_ground_objects(self, scene: QGraphicsScene, cp: ControlPoint) -> None: - added_objects = [] - for ground_object in cp.ground_objects: - if ground_object.obj_name in added_objects: - continue - - go_pos = self._transform_point(ground_object.position) - if not ground_object.airbase_group: - buildings = self.game.theater.find_ground_objects_by_obj_name( - ground_object.obj_name - ) - scene.addItem( - QMapGroundObject( - self, - go_pos[0], - go_pos[1], - 14, - 12, - cp, - ground_object, - self.game, - buildings, - ) - ) - - should_display = self.should_display_ground_objects_at(cp) - if ground_object.might_have_aa and should_display: - for group in ground_object.groups: - self.draw_threat_range(scene, group, ground_object, cp) - added_objects.append(ground_object.obj_name) - - def reload_scene(self): - scene = self.scene() - scene.clear() - - playerColor = self.game.get_player_color() - enemyColor = self.game.get_enemy_color() - - self.addBackground() - - # Display Culling - if DisplayOptions.culling and self.game.settings.perf_culling: - self.display_culling(scene) - - self.display_threat_zones(scene, DisplayOptions.blue_threat_zones, player=True) - self.display_threat_zones(scene, DisplayOptions.red_threat_zones, player=False) - - if DisplayOptions.navmeshes.blue_navmesh: - self.display_navmesh(scene, player=True) - if DisplayOptions.navmeshes.red_navmesh: - self.display_navmesh(scene, player=False) - - for cp in self.game.theater.controlpoints: - - pos = self._transform_point(cp.position) - - scene.addItem( - QMapControlPoint( - self, - pos[0] - CONST.CP_SIZE / 2, - pos[1] - CONST.CP_SIZE / 2, - CONST.CP_SIZE, - CONST.CP_SIZE, - cp, - self.game_model, - ) - ) - - if cp.captured: - pen = QPen(brush=CONST.COLORS[playerColor]) - brush = CONST.COLORS[playerColor + "_transparent"] - else: - pen = QPen(brush=CONST.COLORS[enemyColor]) - brush = CONST.COLORS[enemyColor + "_transparent"] - - self.draw_ground_objects(scene, cp) - - if cp.target_position is not None: - proj = self._transform_point(cp.target_position) - scene.addLine( - QLineF(QPointF(pos[0], pos[1]), QPointF(proj[0], proj[1])), - QPen(CONST.COLORS["green"], width=10, s=Qt.DashDotLine), - ) - - for cp in self.game.theater.enemy_points(): - if DisplayOptions.lines: - self.scene_create_lines_for_cp(cp, playerColor, enemyColor) - - for cp in self.game.theater.player_points(): - if DisplayOptions.lines: - self.scene_create_lines_for_cp(cp, playerColor, enemyColor) - - self.draw_flight_plans(scene) - - for cp in self.game.theater.controlpoints: - pos = self._transform_point(cp.position) - text = scene.addText(cp.name, font=CONST.FONT_MAP) - text.setPos(pos[0] + CONST.CP_SIZE, pos[1] - CONST.CP_SIZE / 2) - text = scene.addText(cp.name, font=CONST.FONT_MAP) - text.setDefaultTextColor(Qt.white) - text.setPos(pos[0] + CONST.CP_SIZE + 1, pos[1] - CONST.CP_SIZE / 2 + 1) - - def clear_flight_paths(self, scene: QGraphicsScene) -> None: - for item in self.flight_path_items: - try: - scene.removeItem(item) - except RuntimeError: - # Something may have caused those items to already be removed. - pass - self.flight_path_items.clear() - - def draw_flight_plans(self, scene: QGraphicsScene) -> None: - self.clear_flight_paths(scene) - if DisplayOptions.flight_paths.hide: - return - packages = list(self.game_model.ato_model.packages) - if self.game.settings.show_red_ato: - packages.extend(self.game_model.red_ato_model.packages) - for p_idx, package_model in enumerate(packages): - for f_idx, flight in enumerate(package_model.flights): - if self.selected_flight is None: - selected = False - else: - selected = (p_idx, f_idx) == self.selected_flight - if DisplayOptions.flight_paths.only_selected and not selected: - continue - self.draw_flight_plan(scene, flight, selected) - - def draw_flight_plan( - self, scene: QGraphicsScene, flight: Flight, selected: bool - ) -> None: - is_player = flight.from_cp.captured - pos = self._transform_point(flight.from_cp.position) - - self.draw_waypoint(scene, pos, is_player, selected) - prev_pos = tuple(pos) - drew_target = False - target_types = ( - FlightWaypointType.TARGET_GROUP_LOC, - FlightWaypointType.TARGET_POINT, - FlightWaypointType.TARGET_SHIP, - ) - for idx, point in enumerate(flight.flight_plan.waypoints[1:]): - if point.waypoint_type == FlightWaypointType.DIVERT: - # Don't clutter the map showing divert points. - continue - - new_pos = self._transform_point(Point(point.x, point.y)) - self.draw_flight_path(scene, prev_pos, new_pos, is_player, selected) - self.draw_waypoint(scene, new_pos, is_player, selected) - if selected and DisplayOptions.waypoint_info: - if point.waypoint_type in target_types: - if drew_target: - # Don't draw dozens of targets over each other. - continue - drew_target = True - self.draw_waypoint_info( - scene, idx + 1, point, new_pos, flight.flight_plan - ) - prev_pos = tuple(new_pos) - - if selected and DisplayOptions.patrol_engagement_range: - self.draw_patrol_commit_range(scene, flight) - - def draw_patrol_commit_range(self, scene: QGraphicsScene, flight: Flight) -> None: - if not isinstance(flight.flight_plan, PatrollingFlightPlan): - return - start = flight.flight_plan.patrol_start - end = flight.flight_plan.patrol_end - line = LineString( - [ - ShapelyPoint(start.x, start.y), - ShapelyPoint(end.x, end.y), - ] - ) - doctrine = self.game.faction_for(flight.departure.captured).doctrine - bubble = line.buffer(doctrine.cap_engagement_range.meters) - self.flight_path_items.append( - self.draw_shapely_poly( - scene, bubble, CONST.COLORS["yellow"], CONST.COLORS["transparent"] - ) - ) - - def draw_waypoint( - self, - scene: QGraphicsScene, - position: Tuple[float, float], - player: bool, - selected: bool, - ) -> None: - waypoint_pen = self.waypoint_pen(player, selected) - waypoint_brush = self.waypoint_brush(player, selected) - self.flight_path_items.append( - scene.addEllipse( - position[0], - position[1], - self.WAYPOINT_SIZE, - self.WAYPOINT_SIZE, - waypoint_pen, - waypoint_brush, - ) - ) - - def draw_waypoint_info( - self, - scene: QGraphicsScene, - number: int, - waypoint: FlightWaypoint, - position: Tuple[int, int], - flight_plan: FlightPlan, - ) -> None: - - altitude = int(waypoint.alt.feet) - altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL" - - prefix = "TOT" - time = flight_plan.tot_for_waypoint(waypoint) - if time is None: - prefix = "Depart" - time = flight_plan.depart_time_for_waypoint(waypoint) - if time is None: - tot = "" - else: - time = datetime.timedelta(seconds=int(time.total_seconds())) - tot = f"{prefix} T+{time}" - - pen = QPen(QColor("black"), 0.3) - brush = QColor("white") - - text = "\n".join( - [ - f"{number} {waypoint.name}", - f"{altitude} ft {altitude_type}", - tot, - ] - ) - - item = scene.addSimpleText(text, self.waypoint_info_font) - item.setFlag(QGraphicsItem.ItemIgnoresTransformations) - item.setBrush(brush) - item.setPen(pen) - item.moveBy(position[0] + 8, position[1]) - item.setZValue(2) - self.flight_path_items.append(item) - - def draw_flight_path( - self, - scene: QGraphicsScene, - pos0: Tuple[float, float], - pos1: Tuple[float, float], - player: bool, - selected: bool, - ) -> None: - flight_path_pen = self.flight_path_pen(player, selected) - # Draw the line to the *middle* of the waypoint. - offset = self.WAYPOINT_SIZE // 2 - self.flight_path_items.append( - scene.addLine( - pos0[0] + offset, - pos0[1] + offset, - pos1[0] + offset, - pos1[1] + offset, - flight_path_pen, - ) - ) - - def draw_bezier_frontline( - self, scene: QGraphicsScene, pen: QPen, frontline: FrontLine - ) -> None: - """ - Thanks to Alquimista for sharing a python implementation of the bezier algorithm this is adapted from. - https://gist.github.com/Alquimista/1274149#file-bezdraw-py - """ - bezier_fixed_points = [] - for segment in frontline.segments: - bezier_fixed_points.append(self._transform_point(segment.point_a)) - bezier_fixed_points.append(self._transform_point(segment.point_b)) - - old_point = bezier_fixed_points[0] - for point in bezier_curve_range( - int(len(bezier_fixed_points) * 2), bezier_fixed_points - ): - scene.addLine(old_point[0], old_point[1], point[0], point[1], pen=pen) - old_point = point - - def scene_create_lines_for_cp(self, cp: ControlPoint, playerColor, enemyColor): - scene = self.scene() - for connected_cp in cp.connected_points: - pos2 = self._transform_point(connected_cp.position) - if not cp.captured: - color = CONST.COLORS["dark_" + enemyColor] - else: - color = CONST.COLORS["dark_" + playerColor] - pen = QPen(brush=color) - pen.setColor(color) - pen.setWidth(6) - frontline = FrontLine(cp, connected_cp, self.game.theater) - if ( - cp.captured - and not connected_cp.captured - and Conflict.has_frontline_between(cp, connected_cp) - ): - if DisplayOptions.actual_frontline_pos: - self.draw_actual_frontline(frontline, scene, pen) - else: - self.draw_frontline_approximation(frontline, scene, pen) - else: - self.draw_bezier_frontline(scene, pen, frontline) - - def draw_frontline_approximation( - self, frontline: FrontLine, scene: QGraphicsScene, pen: QPen - ) -> None: - posx = frontline.position - h = frontline.attack_heading - pos2 = self._transform_point(posx) - self.draw_bezier_frontline(scene, pen, frontline) - p1 = point_from_heading(pos2[0], pos2[1], h + 180, 25) - p2 = point_from_heading(pos2[0], pos2[1], h, 25) - scene.addItem( - QFrontLine(p1[0], p1[1], p2[0], p2[1], frontline, self.game_model) - ) - - def draw_actual_frontline( - self, frontline: FrontLine, scene: QGraphicsScene, pen: QPen - ) -> None: - self.draw_bezier_frontline(scene, pen, frontline) - vector = Conflict.frontline_vector( - frontline.control_point_a, frontline.control_point_b, self.game.theater - ) - left_pos = self._transform_point(vector[0]) - right_pos = self._transform_point( - vector[0].point_from_heading(vector[1], vector[2]) - ) - scene.addItem( - QFrontLine( - left_pos[0], - left_pos[1], - right_pos[0], - right_pos[1], - frontline, - self.game_model, - ) - ) - - def draw_scale(self, scale_distance_nm=20, number_of_points=4): - - PADDING = 14 - POS_X = 0 - POS_Y = 10 - BIG_LINE = 5 - SMALL_LINE = 2 - - dist = self.distance_to_pixels(nautical_miles(scale_distance_nm)) - l = self.scene().addLine( - POS_X + PADDING, - POS_Y + BIG_LINE * 2, - POS_X + PADDING + dist, - POS_Y + BIG_LINE * 2, - ) - l.setPen(CONST.COLORS["black"]) - - lw = self.scene().addLine( - POS_X + PADDING + 1, - POS_Y + BIG_LINE * 2 + 1, - POS_X + PADDING + dist + 1, - POS_Y + BIG_LINE * 2 + 1, - ) - lw.setPen(CONST.COLORS["white"]) - - text = self.scene().addText( - "0nm", font=QFont("Trebuchet MS", 6, weight=5, italic=False) - ) - text.setPos(POS_X, POS_Y + BIG_LINE * 2) - text.setDefaultTextColor(Qt.black) - - text_white = self.scene().addText( - "0nm", font=QFont("Trebuchet MS", 6, weight=5, italic=False) - ) - text_white.setPos(POS_X + 1, POS_Y + BIG_LINE * 2) - text_white.setDefaultTextColor(Qt.white) - - text2 = self.scene().addText( - str(scale_distance_nm) + "nm", - font=QFont("Trebuchet MS", 6, weight=5, italic=False), - ) - text2.setPos(POS_X + dist, POS_Y + BIG_LINE * 2) - text2.setDefaultTextColor(Qt.black) - - text2_white = self.scene().addText( - str(scale_distance_nm) + "nm", - font=QFont("Trebuchet MS", 6, weight=5, italic=False), - ) - text2_white.setPos(POS_X + dist + 1, POS_Y + BIG_LINE * 2) - text2_white.setDefaultTextColor(Qt.white) - - for i in range(number_of_points + 1): - d = float(i) / float(number_of_points) - if i == 0 or i == number_of_points: - h = BIG_LINE - else: - h = SMALL_LINE - - l = self.scene().addLine( - POS_X + PADDING + d * dist, - POS_Y + BIG_LINE * 2, - POS_X + PADDING + d * dist, - POS_Y + BIG_LINE - h, - ) - l.setPen(CONST.COLORS["black"]) - - lw = self.scene().addLine( - POS_X + PADDING + d * dist + 1, - POS_Y + BIG_LINE * 2, - POS_X + PADDING + d * dist + 1, - POS_Y + BIG_LINE - h, - ) - lw.setPen(CONST.COLORS["white"]) - - def wheelEvent(self, event: QWheelEvent): - if event.angleDelta().y() > 0: - factor = 1.25 - self._zoom += 1 - if self._zoom < 10: - self.scale(factor, factor) - self.factorized *= factor - else: - self._zoom = 9 - else: - factor = 0.8 - self._zoom -= 1 - if self._zoom > -5: - self.scale(factor, factor) - self.factorized *= factor - else: - self._zoom = -4 - - @staticmethod - def _transpose_point(p: Point) -> Point: - return Point(p.y, p.x) - - def _scaling_factor(self) -> Point: - point_a = self.game.theater.reference_points[0] - point_b = self.game.theater.reference_points[1] - - world_distance = self._transpose_point( - point_b.world_coordinates - point_a.world_coordinates - ) - image_distance = point_b.image_coordinates - point_a.image_coordinates - - x_scale = image_distance.x / world_distance.x - y_scale = image_distance.y / world_distance.y - return Point(x_scale, y_scale) - - # TODO: Move this and its inverse into ConflictTheater. - def _transform_point(self, world_point: Point) -> Tuple[float, float]: - """Transforms world coordinates to image coordinates. - - World coordinates are transposed. X increases toward the North, Y - increases toward the East. The origin point depends on the map. - - Image coordinates originate from the top left. X increases to the right, - Y increases toward the bottom. - - The two points should be as distant as possible in both latitude and - logitude, and tuning the reference points will be simpler if they are in - geographically recognizable locations. For example, the Caucasus map is - aligned using the first point on Gelendzhik and the second on Batumi. - - The distances between each point are computed and a scaling factor is - determined from that. The given point is then offset from the first - point using the scaling factor. - - X is latitude, increasing northward. - Y is longitude, increasing eastward. - """ - point_a = self.game.theater.reference_points[0] - scale = self._scaling_factor() - - offset = self._transpose_point(point_a.world_coordinates - world_point) - scaled = Point(offset.x * scale.x, offset.y * scale.y) - transformed = point_a.image_coordinates - scaled - return transformed.x, transformed.y - - def _scene_to_dcs_coords(self, scene_point: Point) -> Point: - point_a = self.game.theater.reference_points[0] - scale = self._scaling_factor() - - offset = point_a.image_coordinates - scene_point - scaled = self._transpose_point(Point(offset.x / scale.x, offset.y / scale.y)) - return point_a.world_coordinates - scaled - - def distance_to_pixels(self, distance: Distance) -> int: - p1 = Point(0, 0) - p2 = Point(0, distance.meters) - p1a = Point(*self._transform_point(p1)) - p2a = Point(*self._transform_point(p2)) - return int(p1a.distance_to_point(p2a)) - - def highlight_color(self, transparent: Optional[bool] = False) -> QColor: - return QColor(255, 255, 0, 20 if transparent else 255) - - def base_faction_color_name(self, player: bool) -> str: - if player: - return self.game.get_player_color() - else: - return self.game.get_enemy_color() - - def waypoint_pen(self, player: bool, selected: bool) -> QColor: - if selected and DisplayOptions.flight_paths.all: - return self.highlight_color() - name = self.base_faction_color_name(player) - return CONST.COLORS[name] - - def waypoint_brush(self, player: bool, selected: bool) -> QColor: - if selected and DisplayOptions.flight_paths.all: - return self.highlight_color(transparent=True) - name = self.base_faction_color_name(player) - return CONST.COLORS[f"{name}_transparent"] - - def threat_pen(self, player: bool) -> QPen: - color = "blue" if player else "red" - return QPen(CONST.COLORS[color]) - - def detection_pen(self, player: bool) -> QPen: - color = "purple" if player else "yellow" - qpen = QPen(CONST.COLORS[color]) - qpen.setStyle(Qt.DotLine) - return qpen - - def flight_path_pen(self, player: bool, selected: bool) -> QPen: - if selected and DisplayOptions.flight_paths.all: - return self.highlight_color() - - name = self.base_faction_color_name(player) - color = CONST.COLORS[name] - pen = QPen(brush=color) - pen.setColor(color) - pen.setWidth(1) - pen.setStyle(Qt.DashDotLine) - return pen - - def addBackground(self): - scene = self.scene() - - if not DisplayOptions.map_poly: - bg = QPixmap("./resources/" + self.game.theater.overview_image) - scene.addPixmap(bg) - - # Apply graphical effects to simulate current daytime - if self.game.current_turn_time_of_day == TimeOfDay.Day: - pass - elif self.game.current_turn_time_of_day == TimeOfDay.Night: - ov = QPixmap(bg.width(), bg.height()) - ov.fill(CONST.COLORS["night_overlay"]) - overlay = scene.addPixmap(ov) - effect = QGraphicsOpacityEffect() - effect.setOpacity(0.7) - overlay.setGraphicsEffect(effect) - else: - ov = QPixmap(bg.width(), bg.height()) - ov.fill(CONST.COLORS["dawn_dust_overlay"]) - overlay = scene.addPixmap(ov) - effect = QGraphicsOpacityEffect() - effect.setOpacity(0.3) - overlay.setGraphicsEffect(effect) - - if DisplayOptions.map_poly or self.reference_point_setup_mode: - # Polygon display mode - if self.game.theater.landmap is not None: - - for sea_zone in self.game.theater.landmap.sea_zones: - print(sea_zone) - poly = QPolygonF( - [ - QPointF(*self._transform_point(Point(point[0], point[1]))) - for point in sea_zone.exterior.coords - ] - ) - if self.reference_point_setup_mode: - color = "sea_blue_transparent" - else: - color = "sea_blue" - scene.addPolygon(poly, CONST.COLORS[color], CONST.COLORS[color]) - - for inclusion_zone in self.game.theater.landmap.inclusion_zones: - poly = QPolygonF( - [ - QPointF(*self._transform_point(Point(point[0], point[1]))) - for point in inclusion_zone.exterior.coords - ] - ) - if self.reference_point_setup_mode: - scene.addPolygon( - poly, - CONST.COLORS["grey_transparent"], - CONST.COLORS["dark_grey_transparent"], - ) - else: - scene.addPolygon( - poly, CONST.COLORS["grey"], CONST.COLORS["dark_grey"] - ) - - for exclusion_zone in self.game.theater.landmap.exclusion_zones: - poly = QPolygonF( - [ - QPointF(*self._transform_point(Point(point[0], point[1]))) - for point in exclusion_zone.exterior.coords - ] - ) - if self.reference_point_setup_mode: - scene.addPolygon( - poly, - CONST.COLORS["grey_transparent"], - CONST.COLORS["dark_dark_grey_transparent"], - ) - else: - scene.addPolygon( - poly, CONST.COLORS["grey"], CONST.COLORS["dark_dark_grey"] - ) - - # Uncomment to display plan projection test - # self.projection_test() - self.draw_scale() - - if self.reference_point_setup_mode: - for i, point in enumerate(self.game.theater.reference_points): - self.scene().addRect( - QRectF( - point.image_coordinates.x, point.image_coordinates.y, 25, 25 - ), - pen=CONST.COLORS["red"], - brush=CONST.COLORS["red"], - ) - text = self.scene().addText( - f"P{i} = {point.image_coordinates}", - font=QFont("Trebuchet MS", 14, weight=8, italic=False), - ) - text.setDefaultTextColor(CONST.COLORS["red"]) - text.setPos(point.image_coordinates.x + 26, point.image_coordinates.y) - - # Set to True to visually debug _transform_point. - draw_transformed = False - if draw_transformed: - x, y = self._transform_point(point.world_coordinates) - self.scene().addRect( - QRectF(x, y, 25, 25), - pen=CONST.COLORS["red"], - brush=CONST.COLORS["red"], - ) - text = self.scene().addText( - f"P{i}' = {x}, {y}", - font=QFont("Trebuchet MS", 14, weight=8, italic=False), - ) - text.setDefaultTextColor(CONST.COLORS["red"]) - text.setPos(x + 26, y) - - def projection_test(self): - for i in range(100): - for j in range(100): - x = i * 100.0 - y = j * 100.0 - original = Point(x, y) - proj = self._scene_to_dcs_coords(original) - unproj = self._transform_point(proj) - converted = Point(*unproj) - assert math.isclose(original.x, converted.x, abs_tol=0.00000001) - assert math.isclose(original.y, converted.y, abs_tol=0.00000001) - - def setSelectedUnit(self, selected_cp: QMapControlPoint): - self.state = QLiberationMapState.MOVING_UNIT - self.selected_cp = selected_cp - position = self._transform_point(selected_cp.control_point.position) - self.movement_line = QtWidgets.QGraphicsLineItem( - QLineF(QPointF(*position), QPointF(*position)) - ) - self.scene().addItem(self.movement_line) - - def is_valid_ship_pos(self, scene_position: Point) -> bool: - world_destination = self._scene_to_dcs_coords(scene_position) - distance = self.selected_cp.control_point.position.distance_to_point( - world_destination - ) - if meters(distance) > MAX_SHIP_DISTANCE: - return False - return self.game.theater.is_in_sea(world_destination) - - def sceneMouseMovedEvent(self, event: QGraphicsSceneMouseEvent): - if self.game is None: - return - - mouse_position = Point(event.scenePos().x(), event.scenePos().y()) - if self.state == QLiberationMapState.MOVING_UNIT: - self.setCursor(Qt.PointingHandCursor) - self.movement_line.setLine( - QLineF(self.movement_line.line().p1(), event.scenePos()) - ) - - if self.is_valid_ship_pos(mouse_position): - self.movement_line.setPen(CONST.COLORS["green"]) - else: - self.movement_line.setPen(CONST.COLORS["red"]) - - mouse_world_pos = self._scene_to_dcs_coords(mouse_position) - if DisplayOptions.navmeshes.blue_navmesh: - self.highlight_mouse_navmesh( - self.scene(), - self.game.blue_navmesh, - self._scene_to_dcs_coords(mouse_position), - ) - if DisplayOptions.path_debug.shortest_path: - self.draw_shortest_path( - self.scene(), self.game.blue_navmesh, mouse_world_pos, player=True - ) - - if DisplayOptions.navmeshes.red_navmesh: - self.highlight_mouse_navmesh( - self.scene(), self.game.red_navmesh, mouse_world_pos - ) - - debug_blue = DisplayOptions.path_debug_faction.blue - if DisplayOptions.path_debug.shortest_path: - self.draw_shortest_path( - self.scene(), - self.game.navmesh_for(player=debug_blue), - mouse_world_pos, - player=False, - ) - elif not DisplayOptions.path_debug.hide: - if DisplayOptions.path_debug.barcap: - task = FlightType.BARCAP - elif DisplayOptions.path_debug.cas: - task = FlightType.CAS - elif DisplayOptions.path_debug.sweep: - task = FlightType.SWEEP - elif DisplayOptions.path_debug.strike: - task = FlightType.STRIKE - elif DisplayOptions.path_debug.tarcap: - task = FlightType.TARCAP - else: - raise ValueError("Unexpected value for DisplayOptions.path_debug") - self.draw_test_flight_plan( - self.scene(), task, mouse_world_pos, player=debug_blue - ) - - def sceneMousePressEvent(self, event: QGraphicsSceneMouseEvent): - if self.state == QLiberationMapState.MOVING_UNIT: - if event.buttons() == Qt.RightButton: - pass - elif event.buttons() == Qt.LeftButton: - if self.selected_cp is not None: - # Set movement position for the cp - pos = event.scenePos() - point = Point(int(pos.x()), int(pos.y())) - proj = self._scene_to_dcs_coords(point) - - if self.is_valid_ship_pos(point): - self.selected_cp.control_point.target_position = proj - else: - self.selected_cp.control_point.target_position = None - - GameUpdateSignal.get_instance().updateGame(self.game_model.game) - else: - return - self.state = QLiberationMapState.NORMAL - try: - self.scene().removeItem(self.movement_line) - except: - pass - self.selected_cp = None + self.map_model.reset() diff --git a/qt_ui/widgets/map/QLiberationScene.py b/qt_ui/widgets/map/QLiberationScene.py deleted file mode 100644 index fff8c379..00000000 --- a/qt_ui/widgets/map/QLiberationScene.py +++ /dev/null @@ -1,21 +0,0 @@ -from PySide2.QtWidgets import QGraphicsScene, QGraphicsSceneMouseEvent - -import qt_ui.uiconstants as CONST - - -class QLiberationScene(QGraphicsScene): - def __init__(self, parent): - super().__init__(parent) - item = self.addText( - 'Go to "File/New Game" to setup a new campaign or go to "File/Open" to load an existing save game.', - CONST.FONT_PRIMARY, - ) - item.setDefaultTextColor(CONST.COLORS["white"]) - - def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent): - super(QLiberationScene, self).mouseMoveEvent(event) - self.parent().sceneMouseMovedEvent(event) - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - super(QLiberationScene, self).mousePressEvent(event) - self.parent().sceneMousePressEvent(event) diff --git a/qt_ui/widgets/map/QMapControlPoint.py b/qt_ui/widgets/map/QMapControlPoint.py deleted file mode 100644 index 12bfce6a..00000000 --- a/qt_ui/widgets/map/QMapControlPoint.py +++ /dev/null @@ -1,123 +0,0 @@ -from typing import Optional - -from PySide2.QtGui import QColor, QPainter -from PySide2.QtWidgets import QAction, QMenu - -import qt_ui.uiconstants as const -from game.theater import ControlPoint, NavalControlPoint -from qt_ui.models import GameModel -from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 -from .QMapObject import QMapObject -from ...displayoptions import DisplayOptions -from ...windows.GameUpdateSignal import GameUpdateSignal - - -class QMapControlPoint(QMapObject): - def __init__( - self, - parent, - x: float, - y: float, - w: float, - h: float, - control_point: ControlPoint, - game_model: GameModel, - ) -> None: - super().__init__(x, y, w, h, mission_target=control_point) - self.game_model = game_model - self.control_point = control_point - self.parent = parent - self.setZValue(1) - self.setToolTip(self.control_point.name) - self.base_details_dialog: Optional[QBaseMenu2] = None - self.capture_action = QAction(f"CHEAT: Capture {self.control_point.name}") - self.capture_action.triggered.connect(self.cheat_capture) - - self.move_action = QAction("Move") - self.move_action.triggered.connect(self.move) - - self.cancel_move_action = QAction("Cancel Move") - self.cancel_move_action.triggered.connect(self.cancel_move) - - def paint(self, painter, option, widget=None) -> None: - if DisplayOptions.control_points: - painter.save() - painter.setRenderHint(QPainter.Antialiasing) - painter.setBrush(self.brush_color) - painter.setPen(self.pen_color) - - if not self.control_point.runway_is_operational(): - painter.setBrush(const.COLORS["black"]) - painter.setPen(self.brush_color) - - r = option.rect - painter.drawEllipse(r.x(), r.y(), r.width(), r.height()) - # TODO: Draw sunk carriers differently. - # Either don't draw them at all, or perhaps use a sunk ship icon. - painter.restore() - - @property - def brush_color(self) -> QColor: - if self.control_point.captured: - return const.COLORS["blue"] - else: - return const.COLORS["super_red"] - - @property - def pen_color(self) -> QColor: - return const.COLORS["white"] - - @property - def object_dialog_text(self) -> str: - if self.control_point.captured: - return "Open base menu" - else: - return "Open intel menu" - - def on_click(self) -> None: - self.base_details_dialog = QBaseMenu2( - self.window(), self.control_point, self.game_model - ) - self.base_details_dialog.show() - - def add_context_menu_actions(self, menu: QMenu) -> None: - - if self.control_point.moveable and self.control_point.captured: - menu.addAction(self.move_action) - if self.control_point.target_position is not None: - menu.addAction(self.cancel_move_action) - - if self.control_point.is_fleet: - return - - if self.control_point.captured: - return - - for connected in self.control_point.connected_points: - if ( - connected.captured - and self.game_model.game.settings.enable_base_capture_cheat - ): - menu.addAction(self.capture_action) - break - - def cheat_capture(self) -> None: - self.control_point.capture(self.game_model.game, for_player=True) - # Reinitialized ground planners and the like. - self.game_model.game.initialize_turn() - GameUpdateSignal.get_instance().updateGame(self.game_model.game) - - def move(self): - self.parent.setSelectedUnit(self) - - def cancel_move(self): - self.control_point.target_position = None - GameUpdateSignal.get_instance().updateGame(self.game_model.game) - - def open_new_package_dialog(self) -> None: - """Extends the default packagedialog to redirect to base menu for red air base.""" - is_navy = isinstance(self.control_point, NavalControlPoint) - if self.control_point.captured or is_navy: - super().open_new_package_dialog() - return - self.on_click() diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py deleted file mode 100644 index 10c52d09..00000000 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ /dev/null @@ -1,173 +0,0 @@ -from typing import List, Optional - -from PySide2.QtCore import QRect -from PySide2.QtGui import QBrush -from PySide2.QtWidgets import QGraphicsItem - -import qt_ui.uiconstants as const -from game import Game -from game.data.building_data import FORTIFICATION_BUILDINGS -from game.db import REWARDS -from game.theater import ControlPoint, TheaterGroundObject -from game.theater.theatergroundobject import ( - MissileSiteGroundObject, - CoastalSiteGroundObject, -) -from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu -from .QMapObject import QMapObject -from ...displayoptions import DisplayOptions - - -class QMapGroundObject(QMapObject): - def __init__( - self, - parent, - x: float, - y: float, - w: float, - h: float, - control_point: ControlPoint, - ground_object: TheaterGroundObject, - game: Game, - buildings: Optional[List[TheaterGroundObject]] = None, - ) -> None: - super().__init__(x, y, w, h, mission_target=ground_object) - self.ground_object = ground_object - self.control_point = control_point - self.parent = parent - self.game = game - self.setZValue(2) - self.buildings = buildings if buildings is not None else [] - self.setFlag(QGraphicsItem.ItemIgnoresTransformations, False) - self.ground_object_dialog: Optional[QGroundObjectMenu] = None - self.setToolTip(self.tooltip) - - @property - def tooltip(self) -> str: - lines = [ - f"[{self.ground_object.obj_name}]", - f"${self.production_per_turn} per turn", - ] - if self.ground_object.groups: - units = {} - for g in self.ground_object.groups: - for u in g.units: - if u.type in units: - units[u.type] = units[u.type] + 1 - else: - units[u.type] = 1 - - for unit in units.keys(): - lines.append(f"{unit} x {units[unit]}") - else: - for building in self.buildings: - if not building.is_dead: - lines.append(f"{building.dcs_identifier}") - - return "\n".join(lines) - - @property - def production_per_turn(self) -> int: - production = 0 - for building in self.buildings: - if building.is_dead: - continue - if building.category in REWARDS.keys(): - production += REWARDS[building.category] - return production - - def paint(self, painter, option, widget=None) -> None: - player_icons = "_blue" - enemy_icons = "" - - if DisplayOptions.ground_objects: - painter.save() - - cat = self.ground_object.category - if cat == "aa" and self.ground_object.sea_object: - cat = "ship" - if isinstance(self.ground_object, MissileSiteGroundObject): - cat = "missile" - if isinstance(self.ground_object, CoastalSiteGroundObject): - cat = "coastal" - - rect = QRect( - option.rect.x() + 2, - option.rect.y(), - option.rect.width() - 2, - option.rect.height(), - ) - - is_dead = self.ground_object.is_dead - for building in self.buildings: - if not building.is_dead: - is_dead = False - break - - if cat == "aa": - has_threat = False - for group in self.ground_object.groups: - if self.ground_object.threat_range(group).distance_in_meters > 0: - has_threat = True - - if not is_dead and not self.control_point.captured: - if cat == "aa" and not has_threat: - painter.drawPixmap(rect, const.ICONS["nothreat" + enemy_icons]) - else: - painter.drawPixmap(rect, const.ICONS[cat + enemy_icons]) - elif not is_dead: - if cat == "aa" and not has_threat: - painter.drawPixmap(rect, const.ICONS["nothreat" + player_icons]) - else: - painter.drawPixmap(rect, const.ICONS[cat + player_icons]) - else: - painter.drawPixmap(rect, const.ICONS["destroyed"]) - - self.draw_health_gauge(painter, option) - painter.restore() - - def draw_health_gauge(self, painter, option) -> None: - units_alive = 0 - units_dead = 0 - - if len(self.ground_object.groups) == 0: - for building in self.buildings: - if building.dcs_identifier in FORTIFICATION_BUILDINGS: - continue - if building.is_dead: - units_dead += 1 - else: - units_alive += 1 - - for g in self.ground_object.groups: - units_alive += len(g.units) - if hasattr(g, "units_losts"): - units_dead += len(g.units_losts) - - if units_dead + units_alive > 0: - ratio = float(units_alive) / (float(units_dead) + float(units_alive)) - bar_height = ratio * option.rect.height() - painter.fillRect( - option.rect.x(), - option.rect.y(), - 2, - option.rect.height(), - QBrush(const.COLORS["dark_red"]), - ) - painter.fillRect( - option.rect.x(), - option.rect.y(), - 2, - bar_height, - QBrush(const.COLORS["green"]), - ) - - def on_click(self) -> None: - self.ground_object_dialog = QGroundObjectMenu( - self.window(), - self.ground_object, - self.buildings, - self.control_point, - self.game, - ) - self.ground_object_dialog.show() diff --git a/qt_ui/widgets/map/QMapObject.py b/qt_ui/widgets/map/QMapObject.py deleted file mode 100644 index f4b0bfb6..00000000 --- a/qt_ui/widgets/map/QMapObject.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Common base for objects drawn on the game map.""" -from typing import Optional - -from PySide2.QtCore import Qt -from PySide2.QtWidgets import ( - QAction, - QGraphicsRectItem, - QGraphicsSceneContextMenuEvent, - QGraphicsSceneHoverEvent, - QGraphicsSceneMouseEvent, - QMenu, -) - -from qt_ui.dialogs import Dialog -from qt_ui.windows.mission.QPackageDialog import QNewPackageDialog -from game.theater.missiontarget import MissionTarget - - -class QMapObject(QGraphicsRectItem): - """Base class for objects drawn on the game map. - - Game map objects have an on_click behavior that triggers on left click, and - change the mouse cursor on hover. - """ - - def __init__( - self, x: float, y: float, w: float, h: float, mission_target: MissionTarget - ) -> None: - super().__init__(x, y, w, h) - self.mission_target = mission_target - self.new_package_dialog: Optional[QNewPackageDialog] = None - self.setAcceptHoverEvents(True) - - def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): - self.setCursor(Qt.PointingHandCursor) - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - if event.button() == Qt.LeftButton: - self.on_click() - - def add_context_menu_actions(self, menu: QMenu) -> None: - pass - - def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: - menu = QMenu("Menu", self.parent) - - object_details_action = QAction(self.object_dialog_text) - object_details_action.triggered.connect(self.on_click) - menu.addAction(object_details_action) - - # Not all locations have valid objectives. Off-map spawns, for example, - # have no mission types. - if list(self.mission_target.mission_types(for_player=True)): - new_package_action = QAction(f"New package") - new_package_action.triggered.connect(self.open_new_package_dialog) - menu.addAction(new_package_action) - - self.add_context_menu_actions(menu) - - menu.exec_(event.screenPos()) - - @property - def object_dialog_text(self) -> str: - """Text to for the object's dialog in the context menu. - - Right clicking a map object will open a context menu and the first item - will open the details dialog for this object. This menu action has the - same behavior as the on_click event. - - Return: - The text that should be displayed for the menu item. - """ - return "Details" - - def on_click(self) -> None: - """The action to take when this map object is left-clicked. - - Typically this should open a details view of the object. - """ - raise NotImplementedError - - def open_new_package_dialog(self) -> None: - """Opens the dialog for planning a new mission package.""" - Dialog.open_new_package_dialog(self.mission_target) diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py new file mode 100644 index 00000000..79aa0d70 --- /dev/null +++ b/qt_ui/widgets/map/mapmodel.py @@ -0,0 +1,1002 @@ +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import List, Optional, Tuple, Union, Iterator + +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 game import Game, db +from game.navmesh import NavMesh +from game.profiling import logged_duration +from game.theater import ( + ConflictTheater, + ControlPoint, + TheaterGroundObject, + FrontLine, + LatLon, + ControlPointStatus, +) +from game.threatzones import ThreatZones +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 qt_ui.dialogs import Dialog +from qt_ui.models import GameModel, AtoModel +from qt_ui.windows.GameUpdateSignal import GameUpdateSignal +from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 +from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu + +LeafletLatLon = list[float] +LeafletPoly = list[LeafletLatLon] + +MAX_SHIP_DISTANCE = nautical_miles(80) + +# **EVERY PROPERTY NEEDS A NOTIFY SIGNAL** +# +# https://bugreports.qt.io/browse/PYSIDE-1426 +# +# PySide2 5.15.2 released 6 days before the fix for this was merged, but presumably we +# can clean up after 5.15.3 (or a future version) is released. +# +# Until then, all properties must use a notify signal. For some reason the error doesn't +# show up when running from source, and member properties also are not sufficient. +# Failing to do this will cause every sync of the property to emit an expensive log +# message. This can prevent the UI from being responsive. +# +# A local signal (i.e. `@Property(t, notify=Signal())`) is not sufficient. The class +# needs a named signal for every property, even if it is constant. + + +def shapely_poly_to_leaflet_points( + poly: Polygon, theater: ConflictTheater +) -> LeafletPoly: + if poly.is_empty: + return [] + return [theater.point_to_ll(Point(x, y)).as_list() for x, y in poly.exterior.coords] + + +def shapely_to_leaflet_polys( + poly: Union[Polygon, MultiPolygon], theater: ConflictTheater +) -> list[LeafletPoly]: + if isinstance(poly, MultiPolygon): + polys = poly.geoms + else: + polys = [poly] + return [shapely_poly_to_leaflet_points(poly, theater) for poly in polys] + + +class ControlPointJs(QObject): + nameChanged = Signal() + blueChanged = Signal() + positionChanged = Signal() + mobileChanged = Signal() + destinationChanged = Signal(list) + categoryChanged = Signal() + statusChanged = Signal() + + def __init__( + self, + control_point: ControlPoint, + game_model: GameModel, + theater: ConflictTheater, + ) -> None: + super().__init__() + self.control_point = control_point + self.game_model = game_model + self.theater = theater + self.dialog: Optional[QBaseMenu2] = None + + @Property(str, notify=nameChanged) + def name(self) -> str: + return self.control_point.name + + @Property(bool, notify=blueChanged) + def blue(self) -> bool: + return self.control_point.captured + + @Property(str, notify=categoryChanged) + def category(self) -> str: + return self.control_point.category + + @Property(str, notify=statusChanged) + def status(self) -> str: + status = self.control_point.status + if status is ControlPointStatus.Functional: + return "alive" + elif status is ControlPointStatus.Damaged: + return "damaged" + elif status is ControlPointStatus.Destroyed: + return "destroyed" + raise ValueError(f"Unhandled ControlPointStatus: {status.name}") + + @Property(list, notify=positionChanged) + def position(self) -> LeafletLatLon: + ll = self.theater.point_to_ll(self.control_point.position) + return [ll.latitude, ll.longitude] + + @Property(bool, notify=mobileChanged) + def mobile(self) -> bool: + return self.control_point.moveable and self.control_point.captured + + @Property(list, notify=destinationChanged) + def destination(self) -> LeafletLatLon: + if self.control_point.target_position is None: + # Qt seems to convert None to [] for list Properties :( + return [] + return self.theater.point_to_ll(self.control_point.target_position).as_list() + + def destination_in_range(self, destination: Point) -> bool: + move_distance = meters( + destination.distance_to_point(self.control_point.position) + ) + return move_distance <= MAX_SHIP_DISTANCE + + @Slot(list, result=bool) + def destinationInRange(self, destination: LeafletLatLon) -> bool: + return self.destination_in_range(self.theater.ll_to_point(LatLon(*destination))) + + @Slot(list, result=str) + def setDestination(self, destination: LeafletLatLon) -> str: + if not self.control_point.moveable: + return f"{self.control_point} is not mobile" + if not self.control_point.captured: + return f"{self.control_point} is not owned by player" + + point = self.theater.ll_to_point(LatLon(*destination)) + if not self.destination_in_range(point): + return ( + f"Cannot move {self.control_point} more than " + f"{MAX_SHIP_DISTANCE.nautical_miles}nm." + ) + self.control_point.target_position = point + self.destinationChanged.emit(destination) + return "" + + @Slot() + def cancelTravel(self) -> None: + self.control_point.target_position = None + self.destinationChanged.emit([]) + + @Slot() + def showInfoDialog(self) -> None: + if self.dialog is None: + self.dialog = QBaseMenu2(None, self.control_point, self.game_model) + self.dialog.show() + + @Slot() + def showPackageDialog(self) -> None: + Dialog.open_new_package_dialog(self.control_point) + + +class GroundObjectJs(QObject): + nameChanged = Signal() + unitsChanged = Signal() + blueChanged = Signal() + positionChanged = Signal() + samThreatRangesChanged = Signal() + samDetectionRangesChanged = Signal() + categoryChanged = Signal() + deadChanged = Signal() + + def __init__(self, tgo: TheaterGroundObject, game: Game) -> None: + super().__init__() + self.tgo = tgo + self.game = game + self.theater = game.theater + self.buildings = self.theater.find_ground_objects_by_obj_name(self.tgo.obj_name) + + if self.tgo.is_friendly(to_player=True): + self.country = game.player_country + else: + self.country = game.enemy_country + + self.dialog: Optional[QGroundObjectMenu] = None + + @Slot() + def showInfoDialog(self) -> None: + if self.dialog is None: + self.dialog = QGroundObjectMenu( + None, + self.tgo, + self.buildings, + self.tgo.control_point, + self.game, + ) + self.dialog.show() + + @Slot() + def showPackageDialog(self) -> None: + Dialog.open_new_package_dialog(self.tgo) + + @Property(str, notify=nameChanged) + def name(self) -> str: + return self.tgo.name + + @Property(str, notify=categoryChanged) + def category(self) -> str: + return self.tgo.category + + def make_unit_name(self, unit: Unit, dead: bool) -> str: + dead_label = " [DEAD]" if dead else "" + unit_display_name = unit.type + unit_type = vehicle_map.get(unit.type) + if unit_type is not None: + unit_display_name = db.unit_get_expanded_info( + self.country, unit_type, "name" + ) + return f"Unit #{unit.id} - {unit_display_name}{dead_label}" + + @Property(list, notify=unitsChanged) + def units(self) -> List[str]: + units = [] + # TGOs with a non-empty group set are non-building TGOs. Building TGOs have no + # groups set, but instead are one TGO per building "group" (DCS doesn't support + # groups of statics) all with the same name. + if self.tgo.groups: + for unit in self.tgo.units: + units.append(self.make_unit_name(unit, dead=False)) + for unit in self.tgo.dead_units: + units.append(self.make_unit_name(unit, dead=True)) + else: + for building in self.buildings: + dead = " [DEAD]" if building.is_dead else "" + units.append(f"{building.dcs_identifier}{dead}") + return units + + @Property(bool, notify=blueChanged) + def blue(self) -> bool: + return self.tgo.control_point.captured + + @Property(list, notify=positionChanged) + def position(self) -> LeafletLatLon: + ll = self.theater.point_to_ll(self.tgo.position) + return [ll.latitude, ll.longitude] + + @Property(bool, notify=deadChanged) + def dead(self) -> bool: + if not self.tgo.groups: + return all(b.is_dead for b in self.buildings) + return not any(g.units for g in self.tgo.groups) + + @Property(list, notify=samThreatRangesChanged) + def samThreatRanges(self) -> List[float]: + if not self.tgo.might_have_aa: + return [] + + ranges = [] + for group in self.tgo.groups: + threat_range = self.tgo.threat_range(group) + if threat_range: + ranges.append(threat_range.meters) + return ranges + + @Property(list, notify=samDetectionRangesChanged) + def samDetectionRanges(self) -> List[float]: + if not self.tgo.might_have_aa: + return [] + + ranges = [] + for group in self.tgo.groups: + detection_range = self.tgo.detection_range(group) + if detection_range: + ranges.append(detection_range.meters) + return ranges + + +class SupplyRouteJs(QObject): + pointsChanged = Signal() + frontActiveChanged = Signal() + isSeaChanged = Signal() + blueChanged = Signal() + activeTransportsChanged = Signal() + + def __init__( + self, + a: ControlPoint, + b: ControlPoint, + points: List[LeafletLatLon], + sea_route: bool, + game: Game, + ) -> None: + super().__init__() + self.control_point_a = a + self.control_point_b = b + self._points = points + self.sea_route = sea_route + self.game = game + + def find_in_transport_map( + self, transport_map: TransportMap + ) -> List[MultiGroupTransport]: + transports = [] + transport = transport_map.find_transport( + self.control_point_a, self.control_point_b + ) + if transport is not None: + transports.append(transport) + transport = transport_map.find_transport( + self.control_point_b, self.control_point_a + ) + if transport is not None: + transports.append(transport) + return transports + + 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) + + @Property(list, notify=activeTransportsChanged) + def activeTransports(self) -> List[str]: + transports = self.find_transports() + if not transports: + return [] + + descriptions = [] + for transport in transports: + units = "units" if transport.size > 1 else "unit" + descriptions.append( + f"{transport.size} {units} transferring from {transport.origin} to " + f"{transport.destination}" + ) + return descriptions + + @Property(list, notify=pointsChanged) + def points(self) -> List[LeafletLatLon]: + return self._points + + @Property(bool, notify=frontActiveChanged) + def frontActive(self) -> bool: + if self.sea_route: + return False + return self.control_point_a.front_is_active(self.control_point_b) + + @Property(bool, notify=isSeaChanged) + def isSea(self) -> bool: + return self.sea_route + + @Property(bool, notify=blueChanged) + def blue(self) -> bool: + return self.control_point_a.captured + + +class FrontLineJs(QObject): + extentsChanged = Signal() + + def __init__(self, front_line: FrontLine, theater: ConflictTheater) -> None: + super().__init__() + self.front_line = front_line + self.theater = theater + + @Property(list, notify=extentsChanged) + 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 + ) + ) + b = self.theater.point_to_ll( + self.front_line.position.point_from_heading( + self.front_line.attack_heading + 270, nautical_miles(2).meters + ) + ) + return [[a.latitude, a.longitude], [b.latitude, b.longitude]] + + @Slot() + def showPackageDialog(self) -> None: + Dialog.open_new_package_dialog(self.front_line) + + +class WaypointJs(QObject): + numberChanged = Signal() + positionChanged = Signal() + altitudeFtChanged = Signal() + altitudeReferenceChanged = Signal() + nameChanged = Signal() + timingChanged = Signal() + isTargetPointChanged = Signal() + isTakeoffChanged = Signal() + isLandingChanged = Signal() + isDivertChanged = Signal() + isBullseyeChanged = Signal() + + def __init__( + self, + waypoint: FlightWaypoint, + number: int, + flight_model: FlightJs, + theater: ConflictTheater, + ato_model: AtoModel, + ) -> None: + super().__init__() + self.waypoint = waypoint + self._number = number + self.flight_model = flight_model + self.theater = theater + self.ato_model = ato_model + + @property + def flight(self) -> Flight: + return self.flight_model.flight + + @property + def flight_plan(self) -> FlightPlan: + return self.flight.flight_plan + + @Property(int, notify=numberChanged) + def number(self) -> int: + return self._number + + @Property(list, notify=positionChanged) + def position(self) -> LeafletLatLon: + ll = self.theater.point_to_ll(self.waypoint.position) + return [ll.latitude, ll.longitude] + + @Property(int, notify=altitudeFtChanged) + def altitudeFt(self) -> int: + return int(self.waypoint.alt.feet) + + @Property(str, notify=altitudeReferenceChanged) + def altitudeReference(self) -> str: + return "AGL" if self.waypoint.alt_type == "RADIO" else "MSL" + + @Property(str, notify=nameChanged) + def name(self) -> str: + return self.waypoint.name + + @Property(str, notify=timingChanged) + def timing(self) -> str: + prefix = "TOT" + time = self.flight_plan.tot_for_waypoint(self.waypoint) + if time is None: + prefix = "Depart" + time = self.flight_plan.depart_time_for_waypoint(self.waypoint) + if time is None: + return "" + return f"{prefix} T+{timedelta(seconds=int(time.total_seconds()))}" + + @Property(bool, notify=isTargetPointChanged) + def isTargetPoint(self) -> bool: + return self.waypoint.waypoint_type is FlightWaypointType.TARGET_POINT + + @Property(bool, notify=isTakeoffChanged) + def isTakeoff(self) -> bool: + return self.waypoint.waypoint_type is FlightWaypointType.TAKEOFF + + @Property(bool, notify=isLandingChanged) + def isLanding(self) -> bool: + return self.waypoint.waypoint_type is FlightWaypointType.LANDING_POINT + + @Property(bool, notify=isDivertChanged) + def isDivert(self) -> bool: + return self.waypoint.waypoint_type is FlightWaypointType.DIVERT + + @Property(bool, notify=isBullseyeChanged) + def isBullseye(self) -> bool: + return self.waypoint.waypoint_type is FlightWaypointType.BULLSEYE + + @Slot(list, result=str) + def setPosition(self, position: LeafletLatLon) -> str: + point = self.theater.ll_to_point(LatLon(*position)) + self.waypoint.x = point.x + self.waypoint.y = point.y + package = self.ato_model.find_matching_package_model(self.flight.package) + if package is None: + return "Could not find package model containing modified flight" + package.update_tot() + self.positionChanged.emit() + self.flight_model.commitBoundaryChanged.emit() + return "" + + +class FlightJs(QObject): + flightPlanChanged = Signal() + blueChanged = Signal() + selectedChanged = Signal() + commitBoundaryChanged = Signal() + + def __init__( + self, + flight: Flight, + selected: bool, + theater: ConflictTheater, + ato_model: AtoModel, + ) -> None: + super().__init__() + self.flight = flight + self._selected = selected + self.theater = theater + self.ato_model = ato_model + self._waypoints = self.make_waypoints() + + def update_waypoints(self) -> None: + for waypoint in self._waypoints: + waypoint.timingChanged.emit() + + def make_waypoints(self) -> List[WaypointJs]: + departure = FlightWaypoint( + FlightWaypointType.TAKEOFF, + self.flight.departure.position.x, + self.flight.departure.position.y, + meters(0), + ) + departure.alt_type = "RADIO" + waypoints = [] + for idx, point in enumerate([departure] + self.flight.points): + waypoint = WaypointJs(point, idx, self, self.theater, self.ato_model) + waypoint.positionChanged.connect(self.update_waypoints) + waypoints.append(waypoint) + return waypoints + + @Property(list, notify=flightPlanChanged) + def flightPlan(self) -> List[WaypointJs]: + return self._waypoints + + @Property(bool, notify=blueChanged) + def blue(self) -> bool: + return self.flight.departure.captured + + @Property(bool, notify=selectedChanged) + def selected(self) -> bool: + return self._selected + + @Property(list, notify=commitBoundaryChanged) + def commitBoundary(self) -> LeafletPoly: + if not isinstance(self.flight.flight_plan, PatrollingFlightPlan): + return [] + start = self.flight.flight_plan.patrol_start + end = self.flight.flight_plan.patrol_end + if isinstance(self.flight.flight_plan, CasFlightPlan): + center = self.flight.flight_plan.target.position + commit_center = ShapelyPoint(center.x, center.y) + else: + commit_center = LineString( + [ + ShapelyPoint(start.x, start.y), + ShapelyPoint(end.x, end.y), + ] + ) + bubble = commit_center.buffer( + self.flight.flight_plan.engagement_distance.meters + ) + return shapely_poly_to_leaflet_points(bubble, self.theater) + + +class ThreatZonesJs(QObject): + fullChanged = Signal() + aircraftChanged = Signal() + airDefensesChanged = Signal() + radarSamsChanged = Signal() + + def __init__( + self, + full: list[LeafletPoly], + aircraft: list[LeafletPoly], + air_defenses: list[LeafletPoly], + radar_sams: list[LeafletPoly], + ) -> None: + super().__init__() + self._full = full + self._aircraft = aircraft + self._air_defenses = air_defenses + self._radar_sams = radar_sams + + @Property(list, notify=fullChanged) + def full(self) -> list[LeafletPoly]: + return self._full + + @Property(list, notify=aircraftChanged) + def aircraft(self) -> list[LeafletPoly]: + return self._aircraft + + @Property(list, notify=airDefensesChanged) + def airDefenses(self) -> list[LeafletPoly]: + return self._air_defenses + + @Property(list, notify=radarSamsChanged) + def radarSams(self) -> list[LeafletPoly]: + return self._radar_sams + + @classmethod + def from_zones(cls, zones: ThreatZones, theater: ConflictTheater) -> ThreatZonesJs: + return ThreatZonesJs( + shapely_to_leaflet_polys(zones.all, theater), + shapely_to_leaflet_polys(zones.airbases, theater), + shapely_to_leaflet_polys(zones.air_defenses, theater), + shapely_to_leaflet_polys(zones.radar_sam_threats, theater), + ) + + @classmethod + def empty(cls) -> ThreatZonesJs: + return ThreatZonesJs([], [], [], []) + + +class ThreatZoneContainerJs(QObject): + blueChanged = Signal() + redChanged = Signal() + + def __init__(self, blue: ThreatZonesJs, red: ThreatZonesJs) -> None: + super().__init__() + self._blue = blue + self._red = red + + @Property(ThreatZonesJs, notify=blueChanged) + def blue(self) -> ThreatZonesJs: + return self._blue + + @Property(ThreatZonesJs, notify=redChanged) + def red(self) -> ThreatZonesJs: + return self._red + + +class NavMeshJs(QObject): + blueChanged = Signal() + redChanged = Signal() + + def __init__(self, blue: list[LeafletPoly], red: list[LeafletPoly]) -> None: + super().__init__() + self._blue = blue + self._red = red + # TODO: Boundary markers. + # TODO: Numbering. + # TODO: Localization debugging. + + @Property(list, notify=blueChanged) + def blue(self) -> list[LeafletPoly]: + return self._blue + + @Property(list, notify=redChanged) + def red(self) -> list[LeafletPoly]: + return self._red + + @staticmethod + def to_polys(navmesh: NavMesh, theater: ConflictTheater) -> list[LeafletPoly]: + polys = [] + for poly in navmesh.polys: + polys.append(shapely_poly_to_leaflet_points(poly.poly, theater)) + return polys + + @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), + ) + + +class MapZonesJs(QObject): + inclusionZonesChanged = Signal() + exclusionZonesChanged = Signal() + seaZonesChanged = Signal() + + def __init__( + self, + inclusion_zones: list[LeafletPoly], + exclusion_zones: list[LeafletPoly], + sea_zones: list[LeafletPoly], + ) -> None: + super().__init__() + self._inclusion_zones = inclusion_zones + self._exclusion_zones = exclusion_zones + self._sea_zones = sea_zones + + @Property(list, notify=inclusionZonesChanged) + def inclusionZones(self) -> list[LeafletPoly]: + return self._inclusion_zones + + @Property(list, notify=exclusionZonesChanged) + def exclusionZones(self) -> list[LeafletPoly]: + return self._exclusion_zones + + @Property(list, notify=seaZonesChanged) + def seaZones(self) -> list[LeafletPoly]: + return self._sea_zones + + @classmethod + def from_game(cls, game: Game) -> MapZonesJs: + zones = game.theater.landmap + return MapZonesJs( + shapely_to_leaflet_polys(zones.inclusion_zones, game.theater), + shapely_to_leaflet_polys(zones.exclusion_zones, game.theater), + shapely_to_leaflet_polys(zones.sea_zones, game.theater), + ) + + +class UnculledZone(QObject): + positionChanged = Signal() + radiusChanged = Signal() + + def __init__(self, position: LeafletLatLon, radius: float) -> None: + super().__init__() + self._position = position + self._radius = radius + + @Property(list, notify=positionChanged) + def position(self) -> LeafletLatLon: + return self._position + + @Property(float, notify=radiusChanged) + def radius(self) -> float: + return self._radius + + @classmethod + def each_from_game(cls, game: Game) -> Iterator[UnculledZone]: + for zone in game.get_culling_zones(): + ll = game.theater.point_to_ll(zone) + yield UnculledZone( + [ll.latitude, ll.longitude], game.settings.perf_culling_distance * 1000 + ) + + +class MapModel(QObject): + cleared = Signal() + + mapCenterChanged = Signal(list) + controlPointsChanged = Signal() + groundObjectsChanged = Signal() + supplyRoutesChanged = Signal() + flightsChanged = Signal() + frontLinesChanged = Signal() + threatZonesChanged = Signal() + navmeshesChanged = Signal() + mapZonesChanged = Signal() + unculledZonesChanged = Signal() + + def __init__(self, game_model: GameModel) -> None: + super().__init__() + self.game_model = game_model + self._map_center = [0, 0] + self._control_points = [] + self._ground_objects = [] + self._supply_routes = [] + self._flights = [] + self._front_lines = [] + self._threat_zones = ThreatZoneContainerJs( + ThreatZonesJs.empty(), ThreatZonesJs.empty() + ) + self._navmeshes = NavMeshJs([], []) + self._map_zones = MapZonesJs([], [], []) + self._unculled_zones = [] + 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) + GameUpdateSignal.get_instance().package_selection_changed.connect( + self.set_package_selection + ) + GameUpdateSignal.get_instance().flight_selection_changed.connect( + self.set_flight_selection + ) + self.reset() + + def clear(self) -> None: + self._control_points = [] + self._supply_routes = [] + self._ground_objects = [] + self._flights = [] + self._front_lines = [] + self._threat_zones = ThreatZoneContainerJs( + ThreatZonesJs.empty(), ThreatZonesJs.empty() + ) + self._navmeshes = NavMeshJs([], []) + self._map_zones = MapZonesJs([], [], []) + self._unculled_zones = [] + self.cleared.emit() + + def set_package_selection(self, index: int) -> None: + # Optional[int] isn't a valid type for a Qt signal. None will be converted to + # zero automatically. We use -1 to indicate no selection. + if index == -1: + self._selected_flight_index = None + else: + self._selected_flight_index = index, 0 + self.reset_atos() + + def set_flight_selection(self, index: int) -> None: + if self._selected_flight_index is None: + if index != -1: + # We don't know what order update_package_selection and + # update_flight_selection will be called in when the last + # package is removed. If no flight is selected, it's not a + # problem to also have no package selected. + logging.error("Flight was selected with no package selected") + return + + # Optional[int] isn't a valid type for a Qt signal. None will be converted to + # zero automatically. We use -1 to indicate no selection. + if index == -1: + self._selected_flight_index = self._selected_flight_index[0], None + self._selected_flight_index = self._selected_flight_index[0], index + self.reset_atos() + + @staticmethod + def leaflet_coord_for(point: Point, theater: ConflictTheater) -> LeafletLatLon: + ll = theater.point_to_ll(point) + return [ll.latitude, ll.longitude] + + def reset(self) -> None: + if self.game_model.game is None: + self.clear() + return + with logged_duration("Map reset"): + self.reset_control_points() + self.reset_ground_objects() + self.reset_routes() + self.reset_atos() + self.reset_front_lines() + self.reset_threat_zones() + self.reset_navmeshes() + self.reset_map_zones() + self.reset_unculled_zones() + + def on_game_load(self, game: Optional[Game]) -> None: + if game is not None: + self.reset_map_center(game.theater) + + def reset_map_center(self, theater: ConflictTheater) -> None: + ll = theater.point_to_ll(theater.terrain.map_view_default.position) + self._map_center = [ll.latitude, ll.longitude] + self.mapCenterChanged.emit(self._map_center) + + @Property(list, notify=mapCenterChanged) + def mapCenter(self) -> LeafletLatLon: + return self._map_center + + def _flights_in_ato(self, ato: AirTaskingOrder, blue: bool) -> List[FlightJs]: + flights = [] + for p_idx, package in enumerate(ato.packages): + for f_idx, flight in enumerate(package.flights): + flights.append( + FlightJs( + flight, + selected=blue and (p_idx, f_idx) == self._selected_flight_index, + theater=self.game.theater, + ato_model=self.game_model.ato_model_for(blue), + ) + ) + return flights + + 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() + + @Property(list, notify=flightsChanged) + def flights(self) -> List[FlightJs]: + return self._flights + + def reset_control_points(self) -> None: + self._control_points = [ + ControlPointJs(c, self.game_model, self.game.theater) + for c in self.game.theater.controlpoints + ] + self.controlPointsChanged.emit() + + @Property(list, notify=controlPointsChanged) + def controlPoints(self) -> List[ControlPointJs]: + return self._control_points + + def reset_ground_objects(self) -> None: + seen = set() + self._ground_objects = [] + for cp in self.game.theater.controlpoints: + for tgo in cp.ground_objects: + if tgo.name in seen: + continue + seen.add(tgo.name) + + if tgo.is_control_point: + # TGOs that are the CP (CV groups) are an implementation quirk that + # we don't need to expose to the UI. + continue + + self._ground_objects.append(GroundObjectJs(tgo, self.game)) + self.groundObjectsChanged.emit() + + @Property(list, notify=groundObjectsChanged) + def groundObjects(self) -> List[GroundObjectJs]: + return self._ground_objects + + def reset_routes(self) -> None: + seen = set() + self._supply_routes = [] + for control_point in self.game.theater.controlpoints: + seen.add(control_point) + for destination, convoy_route in control_point.convoy_routes.items(): + if destination in seen: + continue + self._supply_routes.append( + SupplyRouteJs( + control_point, + destination, + [ + self.leaflet_coord_for(p, self.game.theater) + for p in convoy_route + ], + sea_route=False, + game=self.game, + ) + ) + for destination, shipping_lane in control_point.shipping_lanes.items(): + if destination in seen: + continue + if control_point.is_friendly(destination.captured): + self._supply_routes.append( + SupplyRouteJs( + control_point, + destination, + [ + self.leaflet_coord_for(p, self.game.theater) + for p in shipping_lane + ], + sea_route=True, + game=self.game, + ) + ) + self.supplyRoutesChanged.emit() + + @Property(list, notify=supplyRoutesChanged) + def supplyRoutes(self) -> List[SupplyRouteJs]: + return self._supply_routes + + def reset_front_lines(self) -> None: + self._front_lines = [ + FrontLineJs(f, self.game.theater) for f in self.game.theater.conflicts() + ] + self.frontLinesChanged.emit() + + @Property(list, notify=frontLinesChanged) + def frontLines(self) -> List[FrontLineJs]: + return self._front_lines + + def reset_threat_zones(self) -> None: + self._threat_zones = ThreatZoneContainerJs( + ThreatZonesJs.from_zones( + self.game.threat_zone_for(player=True), self.game.theater + ), + ThreatZonesJs.from_zones( + self.game.threat_zone_for(player=False), self.game.theater + ), + ) + self.threatZonesChanged.emit() + + @Property(ThreatZoneContainerJs, notify=threatZonesChanged) + def threatZones(self) -> ThreatZoneContainerJs: + return self._threat_zones + + def reset_navmeshes(self) -> None: + self._navmeshes = NavMeshJs.from_game(self.game) + self.navmeshesChanged.emit() + + @Property(NavMeshJs, notify=navmeshesChanged) + def navmeshes(self) -> NavMeshJs: + return self._navmeshes + + def reset_map_zones(self) -> None: + self._map_zones = MapZonesJs.from_game(self.game) + self.mapZonesChanged.emit() + + @Property(MapZonesJs, notify=mapZonesChanged) + def mapZones(self) -> NavMeshJs: + return self._map_zones + + def reset_unculled_zones(self) -> None: + self._unculled_zones = list(UnculledZone.each_from_game(self.game)) + self.unculledZonesChanged.emit() + + @Property(list, notify=unculledZonesChanged) + def unculledZones(self) -> list[UnculledZone]: + return self._unculled_zones + + @property + def game(self) -> Game: + if self.game_model.game is None: + raise RuntimeError("No game loaded") + return self.game_model.game diff --git a/qt_ui/widgets/spinsliders.py b/qt_ui/widgets/spinsliders.py index 90623a6d..93d3017e 100644 --- a/qt_ui/widgets/spinsliders.py +++ b/qt_ui/widgets/spinsliders.py @@ -1,7 +1,9 @@ +from PySide2 import QtWidgets from PySide2.QtCore import Qt from PySide2.QtWidgets import QGridLayout, QLabel, QSlider - +from typing import Optional from qt_ui.widgets.floatspinners import TenthsSpinner +from datetime import timedelta class TenthsSpinSlider(QGridLayout): @@ -23,3 +25,68 @@ class TenthsSpinSlider(QGridLayout): @property def value(self) -> float: return self.spinner.value() / 10 + + +class TimeInputs(QtWidgets.QGridLayout): + def __init__(self, label: str, initial: timedelta) -> None: + super().__init__() + self.addWidget(QtWidgets.QLabel(label), 0, 0) + + minimum_minutes = 30 + maximum_minutes = 150 + initial_minutes = int(initial.total_seconds() / 60) + + slider = QtWidgets.QSlider(Qt.Horizontal) + slider.setMinimum(minimum_minutes) + slider.setMaximum(maximum_minutes) + slider.setValue(initial_minutes) + self.spinner = TimeSpinner(minimum_minutes, maximum_minutes, initial_minutes) + slider.valueChanged.connect(lambda x: self.spinner.setValue(x)) + self.spinner.valueChanged.connect(lambda x: slider.setValue(x)) + + self.addWidget(slider, 1, 0) + self.addWidget(self.spinner, 1, 1) + + @property + def value(self) -> timedelta: + return timedelta(minutes=self.spinner.value()) + + +class TimeSpinner(QtWidgets.QSpinBox): + def __init__( + self, + minimum: Optional[int] = None, + maximum: Optional[int] = None, + initial: Optional[int] = None, + ) -> None: + super().__init__() + + if minimum is not None: + self.setMinimum(minimum) + if maximum is not None: + self.setMaximum(maximum) + if initial is not None: + self.setValue(initial) + + def textFromValue(self, val: int) -> str: + return f"{val} minutes" + + +class CurrencySpinner(QtWidgets.QSpinBox): + def __init__( + self, + minimum: Optional[int] = None, + maximum: Optional[int] = None, + initial: Optional[int] = None, + ) -> None: + super().__init__() + + if minimum is not None: + self.setMinimum(minimum) + if maximum is not None: + self.setMaximum(maximum) + if initial is not None: + self.setValue(initial) + + def textFromValue(self, val: int) -> str: + return f"${val}" diff --git a/qt_ui/widgets/views/QSeadTargetInfoView.py b/qt_ui/widgets/views/QSeadTargetInfoView.py deleted file mode 100644 index 0fc40b22..00000000 --- a/qt_ui/widgets/views/QSeadTargetInfoView.py +++ /dev/null @@ -1,33 +0,0 @@ -from PySide2.QtGui import QStandardItemModel, QStandardItem -from PySide2.QtWidgets import QGroupBox, QVBoxLayout, QListView, QAbstractItemView - -from qt_ui.widgets.combos.QSEADTargetSelectionComboBox import SEADTargetInfo - - -class QSeadTargetInfoView(QGroupBox): - """ - UI Component to display info about a sead target - """ - - def __init__(self, sead_target_infos: SEADTargetInfo): - if sead_target_infos is None: - sead_target_infos = SEADTargetInfo() - super(QSeadTargetInfoView, self).__init__("Target : " + sead_target_infos.name) - self.sead_target_infos = sead_target_infos - self.radar_list = QListView() - self.init_ui() - - def init_ui(self): - layout = QVBoxLayout(self) - layout.setSpacing(0) - layout.addWidget(self.radar_list) - self.setLayout(layout) - - def setTarget(self, target: SEADTargetInfo): - self.setTitle(target.name) - self.sead_target_infos = target - radar_list_model = QStandardItemModel() - self.radar_list.setSelectionMode(QAbstractItemView.NoSelection) - for r in self.sead_target_infos.radars: - radar_list_model.appendRow(QStandardItem(r.type)) - self.radar_list.setModel(radar_list_model) diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py new file mode 100644 index 00000000..80c6443a --- /dev/null +++ b/qt_ui/windows/AirWingDialog.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Type, Iterator + +from PySide2.QtCore import ( + QItemSelectionModel, + QModelIndex, + Qt, + QSize, +) +from PySide2.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QDialog, + QListView, + QVBoxLayout, + QTabWidget, + QTableWidget, + QTableWidgetItem, + QWidget, +) +from dcs.unittype import FlyingType + +from game import db +from game.inventory import ControlPointAircraftInventory +from game.squadrons import Squadron +from gen.flights.flight import Flight +from qt_ui.delegates import TwoColumnRowDelegate +from qt_ui.models import GameModel, AirWingModel, SquadronModel +from qt_ui.windows.SquadronDialog import SquadronDialog + + +class SquadronDelegate(TwoColumnRowDelegate): + def __init__(self, air_wing_model: AirWingModel) -> None: + super().__init__(rows=2, columns=2, font_size=12) + self.air_wing_model = air_wing_model + + @staticmethod + def squadron(index: QModelIndex) -> Squadron: + return index.data(AirWingModel.SquadronRole) + + def text_for(self, index: QModelIndex, row: int, column: int) -> str: + if (row, column) == (0, 0): + return self.air_wing_model.data(index, Qt.DisplayRole) + elif (row, column) == (0, 1): + squadron = self.air_wing_model.data(index, AirWingModel.SquadronRole) + return db.unit_get_expanded_info( + squadron.country, squadron.aircraft, "name" + ) + elif (row, column) == (1, 0): + return self.squadron(index).nickname + elif (row, column) == (1, 1): + squadron = self.squadron(index) + alive = squadron.number_of_living_pilots + active = len(squadron.active_pilots) + available = len(squadron.available_pilots) + return f"{alive} pilots, {active} active, {available} unassigned" + return "" + + +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() + + +@dataclass(frozen=True) +class AircraftInventoryData: + location: str + unit_type: str + task: str + target: str + pilot: str + player: str + + @classmethod + def headers(cls) -> list[str]: + return ["Base", "Type", "Flight Type", "Target", "Pilot", "Player"] + + @property + def columns(self) -> Iterator[str]: + yield self.location + yield self.unit_type + yield self.task + yield self.target + yield self.pilot + yield self.player + + @classmethod + def from_flight(cls, flight: Flight) -> Iterator[AircraftInventoryData]: + unit_type_name = cls.format_unit_type(flight.unit_type, flight.country) + num_units = flight.count + flight_type = flight.flight_type.value + target = flight.package.target.name + for idx in range(0, num_units): + pilot = flight.roster.pilots[idx] + if pilot is None: + pilot_name = "Unassigned" + player = "" + else: + pilot_name = pilot.name + player = "Player" if pilot.player else "AI" + yield AircraftInventoryData( + flight.departure.name, + unit_type_name, + flight_type, + target, + pilot_name, + player, + ) + + @classmethod + def each_from_inventory( + cls, inventory: ControlPointAircraftInventory, country: str + ) -> Iterator[AircraftInventoryData]: + for unit_type, num_units in inventory.all_aircraft: + unit_type_name = cls.format_unit_type(unit_type, country) + for _ in range(0, num_units): + yield AircraftInventoryData( + inventory.control_point.name, + unit_type_name, + "Idle", + "N/A", + "N/A", + "N/A", + ) + + @staticmethod + def format_unit_type(aircraft: Type[FlyingType], country: str) -> str: + return db.unit_get_expanded_info(country, aircraft, "name") + + +class AirInventoryView(QWidget): + def __init__(self, game_model: GameModel) -> None: + super().__init__() + + self.game_model = game_model + self.country = self.game_model.game.country_for(player=True) + + layout = QVBoxLayout() + self.setLayout(layout) + + self.only_unallocated_cb = QCheckBox("Unallocated Only?") + self.only_unallocated_cb.toggled.connect(self.update_table) + + layout.addWidget(self.only_unallocated_cb) + + self.table = QTableWidget() + layout.addWidget(self.table) + + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.verticalHeader().setVisible(False) + self.update_table(False) + + def update_table(self, only_unallocated: bool) -> None: + self.table.setSortingEnabled(False) + self.table.clear() + + inventory_rows = list(self.get_data(only_unallocated)) + self.table.setRowCount(len(inventory_rows)) + headers = AircraftInventoryData.headers() + self.table.setColumnCount(len(headers)) + self.table.setHorizontalHeaderLabels(headers) + + for row, data in enumerate(inventory_rows): + for column, value in enumerate(data.columns): + self.table.setItem(row, column, QTableWidgetItem(value)) + + self.table.resizeColumnsToContents() + self.table.setSortingEnabled(True) + + def iter_allocated_aircraft(self) -> Iterator[AircraftInventoryData]: + for package in self.game_model.game.blue_ato.packages: + for flight in package.flights: + yield from AircraftInventoryData.from_flight(flight) + + def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]: + game = self.game_model.game + for control_point, inventory in game.aircraft_inventory.inventories.items(): + if control_point.captured: + yield from AircraftInventoryData.each_from_inventory( + inventory, game.country_for(player=True) + ) + + def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]: + yield from self.iter_unallocated_aircraft() + if not only_unallocated: + yield from self.iter_allocated_aircraft() + + +class AirWingTabs(QTabWidget): + def __init__(self, game_model: GameModel) -> None: + super().__init__() + + self.addTab(SquadronList(game_model.blue_air_wing_model), "Squadrons") + self.addTab(AirInventoryView(game_model), "Inventory") + + +class AirWingDialog(QDialog): + """Dialog window showing the player's air wing.""" + + def __init__(self, game_model: GameModel, parent) -> None: + super().__init__(parent) + self.air_wing_model = game_model.blue_air_wing_model + + self.setMinimumSize(1000, 440) + self.setWindowTitle(f"Air Wing") + # TODO: self.setWindowIcon() + + layout = QVBoxLayout() + self.setLayout(layout) + + layout.addWidget(AirWingTabs(game_model)) diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py index f70c0e49..6f2119e6 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -15,6 +15,8 @@ class GameUpdateSignal(QObject): budgetupdated = Signal(Game) debriefingReceived = Signal(Debriefing) + game_loaded = Signal(Game) + flight_paths_changed = Signal() package_selection_changed = Signal(int) # -1 indicates no selection. flight_selection_changed = Signal(int) # -1 indicates no selection. @@ -23,6 +25,8 @@ class GameUpdateSignal(QObject): super(GameUpdateSignal, self).__init__() GameUpdateSignal.instance = self + self.game_loaded.connect(self.updateGame) + def select_package(self, index: Optional[int]) -> None: # noinspection PyUnresolvedReferences self.package_selection_changed.emit(-1 if index is None else index) diff --git a/qt_ui/windows/PendingTransfersDialog.py b/qt_ui/windows/PendingTransfersDialog.py new file mode 100644 index 00000000..982ae9f8 --- /dev/null +++ b/qt_ui/windows/PendingTransfersDialog.py @@ -0,0 +1,126 @@ +from PySide2.QtCore import ( + QItemSelection, + QItemSelectionModel, + QModelIndex, + Qt, +) +from PySide2.QtGui import QContextMenuEvent +from PySide2.QtWidgets import ( + QAbstractItemView, + QAction, + QDialog, + QHBoxLayout, + QListView, + QMenu, + QPushButton, + QVBoxLayout, +) + +from game.transfers import TransferOrder +from qt_ui.delegates import TwoColumnRowDelegate +from qt_ui.models import GameModel, TransferModel + + +class TransferDelegate(TwoColumnRowDelegate): + def __init__(self, transfer_model: TransferModel) -> None: + super().__init__(rows=2, columns=1, font_size=12) + self.transfer_model = transfer_model + + @staticmethod + def transfer(index: QModelIndex) -> TransferOrder: + return index.data(TransferModel.TransferRole) + + def text_for(self, index: QModelIndex, row: int, column: int) -> str: + if row == 0: + return self.transfer_model.data(index, Qt.DisplayRole) + elif row == 1: + return self.transfer(index).description + return "" + + +class PendingTransfersList(QListView): + """List view for displaying the pending unit transfers.""" + + def __init__(self, transfer_model: TransferModel) -> None: + super().__init__() + self.transfer_model = transfer_model + + self.setItemDelegate(TransferDelegate(self.transfer_model)) + self.setModel(self.transfer_model) + self.selectionModel().setCurrentIndex( + self.transfer_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select + ) + + # self.setIconSize(QSize(91, 24)) + self.setSelectionBehavior(QAbstractItemView.SelectItems) + + def contextMenuEvent(self, event: QContextMenuEvent) -> None: + index = self.indexAt(event.pos()) + if not index.isValid(): + return + if not self.transfer_model.transfer_at_index(index).player: + return + + menu = QMenu("Menu") + + delete_action = QAction("Cancel") + delete_action.triggered.connect(lambda: self.cancel_transfer(index)) + menu.addAction(delete_action) + + menu.exec_(event.globalPos()) + + def cancel_transfer(self, index: QModelIndex) -> None: + """Cancels the given transfer order.""" + self.transfer_model.cancel_transfer_at_index(index) + + +class PendingTransfersDialog(QDialog): + """Dialog window showing all scheduled transfers for the player.""" + + def __init__(self, game_model: GameModel, parent=None) -> None: + super().__init__(parent) + self.transfer_model = game_model.transfer_model + + self.setMinimumSize(1000, 440) + self.setWindowTitle(f"Pending Transfers") + # TODO: self.setWindowIcon() + + layout = QVBoxLayout() + self.setLayout(layout) + + self.transfer_list = PendingTransfersList(self.transfer_model) + self.transfer_list.selectionModel().selectionChanged.connect( + self.on_selection_changed + ) + layout.addWidget(self.transfer_list) + + button_layout = QHBoxLayout() + layout.addLayout(button_layout) + + button_layout.addStretch() + + self.cancel_button = QPushButton("Cancel Transfer") + self.cancel_button.setProperty("style", "btn-danger") + self.cancel_button.clicked.connect(self.on_cancel_transfer) + self.cancel_button.setEnabled( + self.can_cancel(self.transfer_list.currentIndex()) + ) + button_layout.addWidget(self.cancel_button) + + def on_cancel_transfer(self) -> None: + """Cancels the selected transfer order.""" + self.transfer_model.cancel_transfer_at_index(self.transfer_list.currentIndex()) + + def can_cancel(self, index: QModelIndex) -> bool: + if not index.isValid(): + return False + return self.transfer_model.transfer_at_index(index).player + + def on_selection_changed( + self, selected: QItemSelection, _deselected: QItemSelection + ) -> None: + """Updates the state of the delete button.""" + if selected.empty(): + self.cancel_button.setEnabled(False) + return + self.cancel_button.setEnabled(self.can_cancel(selected.indexes()[0])) diff --git a/qt_ui/windows/QDebriefingWindow.py b/qt_ui/windows/QDebriefingWindow.py index d4fa312b..86e59e0d 100644 --- a/qt_ui/windows/QDebriefingWindow.py +++ b/qt_ui/windows/QDebriefingWindow.py @@ -1,4 +1,5 @@ import logging +from typing import Callable, Dict, TypeVar from PySide2.QtGui import QIcon, QPixmap from PySide2.QtWidgets import ( @@ -14,6 +15,57 @@ from game import db from game.debriefing import Debriefing +T = TypeVar("T") + + +class LossGrid(QGridLayout): + def __init__(self, debriefing: Debriefing, player: bool) -> None: + super().__init__() + + if player: + country = debriefing.player_country + else: + country = debriefing.enemy_country + + self.add_loss_rows( + debriefing.air_losses.by_type(player), + lambda u: db.unit_get_expanded_info(country, u, "name"), + ) + self.add_loss_rows( + debriefing.front_line_losses_by_type(player), + lambda u: db.unit_type_name(u), + ) + self.add_loss_rows( + debriefing.convoy_losses_by_type(player), + lambda u: f"{db.unit_type_name(u)} from convoy", + ) + self.add_loss_rows( + debriefing.cargo_ship_losses_by_type(player), + lambda u: f"{db.unit_type_name(u)} from cargo ship", + ) + self.add_loss_rows( + debriefing.airlift_losses_by_type(player), + lambda u: f"{db.unit_type_name(u)} from airlift", + ) + self.add_loss_rows( + debriefing.building_losses_by_type(player), + lambda u: u, + ) + + # TODO: Display dead ground object units and runways. + + def add_loss_rows(self, losses: Dict[T, int], make_name: Callable[[T], str]): + for unit_type, count in losses.items(): + row = self.rowCount() + try: + name = make_name(unit_type) + except AttributeError: + logging.exception(f"Could not make unit name for {unit_type}") + name = unit_type.id + self.addWidget(QLabel(name), row, 0) + self.addWidget(QLabel(str(count)), row, 1) + + class QDebriefingWindow(QDialog): def __init__(self, debriefing: Debriefing): super(QDebriefingWindow, self).__init__() @@ -24,111 +76,27 @@ class QDebriefingWindow(QDialog): self.setMinimumSize(300, 200) self.setWindowIcon(QIcon("./resources/icon.png")) - self.initUI() - - def initUI(self): - - self.layout = QVBoxLayout() + layout = QVBoxLayout() + self.setLayout(layout) header = QLabel(self) header.setGeometry(0, 0, 655, 106) pixmap = QPixmap("./resources/ui/debriefing.png") header.setPixmap(pixmap) - self.layout.addWidget(header) - self.layout.addStretch() + layout.addWidget(header) + layout.addStretch() title = QLabel("Casualty report") - self.layout.addWidget(title) + layout.addWidget(title) - # Player lost units - lostUnits = QGroupBox(f"{self.debriefing.player_country}'s lost units:") - lostUnitsLayout = QGridLayout() - lostUnits.setLayout(lostUnitsLayout) + player_lost_units = QGroupBox(f"{self.debriefing.player_country}'s lost units:") + player_lost_units.setLayout(LossGrid(debriefing, player=True)) + layout.addWidget(player_lost_units) - row = 0 - player_air_losses = self.debriefing.air_losses.by_type(player=True) - for unit_type, count in player_air_losses.items(): - try: - lostUnitsLayout.addWidget( - QLabel( - db.unit_get_expanded_info( - self.debriefing.player_country, unit_type, "name" - ) - ), - row, - 0, - ) - lostUnitsLayout.addWidget(QLabel(str(count)), row, 1) - row += 1 - except AttributeError: - logging.exception(f"Issue adding {unit_type} to debriefing information") + enemy_lost_units = QGroupBox(f"{self.debriefing.enemy_country}'s lost units:") + enemy_lost_units.setLayout(LossGrid(debriefing, player=False)) + layout.addWidget(enemy_lost_units) - front_line_losses = self.debriefing.front_line_losses_by_type(player=True) - for unit_type, count in front_line_losses.items(): - try: - lostUnitsLayout.addWidget(QLabel(db.unit_type_name(unit_type)), row, 0) - lostUnitsLayout.addWidget(QLabel(str(count)), row, 1) - row += 1 - except AttributeError: - logging.exception(f"Issue adding {unit_type} to debriefing information") - - building_losses = self.debriefing.building_losses_by_type(player=True) - for building, count in building_losses.items(): - try: - lostUnitsLayout.addWidget(QLabel(building), row, 0) - lostUnitsLayout.addWidget(QLabel(str(count)), row, 1) - row += 1 - except AttributeError: - logging.exception(f"Issue adding {building} to debriefing information") - - self.layout.addWidget(lostUnits) - - # Enemy lost units - enemylostUnits = QGroupBox(f"{self.debriefing.enemy_country}'s lost units:") - enemylostUnitsLayout = QGridLayout() - enemylostUnits.setLayout(enemylostUnitsLayout) - - enemy_air_losses = self.debriefing.air_losses.by_type(player=False) - for unit_type, count in enemy_air_losses.items(): - try: - enemylostUnitsLayout.addWidget( - QLabel( - db.unit_get_expanded_info( - self.debriefing.enemy_country, unit_type, "name" - ) - ), - row, - 0, - ) - enemylostUnitsLayout.addWidget(QLabel(str(count)), row, 1) - row += 1 - except AttributeError: - logging.exception(f"Issue adding {unit_type} to debriefing information") - - front_line_losses = self.debriefing.front_line_losses_by_type(player=False) - for unit_type, count in front_line_losses.items(): - if count == 0: - continue - enemylostUnitsLayout.addWidget(QLabel(db.unit_type_name(unit_type)), row, 0) - enemylostUnitsLayout.addWidget(QLabel("{}".format(count)), row, 1) - row += 1 - - building_losses = self.debriefing.building_losses_by_type(player=False) - for building, count in building_losses.items(): - try: - enemylostUnitsLayout.addWidget(QLabel(building), row, 0) - enemylostUnitsLayout.addWidget(QLabel("{}".format(count)), row, 1) - row += 1 - except AttributeError: - logging.exception(f"Issue adding {building} to debriefing information") - - self.layout.addWidget(enemylostUnits) - - # TODO: Display dead ground object units and runways. - - # confirm button okay = QPushButton("Okay") okay.clicked.connect(self.close) - self.layout.addWidget(okay) - - self.setLayout(self.layout) + layout.addWidget(okay) diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index 8098ed77..c2a96a4d 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -22,7 +22,6 @@ from game import Game, VERSION, persistency from game.debriefing import Debriefing from qt_ui import liberation_install from qt_ui.dialogs import Dialog -from qt_ui.displayoptions import DisplayGroup, DisplayOptions, DisplayRule from qt_ui.models import GameModel from qt_ui.uiconstants import URLS from qt_ui.widgets.QTopPanel import QTopPanel @@ -35,6 +34,8 @@ from qt_ui.windows.newgame.QNewGameWizard import NewGameWizard from qt_ui.windows.preferences.QLiberationPreferencesWindow import ( QLiberationPreferencesWindow, ) +from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow +from qt_ui.windows.stats.QStatsWindow import QStatsWindow class QLiberationWindow(QMainWindow): @@ -46,7 +47,7 @@ class QLiberationWindow(QMainWindow): Dialog.set_game(self.game_model) self.ato_panel = QAirTaskingOrderPanel(self.game_model) self.info_panel = QInfoPanel(self.game) - self.liberation_map = QLiberationMap(self.game_model) + self.liberation_map = QLiberationMap(self.game_model, self) self.setGeometry(300, 100, 270, 100) self.setWindowTitle(f"DCS Liberation - v{VERSION}") @@ -143,9 +144,19 @@ class QLiberationWindow(QMainWindow): self.openGithubAction = QAction("&Github Repo", self) self.openGithubAction.setIcon(CONST.ICONS["Github"]) self.openGithubAction.triggered.connect( - lambda: webbrowser.open_new_tab("https://github.com/khopa/dcs_liberation") + lambda: webbrowser.open_new_tab( + "https://github.com/dcs-liberation/dcs_liberation" + ) ) + self.openSettingsAction = QAction("Settings", self) + self.openSettingsAction.setIcon(CONST.ICONS["Settings"]) + self.openSettingsAction.triggered.connect(self.showSettingsDialog) + + self.openStatsAction = QAction("Stats", self) + self.openStatsAction.setIcon(CONST.ICONS["Statistics"]) + self.openStatsAction.triggered.connect(self.showStatsDialog) + def initToolbar(self): self.tool_bar = self.addToolBar("File") self.tool_bar.addAction(self.newGameAction) @@ -156,7 +167,9 @@ class QLiberationWindow(QMainWindow): self.links_bar.addAction(self.openDiscordAction) self.links_bar.addAction(self.openGithubAction) - self.display_bar = self.addToolBar("Display") + self.actions_bar = self.addToolBar("Actions") + self.actions_bar.addAction(self.openSettingsAction) + self.actions_bar.addAction(self.openStatsAction) def initMenuBar(self): self.menu = self.menuBar() @@ -172,37 +185,13 @@ class QLiberationWindow(QMainWindow): file_menu.addSeparator() file_menu.addAction("E&xit", self.close) - displayMenu = self.menu.addMenu("&Display") - - last_was_group = False - for item in DisplayOptions.menu_items(): - if isinstance(item, DisplayRule): - if last_was_group: - displayMenu.addSeparator() - self.display_bar.addSeparator() - action = self.make_display_rule_action(item) - displayMenu.addAction(action) - if action.icon(): - self.display_bar.addAction(action) - last_was_group = False - elif isinstance(item, DisplayGroup): - displayMenu.addSeparator() - self.display_bar.addSeparator() - group = QActionGroup(displayMenu) - for display_rule in item: - action = self.make_display_rule_action(display_rule, group) - displayMenu.addAction(action) - if action.icon(): - self.display_bar.addAction(action) - last_was_group = True - help_menu = self.menu.addMenu("&Help") help_menu.addAction(self.openDiscordAction) help_menu.addAction(self.openGithubAction) help_menu.addAction( "&Releases", lambda: webbrowser.open_new_tab( - "https://github.com/Khopa/dcs_liberation/releases" + "https://github.com/dcs-liberation/dcs_liberation/releases" ), ) help_menu.addAction( @@ -252,14 +241,13 @@ class QLiberationWindow(QMainWindow): ) if file is not None: game = persistency.load_game(file[0]) - GameUpdateSignal.get_instance().updateGame(game) + GameUpdateSignal.get_instance().game_loaded.emit(game) def saveGame(self): logging.info("Saving game") if self.game.savepath: persistency.save_game(self.game) - GameUpdateSignal.get_instance().updateGame(self.game) liberation_install.setup_last_save_file(self.game.savepath) liberation_install.save_config() else: @@ -281,7 +269,7 @@ class QLiberationWindow(QMainWindow): def onGameGenerated(self, game: Game): logging.info("On Game generated") self.game = game - GameUpdateSignal.get_instance().updateGame(self.game) + GameUpdateSignal.get_instance().game_loaded.emit(self.game) def setGame(self, game: Optional[Game]): try: @@ -289,8 +277,7 @@ class QLiberationWindow(QMainWindow): if self.info_panel is not None: self.info_panel.setGame(game) self.game_model.set(self.game) - if self.liberation_map is not None: - self.liberation_map.setGame(game) + self.liberation_map.set_game(game) except AttributeError: logging.exception("Incompatible save game") QMessageBox.critical( @@ -309,7 +296,7 @@ class QLiberationWindow(QMainWindow): "

DCS Liberation " + VERSION + "

" - + "Source code : https://github.com/khopa/dcs_liberation" + + "Source code : https://github.com/dcs-liberation/dcs_liberation" + "

Authors

" + "

DCS Liberation was originally developed by shdwp, DCS Liberation 2.0 is a partial rewrite based on this work by Khopa." "

Contributors

" @@ -332,6 +319,14 @@ class QLiberationWindow(QMainWindow): self.subwindow = QLiberationPreferencesWindow() self.subwindow.show() + def showSettingsDialog(self) -> None: + self.dialog = QSettingsWindow(self.game) + self.dialog.show() + + def showStatsDialog(self): + self.dialog = QStatsWindow(self.game) + self.dialog.show() + def onDebriefing(self, debrief: Debriefing): logging.info("On Debriefing") self.debriefing = QDebriefingWindow(debrief) diff --git a/qt_ui/windows/QUnitInfoWindow.py b/qt_ui/windows/QUnitInfoWindow.py index cd87a07f..dca1aaf1 100644 --- a/qt_ui/windows/QUnitInfoWindow.py +++ b/qt_ui/windows/QUnitInfoWindow.py @@ -128,10 +128,7 @@ class QUnitInfoWindow(QDialog): aircraft_tasks + f"{FlightType.BARCAP}, {FlightType.ESCORT}, {FlightType.INTERCEPTION}, {FlightType.SWEEP}, {FlightType.TARCAP}, " ) - if ( - self.unit_type in gen.flights.ai_flight_planner_db.CAS_CAPABLE - or self.unit_type in gen.flights.ai_flight_planner_db.TRANSPORT_CAPABLE - ): + if self.unit_type in gen.flights.ai_flight_planner_db.CAS_CAPABLE: aircraft_tasks = ( aircraft_tasks + f"{FlightType.CAS}, {FlightType.BAI}, {FlightType.OCA_AIRCRAFT}, " @@ -144,9 +141,6 @@ class QUnitInfoWindow(QDialog): aircraft_tasks = aircraft_tasks + f"{FlightType.ANTISHIP}, " if self.unit_type in gen.flights.ai_flight_planner_db.RUNWAY_ATTACK_CAPABLE: aircraft_tasks = aircraft_tasks + f"{FlightType.OCA_RUNWAY}, " - if ( - self.unit_type in gen.flights.ai_flight_planner_db.STRIKE_CAPABLE - or self.unit_type in gen.flights.ai_flight_planner_db.TRANSPORT_CAPABLE - ): + if self.unit_type in gen.flights.ai_flight_planner_db.STRIKE_CAPABLE: aircraft_tasks = aircraft_tasks + f"{FlightType.STRIKE}, " return aircraft_tasks[:-2] diff --git a/qt_ui/windows/QWaitingForMissionResultWindow.py b/qt_ui/windows/QWaitingForMissionResultWindow.py index 2efa730b..f4ce44fb 100644 --- a/qt_ui/windows/QWaitingForMissionResultWindow.py +++ b/qt_ui/windows/QWaitingForMissionResultWindow.py @@ -2,8 +2,7 @@ from __future__ import annotations import json import os -import timeit -from datetime import timedelta +from typing import Sized from PySide2 import QtCore from PySide2.QtCore import QObject, Qt, Signal @@ -24,6 +23,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape from game.debriefing import Debriefing, wait_for_debriefing from game.game import Event, Game, logging from game.persistency import base_path +from game.profiling import logged_duration from game.unitmap import UnitMap from qt_ui.windows.GameUpdateSignal import GameUpdateSignal @@ -132,32 +132,50 @@ class QWaitingForMissionResultWindow(QDialog): self.layout.addLayout(self.gridLayout, 1, 0) self.setLayout(self.layout) + @staticmethod + def add_update_row(description: str, count: int, layout: QGridLayout) -> None: + row = layout.rowCount() + layout.addWidget(QLabel(f"{description}"), row, 0) + layout.addWidget(QLabel(f"{count}"), row, 1) + def updateLayout(self, debriefing: Debriefing) -> None: updateBox = QGroupBox("Mission status") - updateLayout = QGridLayout() - updateBox.setLayout(updateLayout) + update_layout = QGridLayout() + updateBox.setLayout(update_layout) self.debriefing = debriefing - updateLayout.addWidget(QLabel("Aircraft destroyed"), 0, 0) - updateLayout.addWidget( - QLabel(str(len(list(debriefing.air_losses.losses)))), 0, 1 + self.add_update_row( + "Aircraft destroyed", len(list(debriefing.air_losses.losses)), update_layout ) - - updateLayout.addWidget(QLabel("Front line units destroyed"), 1, 0) - updateLayout.addWidget( - QLabel(str(len(list(debriefing.front_line_losses)))), 1, 1 + self.add_update_row( + "Front line units destroyed", + len(list(debriefing.front_line_losses)), + update_layout, ) - - updateLayout.addWidget(QLabel("Other ground units destroyed"), 2, 0) - updateLayout.addWidget( - QLabel(str(len(list(debriefing.ground_object_losses)))), 2, 1 + self.add_update_row( + "Convoy units destroyed", len(list(debriefing.convoy_losses)), update_layout + ) + self.add_update_row( + "Shipping cargo destroyed", + len(list(debriefing.cargo_ship_losses)), + update_layout, + ) + self.add_update_row( + "Airlift cargo destroyed", + sum(len(loss.cargo) for loss in debriefing.airlift_losses), + update_layout, + ) + self.add_update_row( + "Ground units lost at objective areas", + len(list(debriefing.ground_object_losses)), + update_layout, + ) + self.add_update_row( + "Buildings destroyed", len(list(debriefing.building_losses)), update_layout + ) + self.add_update_row( + "Base capture events", len(debriefing.base_captures), update_layout ) - - updateLayout.addWidget(QLabel("Buildings destroyed"), 3, 0) - updateLayout.addWidget(QLabel(str(len(list(debriefing.building_losses)))), 3, 1) - - updateLayout.addWidget(QLabel("Base Capture Events"), 4, 0) - updateLayout.addWidget(QLabel(str(len(debriefing.base_capture_events))), 4, 1) # Clear previous content of the window for i in reversed(range(self.gridLayout.count())): @@ -188,14 +206,12 @@ class QWaitingForMissionResultWindow(QDialog): ) def process_debriefing(self): - start = timeit.default_timer() - self.game.finish_event(event=self.gameEvent, debriefing=self.debriefing) - self.game.pass_turn() + with logged_duration("Turn processing"): + self.game.finish_event(event=self.gameEvent, debriefing=self.debriefing) + self.game.pass_turn() - GameUpdateSignal.get_instance().sendDebriefing(self.debriefing) - GameUpdateSignal.get_instance().updateGame(self.game) - end = timeit.default_timer() - logging.info("Turn processing took %s", timedelta(seconds=end - start)) + GameUpdateSignal.get_instance().sendDebriefing(self.debriefing) + GameUpdateSignal.get_instance().updateGame(self.game) self.close() def debriefing_directory_location(self) -> str: diff --git a/qt_ui/windows/SquadronDialog.py b/qt_ui/windows/SquadronDialog.py new file mode 100644 index 00000000..31cf5587 --- /dev/null +++ b/qt_ui/windows/SquadronDialog.py @@ -0,0 +1,191 @@ +import logging +from typing import Callable + +from PySide2.QtCore import ( + QItemSelectionModel, + QModelIndex, + Qt, + QItemSelection, +) +from PySide2.QtWidgets import ( + QAbstractItemView, + QDialog, + QListView, + QVBoxLayout, + QPushButton, + QHBoxLayout, + QGridLayout, + QLabel, + QCheckBox, +) + +from game.squadrons import Pilot +from gen.flights.flight import FlightType +from qt_ui.delegates import TwoColumnRowDelegate +from qt_ui.models import SquadronModel + + +class PilotDelegate(TwoColumnRowDelegate): + def __init__(self, squadron_model: SquadronModel) -> None: + super().__init__(rows=2, columns=2, font_size=12) + self.squadron_model = squadron_model + + @staticmethod + def pilot(index: QModelIndex) -> Pilot: + return index.data(SquadronModel.PilotRole) + + def text_for(self, index: QModelIndex, row: int, column: int) -> str: + pilot = self.pilot(index) + if (row, column) == (0, 0): + return self.squadron_model.data(index, Qt.DisplayRole) + elif (row, column) == (0, 1): + flown = pilot.record.missions_flown + missions = "missions" if flown != 1 else "mission" + return f"{flown} {missions} flown" + elif (row, column) == (1, 0): + return "Player" if pilot.player else "AI" + elif (row, column) == (1, 1): + return pilot.status.value + return "" + + +class PilotList(QListView): + """List view for displaying a squadron's pilots.""" + + def __init__(self, squadron_model: SquadronModel) -> None: + super().__init__() + self.squadron_model = squadron_model + + self.setItemDelegate(PilotDelegate(self.squadron_model)) + self.setModel(self.squadron_model) + self.selectionModel().setCurrentIndex( + self.squadron_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select + ) + + # self.setIconSize(QSize(91, 24)) + self.setSelectionBehavior(QAbstractItemView.SelectItems) + + +class AutoAssignedTaskControls(QVBoxLayout): + def __init__(self, squadron_model: SquadronModel) -> None: + super().__init__() + self.squadron_model = squadron_model + + self.addWidget(QLabel("Auto-assignable 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 squadron_model.squadron.mission_types: + checkbox = QCheckBox(text=task.value) + checkbox.setChecked(squadron_model.is_auto_assignable(task)) + checkbox.toggled.connect(make_callback(task)) + self.addWidget(checkbox) + + self.addStretch() + + def on_toggled(self, task: FlightType, checked: bool) -> None: + self.squadron_model.set_auto_assignable(task, checked) + + +class SquadronDialog(QDialog): + """Dialog window showing a squadron.""" + + def __init__(self, squadron_model: SquadronModel, parent) -> None: + super().__init__(parent) + self.squadron_model = squadron_model + + self.setMinimumSize(1000, 440) + self.setWindowTitle(str(squadron_model.squadron)) + # TODO: self.setWindowIcon() + + layout = QVBoxLayout() + self.setLayout(layout) + + columns = QHBoxLayout() + layout.addLayout(columns) + + auto_assigned_tasks = AutoAssignedTaskControls(squadron_model) + columns.addLayout(auto_assigned_tasks) + + self.pilot_list = PilotList(squadron_model) + self.pilot_list.selectionModel().selectionChanged.connect( + self.on_selection_changed + ) + columns.addWidget(self.pilot_list) + + button_panel = QHBoxLayout() + button_panel.addStretch() + layout.addLayout(button_panel) + + self.toggle_ai_button = QPushButton() + self.reset_ai_toggle_state(self.pilot_list.currentIndex()) + self.toggle_ai_button.setProperty("style", "start-button") + self.toggle_ai_button.clicked.connect(self.toggle_ai) + button_panel.addWidget(self.toggle_ai_button, alignment=Qt.AlignRight) + + self.toggle_leave_button = QPushButton() + self.reset_leave_toggle_state(self.pilot_list.currentIndex()) + self.toggle_leave_button.setProperty("style", "start-button") + self.toggle_leave_button.clicked.connect(self.toggle_leave) + button_panel.addWidget(self.toggle_leave_button, alignment=Qt.AlignRight) + + def check_disabled_button_states( + self, button: QPushButton, index: QModelIndex + ) -> bool: + if not index.isValid(): + button.setText("No pilot selected") + button.setDisabled(True) + return True + pilot = self.squadron_model.pilot_at_index(index) + if not pilot.alive: + button.setText("Pilot is dead") + button.setDisabled(True) + return True + return False + + def toggle_ai(self) -> None: + index = self.pilot_list.currentIndex() + if not index.isValid(): + logging.error("Cannot toggle player/AI: no pilot is selected") + return + self.squadron_model.toggle_ai_state(index) + + def reset_ai_toggle_state(self, index: QModelIndex) -> None: + if self.check_disabled_button_states(self.toggle_ai_button, index): + return + if not self.squadron_model.squadron.aircraft.flyable: + self.toggle_ai_button.setText("Not flyable") + self.toggle_ai_button.setDisabled(True) + return + self.toggle_ai_button.setEnabled(True) + pilot = self.squadron_model.pilot_at_index(index) + self.toggle_ai_button.setText( + "Convert to AI" if pilot.player else "Convert to player" + ) + + def toggle_leave(self) -> None: + index = self.pilot_list.currentIndex() + if not index.isValid(): + logging.error("Cannot toggle on leave state: no pilot is selected") + return + self.squadron_model.toggle_leave_state(index) + + def reset_leave_toggle_state(self, index: QModelIndex) -> None: + if self.check_disabled_button_states(self.toggle_leave_button, index): + return + pilot = self.squadron_model.pilot_at_index(index) + self.toggle_leave_button.setEnabled(True) + self.toggle_leave_button.setText( + "Return from leave" if pilot.on_leave else "Send on leave" + ) + + def on_selection_changed( + self, selected: QItemSelection, _deselected: QItemSelection + ) -> None: + index = selected.indexes()[0] + self.reset_ai_toggle_state(index) + self.reset_leave_toggle_state(index) diff --git a/qt_ui/windows/basemenu/DepartingConvoysMenu.py b/qt_ui/windows/basemenu/DepartingConvoysMenu.py new file mode 100644 index 00000000..8c1e67bd --- /dev/null +++ b/qt_ui/windows/basemenu/DepartingConvoysMenu.py @@ -0,0 +1,103 @@ +from PySide2.QtCore import Qt +from PySide2.QtWidgets import ( + QFrame, + QGridLayout, + QGroupBox, + QLabel, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from game import db +from game.theater import ControlPoint +from game.transfers import MultiGroupTransport +from qt_ui.dialogs import Dialog +from qt_ui.models import GameModel +from qt_ui.uiconstants import VEHICLES_ICONS + + +class DepartingConvoyInfo(QGroupBox): + def __init__(self, convoy: MultiGroupTransport, game_model: GameModel) -> None: + super().__init__(f"{convoy.name} to {convoy.destination}") + self.convoy = convoy + + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + unit_layout = QGridLayout() + main_layout.addLayout(unit_layout) + + for idx, (unit_type, count) in enumerate(convoy.units.items()): + icon = QLabel() + if unit_type.id in VEHICLES_ICONS.keys(): + icon.setPixmap(VEHICLES_ICONS[unit_type.id]) + else: + icon.setText("" + unit_type.id[:8] + "") + icon.setProperty("style", "icon-armor") + unit_layout.addWidget(icon, idx, 0) + unit_display_name = db.unit_get_expanded_info( + game_model.game.enemy_country, unit_type, "name" + ) + unit_layout.addWidget( + QLabel(f"{count} x {unit_display_name}"), + idx, + 1, + ) + + if not convoy.units: + unit_layout.addWidget(QLabel("/"), 0, 0) + + attack_button = QPushButton("Attack") + attack_button.setProperty("style", "btn-danger") + attack_button.setMaximumWidth(180) + attack_button.clicked.connect(self.on_attack) + main_layout.addWidget(attack_button, 0, Qt.AlignLeft) + + def on_attack(self): + # TODO: Maintain Convoy list in Game. + # The fact that we create these here makes some of the other bookkeeping + # complicated. We could instead generate this at the start of the turn (and + # update whenever transfers are created or canceled) and also use that time to + # precalculate things like the next stop and group names. + Dialog.open_new_package_dialog(self.convoy, parent=self.window()) + + +class DepartingConvoysList(QFrame): + def __init__(self, cp: ControlPoint, game_model: GameModel): + super().__init__() + self.cp = cp + self.game_model = game_model + self.setMinimumWidth(500) + + layout = QVBoxLayout() + self.setLayout(layout) + + scroll_content = QWidget() + task_box_layout = QGridLayout() + scroll_content.setLayout(task_box_layout) + + for convoy in game_model.game.transfers.convoys.departing_from(cp): + group_info = DepartingConvoyInfo(convoy, game_model) + task_box_layout.addWidget(group_info) + + for cargo_ship in game_model.game.transfers.cargo_ships.departing_from(cp): + group_info = DepartingConvoyInfo(cargo_ship, game_model) + task_box_layout.addWidget(group_info) + + scroll_content.setLayout(task_box_layout) + scroll = QScrollArea() + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + scroll.setWidgetResizable(True) + scroll.setWidget(scroll_content) + layout.addWidget(scroll) + + +class DepartingConvoysMenu(QFrame): + def __init__(self, cp: ControlPoint, game_model: GameModel): + super().__init__() + layout = QVBoxLayout() + layout.addWidget(DepartingConvoysList(cp, game_model)) + self.setLayout(layout) diff --git a/qt_ui/windows/basemenu/NewUnitTransferDialog.py b/qt_ui/windows/basemenu/NewUnitTransferDialog.py new file mode 100644 index 00000000..5b58cc4f --- /dev/null +++ b/qt_ui/windows/basemenu/NewUnitTransferDialog.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from typing import Callable, Dict, Type + +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import ( + QComboBox, + QDialog, + QFrame, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QSizePolicy, + QSpacerItem, + QVBoxLayout, + QWidget, +) +from dcs.task import PinpointStrike +from dcs.unittype import UnitType + +from game import Game, db +from game.theater import ControlPoint +from game.transfers import TransferOrder +from qt_ui.models import GameModel +from qt_ui.widgets.QLabeledWidget import QLabeledWidget + + +class TransferDestinationComboBox(QComboBox): + def __init__(self, game: Game, origin: ControlPoint) -> None: + super().__init__() + self.game = game + self.origin = origin + + for cp in self.game.theater.controlpoints: + if ( + cp != self.origin + and cp.is_friendly(to_player=True) + and cp.can_deploy_ground_units + ): + self.addItem(cp.name, cp) + self.model().sort(0) + self.setCurrentIndex(0) + + +class UnitTransferList(QFrame): + def __init__(self, cp: ControlPoint, game_model: GameModel): + super().__init__(self) + self.cp = cp + self.game_model = game_model + + self.bought_amount_labels = {} + self.existing_units_labels = {} + + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + scroll_content = QWidget() + task_box_layout = QGridLayout() + scroll_content.setLayout(task_box_layout) + + units_column = sorted( + cp.base.armor, + key=lambda u: db.unit_get_expanded_info( + self.game_model.game.player_country, u, "name" + ), + ) + + count = 0 + for count, unit_type in enumerate(units_column): + self.add_purchase_row(unit_type, task_box_layout, count) + stretch = QVBoxLayout() + stretch.addStretch() + task_box_layout.addLayout(stretch, count, 0) + + scroll_content.setLayout(task_box_layout) + scroll = QScrollArea() + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + scroll.setWidgetResizable(True) + scroll.setWidget(scroll_content) + main_layout.addWidget(scroll) + + +class TransferOptionsPanel(QVBoxLayout): + def __init__(self, game: Game, origin: ControlPoint) -> None: + super().__init__() + + self.source_combo_box = TransferDestinationComboBox(game, origin) + self.addLayout(QLabeledWidget("Destination:", self.source_combo_box)) + + @property + def changed(self): + return self.source_combo_box.currentIndexChanged + + @property + def current(self) -> ControlPoint: + return self.source_combo_box.currentData() + + +class TransferControls(QGroupBox): + def __init__( + self, + increase_text: str, + on_increase: Callable[[TransferControls], None], + decrease_text: str, + on_decrease: Callable[[TransferControls], None], + initial_amount: int = 0, + disabled: bool = False, + ) -> None: + super().__init__() + + self.quantity = initial_amount + + self.setProperty("style", "buy-box") + self.setMaximumHeight(36) + self.setMinimumHeight(36) + layout = QHBoxLayout() + self.setLayout(layout) + + decrease = QPushButton(decrease_text) + decrease.setProperty("style", "btn-sell") + decrease.setDisabled(disabled) + decrease.setMinimumSize(16, 16) + decrease.setMaximumSize(16, 16) + decrease.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) + decrease.clicked.connect(lambda: on_decrease(self)) + layout.addWidget(decrease) + + self.count_label = QLabel() + self.count_label.setSizePolicy( + QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + ) + self.set_quantity(initial_amount) + layout.addWidget(self.count_label) + + increase = QPushButton(increase_text) + increase.setProperty("style", "btn-buy") + increase.setDisabled(disabled) + increase.setMinimumSize(16, 16) + increase.setMaximumSize(16, 16) + increase.clicked.connect(lambda: on_increase(self)) + increase.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) + layout.addWidget(increase) + + def set_quantity(self, quantity: int) -> None: + self.quantity = quantity + self.count_label.setText(f"{self.quantity}") + + +class ScrollingUnitTransferGrid(QFrame): + transfer_quantity_changed = Signal() + + def __init__(self, cp: ControlPoint, game_model: GameModel) -> None: + super().__init__() + self.cp = cp + self.game_model = game_model + self.transfers: Dict[Type[UnitType, int]] = defaultdict(int) + + main_layout = QVBoxLayout() + + scroll_content = QWidget() + task_box_layout = QGridLayout() + + unit_types = set( + db.find_unittype(PinpointStrike, self.game_model.game.player_name) + ) + sorted_units = sorted( + {u for u in unit_types if self.cp.base.total_units_of_type(u)}, + key=lambda u: db.unit_get_expanded_info( + self.game_model.game.player_country, u, "name" + ), + ) + for row, unit_type in enumerate(sorted_units): + self.add_unit_row(unit_type, task_box_layout, row) + stretch = QVBoxLayout() + stretch.addStretch() + task_box_layout.addLayout(stretch, task_box_layout.count(), 0) + + scroll_content.setLayout(task_box_layout) + scroll = QScrollArea() + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + scroll.setWidgetResizable(True) + scroll.setWidget(scroll_content) + main_layout.addWidget(scroll) + self.setLayout(main_layout) + + def add_unit_row( + self, + unit_type: Type[UnitType], + layout: QGridLayout, + row: int, + ) -> None: + exist = QGroupBox() + exist.setProperty("style", "buy-box") + exist.setMaximumHeight(36) + exist.setMinimumHeight(36) + origin_inventory_layout = QHBoxLayout() + exist.setLayout(origin_inventory_layout) + + origin_inventory = self.cp.base.total_units_of_type(unit_type) + + unit_name = QLabel( + "" + + db.unit_get_expanded_info( + self.game_model.game.player_country, unit_type, "name" + ) + + "" + ) + unit_name.setSizePolicy( + QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + ) + + origin_inventory_label = QLabel(str(origin_inventory)) + origin_inventory_label.setSizePolicy( + QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + ) + + def increase(controls: TransferControls): + nonlocal origin_inventory + nonlocal origin_inventory_label + if not origin_inventory: + return + + self.transfers[unit_type] += 1 + origin_inventory -= 1 + controls.set_quantity(self.transfers[unit_type]) + origin_inventory_label.setText(str(origin_inventory)) + self.transfer_quantity_changed.emit() + + def decrease(controls: TransferControls): + nonlocal origin_inventory + nonlocal origin_inventory_label + if not controls.quantity: + return + + self.transfers[unit_type] -= 1 + origin_inventory += 1 + controls.set_quantity(self.transfers[unit_type]) + origin_inventory_label.setText(str(origin_inventory)) + self.transfer_quantity_changed.emit() + + transfer_controls = TransferControls("->", increase, "<-", decrease) + + origin_inventory_layout.addWidget(unit_name) + origin_inventory_layout.addItem( + QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Minimum) + ) + origin_inventory_layout.addWidget(origin_inventory_label) + origin_inventory_layout.addItem( + QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Minimum) + ) + + layout.addWidget(exist, row, 1) + layout.addWidget(transfer_controls, row, 2) + + +class NewUnitTransferDialog(QDialog): + def __init__( + self, + game_model: GameModel, + origin: ControlPoint, + parent=None, + ) -> None: + super().__init__(parent) + self.origin = origin + self.setWindowTitle(f"New unit transfer from {origin.name}") + + self.game_model = game_model + + layout = QVBoxLayout() + self.setLayout(layout) + + self.dest_panel = TransferOptionsPanel(game_model.game, origin) + layout.addLayout(self.dest_panel) + + self.transfer_panel = ScrollingUnitTransferGrid(origin, game_model) + self.transfer_panel.transfer_quantity_changed.connect( + self.on_transfer_quantity_changed + ) + layout.addWidget(self.transfer_panel) + + self.submit_button = QPushButton("Create Transfer Order", parent=self) + self.submit_button.clicked.connect(self.on_submit) + self.submit_button.setProperty("style", "start-button") + self.submit_button.setDisabled(True) + layout.addWidget(self.submit_button) + + def on_submit(self) -> None: + destination = self.dest_panel.current + transfers = {} + for unit_type, count in self.transfer_panel.transfers.items(): + if not count: + continue + + logging.info( + f"Transferring {count} {unit_type.id} from {self.origin} to " + f"{destination}" + ) + transfers[unit_type] = count + + transfer = TransferOrder( + origin=self.origin, + destination=destination, + units=transfers, + ) + self.game_model.transfer_model.new_transfer(transfer) + self.close() + + def on_transfer_quantity_changed(self) -> None: + has_transfer_items = any(self.transfer_panel.transfers.values()) + self.submit_button.setDisabled(not has_transfer_items) diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index a4793b97..4b8265c8 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -11,12 +11,18 @@ from PySide2.QtWidgets import ( ) from game import Game, db -from game.theater import ControlPoint, ControlPointType +from game.theater import ( + ControlPoint, + ControlPointType, + FREE_FRONTLINE_UNIT_SUPPLY, + AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION, +) from gen.flights.flight import FlightType from qt_ui.dialogs import Dialog from qt_ui.models import GameModel from qt_ui.uiconstants import EVENT_ICONS from qt_ui.windows.GameUpdateSignal import GameUpdateSignal +from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour @@ -43,8 +49,8 @@ class QBaseMenu2(QDialog): self.setWindowFlags(Qt.WindowStaysOnTopHint) self.setMinimumSize(300, 200) - self.setMinimumWidth(800) - self.setMaximumWidth(800) + self.setMinimumWidth(1024) + self.setMaximumWidth(1024) self.setModal(True) self.setWindowTitle(self.cp.name) @@ -61,6 +67,7 @@ class QBaseMenu2(QDialog): title.setAlignment(Qt.AlignLeft | Qt.AlignTop) title.setProperty("style", "base-title") self.intel_summary = QLabel() + self.intel_summary.setToolTip(self.generate_intel_tooltip()) self.update_intel_summary() top_layout.addWidget(title) top_layout.addWidget(self.intel_summary) @@ -88,6 +95,18 @@ class QBaseMenu2(QDialog): runway_attack_button.setProperty("style", "btn-danger") runway_attack_button.clicked.connect(self.new_package) + if self.cp.captured and self.has_transfer_destinations: + transfer_button = QPushButton("Transfer Units") + transfer_button.setProperty("style", "btn-success") + bottom_row.addWidget(transfer_button) + transfer_button.clicked.connect(self.open_transfer_dialog) + + if self.cheat_capturable: + capture_button = QPushButton("CHEAT: Capture") + capture_button.setProperty("style", "btn-danger") + bottom_row.addWidget(capture_button) + capture_button.clicked.connect(self.cheat_capture) + self.budget_display = QLabel( QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.budget) ) @@ -97,6 +116,32 @@ class QBaseMenu2(QDialog): GameUpdateSignal.get_instance().budgetupdated.connect(self.update_budget) self.setLayout(main_layout) + @property + def cheat_capturable(self) -> bool: + if not self.game_model.game.settings.enable_base_capture_cheat: + return False + if self.cp.captured: + return False + + for connected in self.cp.connected_points: + if connected.captured: + return True + return False + + def cheat_capture(self) -> None: + self.cp.capture(self.game_model.game, for_player=True) + # 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) + + @property + def has_transfer_destinations(self) -> bool: + return self.game_model.game.transit_network_for( + self.cp.captured + ).has_destinations(self.cp) + @property def can_repair_runway(self) -> bool: return self.cp.captured and self.cp.runway_can_be_repaired @@ -154,16 +199,51 @@ class QBaseMenu2(QDialog): self.repair_button.setDisabled(True) def update_intel_summary(self) -> None: + aircraft = self.cp.base.total_aircraft + parking = self.cp.total_aircraft_parking + ground_unit_limit = self.cp.frontline_unit_count_limit + deployable_unit_info = "" + + allocated = self.cp.allocated_ground_units(self.game_model.game.transfers) + unit_overage = max( + allocated.total_present - self.cp.frontline_unit_count_limit, 0 + ) + if self.cp.has_active_frontline: + deployable_unit_info = ( + f" (Up to {ground_unit_limit} deployable, {unit_overage} reserve)" + ) + self.intel_summary.setText( "\n".join( [ - f"{self.cp.base.total_aircraft} aircraft", - f"{self.cp.base.total_armor} ground units", + f"{aircraft}/{parking} aircraft", + f"{self.cp.base.total_armor} ground units" + deployable_unit_info, + f"{allocated.total_transferring} more ground units en route, {allocated.total_ordered} ordered", str(self.cp.runway_status), + f"{self.cp.active_ammo_depots_count}/{self.cp.total_ammo_depots_count} ammo depots", + f"{'Factory can produce units' if self.cp.has_factory else 'Does not have a factory'}", ] ) ) + def generate_intel_tooltip(self) -> str: + tooltip = ( + f"Deployable unit limit ({self.cp.frontline_unit_count_limit}) = {FREE_FRONTLINE_UNIT_SUPPLY} (base) + " + f" {AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION} (per connected ammo depot) * {self.cp.total_ammo_depots_count} " + f"(depots)" + ) + + if self.cp.has_active_frontline: + unit_overage = max( + self.cp.base.total_armor - self.cp.frontline_unit_count_limit, 0 + ) + tooltip += ( + f"\n{unit_overage} units will be held in reserve and will not be deployed to " + f"connected frontlines for this turn" + ) + + return tooltip + def closeEvent(self, close_event: QCloseEvent): GameUpdateSignal.get_instance().updateGame(self.game_model.game) @@ -180,5 +260,8 @@ class QBaseMenu2(QDialog): def new_package(self) -> None: Dialog.open_new_package_dialog(self.cp, parent=self.window()) + def open_transfer_dialog(self) -> None: + NewUnitTransferDialog(self.game_model, self.cp, parent=self.window()).show() + def update_budget(self, game: Game) -> None: self.budget_display.setText(QRecruitBehaviour.BUDGET_FORMAT.format(game.budget)) diff --git a/qt_ui/windows/basemenu/QBaseMenuTabs.py b/qt_ui/windows/basemenu/QBaseMenuTabs.py index c6c35603..a8389e95 100644 --- a/qt_ui/windows/basemenu/QBaseMenuTabs.py +++ b/qt_ui/windows/basemenu/QBaseMenuTabs.py @@ -2,8 +2,8 @@ from PySide2.QtWidgets import QTabWidget from game.theater import ControlPoint, OffMapSpawn, Fob from qt_ui.models import GameModel +from qt_ui.windows.basemenu.DepartingConvoysMenu import DepartingConvoysMenu from qt_ui.windows.basemenu.airfield.QAirfieldCommand import QAirfieldCommand -from qt_ui.windows.basemenu.base_defenses.QBaseDefensesHQ import QBaseDefensesHQ from qt_ui.windows.basemenu.ground_forces.QGroundForcesHQ import QGroundForcesHQ from qt_ui.windows.basemenu.intel.QIntelInfo import QIntelInfo @@ -13,32 +13,22 @@ class QBaseMenuTabs(QTabWidget): super(QBaseMenuTabs, self).__init__() if not cp.captured: - if not cp.is_carrier and not isinstance(cp, OffMapSpawn): - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Base Defenses") self.intel = QIntelInfo(cp, game_model.game) self.addTab(self.intel, "Intel") + + self.departing_convoys = DepartingConvoysMenu(cp, game_model) + self.addTab(self.departing_convoys, "Departing Convoys") + return + + if isinstance(cp, Fob): + self.ground_forces_hq = QGroundForcesHQ(cp, game_model) + self.addTab(self.ground_forces_hq, "Ground Forces HQ") + if cp.helipads: + self.airfield_command = QAirfieldCommand(cp, game_model) + self.addTab(self.airfield_command, "Heliport") else: - - if cp: - if isinstance(cp, Fob): - self.ground_forces_hq = QGroundForcesHQ(cp, game_model) - self.addTab(self.ground_forces_hq, "Ground Forces HQ") - if cp.helipads: - self.airfield_command = QAirfieldCommand(cp, game_model) - self.addTab(self.airfield_command, "Heliport") - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Base Defenses") - else: - - self.airfield_command = QAirfieldCommand(cp, game_model) - self.addTab(self.airfield_command, "Airfield Command") - - if cp.is_carrier: - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Fleet") - elif not isinstance(cp, OffMapSpawn): - self.ground_forces_hq = QGroundForcesHQ(cp, game_model) - self.addTab(self.ground_forces_hq, "Ground Forces HQ") - self.base_defenses_hq = QBaseDefensesHQ(cp, game_model.game) - self.addTab(self.base_defenses_hq, "Base Defenses") + self.airfield_command = QAirfieldCommand(cp, game_model) + self.addTab(self.airfield_command, "Airfield Command") + if not isinstance(cp, OffMapSpawn): + self.ground_forces_hq = QGroundForcesHQ(cp, game_model) + self.addTab(self.ground_forces_hq, "Ground Forces HQ") diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py index 97ef9b2a..a9209521 100644 --- a/qt_ui/windows/basemenu/QRecruitBehaviour.py +++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py @@ -1,7 +1,6 @@ import logging from typing import Type -from PySide2.QtCore import Qt from PySide2.QtWidgets import ( QGroupBox, QHBoxLayout, @@ -14,8 +13,8 @@ from PySide2.QtWidgets import ( from dcs.unittype import UnitType from game import db -from game.event import UnitsDeliveryEvent from game.theater import ControlPoint +from game.unitdelivery import PendingUnitDeliveries from qt_ui.models import GameModel from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.QUnitInfoWindow import QUnitInfoWindow @@ -27,17 +26,15 @@ class QRecruitBehaviour: existing_units_labels = None bought_amount_labels = None maximum_units = -1 - recruitable_types = [] BUDGET_FORMAT = "Available Budget: ${:.2f}M" def __init__(self) -> None: self.bought_amount_labels = {} self.existing_units_labels = {} - self.recruitable_types = [] self.update_available_budget() @property - def pending_deliveries(self) -> UnitsDeliveryEvent: + def pending_deliveries(self) -> PendingUnitDeliveries: return self.cp.pending_unit_deliveries @property @@ -53,7 +50,6 @@ class QRecruitBehaviour: unit_type: Type[UnitType], layout: QLayout, row: int, - disabled: bool = False, ) -> int: exist = QGroupBox() exist.setProperty("style", "buy-box") @@ -97,19 +93,31 @@ class QRecruitBehaviour: buy = QPushButton("+") buy.setProperty("style", "btn-buy") - buy.setDisabled(disabled) + buy.setDisabled(not self.enable_purchase(unit_type)) buy.setMinimumSize(16, 16) buy.setMaximumSize(16, 16) - buy.clicked.connect(lambda: self.buy(unit_type)) + + def on_buy(): + self.buy(unit_type) + buy.setDisabled(not self.enable_purchase(unit_type)) + sell.setDisabled(not self.enable_sale(unit_type)) + + buy.clicked.connect(on_buy) buy.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) sell = QPushButton("-") sell.setProperty("style", "btn-sell") - sell.setDisabled(disabled) + sell.setDisabled(not self.enable_sale(unit_type)) sell.setMinimumSize(16, 16) sell.setMaximumSize(16, 16) sell.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) - sell.clicked.connect(lambda: self.sell(unit_type)) + + def on_sell(): + self.sell(unit_type) + sell.setDisabled(not self.enable_sale(unit_type)) + buy.setDisabled(not self.enable_purchase(unit_type)) + + sell.clicked.connect(on_sell) info = QGroupBox() info.setProperty("style", "buy-box") @@ -120,7 +128,6 @@ class QRecruitBehaviour: unitInfo = QPushButton("i") unitInfo.setProperty("style", "btn-info") - unitInfo.setDisabled(disabled) unitInfo.setMinimumSize(16, 16) unitInfo.setMaximumSize(16, 16) unitInfo.clicked.connect(lambda: self.info(unit_type)) @@ -166,13 +173,13 @@ class QRecruitBehaviour: GameUpdateSignal.get_instance().updateBudget(self.game_model.game) def buy(self, unit_type: Type[UnitType]): + if not self.enable_purchase(unit_type): + logging.error(f"Purchase of {unit_type.id} not allowed at {self.cp.name}") + return + price = db.PRICES[unit_type] - if self.budget >= price: - self.pending_deliveries.order({unit_type: 1}) - self.budget -= price - else: - # TODO : display modal warning - logging.info("Not enough money !") + self.pending_deliveries.order({unit_type: 1}) + self.budget -= price self._update_count_label(unit_type) self.update_available_budget() @@ -186,6 +193,13 @@ class QRecruitBehaviour: self._update_count_label(unit_type) self.update_available_budget() + def enable_purchase(self, unit_type: Type[UnitType]) -> bool: + price = db.PRICES[unit_type] + return self.budget >= price + + def enable_sale(self, unit_type: Type[UnitType]) -> bool: + return True + def info(self, unit_type): self.info_window = QUnitInfoWindow(self.game_model.game, unit_type) self.info_window.show() @@ -195,9 +209,3 @@ class QRecruitBehaviour: Set the maximum number of units that can be bought """ self.maximum_units = maximum_units - - def set_recruitable_types(self, recruitables_types): - """ - Set the maximum number of units that can be bought - """ - self.recruitables_types = recruitables_types diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index 53f6cf69..74afe47e 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -13,7 +13,7 @@ from PySide2.QtWidgets import ( QWidget, ) from dcs.helicopters import helicopter_map -from dcs.task import CAP, CAS, AWACS +from dcs.task import CAP, CAS, AWACS, Transport from dcs.unittype import FlyingType, UnitType from game import db @@ -34,7 +34,6 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): # Determine maximum number of aircrafts that can be bought self.set_maximum_units(self.cp.total_aircraft_parking) - self.set_recruitable_types([CAP, CAS]) self.bought_amount_labels = {} self.existing_units_labels = {} @@ -46,30 +45,26 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): def init_ui(self): main_layout = QVBoxLayout() - tasks = [CAP, CAS, AWACS] + tasks = [CAP, CAS, AWACS, Transport] scroll_content = QWidget() task_box_layout = QGridLayout() row = 0 unit_types: Set[Type[FlyingType]] = set() - for task in tasks: - units = db.find_unittype(task, self.game_model.game.player_name) - if not units: + for unit_type in self.game_model.game.player_faction.aircrafts: + if not issubclass(unit_type, FlyingType): + raise RuntimeError(f"Non-flying aircraft found in faction: {unit_type}") + if self.cp.is_carrier and unit_type not in db.CARRIER_CAPABLE: continue - for unit in units: - if not issubclass(unit, FlyingType): - continue - if self.cp.is_carrier and unit not in db.CARRIER_CAPABLE: - continue - if self.cp.is_lha and unit not in db.LHA_CAPABLE: - continue - if ( - self.cp.cptype in [ControlPointType.FOB, ControlPointType.FARP] - and unit not in helicopter_map.values() - ): - continue - unit_types.add(unit) + if self.cp.is_lha and unit_type not in db.LHA_CAPABLE: + continue + if ( + self.cp.cptype in [ControlPointType.FOB, ControlPointType.FARP] + and unit_type not in helicopter_map.values() + ): + continue + unit_types.add(unit_type) sorted_units = sorted( unit_types, @@ -78,12 +73,7 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): ), ) for unit_type in sorted_units: - row = self.add_purchase_row( - unit_type, - task_box_layout, - row, - disabled=not self.cp.can_operate(unit_type), - ) + row = self.add_purchase_row(unit_type, task_box_layout, row) stretch = QVBoxLayout() stretch.addStretch() task_box_layout.addLayout(stretch, row, 0) @@ -98,6 +88,22 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): main_layout.addWidget(scroll) self.setLayout(main_layout) + def enable_purchase(self, unit_type: Type[UnitType]) -> bool: + if not super().enable_purchase(unit_type): + return False + if not issubclass(unit_type, FlyingType): + return False + if not self.cp.can_operate(unit_type): + return False + return True + + def enable_sale(self, unit_type: Type[UnitType]) -> bool: + if not issubclass(unit_type, FlyingType): + return False + if not self.cp.can_operate(unit_type): + return False + return True + def buy(self, unit_type): if self.maximum_units > 0: if self.cp.unclaimed_parking(self.game_model.game) <= 0: @@ -158,16 +164,16 @@ class QHangarStatus(QHBoxLayout): self.setAlignment(Qt.AlignLeft) def update_label(self) -> None: - next_turn = self.control_point.expected_aircraft_next_turn(self.game_model.game) + next_turn = self.control_point.allocated_aircraft(self.game_model.game) max_amount = self.control_point.total_aircraft_parking - components = [f"{next_turn.present} present"] - if next_turn.ordered > 0: - components.append(f"{next_turn.ordered} purchased") - elif next_turn.ordered < 0: - components.append(f"{-next_turn.ordered} sold") + components = [f"{next_turn.total_present} present"] + if next_turn.total_ordered > 0: + components.append(f"{next_turn.total_ordered} purchased") + elif next_turn.total_ordered < 0: + components.append(f"{-next_turn.total_ordered} sold") - transferring = next_turn.transferring + transferring = next_turn.total_transferring if transferring > 0: components.append(f"{transferring} transferring in") if transferring < 0: diff --git a/qt_ui/windows/basemenu/base_defenses/QBaseDefenseGroupInfo.py b/qt_ui/windows/basemenu/base_defenses/QBaseDefenseGroupInfo.py deleted file mode 100644 index 618e62dd..00000000 --- a/qt_ui/windows/basemenu/base_defenses/QBaseDefenseGroupInfo.py +++ /dev/null @@ -1,106 +0,0 @@ -from PySide2.QtCore import Qt -from PySide2.QtWidgets import ( - QGridLayout, - QGroupBox, - QLabel, - QPushButton, - QVBoxLayout, -) - -from game.theater import ControlPoint, TheaterGroundObject -from qt_ui.dialogs import Dialog -from qt_ui.uiconstants import VEHICLES_ICONS -from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu -from game import db -from dcs import vehicles - - -class QBaseDefenseGroupInfo(QGroupBox): - def __init__(self, cp: ControlPoint, ground_object: TheaterGroundObject, game): - super(QBaseDefenseGroupInfo, self).__init__("Group : " + ground_object.obj_name) - self.ground_object = ground_object - self.cp = cp - self.game = game - self.buildings = game.theater.find_ground_objects_by_obj_name( - self.ground_object.obj_name - ) - - self.main_layout = QVBoxLayout() - self.unit_layout = QGridLayout() - - self.init_ui() - - def init_ui(self): - - self.buildLayout() - self.main_layout.addLayout(self.unit_layout) - if not self.cp.captured and not self.ground_object.is_dead: - attack_button = QPushButton("Attack") - attack_button.setProperty("style", "btn-danger") - attack_button.setMaximumWidth(180) - attack_button.clicked.connect(self.onAttack) - self.main_layout.addWidget(attack_button, 0, Qt.AlignLeft) - - if self.cp.captured: - manage_button = QPushButton("Manage") - manage_button.setProperty("style", "btn-success") - manage_button.setMaximumWidth(180) - manage_button.clicked.connect(self.onManage) - self.main_layout.addWidget(manage_button, 0, Qt.AlignLeft) - - self.setLayout(self.main_layout) - - def buildLayout(self): - unit_dict = {} - for i in range(self.unit_layout.rowCount()): - for j in range(self.unit_layout.columnCount()): - item = self.unit_layout.itemAtPosition(i, j) - if item is not None and item.widget() is not None: - item.widget().setParent(None) - print("Remove " + str(i) + ", " + str(j)) - - for g in self.ground_object.groups: - for u in g.units: - if u.type in unit_dict.keys(): - unit_dict[u.type] = unit_dict[u.type] + 1 - else: - unit_dict[u.type] = 1 - i = 0 - for k, v in unit_dict.items(): - icon = QLabel() - if k in VEHICLES_ICONS.keys(): - icon.setPixmap(VEHICLES_ICONS[k]) - else: - icon.setText("" + k[:8] + "") - icon.setProperty("style", "icon-armor") - self.unit_layout.addWidget(icon, i, 0) - unit_display_name = k - unit_type = vehicles.vehicle_map.get(k) - if unit_type is not None: - unit_display_name = db.unit_get_expanded_info( - self.game.enemy_country, unit_type, "name" - ) - self.unit_layout.addWidget( - QLabel(str(v) + " x " + "" + unit_display_name + ""), - i, - 1, - ) - i = i + 1 - - if len(unit_dict.items()) == 0: - self.unit_layout.addWidget(QLabel("/"), 0, 0) - - self.setLayout(self.main_layout) - - def onAttack(self): - Dialog.open_new_package_dialog(self.ground_object, parent=self.window()) - - def onManage(self): - self.edition_menu = QGroundObjectMenu( - self.window(), self.ground_object, self.buildings, self.cp, self.game - ) - self.edition_menu.show() - self.edition_menu.changed.connect(self.onEdition) - - def onEdition(self): - self.buildLayout() diff --git a/qt_ui/windows/basemenu/base_defenses/QBaseDefensesHQ.py b/qt_ui/windows/basemenu/base_defenses/QBaseDefensesHQ.py deleted file mode 100644 index f1d99d67..00000000 --- a/qt_ui/windows/basemenu/base_defenses/QBaseDefensesHQ.py +++ /dev/null @@ -1,19 +0,0 @@ -from PySide2.QtWidgets import QFrame, QGridLayout - -from game import Game -from game.theater import ControlPoint -from qt_ui.windows.basemenu.base_defenses.QBaseInformation import QBaseInformation - - -class QBaseDefensesHQ(QFrame): - def __init__(self, cp: ControlPoint, game: Game): - super(QBaseDefensesHQ, self).__init__() - self.cp = cp - self.game = game - self.init_ui() - - def init_ui(self): - airport = self.game.theater.terrain.airport_by_id(self.cp.id) - layout = QGridLayout() - layout.addWidget(QBaseInformation(self.cp, airport, self.game)) - self.setLayout(layout) diff --git a/qt_ui/windows/basemenu/base_defenses/QBaseInformation.py b/qt_ui/windows/basemenu/base_defenses/QBaseInformation.py deleted file mode 100644 index f2b874dd..00000000 --- a/qt_ui/windows/basemenu/base_defenses/QBaseInformation.py +++ /dev/null @@ -1,56 +0,0 @@ -from PySide2.QtGui import Qt -from PySide2.QtWidgets import ( - QFrame, - QGridLayout, - QScrollArea, - QVBoxLayout, - QWidget, -) - -from game.theater import Airport, ControlPoint, Fob -from game.theater.theatergroundobject import BuildingGroundObject -from qt_ui.windows.basemenu.base_defenses.QBaseDefenseGroupInfo import ( - QBaseDefenseGroupInfo, -) - - -class QBaseInformation(QFrame): - def __init__(self, cp: ControlPoint, airport: Airport, game): - super(QBaseInformation, self).__init__() - self.cp = cp - self.airport = airport - self.game = game - self.setMinimumWidth(500) - self.init_ui() - - def init_ui(self): - self.mainLayout = QVBoxLayout() - - scroll_content = QWidget() - task_box_layout = QGridLayout() - scroll_content.setLayout(task_box_layout) - - for g in self.cp.ground_objects: - # Airbase groups are the objects that are hidden on the map because - # they're shown in the base menu. - if not g.airbase_group: - continue - - # Of these, we need to ignore the FOB structure itself since that's - # not supposed to be targetable. - if isinstance(self.cp, Fob) and isinstance(g, BuildingGroundObject): - continue - - group_info = QBaseDefenseGroupInfo(self.cp, g, self.game) - task_box_layout.addWidget(group_info) - - scroll_content.setLayout(task_box_layout) - scroll = QScrollArea() - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) - scroll.setWidgetResizable(True) - scroll.setWidget(scroll_content) - - self.mainLayout.addWidget(scroll) - - self.setLayout(self.mainLayout) diff --git a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py index 46536204..0a421a6a 100644 --- a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py @@ -1,3 +1,5 @@ +from typing import Type + from PySide2.QtCore import Qt from PySide2.QtWidgets import ( QFrame, @@ -65,14 +67,10 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): main_layout.addWidget(scroll) self.setLayout(main_layout) - def sell(self, unit_type: UnitType): - if self.pending_deliveries.available_next_turn(unit_type) <= 0: - QMessageBox.critical( - self, - "Could not sell ground unit", - f"Attempted to sell one {unit_type.id} at {self.cp.name} " - "but none are available.", - QMessageBox.Ok, - ) - return - super().sell(unit_type) + def enable_purchase(self, unit_type: Type[UnitType]) -> bool: + if not super().enable_purchase(unit_type): + return False + return self.cp.has_ground_unit_source(self.game_model.game) + + def enable_sale(self, unit_type: Type[UnitType]) -> bool: + return self.pending_deliveries.pending_orders(unit_type) > 0 diff --git a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py index c903413b..ec467b92 100644 --- a/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py +++ b/qt_ui/windows/basemenu/ground_forces/QGroundForcesStrategy.py @@ -1,7 +1,10 @@ -from PySide2.QtWidgets import QGroupBox, QLabel, QVBoxLayout +from collections import Callable + +from PySide2.QtWidgets import QGroupBox, QLabel, QVBoxLayout, QPushButton from game import Game from game.theater import ControlPoint +from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.basemenu.ground_forces.QGroundForcesStrategySelector import ( QGroundForcesStrategySelector, ) @@ -15,10 +18,44 @@ class QGroundForcesStrategy(QGroupBox): self.init_ui() def init_ui(self): + def make_cheat_callback( + enemy_point: ControlPoint, advance: bool + ) -> Callable[[], None]: + def cheat() -> None: + self.cheat_alter_front_line(enemy_point, advance) + + return cheat + layout = QVBoxLayout() for enemy_cp in self.cp.connected_points: if not enemy_cp.captured: layout.addWidget(QLabel(enemy_cp.name)) layout.addWidget(QGroundForcesStrategySelector(self.cp, enemy_cp)) + if self.game.settings.enable_frontline_cheats: + advance_button = QPushButton("CHEAT: Advance") + advance_button.setProperty("style", "btn-danger") + layout.addWidget(advance_button) + advance_button.clicked.connect( + make_cheat_callback(enemy_cp, advance=True) + ) + + retreat_button = QPushButton("CHEAT: Retreat") + retreat_button.setProperty("style", "btn-danger") + layout.addWidget(retreat_button) + retreat_button.clicked.connect( + make_cheat_callback(enemy_cp, advance=False) + ) + layout.addStretch() self.setLayout(layout) + + def cheat_alter_front_line(self, enemy_point: ControlPoint, advance: bool) -> None: + amount = 0.2 + if not advance: + amount *= -1 + 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 13abb5b3..9df4b454 100644 --- a/qt_ui/windows/groundobject/QGroundObjectMenu.py +++ b/qt_ui/windows/groundobject/QGroundObjectMenu.py @@ -21,8 +21,15 @@ from game import Game, db from game.data.building_data import FORTIFICATION_BUILDINGS from game.db import PRICES, PinpointStrike, REWARDS, unit_type_of from game.theater import ControlPoint, TheaterGroundObject -from game.theater.theatergroundobject import NavalGroundObject +from game.theater.theatergroundobject import ( + NavalGroundObject, + VehicleGroupGroundObject, + SamGroundObject, + EwrGroundObject, + BuildingGroundObject, +) from gen.defenses.armor_group_generator import generate_armor_group_of_type_and_size +from gen.sam.ewr_group_generator import get_faction_possible_ewrs_generator from gen.sam.sam_group_generator import get_faction_possible_sams_generator from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.QBudgetBox import QBudgetBox @@ -32,9 +39,6 @@ from dcs import vehicles class QGroundObjectMenu(QDialog): - - changed = QtCore.Signal() - def __init__( self, parent, @@ -70,12 +74,12 @@ class QGroundObjectMenu(QDialog): self.doLayout() - if self.ground_object.dcs_identifier == "AA": - self.mainLayout.addWidget(self.intelBox) - else: + if isinstance(self.ground_object, BuildingGroundObject): self.mainLayout.addWidget(self.buildingBox) if self.cp.captured: self.mainLayout.addWidget(self.financesBox) + else: + self.mainLayout.addWidget(self.intelBox) self.actionLayout = QHBoxLayout() @@ -87,12 +91,12 @@ class QGroundObjectMenu(QDialog): self.buy_replace.clicked.connect(self.buy_group) self.buy_replace.setProperty("style", "btn-success") - if not isinstance(self.ground_object, NavalGroundObject): + if self.ground_object.purchasable: if self.total_value > 0: self.actionLayout.addWidget(self.sell_all_button) self.actionLayout.addWidget(self.buy_replace) - if self.cp.captured and self.ground_object.dcs_identifier == "AA": + if self.cp.captured and self.ground_object.purchasable: self.mainLayout.addLayout(self.actionLayout) self.setLayout(self.mainLayout) @@ -196,23 +200,21 @@ class QGroundObjectMenu(QDialog): self.actionLayout.setParent(None) self.doLayout() - if self.ground_object.dcs_identifier == "AA": - self.mainLayout.addWidget(self.intelBox) - else: + if isinstance(self.ground_object, BuildingGroundObject): self.mainLayout.addWidget(self.buildingBox) + else: + self.mainLayout.addWidget(self.intelBox) self.actionLayout = QHBoxLayout() if self.total_value > 0: self.actionLayout.addWidget(self.sell_all_button) self.actionLayout.addWidget(self.buy_replace) - if self.cp.captured and self.ground_object.dcs_identifier == "AA": + if self.cp.captured and self.ground_object.purchasable: self.mainLayout.addLayout(self.actionLayout) - except Exception as e: - print(e) + logging.exception(e) self.update_total_value() - self.changed.emit() def update_total_value(self): total_value = 0 @@ -244,7 +246,6 @@ class QGroundObjectMenu(QDialog): logging.info("Repaired unit : " + str(unit.id) + " " + str(unit.type)) self.do_refresh_layout() - self.changed.emit() def sell_all(self): self.update_total_value() @@ -294,9 +295,6 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.buySamBox = QGroupBox("Buy SAM site :") self.buyArmorBox = QGroupBox("Buy defensive position :") - self.init_ui() - - def init_ui(self): faction = self.game.player_faction # Sams @@ -317,6 +315,30 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.buySamButton.clicked.connect(self.buySam) + # EWRs + + buy_ewr_box = QGroupBox("Buy EWR:") + buy_ewr_layout = QGridLayout() + buy_ewr_box.setLayout(buy_ewr_layout) + + buy_ewr_layout.addWidget(QLabel("Radar type:"), 0, 0, Qt.AlignLeft) + + self.ewr_selector = QComboBox() + 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: + self.ewr_selector.addItem( + f"{ewr_type.name()} [${ewr_type.price()}M]", ewr_type + ) + self.ewr_selector.currentIndexChanged.connect(self.on_ewr_selection_changed) + + self.buy_ewr_button = QPushButton("Buy") + self.buy_ewr_button.clicked.connect(self.buy_ewr) + buy_ewr_layout.addWidget(self.buy_ewr_button, 1, 1, alignment=Qt.AlignRight) + stretch = QVBoxLayout() + stretch.addStretch() + buy_ewr_layout.addLayout(stretch, 2, 0) + # Armored units armored_units = db.find_unittype( @@ -354,16 +376,20 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.buyArmorBox.setLayout(self.buyArmorLayout) self.mainLayout = QHBoxLayout() - self.mainLayout.addWidget(self.buySamBox) - if self.ground_object.airbase_group: + if isinstance(self.ground_object, SamGroundObject): + self.mainLayout.addWidget(self.buySamBox) + elif isinstance(self.ground_object, VehicleGroupGroundObject): self.mainLayout.addWidget(self.buyArmorBox) + elif isinstance(self.ground_object, EwrGroundObject): + self.mainLayout.addWidget(buy_ewr_box) self.setLayout(self.mainLayout) try: self.samComboChanged(0) self.armorComboChanged(0) + self.on_ewr_selection_changed(0) except: pass @@ -376,6 +402,12 @@ class QBuyGroupForGroundObjectDialog(QDialog): + "M]" ) + 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]" + ) + def armorComboChanged(self, index): self.buyArmorButton.setText( "Buy [$" @@ -441,6 +473,24 @@ class QBuyGroupForGroundObjectDialog(QDialog): self.changed.emit() self.close() + 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: + 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] + + GameUpdateSignal.get_instance().updateBudget(self.game) + + self.changed.emit() + self.close() + def error_money(self): msg = QMessageBox() msg.setIcon(QMessageBox.Information) diff --git a/qt_ui/windows/intel.py b/qt_ui/windows/intel.py index 5e8784cd..74203adf 100644 --- a/qt_ui/windows/intel.py +++ b/qt_ui/windows/intel.py @@ -1,6 +1,7 @@ import itertools from PySide2.QtWidgets import ( + QCheckBox, QDialog, QFrame, QGridLayout, @@ -42,9 +43,9 @@ class ScrollingFrame(QFrame): class EconomyIntelTab(ScrollingFrame): - def __init__(self, game: Game) -> None: + def __init__(self, game: Game, player: bool) -> None: super().__init__() - self.addLayout(FinancesLayout(game, player=False)) + self.addLayout(FinancesLayout(game, player=player)) class IntelTableLayout(QGridLayout): @@ -93,9 +94,9 @@ class AircraftIntelLayout(IntelTableLayout): class AircraftIntelTab(ScrollingFrame): - def __init__(self, game: Game) -> None: + def __init__(self, game: Game, player: bool) -> None: super().__init__() - self.addLayout(AircraftIntelLayout(game, player=False)) + self.addLayout(AircraftIntelLayout(game, player=player)) class ArmyIntelLayout(IntelTableLayout): @@ -120,18 +121,18 @@ class ArmyIntelLayout(IntelTableLayout): class ArmyIntelTab(ScrollingFrame): - def __init__(self, game: Game) -> None: + def __init__(self, game: Game, player: bool) -> None: super().__init__() - self.addLayout(ArmyIntelLayout(game, player=False)) + self.addLayout(ArmyIntelLayout(game, player=player)) class IntelTabs(QTabWidget): - def __init__(self, game: Game): + def __init__(self, game: Game, player: bool): super().__init__() - self.addTab(EconomyIntelTab(game), "Economy") - self.addTab(AircraftIntelTab(game), "Air forces") - self.addTab(ArmyIntelTab(game), "Ground forces") + self.addTab(EconomyIntelTab(game, player), "Economy") + self.addTab(AircraftIntelTab(game, player), "Air forces") + self.addTab(ArmyIntelTab(game, player), "Ground forces") class IntelWindow(QDialog): @@ -139,12 +140,42 @@ class IntelWindow(QDialog): super().__init__() self.game = game + self.player = True self.setModal(True) self.setWindowTitle("Intelligence") self.setWindowIcon(ICONS["Statistics"]) self.setMinimumSize(600, 500) + self.selected_intel_tab = 0 layout = QVBoxLayout() self.setLayout(layout) + self.refresh_layout() - layout.addWidget(IntelTabs(game), stretch=1) + def on_faction_changed(self) -> None: + self.player = not self.player + self.refresh_layout() + + def refresh_layout(self) -> None: + + # Clear the existing layout + if self.layout(): + idx = 0 + while child := self.layout().itemAt(idx): + self.layout().removeItem(child) + + # Add the new layout + own_faction = QCheckBox("Enemy Info") + own_faction.setChecked(not self.player) + own_faction.stateChanged.connect(self.on_faction_changed) + + intel_tabs = IntelTabs(self.game, self.player) + intel_tabs.currentChanged.connect(self.on_tab_changed) + + if self.selected_intel_tab: + intel_tabs.setCurrentIndex(self.selected_intel_tab) + + self.layout().addWidget(own_faction) + self.layout().addWidget(intel_tabs, stretch=1) + + def on_tab_changed(self, idx: int) -> None: + self.selected_intel_tab = idx diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 3497140f..19634847 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -92,7 +92,7 @@ class QPackageDialog(QDialog): self.tot_column.addWidget(self.auto_asap) self.tot_help_label = QLabel( - 'Help' + 'Help' ) self.tot_help_label.setAlignment(Qt.AlignCenter) self.tot_help_label.setOpenExternalLinks(True) @@ -185,7 +185,6 @@ class QPackageDialog(QDialog): try: planner.populate_flight_plan(flight) except PlanningError as ex: - self.game.aircraft_inventory.return_from_flight(flight) self.package_model.delete_flight(flight) logging.exception("Could not create flight") QMessageBox.critical( @@ -201,7 +200,6 @@ class QPackageDialog(QDialog): if flight is None: logging.error(f"Cannot delete flight when no flight is selected.") return - self.game.aircraft_inventory.return_from_flight(flight) self.package_model.delete_flight(flight) # noinspection PyUnresolvedReferences self.package_changed.emit() @@ -216,9 +214,26 @@ class QNewPackageDialog(QPackageDialog): def __init__( self, game_model: GameModel, model: AtoModel, target: MissionTarget, parent=None ) -> None: - super().__init__(game_model, PackageModel(Package(target)), parent=parent) + super().__init__( + game_model, + PackageModel(Package(target, auto_asap=True), game_model), + parent=parent, + ) self.ato_model = model + # In the *new* package dialog, a package has been created and may have aircraft + # assigned to it, but it is not a part of the ATO until the user saves it. + # + # Other actions (modifying settings, closing some other dialogs like the base + # menu) can cause a Game update which will forcibly close this window without + # either accepting or rejecting it, so we neither save the package nor release + # any allocated units. + # + # While it would be preferable to be able to update this dialog as needed in the + # event of game updates, the quick fix is to just not allow interaction with + # other UI elements until the new package has either been finalized or canceled. + self.setModal(True) + self.save_button = QPushButton("Save") self.save_button.setProperty("style", "start-button") self.save_button.clicked.connect(self.accept) @@ -237,6 +252,7 @@ class QNewPackageDialog(QPackageDialog): super().on_cancel() for flight in self.package_model.package.flights: self.game.aircraft_inventory.return_from_flight(flight) + flight.clear_roster() class QEditPackageDialog(QPackageDialog): diff --git a/qt_ui/windows/mission/QPlannedFlightsView.py b/qt_ui/windows/mission/QPlannedFlightsView.py index 302003ad..42ac4202 100644 --- a/qt_ui/windows/mission/QPlannedFlightsView.py +++ b/qt_ui/windows/mission/QPlannedFlightsView.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from PySide2.QtCore import QItemSelectionModel, QSize from PySide2.QtGui import QStandardItemModel from PySide2.QtWidgets import QAbstractItemView, QListView @@ -5,6 +7,7 @@ from PySide2.QtWidgets import QAbstractItemView, QListView from qt_ui.models import GameModel from qt_ui.windows.mission.QFlightItem import QFlightItem from game.theater.controlpoint import ControlPoint +from gen.flights.traveltime import TotEstimator class QPlannedFlightsView(QListView): @@ -25,8 +28,11 @@ class QPlannedFlightsView(QListView): for flight in package.flights: if flight.from_cp == self.cp: item = QFlightItem(package.package, flight) - self.model.appendRow(item) self.flight_items.append(item) + + self.flight_items.sort(key=self.mission_start_for_flight) + for item in self.flight_items: + self.model.appendRow(item) self.set_selected_flight(0) def set_selected_flight(self, row): @@ -43,3 +49,7 @@ class QPlannedFlightsView(QListView): def set_flight_planner(self) -> None: self.clear_layout() self.setup_content() + + @staticmethod + def mission_start_for_flight(flight_item: QFlightItem) -> timedelta: + return TotEstimator(flight_item.package).mission_start_time(flight_item.flight) diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index f4aa743c..0e8293a1 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -1,4 +1,5 @@ -from typing import Optional +import logging +from typing import Optional, Type from PySide2.QtCore import Qt, Signal from PySide2.QtWidgets import ( @@ -9,13 +10,15 @@ from PySide2.QtWidgets import ( QPushButton, QVBoxLayout, QLineEdit, + QHBoxLayout, ) -from dcs.planes import PlaneType +from dcs.unittype import FlyingType from game import Game +from game.squadrons import Squadron from game.theater import ControlPoint, OffMapSpawn from gen.ato import Package -from gen.flights.flight import Flight +from gen.flights.flight import Flight, FlightRoster from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.QFlightSizeSpinner import QFlightSizeSpinner from qt_ui.widgets.QLabeledWidget import QLabeledWidget @@ -23,6 +26,8 @@ from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelector from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector +from qt_ui.windows.mission.flight.SquadronSelector import SquadronSelector +from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import FlightRosterEditor class QFlightCreator(QDialog): @@ -43,7 +48,7 @@ class QFlightCreator(QDialog): self.task_selector = QFlightTypeComboBox(self.game.theater, package.target) self.task_selector.setCurrentIndex(0) - self.task_selector.currentTextChanged.connect(self.on_task_changed) + self.task_selector.currentIndexChanged.connect(self.on_task_changed) layout.addLayout(QLabeledWidget("Task:", self.task_selector)) self.aircraft_selector = QAircraftTypeSelector( @@ -55,6 +60,14 @@ class QFlightCreator(QDialog): self.aircraft_selector.currentIndexChanged.connect(self.on_aircraft_changed) layout.addLayout(QLabeledWidget("Aircraft:", self.aircraft_selector)) + self.squadron_selector = SquadronSelector( + self.game.air_wing_for(player=True), + self.task_selector.currentData(), + self.aircraft_selector.currentData(), + ) + self.squadron_selector.setCurrentIndex(0) + layout.addLayout(QLabeledWidget("Squadron:", self.squadron_selector)) + self.departure = QOriginAirfieldSelector( self.game.aircraft_inventory, [cp for cp in game.theater.controlpoints if cp.captured], @@ -82,13 +95,20 @@ class QFlightCreator(QDialog): self.update_max_size(self.departure.available) layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner)) - self.client_slots_spinner = QFlightSizeSpinner( - min_size=0, max_size=self.flight_size_spinner.value(), default_size=0 - ) - self.flight_size_spinner.valueChanged.connect( - lambda v: self.client_slots_spinner.setMaximum(v) - ) - layout.addLayout(QLabeledWidget("Client Slots:", self.client_slots_spinner)) + squadron = self.squadron_selector.currentData() + if squadron is None: + roster = None + else: + roster = FlightRoster( + squadron, initial_size=self.flight_size_spinner.value() + ) + self.roster_editor = FlightRosterEditor(roster) + self.flight_size_spinner.valueChanged.connect(self.resize_roster) + self.squadron_selector.currentIndexChanged.connect(self.on_squadron_changed) + roster_layout = QHBoxLayout() + layout.addLayout(roster_layout) + roster_layout.addWidget(QLabel("Assigned pilots:")) + roster_layout.addLayout(self.roster_editor) # When an off-map spawn overrides the start type to in-flight, we save # the selected type into this value. If a non-off-map spawn is selected @@ -131,14 +151,21 @@ class QFlightCreator(QDialog): def set_custom_name_text(self, text: str): self.custom_name_text = text + def resize_roster(self, new_size: int) -> None: + self.roster_editor.roster.resize(new_size) + self.roster_editor.resize(new_size) + def verify_form(self) -> Optional[str]: - aircraft: PlaneType = self.aircraft_selector.currentData() - origin: ControlPoint = self.departure.currentData() - arrival: ControlPoint = self.arrival.currentData() - divert: ControlPoint = self.divert.currentData() + aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData() + squadron: Optional[Squadron] = self.squadron_selector.currentData() + origin: Optional[ControlPoint] = self.departure.currentData() + arrival: Optional[ControlPoint] = self.arrival.currentData() + divert: Optional[ControlPoint] = self.divert.currentData() size: int = self.flight_size_spinner.value() if aircraft is None: return "You must select an aircraft type." + if squadron is None: + return "You must select a squadron." if not origin.captured: return f"{origin.name} is not owned by your coalition." if arrival is not None and not arrival.captured: @@ -163,11 +190,11 @@ class QFlightCreator(QDialog): return task = self.task_selector.currentData() - aircraft = self.aircraft_selector.currentData() + squadron = self.squadron_selector.currentData() origin = self.departure.currentData() arrival = self.arrival.currentData() divert = self.divert.currentData() - size = self.flight_size_spinner.value() + roster = self.roster_editor.roster if arrival is None: arrival = origin @@ -175,16 +202,18 @@ class QFlightCreator(QDialog): flight = Flight( self.package, self.country, - aircraft, - size, + squadron, + # A bit of a hack to work around the old API. Not actually relevant because + # the roster is passed explicitly. Needs a refactor. + roster.max_size, task, self.start_type.currentText(), origin, arrival, divert, custom_name=self.custom_name_text, + roster=roster, ) - flight.client_count = self.client_slots_spinner.value() # noinspection PyUnresolvedReferences self.created.emit(flight) @@ -192,6 +221,9 @@ class QFlightCreator(QDialog): def on_aircraft_changed(self, index: int) -> None: new_aircraft = self.aircraft_selector.itemData(index) + self.squadron_selector.update_items( + self.task_selector.currentData(), new_aircraft + ) self.departure.change_aircraft(new_aircraft) self.arrival.change_aircraft(new_aircraft) self.divert.change_aircraft(new_aircraft) @@ -210,11 +242,22 @@ class QFlightCreator(QDialog): self.start_type.setCurrentText(self.restore_start_type) self.restore_start_type = None - def on_task_changed(self) -> None: - self.aircraft_selector.updateItems( - self.task_selector.currentData(), - self.game.aircraft_inventory.available_types_for_player, + def on_task_changed(self, index: int) -> None: + task = self.task_selector.itemData(index) + self.aircraft_selector.update_items( + task, self.game.aircraft_inventory.available_types_for_player ) + self.squadron_selector.update_items(task, self.aircraft_selector.currentData()) + + def on_squadron_changed(self, index: int) -> None: + squadron = self.squadron_selector.itemData(index) + # Clear the roster first so we return the pilots to the pool. This way if we end + # up repopulating from the same squadron we'll get the same pilots back. + self.roster_editor.replace(None) + if squadron is not None: + self.roster_editor.replace( + FlightRoster(squadron, self.flight_size_spinner.value()) + ) def update_max_size(self, available: int) -> None: self.flight_size_spinner.setMaximum(min(available, 4)) diff --git a/qt_ui/windows/mission/flight/QFlightPlanner.py b/qt_ui/windows/mission/flight/QFlightPlanner.py index 0c1b9e03..695e1575 100644 --- a/qt_ui/windows/mission/flight/QFlightPlanner.py +++ b/qt_ui/windows/mission/flight/QFlightPlanner.py @@ -19,6 +19,7 @@ class QFlightPlanner(QTabWidget): ) self.payload_tab = QFlightPayloadTab(flight, game) self.waypoint_tab = QFlightWaypointTab(game, package_model.package, flight) + self.waypoint_tab.loadout_changed.connect(self.payload_tab.reload_from_flight) self.addTab(self.general_settings_tab, "General Flight settings") self.addTab(self.payload_tab, "Payload") self.addTab(self.waypoint_tab, "Waypoints") diff --git a/qt_ui/windows/mission/flight/SquadronSelector.py b/qt_ui/windows/mission/flight/SquadronSelector.py new file mode 100644 index 00000000..e9b7ae7f --- /dev/null +++ b/qt_ui/windows/mission/flight/SquadronSelector.py @@ -0,0 +1,52 @@ +"""Combo box for selecting squadrons.""" +from typing import Type, Optional + +from PySide2.QtWidgets import QComboBox +from dcs.unittype import FlyingType + +from game.squadrons import AirWing +from gen.flights.flight import FlightType + + +class SquadronSelector(QComboBox): + """Combo box for selecting squadrons compatible with the given requirements.""" + + def __init__( + self, + air_wing: AirWing, + task: Optional[FlightType], + aircraft: Optional[Type[FlyingType]], + ) -> None: + super().__init__() + self.air_wing = air_wing + + self.model().sort(0) + self.setSizeAdjustPolicy(self.AdjustToContents) + self.update_items(task, aircraft) + + def update_items( + self, task: Optional[FlightType], aircraft: Optional[Type[FlyingType]] + ) -> None: + current_squadron = self.currentData() + self.blockSignals(True) + try: + self.clear() + finally: + self.blockSignals(False) + if task is None: + self.addItem("No task selected", None) + return + if aircraft is None: + self.addItem("No aircraft selected", None) + return + + for squadron in self.air_wing.squadrons_for(aircraft): + if task in squadron.mission_types: + self.addItem(f"{squadron}", squadron) + + if self.count() == 0: + self.addItem("No capable aircraft available", None) + return + + if current_squadron is not None: + self.setCurrentText(f"{current_squadron}") diff --git a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py index b61fe432..5cf5b370 100644 --- a/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py +++ b/qt_ui/windows/mission/flight/payload/QFlightPayloadTab.py @@ -1,29 +1,60 @@ -from PySide2.QtWidgets import QFrame, QGridLayout, QLabel from PySide2.QtCore import Qt +from PySide2.QtWidgets import QFrame, QLabel, QComboBox, QVBoxLayout from game import Game from gen.flights.flight import Flight +from gen.flights.loadouts import Loadout from qt_ui.windows.mission.flight.payload.QLoadoutEditor import QLoadoutEditor +class DcsLoadoutSelector(QComboBox): + def __init__(self, flight: Flight) -> None: + super().__init__() + for loadout in Loadout.iter_for(flight): + self.addItem(loadout.name, loadout) + self.model().sort(0) + self.setDisabled(flight.loadout.is_custom) + if flight.loadout.is_custom: + self.setCurrentText(Loadout.default_for(flight).name) + else: + self.setCurrentText(flight.loadout.name) + + class QFlightPayloadTab(QFrame): def __init__(self, flight: Flight, game: Game): super(QFlightPayloadTab, self).__init__() self.flight = flight self.payload_editor = QLoadoutEditor(flight, game) - self.init_ui() + self.payload_editor.toggled.connect(self.on_custom_toggled) - def init_ui(self): - layout = QGridLayout() + layout = QVBoxLayout() # Docs Link docsText = QLabel( - 'How to create your own default loadout' + 'How to create your own default loadout' ) docsText.setAlignment(Qt.AlignCenter) docsText.setOpenExternalLinks(True) + self.loadout_selector = DcsLoadoutSelector(flight) + self.loadout_selector.currentIndexChanged.connect(self.on_new_loadout) + layout.addWidget(self.loadout_selector) layout.addWidget(self.payload_editor) layout.addWidget(docsText) self.setLayout(layout) + + def reload_from_flight(self) -> None: + self.loadout_selector.setCurrentText(self.flight.loadout.name) + + def on_new_loadout(self, index: int) -> None: + self.flight.loadout = self.loadout_selector.itemData(index) + self.payload_editor.reset_pylons() + + def on_custom_toggled(self, use_custom: bool) -> None: + self.loadout_selector.setDisabled(use_custom) + if use_custom: + self.flight.loadout = self.flight.loadout.derive_custom("Custom") + else: + self.flight.loadout = self.loadout_selector.currentData() + self.payload_editor.reset_pylons() diff --git a/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py b/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py index 9d70e82a..ea779c33 100644 --- a/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py +++ b/qt_ui/windows/mission/flight/payload/QLoadoutEditor.py @@ -18,11 +18,9 @@ class QLoadoutEditor(QGroupBox): self.flight = flight self.game = game self.setCheckable(True) - self.setChecked(flight.use_custom_loadout) + self.setChecked(flight.loadout.is_custom) - self.toggled.connect(self.on_toggle) - - hboxLayout = QVBoxLayout(self) + vbox = QVBoxLayout(self) layout = QGridLayout(self) for i, pylon in enumerate(Pylon.iter_pylons(self.flight.unit_type)): @@ -31,16 +29,15 @@ class QLoadoutEditor(QGroupBox): layout.addWidget(label, i, 0) layout.addWidget(QPylonEditor(game, flight, pylon), i, 1) - hboxLayout.addLayout(layout) - hboxLayout.addStretch() - self.setLayout(hboxLayout) + vbox.addLayout(layout) + vbox.addStretch() + self.setLayout(vbox) - if not self.isChecked(): - for i in self.findChildren(QPylonEditor): - i.default_loadout() + for i in self.findChildren(QPylonEditor): + i.set_from(self.flight.loadout) - def on_toggle(self): + def reset_pylons(self) -> None: self.flight.use_custom_loadout = self.isChecked() if not self.isChecked(): for i in self.findChildren(QPylonEditor): - i.default_loadout() + i.set_from(self.flight.loadout) diff --git a/qt_ui/windows/mission/flight/payload/QPylonEditor.py b/qt_ui/windows/mission/flight/payload/QPylonEditor.py index 8591b7d6..3cb22c19 100644 --- a/qt_ui/windows/mission/flight/payload/QPylonEditor.py +++ b/qt_ui/windows/mission/flight/payload/QPylonEditor.py @@ -4,10 +4,10 @@ from typing import Optional from PySide2.QtWidgets import QComboBox -from game import Game, db +from game import Game from game.data.weapons import Pylon, Weapon from gen.flights.flight import Flight -from dcs import weapons_data +from gen.flights.loadouts import Loadout class QPylonEditor(QComboBox): @@ -16,8 +16,9 @@ class QPylonEditor(QComboBox): self.flight = flight self.pylon = pylon self.game = game + self.has_added_clean_item = False - current = self.flight.loadout.get(self.pylon.number) + current = self.flight.loadout.pylons.get(self.pylon.number) self.addItem("None", None) if self.game.settings.restrict_weapons_by_date: @@ -34,57 +35,40 @@ class QPylonEditor(QComboBox): def on_pylon_change(self): selected: Optional[Weapon] = self.currentData() - self.flight.loadout[self.pylon.number] = selected + self.flight.loadout.pylons[self.pylon.number] = selected if selected is None: logging.debug(f"Pylon {self.pylon.number} emptied") else: logging.debug(f"Pylon {self.pylon.number} changed to {selected.name}") - def default_loadout(self) -> None: - self.flight.unit_type.load_payloads() - self.setCurrentText("None") - pylon_default_weapon = None - historical_weapon = None - loadout = None - # Iterate through each possible payload type for a given aircraft. - # Some aircraft have custom loadouts that in aren't the standard set. - for payload_override in db.EXPANDED_TASK_PAYLOAD_OVERRIDE.get( - self.flight.flight_type.name - ): - if loadout is None: - loadout = self.flight.unit_type.loadout_by_name(payload_override) - if loadout is not None: - for i in loadout: - if i[0] == self.pylon.number: - pylon_default_weapon = i[1]["clsid"] - # TODO: Handle removed pylons better. - if pylon_default_weapon == "": - pylon_default_weapon = None - if pylon_default_weapon is not None: - if self.game.settings.restrict_weapons_by_date: - orig_weapon = Weapon.from_clsid(pylon_default_weapon) - if not orig_weapon.available_on(self.game.date): - for fallback in orig_weapon.fallbacks: - if historical_weapon is None: - if not self.pylon.can_equip(fallback): - continue - if not fallback.available_on(self.game.date): - continue - historical_weapon = fallback - else: - historical_weapon = orig_weapon - if historical_weapon is not None: - self.setCurrentText( - weapons_data.weapon_ids.get(historical_weapon.cls_id).get( - "name" - ) - ) - else: - weapon = weapons_data.weapon_ids.get(pylon_default_weapon) - if weapon is not None: - self.setCurrentText( - weapons_data.weapon_ids.get(pylon_default_weapon).get("name") - ) - else: - self.setCurrentText(pylon_default_weapon) + def weapon_from_loadout(self, loadout: Loadout) -> Optional[Weapon]: + weapon = loadout.pylons.get(self.pylon.number) + if weapon is None: + return None + # TODO: Fix pydcs to support the "weapon". + # These are not exported in the pydcs weapon map, which causes the pydcs pylon + # exporter to fail to include them in the supported list. Since they aren't + # known to be compatible (and we can't show them as compatible for *every* + # pylon, because they aren't), we won't have populated a "Clean" weapon when + # creating the selection list, so it's not selectable. To work around this, add + # the item to the list the first time it's encountered for the pylon. + # + # 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 not self.has_added_clean_item: + self.addItem("Clean", weapon) + self.has_added_clean_item = True + return weapon + + def matching_weapon_name(self, loadout: Loadout) -> str: + if self.game.settings.restrict_weapons_by_date: + loadout = loadout.degrade_for_date(self.flight.unit_type, self.game.date) + weapon = self.weapon_from_loadout(loadout) + if weapon is None: + return "None" + return weapon.name + + def set_from(self, loadout: Loadout) -> None: + self.setCurrentText(self.matching_weapon_name(loadout)) diff --git a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py index bf078bfe..3db6ab2d 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py +++ b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py @@ -1,17 +1,195 @@ import logging +from typing import Optional, Callable -from PySide2.QtCore import Signal -from PySide2.QtWidgets import QLabel, QGroupBox, QSpinBox, QGridLayout +from PySide2.QtCore import Signal, QModelIndex +from PySide2.QtWidgets import ( + QLabel, + QGroupBox, + QSpinBox, + QGridLayout, + QComboBox, + QHBoxLayout, + QCheckBox, + QVBoxLayout, +) from game import Game -from gen.flights.flight import Flight +from game.squadrons import Pilot +from gen.flights.flight import Flight, FlightRoster from qt_ui.models import PackageModel +class PilotSelector(QComboBox): + available_pilots_changed = Signal() + + def __init__(self, roster: Optional[FlightRoster], idx: int) -> None: + super().__init__() + self.roster = roster + self.pilot_index = idx + self.rebuild() + + @staticmethod + def text_for(pilot: Pilot) -> str: + return pilot.name + + def _do_rebuild(self) -> None: + self.clear() + if self.roster is None or self.pilot_index >= self.roster.max_size: + self.addItem("No aircraft", None) + self.setDisabled(True) + return + + self.setEnabled(True) + self.addItem("Unassigned", None) + choices = list(self.roster.squadron.available_pilots) + current_pilot = self.roster.pilots[self.pilot_index] + if current_pilot is not None: + choices.append(current_pilot) + # Put players first, otherwise alphabetically. + for pilot in sorted(choices, key=lambda p: (not p.player, p.name)): + self.addItem(self.text_for(pilot), pilot) + if current_pilot is None: + self.setCurrentText("Unassigned") + else: + self.setCurrentText(self.text_for(current_pilot)) + self.currentIndexChanged.connect(self.replace_pilot) + + def rebuild(self) -> None: + # The contents of the selector depend on the selection of the other selectors + # for the flight, so changing the selection of one causes each selector to + # rebuild. A rebuild causes a selection change, so if we don't block signals + # during a rebuild we'll never stop rebuilding. + self.blockSignals(True) + try: + self._do_rebuild() + finally: + self.blockSignals(False) + + def replace_pilot(self, index: QModelIndex) -> None: + if self.itemText(index) == "No aircraft": + # The roster resize is handled separately, so we have no pilots to remove. + return + pilot = self.itemData(index) + if pilot == self.roster.pilots[self.pilot_index]: + return + self.roster.set_pilot(self.pilot_index, pilot) + self.available_pilots_changed.emit() + + def replace(self, new_roster: Optional[FlightRoster]) -> None: + self.roster = new_roster + self.rebuild() + + +class PilotControls(QHBoxLayout): + def __init__(self, roster: Optional[FlightRoster], idx: int) -> None: + super().__init__() + self.roster = roster + self.pilot_index = idx + + self.selector = PilotSelector(roster, idx) + self.selector.currentIndexChanged.connect(self.on_pilot_changed) + self.addWidget(self.selector) + + self.player_checkbox = QCheckBox(text="Player") + self.player_checkbox.setToolTip("Checked if this pilot is a player.") + self.on_pilot_changed(self.selector.currentIndex()) + self.addWidget(self.player_checkbox) + + self.player_checkbox.toggled.connect(self.on_player_toggled) + + @property + def pilot(self) -> Optional[Pilot]: + if self.roster is None or self.pilot_index >= self.roster.max_size: + return None + return self.roster.pilots[self.pilot_index] + + def on_player_toggled(self, checked: bool) -> None: + pilot = self.pilot + if pilot is None: + logging.error("Cannot toggle state of a pilot when none is selected") + return + pilot.player = checked + + def on_pilot_changed(self, index: int) -> None: + pilot = self.selector.itemData(index) + self.player_checkbox.blockSignals(True) + try: + self.player_checkbox.setChecked(pilot is not None and pilot.player) + finally: + self.player_checkbox.blockSignals(False) + + def update_available_pilots(self) -> None: + self.selector.rebuild() + + def enable_and_reset(self) -> None: + self.selector.rebuild() + self.player_checkbox.setEnabled(True) + self.on_pilot_changed(self.selector.currentIndex()) + + def disable_and_clear(self) -> None: + self.selector.rebuild() + self.player_checkbox.blockSignals(True) + try: + self.player_checkbox.setEnabled(False) + self.player_checkbox.setChecked(False) + finally: + self.player_checkbox.blockSignals(False) + + def replace(self, new_roster: Optional[FlightRoster]) -> None: + self.roster = new_roster + if self.roster is None or self.pilot_index >= self.roster.max_size: + self.disable_and_clear() + else: + self.enable_and_reset() + self.selector.replace(new_roster) + + +class FlightRosterEditor(QVBoxLayout): + MAX_PILOTS = 4 + + def __init__(self, roster: Optional[FlightRoster]) -> None: + super().__init__() + self.roster = roster + + self.pilot_controls = [] + for pilot_idx in range(self.MAX_PILOTS): + + def make_reset_callback(source_idx: int) -> Callable[[int], None]: + def callback() -> None: + self.update_available_pilots(source_idx) + + return callback + + controls = PilotControls(roster, pilot_idx) + controls.selector.available_pilots_changed.connect( + make_reset_callback(pilot_idx) + ) + self.pilot_controls.append(controls) + self.addLayout(controls) + + def update_available_pilots(self, source_idx: int) -> None: + for idx, controls in enumerate(self.pilot_controls): + # No need to reset the source of the reset, it was just manually selected. + if idx != source_idx: + controls.update_available_pilots() + + def resize(self, new_size: int) -> None: + if new_size > self.MAX_PILOTS: + raise ValueError("A flight may not have more than four pilots.") + for controls in self.pilot_controls[:new_size]: + controls.enable_and_reset() + for controls in self.pilot_controls[new_size:]: + controls.disable_and_clear() + + def replace(self, new_roster: Optional[FlightRoster]) -> None: + if self.roster is not None: + self.roster.clear() + self.roster = new_roster + for controls in self.pilot_controls: + controls.replace(new_roster) + + class QFlightSlotEditor(QGroupBox): - - changed = Signal() - def __init__(self, package_model: PackageModel, flight: Flight, game: Game): super().__init__("Slots") self.package_model = package_model @@ -32,52 +210,36 @@ class QFlightSlotEditor(QGroupBox): self.aircraft_count_spinner.setValue(flight.count) self.aircraft_count_spinner.valueChanged.connect(self._changed_aircraft_count) - self.client_count = QLabel("Client slots count:") - self.client_count_spinner = QSpinBox() - self.client_count_spinner.setMinimum(0) - self.client_count_spinner.setMaximum(max_count) - self.client_count_spinner.setValue(flight.client_count) - self.client_count_spinner.valueChanged.connect(self._changed_client_count) - - if not self.flight.unit_type.flyable: - self.client_count_spinner.setValue(0) - self.client_count_spinner.setEnabled(False) - layout.addWidget(self.aircraft_count, 0, 0) layout.addWidget(self.aircraft_count_spinner, 0, 1) - layout.addWidget(self.client_count, 1, 0) - layout.addWidget(self.client_count_spinner, 1, 1) + layout.addWidget(QLabel("Squadron:"), 1, 0) + layout.addWidget(QLabel(str(self.flight.squadron)), 1, 1) + + layout.addWidget(QLabel("Assigned pilots:"), 2, 0) + self.roster_editor = FlightRosterEditor(flight.roster) + layout.addLayout(self.roster_editor, 2, 1) self.setLayout(layout) def _changed_aircraft_count(self): - self.game.aircraft_inventory.return_from_flight(self.flight) old_count = self.flight.count - self.flight.count = int(self.aircraft_count_spinner.value()) + new_count = int(self.aircraft_count_spinner.value()) + self.game.aircraft_inventory.return_from_flight(self.flight) + self.flight.resize(new_count) try: self.game.aircraft_inventory.claim_for_flight(self.flight) except ValueError: # The UI should have prevented this, but if we ran out of aircraft # then roll back the inventory change. - difference = self.flight.count - old_count + difference = new_count - self.flight.count available = self.inventory.available(self.flight.unit_type) logging.error( f"Could not add {difference} additional aircraft to " - f"{self.flight} because {self.flight.from_cp} has only " + f"{self.flight} because {self.flight.departure} has only " f"{available} {self.flight.unit_type} remaining" ) - self.flight.count = old_count self.game.aircraft_inventory.claim_for_flight(self.flight) - self.changed.emit() - - def _changed_client_count(self): - self.flight.client_count = int(self.client_count_spinner.value()) - self._cap_client_count() - self.package_model.update_tot() - self.changed.emit() - - def _cap_client_count(self): - if self.flight.client_count > self.flight.count: - self.flight.client_count = self.flight.count - self.client_count_spinner.setValue(self.flight.client_count) + self.flight.resize(old_count) + return + self.roster_editor.resize(new_count) diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointInfoBox.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointInfoBox.py index 29834c01..9bcadd6d 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointInfoBox.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointInfoBox.py @@ -1,22 +1,19 @@ -from PySide2.QtWidgets import QGroupBox, QGridLayout, QLabel, QHBoxLayout, QVBoxLayout +from PySide2.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QVBoxLayout from gen.flights.flight import FlightWaypoint class QFlightWaypointInfoBox(QGroupBox): - def __init__(self, flight_wpt: FlightWaypoint = None): + def __init__(self) -> None: super(QFlightWaypointInfoBox, self).__init__("Waypoint") - self.flight_wpt = flight_wpt - if flight_wpt is None: - self.flight_wpt = FlightWaypoint(0, 0, 0) - self.x_position_label = QLabel(str(self.flight_wpt.x)) - self.y_position_label = QLabel(str(self.flight_wpt.y)) - self.alt_label = QLabel(str(int(self.flight_wpt.alt.feet))) - self.name_label = QLabel(str(self.flight_wpt.name)) - self.desc_label = QLabel(str(self.flight_wpt.description)) + self.x_position_label = QLabel("0") + self.y_position_label = QLabel("0") + self.alt_label = QLabel("0") + self.name_label = QLabel("") + self.desc_label = QLabel("") self.init_ui() - def init_ui(self): + def init_ui(self) -> None: layout = QVBoxLayout() @@ -53,13 +50,10 @@ class QFlightWaypointInfoBox(QGroupBox): self.setLayout(layout) - def set_flight_waypoint(self, flight_wpt: FlightWaypoint): - self.flight_wpt = flight_wpt - if flight_wpt is None: - self.flight_wpt = FlightWaypoint(0, 0, 0) - self.x_position_label.setText(str(self.flight_wpt.x)) - self.y_position_label.setText(str(self.flight_wpt.y)) - self.alt_label.setText(str(int(self.flight_wpt.alt.feet))) - self.name_label.setText(str(self.flight_wpt.name)) - self.desc_label.setText(str(self.flight_wpt.description)) - self.setTitle(self.flight_wpt.name) + def set_flight_waypoint(self, flight_wpt: FlightWaypoint) -> None: + self.x_position_label.setText(str(flight_wpt.x)) + self.y_position_label.setText(str(flight_wpt.y)) + self.alt_label.setText(str(int(flight_wpt.alt.feet))) + self.name_label.setText(str(flight_wpt.name)) + self.desc_label.setText(str(flight_wpt.description)) + self.setTitle(flight_wpt.name) diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py index b9ba7444..b932a8a4 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py @@ -21,9 +21,6 @@ class QFlightWaypointList(QTableView): header = self.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) - - if len(self.flight.points) > 0: - self.selectedPoint = self.flight.points[0] self.update_list() self.selectionModel().setCurrentIndex( @@ -31,6 +28,9 @@ class QFlightWaypointList(QTableView): ) def update_list(self): + # We need to keep just the row and rebuild the index later because the + # QModelIndex will not be valid after the model is cleared. + current_index = self.currentIndex().row() self.model.clear() self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"]) @@ -39,7 +39,7 @@ class QFlightWaypointList(QTableView): for row, waypoint in enumerate(waypoints): self.add_waypoint_row(row, self.flight, waypoint) self.selectionModel().setCurrentIndex( - self.indexAt(QPoint(1, 1)), QItemSelectionModel.Select + self.model.index(current_index, 0), QItemSelectionModel.Select ) self.resizeColumnsToContents() total_column_width = self.verticalHeader().width() + self.lineWidth() diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 95ede450..b38ab146 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -20,6 +20,7 @@ from gen.flights.flightplan import ( PlanningError, StrikeFlightPlan, ) +from gen.flights.loadouts import Loadout from qt_ui.windows.mission.flight.waypoints.QFlightWaypointList import ( QFlightWaypointList, ) @@ -29,6 +30,8 @@ from qt_ui.windows.mission.flight.waypoints.QPredefinedWaypointSelectionWindow i class QFlightWaypointTab(QFrame): + loadout_changed = Signal() + def __init__(self, game: Game, package: Package, flight: Flight): super(QFlightWaypointTab, self).__init__() self.game = game @@ -161,6 +164,9 @@ class QFlightWaypointTab(QFrame): QMessageBox.critical( self, "Could not recreate flight", str(ex), QMessageBox.Ok ) + if not self.flight.loadout.is_custom: + self.flight.loadout = Loadout.default_for(self.flight) + self.loadout_changed.emit() self.flight_waypoint_list.update_list() self.on_change() diff --git a/qt_ui/windows/newgame/QCampaignList.py b/qt_ui/windows/newgame/QCampaignList.py index b97f2ef0..1d74e64a 100644 --- a/qt_ui/windows/newgame/QCampaignList.py +++ b/qt_ui/windows/newgame/QCampaignList.py @@ -4,15 +4,17 @@ import json import logging from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Union, Tuple +import packaging.version from PySide2 import QtGui -from PySide2.QtCore import QItemSelectionModel +from PySide2.QtCore import QItemSelectionModel, QModelIndex, Qt from PySide2.QtGui import QStandardItem, QStandardItemModel from PySide2.QtWidgets import QAbstractItemView, QListView import qt_ui.uiconstants as CONST from game.theater import ConflictTheater +from game.version import CAMPAIGN_FORMAT_VERSION PERF_FRIENDLY = 0 PERF_MEDIUM = 1 @@ -26,6 +28,12 @@ class Campaign: icon_name: str authors: str description: str + + #: The revision of the campaign format the campaign was built for. We do not attempt + #: to migrate old campaigns, but this is used to show a warning in the UI when + #: selecting a campaign that is not up to date. + version: Tuple[int, int] + recommended_player_faction: str recommended_enemy_faction: str performance: Union[PERF_FRIENDLY, PERF_MEDIUM, PERF_HARD, PERF_NASA] @@ -38,11 +46,20 @@ class Campaign: data = json.load(campaign_file) sanitized_theater = data["theater"].replace(" ", "") + version_field = data.get("version", "0") + try: + version = packaging.version.parse(version_field) + except TypeError: + logging.warning( + f"Non-string campaign version in {path}. Parse may be incorrect." + ) + version = packaging.version.parse(str(version_field)) return cls( data["name"], f"Terrain_{sanitized_theater}", data.get("authors", "???"), data.get("description", ""), + (version.major, version.minor), data.get("recommended_player_faction", "USA 2005"), data.get("recommended_enemy_faction", "Russia 1990"), data.get("performance", 0), @@ -53,6 +70,34 @@ class Campaign: def load_theater(self) -> ConflictTheater: return ConflictTheater.from_json(self.path.parent, self.data) + @property + def is_out_of_date(self) -> bool: + """Returns True if this campaign is not up to date with the latest format. + + This is more permissive than is_from_future, which is sensitive to minor version + bumps (the old game definitely doesn't support the minor features added in the + new version, and the campaign may require them. However, the minor version only + indicates *optional* new features, so we do not need to mark out of date + campaigns as incompatible if they are within the same major version. + """ + return self.version[0] < CAMPAIGN_FORMAT_VERSION[0] + + @property + def is_from_future(self) -> bool: + """Returns True if this campaign is newer than the supported format.""" + return self.version > CAMPAIGN_FORMAT_VERSION + + @property + def is_compatible(self) -> bool: + """Returns True is this campaign was built for this version of the game.""" + if not self.version: + return False + if self.is_out_of_date: + return False + if self.is_from_future: + return False + return True + def load_campaigns() -> List[Campaign]: campaign_dir = Path("resources\\campaigns") @@ -71,37 +116,44 @@ def load_campaigns() -> List[Campaign]: class QCampaignItem(QStandardItem): def __init__(self, campaign: Campaign) -> None: super(QCampaignItem, self).__init__() + self.setData(campaign, QCampaignList.CampaignRole) self.setIcon(QtGui.QIcon(CONST.ICONS[campaign.icon_name])) self.setEditable(False) - self.setText(campaign.name) + if campaign.is_compatible: + name = campaign.name + else: + name = f"[INCOMPATIBLE] {campaign.name}" + self.setText(name) class QCampaignList(QListView): - def __init__(self, campaigns: List[Campaign]) -> None: + CampaignRole = Qt.UserRole + + def __init__(self, campaigns: list[Campaign], show_incompatible: bool) -> None: super(QCampaignList, self).__init__() - self.model = QStandardItemModel(self) - self.setModel(self.model) + self.campaign_model = QStandardItemModel(self) + self.setModel(self.campaign_model) self.setMinimumWidth(250) self.setMinimumHeight(350) - self.campaigns = [] + self.campaigns = campaigns self.setSelectionBehavior(QAbstractItemView.SelectItems) - self.setup_content(campaigns) + self.setup_content(show_incompatible) - def setup_content(self, campaigns: List[Campaign]) -> None: - for campaign in campaigns: - self.campaigns.append(campaign) - item = QCampaignItem(campaign) - self.model.appendRow(item) - self.setSelectedCampaign(0) - self.repaint() + @property + def selected_campaign(self) -> Campaign: + return self.currentIndex().data(QCampaignList.CampaignRole) - def setSelectedCampaign(self, row): - self.selectionModel().clearSelection() - index = self.model.index(row, 0) - if not index.isValid(): - index = self.model.index(0, 0) - self.selectionModel().setCurrentIndex(index, QItemSelectionModel.Select) - self.repaint() + def setup_content(self, show_incompatible: bool) -> None: + self.selectionModel().blockSignals(True) + try: + self.campaign_model.clear() + for campaign in self.campaigns: + if show_incompatible or campaign.is_compatible: + item = QCampaignItem(campaign) + self.campaign_model.appendRow(item) + finally: + self.selectionModel().blockSignals(False) - def clear_layout(self): - self.model.removeRows(0, self.model.rowCount()) + self.selectionModel().setCurrentIndex( + self.campaign_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select + ) diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 6eaa93d4..f2e2e0c4 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -1,18 +1,19 @@ from __future__ import unicode_literals import logging -from typing import List, Optional +from datetime import timedelta +from typing import List from PySide2 import QtGui, QtWidgets from PySide2.QtCore import QItemSelectionModel, QPoint, Qt, QDate -from PySide2.QtWidgets import QVBoxLayout, QTextEdit, QLabel +from PySide2.QtWidgets import QVBoxLayout, QTextEdit, QLabel, QCheckBox from jinja2 import Environment, FileSystemLoader, select_autoescape from game import db from game.settings import Settings from game.theater.start_generator import GameGenerator, GeneratorSettings from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar -from qt_ui.widgets.spinsliders import TenthsSpinSlider +from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner from qt_ui.windows.newgame.QCampaignList import ( Campaign, QCampaignList, @@ -30,8 +31,8 @@ jinja_env = Environment( lstrip_blocks=True, ) - DEFAULT_BUDGET = 2000 +DEFAULT_MISSION_LENGTH: timedelta = timedelta(minutes=60) class NewGameWizard(QtWidgets.QWizard): @@ -82,6 +83,9 @@ class NewGameWizard(QtWidgets.QWizard): automate_front_line_reinforcements=self.field( "automate_front_line_purchases" ), + desired_player_mission_duration=timedelta( + minutes=self.field("desired_player_mission_duration") + ), automate_aircraft_reinforcements=self.field("automate_aircraft_purchases"), supercarrier=self.field("supercarrier"), ) @@ -202,7 +206,7 @@ class FactionSelection(QtWidgets.QWizardPage): # Docs Link docsText = QtWidgets.QLabel( - 'How to create your own faction' + 'How to create your own faction' ) docsText.setAlignment(Qt.AlignCenter) docsText.setOpenExternalLinks(True) @@ -315,13 +319,22 @@ class TheaterConfiguration(QtWidgets.QWizardPage): ) # List of campaigns - campaignList = QCampaignList(campaigns) + show_incompatible_campaigns_checkbox = QCheckBox( + text="Show incompatible campaigns" + ) + show_incompatible_campaigns_checkbox.setChecked(False) + campaignList = QCampaignList( + campaigns, show_incompatible_campaigns_checkbox.isChecked() + ) + show_incompatible_campaigns_checkbox.toggled.connect( + lambda checked: campaignList.setup_content(show_incompatible=checked) + ) self.registerField("selectedCampaign", campaignList) # Faction description self.campaignMapDescription = QTextEdit("") self.campaignMapDescription.setReadOnly(True) - self.campaignMapDescription.setMaximumHeight(100) + self.campaignMapDescription.setMaximumHeight(200) self.performanceText = QTextEdit("") self.performanceText.setReadOnly(True) @@ -376,8 +389,7 @@ class TheaterConfiguration(QtWidgets.QWizardPage): template_perf = jinja_env.get_template( "campaign_performance_template_EN.j2" ) - index = campaignList.selectionModel().currentIndex().row() - campaign = campaignList.campaigns[index] + campaign = campaignList.selected_campaign self.setField("selectedCampaign", campaign) self.campaignMapDescription.setText(template.render({"campaign": campaign})) self.faction_selection.setDefaultFactions(campaign) @@ -392,9 +404,12 @@ class TheaterConfiguration(QtWidgets.QWizardPage): campaignList.selectionModel().selectionChanged.connect(on_campaign_selected) on_campaign_selected() - # Docs Link docsText = QtWidgets.QLabel( - 'How to create your own theater' + "

Want more campaigns? You can " + 'offer to help, ' + 'play a community campaign, ' + 'or create your own.' + "

" ) docsText.setAlignment(Qt.AlignCenter) docsText.setOpenExternalLinks(True) @@ -414,7 +429,8 @@ class TheaterConfiguration(QtWidgets.QWizardPage): layout = QtWidgets.QGridLayout() layout.setColumnMinimumWidth(0, 20) layout.addWidget(campaignList, 0, 0, 5, 1) - layout.addWidget(docsText, 5, 0, 1, 1) + layout.addWidget(show_incompatible_campaigns_checkbox, 5, 0, 1, 1) + layout.addWidget(docsText, 6, 0, 1, 1) layout.addWidget(self.campaignMapDescription, 0, 1, 1, 1) layout.addWidget(self.performanceText, 1, 1, 1, 1) layout.addWidget(mapSettingsGroup, 2, 1, 1, 1) @@ -422,26 +438,6 @@ class TheaterConfiguration(QtWidgets.QWizardPage): self.setLayout(layout) -class CurrencySpinner(QtWidgets.QSpinBox): - def __init__( - self, - minimum: Optional[int] = None, - maximum: Optional[int] = None, - initial: Optional[int] = None, - ) -> None: - super().__init__() - - if minimum is not None: - self.setMinimum(minimum) - if maximum is not None: - self.setMaximum(maximum) - if initial is not None: - self.setValue(initial) - - def textFromValue(self, val: int) -> str: - return f"${val}" - - class BudgetInputs(QtWidgets.QGridLayout): def __init__(self, label: str) -> None: super().__init__() @@ -544,6 +540,12 @@ class GeneratorOptions(QtWidgets.QWizardPage): self.registerField("no_player_navy", no_player_navy) no_enemy_navy = QtWidgets.QCheckBox() self.registerField("no_enemy_navy", no_enemy_navy) + desired_player_mission_duration = TimeInputs( + "Desired mission duration", DEFAULT_MISSION_LENGTH + ) + self.registerField( + "desired_player_mission_duration", desired_player_mission_duration.spinner + ) generatorLayout = QtWidgets.QGridLayout() generatorLayout.addWidget(QtWidgets.QLabel("No Aircraft Carriers"), 1, 0) @@ -556,6 +558,7 @@ class GeneratorOptions(QtWidgets.QWizardPage): generatorLayout.addWidget(no_player_navy, 4, 1) generatorLayout.addWidget(QtWidgets.QLabel("No Enemy Navy"), 5, 0) generatorLayout.addWidget(no_enemy_navy, 5, 1) + generatorLayout.addLayout(desired_player_mission_duration, 6, 0) generatorSettingsGroup.setLayout(generatorLayout) mlayout = QVBoxLayout() diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 7432c341..88101b28 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -1,31 +1,31 @@ import logging from typing import Callable -from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint -from PySide2.QtGui import QStandardItemModel, QStandardItem +from PySide2.QtCore import QItemSelectionModel, QPoint, QSize, Qt +from PySide2.QtGui import QStandardItem, QStandardItemModel from PySide2.QtWidgets import ( - QLabel, + QAbstractItemView, + QCheckBox, + QComboBox, QDialog, QGridLayout, - QListView, - QStackedLayout, - QComboBox, - QWidget, - QAbstractItemView, - QPushButton, QGroupBox, - QCheckBox, - QVBoxLayout, + QLabel, + QListView, + QPushButton, QSpinBox, + QStackedLayout, + QVBoxLayout, + QWidget, ) from dcs.forcedoptions import ForcedOptions import qt_ui.uiconstants as CONST from game.game import Game from game.infos.information import Information -from game.settings import Settings +from game.settings import Settings, AutoAtoBehavior from qt_ui.widgets.QLabeledWidget import QLabeledWidget -from qt_ui.widgets.spinsliders import TenthsSpinSlider +from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.finances.QFinancesMenu import QHorizontalSeparationLine from qt_ui.windows.settings.plugins import PluginOptionsPage, PluginsPage @@ -75,6 +75,110 @@ class CheatSettingsBox(QGroupBox): return self.base_capture_cheat_checkbox.isChecked() +class AutoAtoBehaviorSelector(QComboBox): + def __init__(self, default: AutoAtoBehavior) -> None: + super().__init__() + + for behavior in AutoAtoBehavior: + self.addItem(behavior.value, behavior) + self.setCurrentText(default.value) + + +class HqAutomationSettingsBox(QGroupBox): + def __init__(self, game: Game) -> None: + super().__init__("HQ Automation") + self.game = game + + layout = QGridLayout() + self.setLayout(layout) + + runway_repair = QCheckBox() + runway_repair.setChecked(self.game.settings.automate_runway_repair) + runway_repair.toggled.connect(self.set_runway_automation) + + layout.addWidget(QLabel("Automate runway repairs"), 0, 0) + layout.addWidget(runway_repair, 0, 1, Qt.AlignRight) + + front_line = QCheckBox() + front_line.setChecked(self.game.settings.automate_front_line_reinforcements) + front_line.toggled.connect(self.set_front_line_automation) + + layout.addWidget(QLabel("Automate front-line purchases"), 1, 0) + layout.addWidget(front_line, 1, 1, Qt.AlignRight) + + self.automate_aircraft_reinforcements = QCheckBox() + self.automate_aircraft_reinforcements.setChecked( + self.game.settings.automate_aircraft_reinforcements + ) + self.automate_aircraft_reinforcements.toggled.connect( + self.set_aircraft_automation + ) + + layout.addWidget(QLabel("Automate aircraft purchases"), 2, 0) + layout.addWidget(self.automate_aircraft_reinforcements, 2, 1, Qt.AlignRight) + + self.auto_ato_behavior = AutoAtoBehaviorSelector( + self.game.settings.auto_ato_behavior + ) + self.auto_ato_behavior.currentIndexChanged.connect(self.set_auto_ato_behavior) + layout.addWidget( + QLabel( + "Automatic package planning behavior
" + "Aircraft auto-purchase is directed by the auto-planner,
" + "so disabling auto-planning disables auto-purchase.
" + ), + 3, + 0, + ) + layout.addWidget(self.auto_ato_behavior, 3, 1) + + self.auto_ato_player_missions_asap = QCheckBox() + self.auto_ato_player_missions_asap.setChecked( + self.game.settings.auto_ato_player_missions_asap + ) + self.auto_ato_player_missions_asap.toggled.connect( + self.set_auto_ato_player_missions_asap + ) + + layout.addWidget( + QLabel("Automatically generated packages with players are scheduled ASAP"), + 4, + 0, + ) + layout.addWidget(self.auto_ato_player_missions_asap, 4, 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: + self.game.settings.automate_front_line_reinforcements = value + + def set_aircraft_automation(self, value: bool) -> None: + self.game.settings.automate_aircraft_reinforcements = value + + def set_auto_ato_behavior(self, index: int) -> None: + behavior = self.auto_ato_behavior.itemData(index) + self.game.settings.auto_ato_behavior = behavior + if behavior in (AutoAtoBehavior.Disabled, AutoAtoBehavior.Never): + self.auto_ato_player_missions_asap.setChecked(False) + self.auto_ato_player_missions_asap.setEnabled(False) + if behavior is AutoAtoBehavior.Disabled: + self.automate_aircraft_reinforcements.setChecked(False) + self.automate_aircraft_reinforcements.setEnabled(False) + else: + self.auto_ato_player_missions_asap.setEnabled(True) + self.auto_ato_player_missions_asap.setChecked( + self.game.settings.auto_ato_player_missions_asap + ) + self.automate_aircraft_reinforcements.setEnabled(True) + self.automate_aircraft_reinforcements.setChecked( + self.game.settings.automate_aircraft_reinforcements + ) + + def set_auto_ato_player_missions_asap(self, value: bool) -> None: + self.game.settings.auto_ato_player_missions_asap = value + + START_TYPE_TOOLTIP = "Selects the start type used for AI aircraft." @@ -92,7 +196,7 @@ class StartTypeComboBox(QComboBox): class QSettingsWindow(QDialog): def __init__(self, game: Game): - super(QSettingsWindow, self).__init__() + super().__init__() self.game = game self.pluginsPage = None @@ -285,6 +389,23 @@ class QSettingsWindow(QDialog): self.ext_views.setChecked(self.game.settings.external_views_allowed) self.ext_views.toggled.connect(self.applySettings) + def set_invulnerable_player_pilots(checked: bool) -> None: + self.game.settings.invulnerable_player_pilots = checked + + invulnerable_player_pilots_label = QLabel( + "Player pilots cannot be killed
" + "Aircraft are vulnerable, but the player's pilot will be
" + "returned to the squadron at the end of the mission
" + ) + + invulnerable_player_pilots_checkbox = QCheckBox() + invulnerable_player_pilots_checkbox.setChecked( + self.game.settings.invulnerable_player_pilots + ) + invulnerable_player_pilots_checkbox.toggled.connect( + set_invulnerable_player_pilots + ) + self.aiDifficultyLayout.addWidget(QLabel("Player coalition skill"), 0, 0) self.aiDifficultyLayout.addWidget( self.playerCoalitionSkill, 0, 1, Qt.AlignRight @@ -295,6 +416,10 @@ class QSettingsWindow(QDialog): self.aiDifficultyLayout.addWidget(self.enemyAASkill, 2, 1, Qt.AlignRight) self.aiDifficultyLayout.addLayout(self.player_income, 3, 0) self.aiDifficultyLayout.addLayout(self.enemy_income, 4, 0) + self.aiDifficultyLayout.addWidget(invulnerable_player_pilots_label, 5, 0) + self.aiDifficultyLayout.addWidget( + invulnerable_player_pilots_checkbox, 5, 1, Qt.AlignRight + ) self.aiDifficultySettings.setLayout(self.aiDifficultyLayout) self.difficultyLayout.addWidget(self.aiDifficultySettings) @@ -348,60 +473,28 @@ class QSettingsWindow(QDialog): general_layout.addWidget(restrict_weapons, 0, 1, Qt.AlignRight) def set_old_awec(value: bool) -> None: - self.game.settings.disable_legacy_aewc = value + self.game.settings.disable_legacy_aewc = not value old_awac = QCheckBox() - old_awac.setChecked(self.game.settings.disable_legacy_aewc) + old_awac.setChecked(not self.game.settings.disable_legacy_aewc) old_awac.toggled.connect(set_old_awec) old_awec_info = ( - "If checked, the invulnerable friendly AEW&C aircraft that begins " - "the mission in the air will not be spawned. AEW&C missions must " - "be planned in the ATO and will take time to arrive on-station." + "If checked, an invulnerable friendly AEW&C aircraft that begins the " + "mission on station will be be spawned. This behavior will be removed in a " + "future release." ) old_awac.setToolTip(old_awec_info) - old_awac_label = QLabel("Disable invulnerable, always-available AEW&C (WIP)") + old_awac_label = QLabel( + "Spawn invulnerable, always-available AEW&C aircraft (deprecated)" + ) old_awac_label.setToolTip(old_awec_info) general_layout.addWidget(old_awac_label, 1, 0) general_layout.addWidget(old_awac, 1, 1, Qt.AlignRight) - automation = QGroupBox("HQ Automation") - campaign_layout.addWidget(automation) - - automation_layout = QGridLayout() - automation.setLayout(automation_layout) - - def set_runway_automation(value: bool) -> None: - self.game.settings.automate_runway_repair = value - - def set_front_line_automation(value: bool) -> None: - self.game.settings.automate_front_line_reinforcements = value - - def set_aircraft_automation(value: bool) -> None: - self.game.settings.automate_aircraft_reinforcements = value - - runway_repair = QCheckBox() - runway_repair.setChecked(self.game.settings.automate_runway_repair) - runway_repair.toggled.connect(set_runway_automation) - - automation_layout.addWidget(QLabel("Automate runway repairs"), 0, 0) - automation_layout.addWidget(runway_repair, 0, 1, Qt.AlignRight) - - front_line = QCheckBox() - front_line.setChecked(self.game.settings.automate_front_line_reinforcements) - front_line.toggled.connect(set_front_line_automation) - - automation_layout.addWidget(QLabel("Automate front-line purchases"), 1, 0) - automation_layout.addWidget(front_line, 1, 1, Qt.AlignRight) - - aircraft = QCheckBox() - aircraft.setChecked(self.game.settings.automate_aircraft_reinforcements) - aircraft.toggled.connect(set_aircraft_automation) - - automation_layout.addWidget(QLabel("Automate aircraft purchases"), 2, 0) - automation_layout.addWidget(aircraft, 2, 1, Qt.AlignRight) + campaign_layout.addWidget(HqAutomationSettingsBox(self.game)) def initGeneratorLayout(self): self.generatorPage = QWidget() @@ -438,6 +531,14 @@ class QSettingsWindow(QDialog): "spawned immediately. AI wingmen may begin startup immediately." ) + self.desired_player_mission_duration = TimeInputs( + "Desired mission duration", + self.game.settings.desired_player_mission_duration, + ) + self.desired_player_mission_duration.spinner.valueChanged.connect( + self.applySettings + ) + self.gameplayLayout.addWidget(QLabel("Use Supercarrier Module"), 0, 0) self.gameplayLayout.addWidget(self.supercarrier, 0, 1, Qt.AlignRight) self.gameplayLayout.addWidget(QLabel("Put Objective Markers on Map"), 1, 0) @@ -450,6 +551,9 @@ class QSettingsWindow(QDialog): ) self.gameplayLayout.addWidget(dark_kneeboard_label, 2, 0) self.gameplayLayout.addWidget(self.generate_dark_kneeboard, 2, 1, Qt.AlignRight) + self.gameplayLayout.addLayout( + self.desired_player_mission_duration, 5, 0, Qt.AlignRight + ) spawn_players_immediately_tooltip = ( "Always spawns player aircraft immediately, even if their start time is " @@ -486,6 +590,12 @@ class QSettingsWindow(QDialog): self.smoke.setChecked(self.game.settings.perf_smoke_gen) self.smoke.toggled.connect(self.applySettings) + self.smoke_spacing = QSpinBox() + self.smoke_spacing.setMinimum(800) + self.smoke_spacing.setMaximum(24000) + self.smoke_spacing.setValue(self.game.settings.perf_smoke_spacing) + self.smoke_spacing.valueChanged.connect(self.applySettings) + self.red_alert = QCheckBox() self.red_alert.setChecked(self.game.settings.perf_red_alert_state) self.red_alert.toggled.connect(self.applySettings) @@ -526,18 +636,27 @@ class QSettingsWindow(QDialog): QLabel("Smoke visual effect on frontline"), 0, 0 ) self.performanceLayout.addWidget(self.smoke, 0, 1, alignment=Qt.AlignRight) - self.performanceLayout.addWidget(QLabel("SAM starts in RED alert mode"), 1, 0) - self.performanceLayout.addWidget(self.red_alert, 1, 1, alignment=Qt.AlignRight) - self.performanceLayout.addWidget(QLabel("Artillery strikes"), 2, 0) - self.performanceLayout.addWidget(self.arti, 2, 1, alignment=Qt.AlignRight) - self.performanceLayout.addWidget(QLabel("Moving ground units"), 3, 0) self.performanceLayout.addWidget( - self.moving_units, 3, 1, alignment=Qt.AlignRight + QLabel("Smoke generator spacing (higher means less smoke)"), + 1, + 0, + alignment=Qt.AlignRight, ) self.performanceLayout.addWidget( - QLabel("Generate infantry squads along vehicles"), 4, 0 + self.smoke_spacing, 1, 1, alignment=Qt.AlignRight ) - self.performanceLayout.addWidget(self.infantry, 4, 1, alignment=Qt.AlignRight) + self.performanceLayout.addWidget(QLabel("SAM starts in RED alert mode"), 2, 0) + self.performanceLayout.addWidget(self.red_alert, 2, 1, alignment=Qt.AlignRight) + self.performanceLayout.addWidget(QLabel("Artillery strikes"), 3, 0) + self.performanceLayout.addWidget(self.arti, 3, 1, alignment=Qt.AlignRight) + self.performanceLayout.addWidget(QLabel("Moving ground units"), 4, 0) + self.performanceLayout.addWidget( + self.moving_units, 4, 1, alignment=Qt.AlignRight + ) + self.performanceLayout.addWidget( + QLabel("Generate infantry squads along vehicles"), 5, 0 + ) + self.performanceLayout.addWidget(self.infantry, 5, 1, alignment=Qt.AlignRight) self.performanceLayout.addWidget( QLabel("Include destroyed units carcass"), 6, 0 ) @@ -647,8 +766,13 @@ class QSettingsWindow(QDialog): self.generate_dark_kneeboard.isChecked() ) + self.game.settings.desired_player_mission_duration = ( + self.desired_player_mission_duration.value + ) + self.game.settings.perf_red_alert_state = self.red_alert.isChecked() self.game.settings.perf_smoke_gen = self.smoke.isChecked() + self.game.settings.perf_smoke_spacing = self.smoke_spacing.value() self.game.settings.perf_artillery = self.arti.isChecked() self.game.settings.perf_moving_units = self.moving_units.isChecked() self.game.settings.perf_infantry = self.infantry.isChecked() diff --git a/requirements.txt b/requirements.txt index de19d500..f7360083 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,30 @@ altgraph==0.17 appdirs==1.4.4 -black==20.8b1 +black==21.4b0 +certifi==2020.12.5 cfgv==3.2.0 click==7.1.2 distlib==0.3.1 +Faker==8.2.1 filelock==3.0.12 future==0.18.2 identify==1.5.13 Jinja2==2.11.3 MarkupSafe==1.1.1 -mypy==0.782 +mypy==0.812 mypy-extensions==0.4.3 nodeenv==1.5.0 +packaging==20.9 pathspec==0.8.1 pefile==2019.4.18 -Pillow==8.1.1 +Pillow==8.2.0 pre-commit==2.10.1 -PyInstaller==3.6 +pyinstaller==4.3 +pyinstaller-hooks-contrib==2021.1 +pyparsing==2.4.7 +pyproj==3.0.1 PySide2==5.15.2 +python-dateutil==2.8.1 pywin32-ctypes==0.2.0 PyYAML==5.4.1 regex==2020.11.13 @@ -25,7 +32,8 @@ Shapely==1.7.1 shiboken2==5.15.2 six==1.15.0 tabulate==0.8.7 +text-unidecode==1.3 toml==0.10.2 typed-ast==1.4.2 typing-extensions==3.7.4.3 -virtualenv==20.4.2 \ No newline at end of file +virtualenv==20.4.2 diff --git a/resources/campaigns/Battle_for_the_UAE.json b/resources/campaigns/Battle_for_the_UAE.json new file mode 100644 index 00000000..87d1aa15 --- /dev/null +++ b/resources/campaigns/Battle_for_the_UAE.json @@ -0,0 +1,11 @@ +{ + "name": "Persian Gulf - Battle for the UAE", + "theater": "Persian Gulf", + "authors": "Mustang25", + "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": "6.0", + "miz": "Battle_for_the_UAE_v3.0.2.miz", + "performance": 2 +} diff --git a/resources/campaigns/Battle_for_the_UAE_v3.0.2.miz b/resources/campaigns/Battle_for_the_UAE_v3.0.2.miz new file mode 100644 index 00000000..8327345c Binary files /dev/null and b/resources/campaigns/Battle_for_the_UAE_v3.0.2.miz differ diff --git a/resources/campaigns/Caucasus_Multi_Full.json b/resources/campaigns/Caucasus_Multi_Full.json new file mode 100644 index 00000000..0ef642a3 --- /dev/null +++ b/resources/campaigns/Caucasus_Multi_Full.json @@ -0,0 +1,11 @@ +{ + "name": "Caucasus - Full", + "theater": "Caucasus", + "authors": "Doc_of_Mur", + "recommended_player_faction": "Bluefor Modern", + "recommended_enemy_faction": "Russia 2010", + "description": "

This is a complete map of every airbase in the Caucasus Region, all bases are fully defended by Air, Land and/or Sea. The player starts by invading southern Georgia and works their way through Russia. The Strike and SAM targets are limited for performance reasons. If this Scenario is too taxing for your computer you may use the Multi-Part Scenarios. They are copied from this Campaign and are catered toward less powerful machines.

", + "version": "6.0", + "miz": "Caucasus_Multi_Full.miz", + "performance": 3 +} \ No newline at end of file diff --git a/resources/campaigns/Caucasus_Multi_Full.miz b/resources/campaigns/Caucasus_Multi_Full.miz new file mode 100644 index 00000000..6048cc34 Binary files /dev/null and b/resources/campaigns/Caucasus_Multi_Full.miz differ diff --git a/resources/campaigns/Caucasus_Multi_Georgia.json b/resources/campaigns/Caucasus_Multi_Georgia.json new file mode 100644 index 00000000..c10a6e9c --- /dev/null +++ b/resources/campaigns/Caucasus_Multi_Georgia.json @@ -0,0 +1,11 @@ +{ + "name": "Caucasus - Multi-Part Georgia", + "theater": "Caucasus", + "authors": "Doc_of_Mur", + "recommended_player_faction": "Bluefor Modern", + "recommended_enemy_faction": "Georgia 2008", + "description": "

This is Part 1 of the Caucasus Multi-Part Campaign. This is the invasion of Georgia starting from the southwest (Batumi) and ending in both Gudauta and Tiblisi. This is a straightforward campaign that is smaller and simpler than most. However, it acts great as either a stand alone campaign for beginners, or as a lead into the Caucasus Multi-Part Russia campaign.

", + "version": "6.0", + "miz": "Caucasus_Multi_Georgia.miz", + "performance": 1 +} \ No newline at end of file diff --git a/resources/campaigns/Caucasus_Multi_Georgia.miz b/resources/campaigns/Caucasus_Multi_Georgia.miz new file mode 100644 index 00000000..acfc57c8 Binary files /dev/null and b/resources/campaigns/Caucasus_Multi_Georgia.miz differ diff --git a/resources/campaigns/Caucasus_Multi_Russia.json b/resources/campaigns/Caucasus_Multi_Russia.json new file mode 100644 index 00000000..09904d35 --- /dev/null +++ b/resources/campaigns/Caucasus_Multi_Russia.json @@ -0,0 +1,11 @@ +{ + "name": "Caucasus - Multi-Part Russia", + "theater": "Caucasus", + "authors": "Doc_of_Mur", + "recommended_player_faction": "Bluefor Modern", + "recommended_enemy_faction": "Russia 2010", + "description": "

This is part 2 of the Caucasus Multi-part campaign. After completing Multi-Part Georgia, play this campaign to invade Russia and finish the theater. As this is now Russia the recommended enemy faction has changed. To simulate still owning Georgia the player income has been supplemented through an increased number of blue strike targets at the starting bases. This is a more difficult scenario with a higher concentration of Redfor SAMs and Strike targets than usual.

", + "version": "6.0", + "miz": "Caucasus_Multi_Russia.miz", + "performance": 2 +} \ No newline at end of file diff --git a/resources/campaigns/Caucasus_Multi_Russia.miz b/resources/campaigns/Caucasus_Multi_Russia.miz new file mode 100644 index 00000000..fb6704c1 Binary files /dev/null and b/resources/campaigns/Caucasus_Multi_Russia.miz differ diff --git a/resources/campaigns/First_Lebanon_War.json b/resources/campaigns/First_Lebanon_War.json new file mode 100644 index 00000000..07fde1a9 --- /dev/null +++ b/resources/campaigns/First_Lebanon_War.json @@ -0,0 +1,11 @@ +{ + "name": "Syria - First Lebanon War", + "theater": "Syria", + "authors": "Mustang25", + "recommended_player_faction": "Israel 1982", + "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": "6.0", + "performance": 2 + } diff --git a/resources/campaigns/First_Lebanon_War_v3.0.2.miz b/resources/campaigns/First_Lebanon_War_v3.0.2.miz new file mode 100644 index 00000000..2c1c85bc Binary files /dev/null and b/resources/campaigns/First_Lebanon_War_v3.0.2.miz differ diff --git a/resources/campaigns/Operation_Mole_Cricket_2010.json b/resources/campaigns/Operation_Mole_Cricket_2010.json new file mode 100644 index 00000000..cf50a1b0 --- /dev/null +++ b/resources/campaigns/Operation_Mole_Cricket_2010.json @@ -0,0 +1,11 @@ +{ + "name": "Syria - Operation Mole Cricket 2010", + "theater": "Syria", + "authors": "Mustang25", + "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": "6.0", + "miz": "Operation_Mole_Cricket_2010_v3.0.2.miz", + "performance": 2 +} diff --git a/resources/campaigns/Operation_Mole_Cricket_2010_v3.0.2.miz b/resources/campaigns/Operation_Mole_Cricket_2010_v3.0.2.miz new file mode 100644 index 00000000..6fd3d14c Binary files /dev/null and b/resources/campaigns/Operation_Mole_Cricket_2010_v3.0.2.miz differ diff --git a/resources/campaigns/Road_to_Dubai.json b/resources/campaigns/Road_to_Dubai.json new file mode 100644 index 00000000..d0d7e9e8 --- /dev/null +++ b/resources/campaigns/Road_to_Dubai.json @@ -0,0 +1,11 @@ +{ + "name": "Persian Gulf - Road To Dubai", + "theater": "Persian Gulf", + "authors": "Doc_of_Mur", + "description": "

Small beginner friendly map

Note: This scenario is based around Iran invading the UAE and you are trying to take it back. It is small and beginner friendly.

", + "version": "6.0", + "recommended_player_faction": "USA 2005", + "recommended_enemy_faction": "Iran 2015", + "miz": "Road_to_Dubai.miz", + "performance": 1 +} \ No newline at end of file diff --git a/resources/campaigns/Road_to_Dubai.miz b/resources/campaigns/Road_to_Dubai.miz new file mode 100644 index 00000000..4ec69749 Binary files /dev/null and b/resources/campaigns/Road_to_Dubai.miz differ diff --git a/resources/campaigns/around_the_mountain.json b/resources/campaigns/around_the_mountain.json new file mode 100644 index 00000000..5578e551 --- /dev/null +++ b/resources/campaigns/around_the_mountain.json @@ -0,0 +1,11 @@ +{ + "name": "Caucasus - Around The Mountain", + "theater": "Caucasus", + "authors": "Dillie", + "recommended_player_faction": "Russia 2010", + "recommended_enemy_faction": "USA 1990", + "description": "

Scenario from Russia to Georgia in two Frontlines.

", + "version": "6.0", + "miz": "around_the_mountain.miz", + "performance": 2 +} \ No newline at end of file diff --git a/resources/campaigns/around_the_mountain.miz b/resources/campaigns/around_the_mountain.miz new file mode 100644 index 00000000..9a1774bd Binary files /dev/null and b/resources/campaigns/around_the_mountain.miz differ diff --git a/resources/campaigns/battle_of_abu_dhabi.json b/resources/campaigns/battle_of_abu_dhabi.json new file mode 100644 index 00000000..a551607b --- /dev/null +++ b/resources/campaigns/battle_of_abu_dhabi.json @@ -0,0 +1,11 @@ +{ + "name": "Persian Gulf - Battle of Abu Dhabi", + "theater": "Persian Gulf", + "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.

", + "miz": "battle_of_abu_dhabi.miz", + "performance": 2, + "version": "6.0" +} \ No newline at end of file diff --git a/resources/campaigns/battle_of_abu_dhabi.miz b/resources/campaigns/battle_of_abu_dhabi.miz new file mode 100644 index 00000000..3633d627 Binary files /dev/null and b/resources/campaigns/battle_of_abu_dhabi.miz differ diff --git a/resources/campaigns/battle_of_britain.json b/resources/campaigns/battle_of_britain.json deleted file mode 100644 index 1eb7fb74..00000000 --- a/resources/campaigns/battle_of_britain.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "The Channel - Battle of Britain", - "theater": "The Channel", - "authors": "Khopa", - "recommended_player_faction": "United Kingdom 1944", - "recommended_enemy_faction": "Germany 1942", - "description": "

Experience the Battle of Britain on the Channel map !

Note: It is not possible to cross the channel to capture enemy bases yet, but you can consider you won if you manage to destroy all the ennemy targets

", - "miz": "battle_of_britain.miz", - "performance": 1 -} \ No newline at end of file diff --git a/resources/campaigns/battle_of_britain.miz b/resources/campaigns/battle_of_britain.miz deleted file mode 100644 index 754e3f7a..00000000 Binary files a/resources/campaigns/battle_of_britain.miz and /dev/null differ diff --git a/resources/campaigns/black_sea.json b/resources/campaigns/black_sea.json index 83cbe20b..29217f5c 100644 --- a/resources/campaigns/black_sea.json +++ b/resources/campaigns/black_sea.json @@ -4,5 +4,6 @@ "authors": "Colonel Panic", "description": "

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

", "miz": "black_sea.miz", - "performance": 2 + "performance": 2, + "version": "6.0" } \ No newline at end of file diff --git a/resources/campaigns/black_sea.miz b/resources/campaigns/black_sea.miz index c77e79ad..e32a1fb2 100644 Binary files a/resources/campaigns/black_sea.miz and b/resources/campaigns/black_sea.miz differ diff --git a/resources/campaigns/black_sea_lite.json b/resources/campaigns/black_sea_lite.json deleted file mode 100644 index 08fddc25..00000000 --- a/resources/campaigns/black_sea_lite.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "Caucasus - Black Sea Lite", - "theater": "Caucasus", - "authors": "Starfire", - "description": "

A Small sized theater with bases along the coast of the Black Sea, lite version of ColonelPanic's Black Sea campaign scenario.

", - "miz": "black_sea_lite.miz", - "performance": 1 -} \ No newline at end of file diff --git a/resources/campaigns/black_sea_lite.miz b/resources/campaigns/black_sea_lite.miz deleted file mode 100644 index 71a6a285..00000000 Binary files a/resources/campaigns/black_sea_lite.miz and /dev/null differ diff --git a/resources/campaigns/caen_to_evreux.json b/resources/campaigns/caen_to_evreux.json new file mode 100644 index 00000000..5776979e --- /dev/null +++ b/resources/campaigns/caen_to_evreux.json @@ -0,0 +1,11 @@ +{ + "name": "Normandy - From Caen to Evreux", + "theater": "Normandy", + "authors": "Khopa", + "recommended_player_faction": "Allies 1944", + "recommended_enemy_faction": "Germany 1944", + "description": "

This is a light scenario on the Normandy map.

August 1944, allied forces are pushing from Caen/Carpiquet to the cities of Lisieux and Evreux.

Lisieux is an important logistic hub for the Werhmacht, and Evreux airbase is hosting most of the Luftwaffe forces in the region.

", + "miz": "caen_to_evreux.miz", + "performance": 1, + "version": "6.0" +} diff --git a/resources/campaigns/caen_to_evreux.miz b/resources/campaigns/caen_to_evreux.miz new file mode 100644 index 00000000..a0f321ab Binary files /dev/null and b/resources/campaigns/caen_to_evreux.miz differ diff --git a/resources/campaigns/desert_war.json b/resources/campaigns/desert_war.json deleted file mode 100644 index 2c076f9d..00000000 --- a/resources/campaigns/desert_war.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Persian Gulf - Desert War", - "theater": "Persian Gulf", - "authors": "Khopa", - "recommended_player_faction": "USA 2005", - "recommended_enemy_faction": "Iran 2015", - "description": "

This is a simple scenario in the Desert near Dubai and Abu-Dhabi. Progress from Liwa airbase to Al-Minhad.

This scenario shouldn't require too much performance.

", - "miz": "desert_war.miz", - "performance": 1 -} \ No newline at end of file diff --git a/resources/campaigns/desert_war.miz b/resources/campaigns/desert_war.miz deleted file mode 100644 index fedbf10a..00000000 Binary files a/resources/campaigns/desert_war.miz and /dev/null differ diff --git a/resources/campaigns/dunkirk.json b/resources/campaigns/dunkirk.json deleted file mode 100644 index 47859eab..00000000 --- a/resources/campaigns/dunkirk.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "The Channel - Dunkirk", - "theater": "The Channel", - "authors": "Khopa", - "recommended_player_faction": "Allies 1944", - "recommended_enemy_faction": "Germany 1942", - "description": "

In this scenario, your forces starts in Dunkirk and can be supported by the airfields on the other side of the Channel.

If you select the inverted configuration, you can play a German invasion of England.

Note: B-17 should be operated from Manston airfield

", - "miz": "dunkirk.miz", - "performance": 1 -} \ No newline at end of file diff --git a/resources/campaigns/dunkirk.miz b/resources/campaigns/dunkirk.miz deleted file mode 100644 index e722cebe..00000000 Binary files a/resources/campaigns/dunkirk.miz and /dev/null differ diff --git a/resources/campaigns/emirates.json b/resources/campaigns/emirates.json deleted file mode 100644 index 78ffc4f9..00000000 --- a/resources/campaigns/emirates.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Persian Gulf - Emirates", - "theater": "Persian Gulf", - "authors": "Khopa", - "recommended_player_faction": "USA 2005", - "recommended_enemy_faction": "Iran 2015", - "description": "

In this scenario, you can play an invasion of the Emirates and Oman, where your forces starts in Fujairah.

Note: Fujairah airfield has very few slots for aircrafts, so it recommended to operate from carriers at the start of the campaign. Thus, a carrier-capable faction is recommended.

", - "miz": "emirates.miz", - "performance": 1 -} \ No newline at end of file diff --git a/resources/campaigns/emirates.miz b/resources/campaigns/emirates.miz deleted file mode 100644 index 2dba325a..00000000 Binary files a/resources/campaigns/emirates.miz and /dev/null differ diff --git a/resources/campaigns/exercise_vegas_nerve.json b/resources/campaigns/exercise_vegas_nerve.json index 03f2b7af..010a0bb2 100644 --- a/resources/campaigns/exercise_vegas_nerve.json +++ b/resources/campaigns/exercise_vegas_nerve.json @@ -1,8 +1,9 @@ -{ - "name": "Nevada - Exercise Vegas Nerve", - "theater": "Nevada", - "authors": "Starfire", - "description": "

A Red Flag Exercise scenario for the NTTR comprising 4 control points.

", - "miz": "exercise_vegas_nerve.miz", - "performance": 0 +{ + "name": "Nevada - Exercise Vegas Nerve", + "theater": "Nevada", + "authors": "Starfire", + "description": "

A Red Flag Exercise scenario for the NTTR comprising 4 control points.

", + "version": 3, + "miz": "exercise_vegas_nerve.miz", + "performance": 0 } \ No newline at end of file diff --git a/resources/campaigns/exercise_vegas_nerve.miz b/resources/campaigns/exercise_vegas_nerve.miz index 7c0e3e25..32029cd1 100644 Binary files a/resources/campaigns/exercise_vegas_nerve.miz and b/resources/campaigns/exercise_vegas_nerve.miz differ diff --git a/resources/campaigns/full_caucasus.json b/resources/campaigns/full_caucasus.json deleted file mode 100644 index 35fdc4a5..00000000 --- a/resources/campaigns/full_caucasus.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "Caucasus - Full Map", - "theater": "Caucasus", - "authors": "george", - "description": "

Full map of the Caucasus

Note: This scenario is heavy on performance, enabling \"culling\" in settings is highly recommended.

", - "miz": "full_caucasus.miz", - "performance": 3 -} \ No newline at end of file diff --git a/resources/campaigns/full_caucasus.miz b/resources/campaigns/full_caucasus.miz deleted file mode 100644 index 3f27702c..00000000 Binary files a/resources/campaigns/full_caucasus.miz and /dev/null differ diff --git a/resources/campaigns/golan_heights.json b/resources/campaigns/golan_heights.json deleted file mode 100644 index bd6106b0..00000000 --- a/resources/campaigns/golan_heights.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Syria - Battle for Golan Heights", - "theater": "Syria", - "authors": "Khopa", - "recommended_player_faction": "Israel 2000", - "recommended_enemy_faction": "Syria 2011", - "description": "

In this scenario, you start in Israel and the conflict is focused around the golan heights, an historically disputed territory.

You can use the inverted configuration to start on the Syrian side.

If this scenario is too heavy, try the lite version.

", - "miz": "golan_heights.miz", - "performance": 2 -} \ No newline at end of file diff --git a/resources/campaigns/golan_heights.miz b/resources/campaigns/golan_heights.miz deleted file mode 100644 index 3b7e720e..00000000 Binary files a/resources/campaigns/golan_heights.miz and /dev/null differ diff --git a/resources/campaigns/golan_heights_lite.json b/resources/campaigns/golan_heights_lite.json index 70addc83..45d3c728 100644 --- a/resources/campaigns/golan_heights_lite.json +++ b/resources/campaigns/golan_heights_lite.json @@ -1,10 +1,11 @@ { - "name": "Syria - Battle for Golan Heights - Lite", + "name": "Syria - Battle for Golan Heights", "theater": "Syria", "authors": "Khopa", "recommended_player_faction": "Israel 2000", "recommended_enemy_faction": "Syria 2011", "description": "

In this scenario, you start in Israel and the conflict is focused around the golan heights, an historically disputed territory.

This scenario is designed to be performance friendly.

", "miz": "golan_heights_lite.miz", - "performance": 1 -} \ No newline at end of file + "performance": 1, + "version": "6.0" +} diff --git a/resources/campaigns/golan_heights_lite.miz b/resources/campaigns/golan_heights_lite.miz index b7ca6ab4..ed0a46e5 100644 Binary files a/resources/campaigns/golan_heights_lite.miz and b/resources/campaigns/golan_heights_lite.miz differ diff --git a/resources/campaigns/humble_helper.json b/resources/campaigns/humble_helper.json new file mode 100644 index 00000000..db119d3c --- /dev/null +++ b/resources/campaigns/humble_helper.json @@ -0,0 +1,11 @@ +{ + "name": "Syria - Humble Helper", + "theater": "Syria", + "authors": "Headiii", + "recommended_player_faction": "Israel 2012'ish", + "recommended_enemy_faction": "Syria 2012'ish", + "description": "

In this scenario, you start in Israel in an high intensity conflict with Syria, backed by a Russian Expeditiary Force. Your goal is to take the heavily fortified city of Damascus, as fast as you can. The longer you wait, the more resources the enemy can pump into the defense of the city or even might try to take chunks of Israel. ATTENTION: CAMPAIGN INVERTING IS NOT YET IMPLEMENTED!!! Feedback: @Headiii in the DCSLiberation Discord

", + "miz": "humble_helper.miz", + "performance": 1, + "version": "6.0" +} diff --git a/resources/campaigns/humble_helper.miz b/resources/campaigns/humble_helper.miz new file mode 100644 index 00000000..8053703a Binary files /dev/null and b/resources/campaigns/humble_helper.miz differ diff --git a/resources/campaigns/inherent_resolve.json b/resources/campaigns/inherent_resolve.json index 10d8d2fe..66f74d5e 100644 --- a/resources/campaigns/inherent_resolve.json +++ b/resources/campaigns/inherent_resolve.json @@ -5,6 +5,7 @@ "recommended_player_faction": "USA 2005", "recommended_enemy_faction": "Insurgents (Hard)", "description": "

In this scenario, you start from Jordan, and have to fight your way through eastern Syria.

", + "version": "6.0", "miz": "inherent_resolve.miz", - "performance": 1 + "performance": 2 } \ No newline at end of file diff --git a/resources/campaigns/inherent_resolve.miz b/resources/campaigns/inherent_resolve.miz index 64a74485..3c16bace 100644 Binary files a/resources/campaigns/inherent_resolve.miz and b/resources/campaigns/inherent_resolve.miz differ diff --git a/resources/campaigns/invasion_from_turkey.json b/resources/campaigns/invasion_from_turkey.json deleted file mode 100644 index 5610312a..00000000 --- a/resources/campaigns/invasion_from_turkey.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Syria - Invasion from Turkey", - "theater": "Syria", - "authors": "Khopa", - "recommended_player_faction": "Turkey 2005", - "recommended_enemy_faction": "Insurgents (Hard)", - "description": "

In this scenario, you start from Turkey and have to invade territories in northern Syria.

", - "miz": "invasion_from_turkey.miz", - "performance": 1 -} \ No newline at end of file diff --git a/resources/campaigns/invasion_from_turkey.miz b/resources/campaigns/invasion_from_turkey.miz deleted file mode 100644 index 20527bc5..00000000 Binary files a/resources/campaigns/invasion_from_turkey.miz and /dev/null differ diff --git a/resources/campaigns/invasion_of_iran.json b/resources/campaigns/invasion_of_iran.json deleted file mode 100644 index cb22512d..00000000 --- a/resources/campaigns/invasion_of_iran.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Persian Gulf - Invasion of Iran", - "theater": "Persian Gulf", - "authors": "Khopa", - "recommended_player_faction": "USA 2005", - "recommended_enemy_faction": "Iran 2015", - "description": "

In this scenario, you start in Bandar Abbas, and must invade Iran.

", - "miz": "invasion_of_iran.miz", - "performance": 3 -} \ No newline at end of file diff --git a/resources/campaigns/invasion_of_iran.miz b/resources/campaigns/invasion_of_iran.miz deleted file mode 100644 index ea4a51b4..00000000 Binary files a/resources/campaigns/invasion_of_iran.miz and /dev/null differ diff --git a/resources/campaigns/invasion_of_iran_[lite].json b/resources/campaigns/invasion_of_iran_[lite].json deleted file mode 100644 index fbeba09e..00000000 --- a/resources/campaigns/invasion_of_iran_[lite].json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Persian Gulf - Invasion of Iran [Lite]", - "theater": "Persian Gulf", - "authors": "Khopa", - "recommended_player_faction": "USA 2005", - "recommended_enemy_faction": "Iran 2015", - "description": "

This is lighter version of the invasion of Iran scenario.

", - "miz": "invasion_of_iran_lite.miz", - "performance": 1 -} \ No newline at end of file diff --git a/resources/campaigns/invasion_of_iran_lite.miz b/resources/campaigns/invasion_of_iran_lite.miz deleted file mode 100644 index 7791cc97..00000000 Binary files a/resources/campaigns/invasion_of_iran_lite.miz and /dev/null differ diff --git a/resources/campaigns/normandy.json b/resources/campaigns/normandy.json deleted file mode 100644 index 8a21115c..00000000 --- a/resources/campaigns/normandy.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Normandy - Normandy", - "theater": "Normandy", - "authors": "Khopa", - "recommended_player_faction": "Allies 1944", - "recommended_enemy_faction": "Germany 1944", - "description": "

Normandy 1944 D-Day scenario.

", - "miz":"normandy.miz", - "performance": 3 -} \ No newline at end of file diff --git a/resources/campaigns/normandy.miz b/resources/campaigns/normandy.miz deleted file mode 100644 index bdda71e8..00000000 Binary files a/resources/campaigns/normandy.miz and /dev/null differ diff --git a/resources/campaigns/normandy_small.json b/resources/campaigns/normandy_small.json deleted file mode 100644 index d09e9a77..00000000 --- a/resources/campaigns/normandy_small.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Normandy - Normandy Small", - "theater": "Normandy", - "authors": "Khopa", - "recommended_player_faction": "Allies 1944", - "recommended_enemy_faction": "Germany 1944", - "description": "

A lighter version of the Normandy 1944 D-Day scenario.

", - "miz": "normandy_small.miz", - "performance": 2 -} \ No newline at end of file diff --git a/resources/campaigns/north_caucasus.json b/resources/campaigns/north_caucasus.json deleted file mode 100644 index 3090b625..00000000 --- a/resources/campaigns/north_caucasus.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "name": "Caucasus - North Caucasus", - "theater": "Caucasus", - "authors": "Khopa", - "description": "

In this scenario you will have to fight in the moutain of Caucasus

Note: Running CAS in the moutains can be a bit difficult.

", - "player_points": [ - { - "type": "airbase", - "id": "Kutaisi", - "size": 600, - "importance": 1 - }, - { - "type": "airbase", - "id": "Vaziani", - "size": 600, - "importance": 1 - }, - { - "type": "carrier", - "id": 1001, - "x": -285810.6875, - "y": 496399.1875, - "captured_invert": true - }, - { - "type": "lha", - "id": 1002, - "x": -326050.6875, - "y": 519452.1875, - "captured_invert": true - } - ], - "enemy_points": [ - { - "type": "airbase", - "id": "Beslan", - "size": 1000, - "importance": 1 - }, - { - "type": "airbase", - "id": "Nalchik", - "size": 1000, - "importance": 1.1 - }, - { - "type": "airbase", - "id": "Mozdok", - "size": 2000, - "importance": 1.1 - }, - { - "type": "airbase", - "id": "Mineralnye Vody", - "size": 2000, - "importance": 1.3 - }, - { - "type": "airbase", - "id": "Maykop-Khanskaya", - "size": 3000, - "importance": 1.4, - "captured_invert": true - } - ], - "links": [ - [ - "Kutaisi", - "Vaziani" - ], - [ - "Beslan", - "Vaziani" - ], - [ - "Beslan", - "Mozdok" - ], - [ - "Beslan", - "Nalchik" - ], - [ - "Mozdok", - "Nalchik" - ], - [ - "Mineralnye Vody", - "Nalchik" - ], - [ - "Mineralnye Vody", - "Mozdok" - ], - [ - "Maykop-Khanskaya", - "Mineralnye Vody" - ] - ], - "performance": 1 -} \ No newline at end of file diff --git a/resources/campaigns/northern_russia.json b/resources/campaigns/northern_russia.json new file mode 100644 index 00000000..96f8a9c4 --- /dev/null +++ b/resources/campaigns/northern_russia.json @@ -0,0 +1,11 @@ +{ + "name": "Caucasus - Northern Russia", + "theater": "Caucasus", + "authors": "Plob", + "recommended_player_faction": "USA 2005", + "recommended_enemy_faction": "Russia 1990", + "description": "

A medium campaign through the north eastern part of the Caucasus map.

Russia has invaded Georgia through the eastern mountains. Mount a counter offense and push them back!", + "miz": "northern_russia.miz", + "performance": 2, + "version": "6.0" +} \ No newline at end of file diff --git a/resources/campaigns/northern_russia.miz b/resources/campaigns/northern_russia.miz new file mode 100644 index 00000000..b8ea54ad Binary files /dev/null and b/resources/campaigns/northern_russia.miz differ diff --git a/resources/campaigns/operation_allied_sword.json b/resources/campaigns/operation_allied_sword.json new file mode 100644 index 00000000..173a8a73 --- /dev/null +++ b/resources/campaigns/operation_allied_sword.json @@ -0,0 +1,11 @@ +{ + "name": "Syria - Operation Allied Sword", + "theater": "Syria", + "authors": "Fuzzle", + "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": "6.0", + "miz": "operation_allied_sword.miz", + "performance": 2 +} \ No newline at end of file diff --git a/resources/campaigns/operation_allied_sword.miz b/resources/campaigns/operation_allied_sword.miz new file mode 100644 index 00000000..1fbb0bb3 Binary files /dev/null and b/resources/campaigns/operation_allied_sword.miz differ diff --git a/resources/campaigns/operation_peace_spring.json b/resources/campaigns/operation_peace_spring.json new file mode 100644 index 00000000..8b01906e --- /dev/null +++ b/resources/campaigns/operation_peace_spring.json @@ -0,0 +1,11 @@ +{ + "name": "Syria - Operation Peace Spring", + "theater": "Syria", + "authors": "Starfire", + "recommended_player_faction": "Bluefor Modern", + "recommended_enemy_faction": "Turkey 2005", + "description": "

This is a semi-fictional what-if scenario for Operation Peace Spring, during which Turkish forces that crossed into Syria on an offensive against Kurdish militias were emboldened by early successes to continue pushing further southward. Attempts to broker a ceasefire have failed. Members of Operation Inherent Resolve have gathered at Ramat David Airbase in Israel to launch a counter-offensive. Campaign inversion is available if you wish to play as Turkey.

", + "version": 3, + "miz": "operation_peace_spring.miz", + "performance": 1 +} \ No newline at end of file diff --git a/resources/campaigns/operation_peace_spring.miz b/resources/campaigns/operation_peace_spring.miz new file mode 100644 index 00000000..67a01d7e Binary files /dev/null and b/resources/campaigns/operation_peace_spring.miz differ diff --git a/resources/campaigns/persian_gulf_full_map.json b/resources/campaigns/persian_gulf_full_map.json deleted file mode 100644 index 8c6e581d..00000000 --- a/resources/campaigns/persian_gulf_full_map.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "Persian Gulf - Full Map", - "theater": "Persian Gulf", - "authors": "Plob", - "description": "

Full map of the Persian Gulf

Note: This scenario is heavy on performance, enabling \"culling\" in settings is highly recommended.

", - "miz": "persian_gulf_full_map.miz", - "performance": 3 -} \ No newline at end of file diff --git a/resources/campaigns/persian_gulf_full_map.miz b/resources/campaigns/persian_gulf_full_map.miz deleted file mode 100644 index 4c25dda1..00000000 Binary files a/resources/campaigns/persian_gulf_full_map.miz and /dev/null differ diff --git a/resources/campaigns/russia_small.json b/resources/campaigns/russia_small.json index 570c70ee..49a4faab 100644 --- a/resources/campaigns/russia_small.json +++ b/resources/campaigns/russia_small.json @@ -6,5 +6,6 @@ "recommended_enemy_faction": "USA 1990", "description": "

A small theater in Russia, progress from Mozdok to Maykop.

This scenario is pretty simple, it is ideal if you want to run a short campaign. If your PC is not powerful, this is also the less performance heavy scenario.

", "miz": "russia_small.miz", - "performance": 0 -} \ No newline at end of file + "performance": 0, + "version": "6.0" +} diff --git a/resources/campaigns/russia_small.miz b/resources/campaigns/russia_small.miz index de890adb..1441ec8a 100644 Binary files a/resources/campaigns/russia_small.miz and b/resources/campaigns/russia_small.miz differ diff --git a/resources/campaigns/syrian_civil_war.json b/resources/campaigns/syrian_civil_war.json deleted file mode 100644 index 86fb6ad1..00000000 --- a/resources/campaigns/syrian_civil_war.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Syria - Syrian Civil War", - "theater": "Syria", - "authors": "Khopa", - "recommended_player_faction": "Russia 2010", - "recommended_enemy_faction": "Insurgents (Hard)", - "description": "

This scenario can be used to simulate parts of the Syrian Civil War.

You start on the coast with an airbase in Latakia, and ground forces in Tartus.

This scenario can also be used to simulate a western invasion of Syria.

In inverted configuration you start in Aleppo.

", - "miz": "syrian_civil_war.miz", - "performance": 2 -} \ No newline at end of file diff --git a/resources/campaigns/syrian_civil_war.miz b/resources/campaigns/syrian_civil_war.miz deleted file mode 100644 index 3fd34fe0..00000000 Binary files a/resources/campaigns/syrian_civil_war.miz and /dev/null differ diff --git a/resources/customized_payloads/F-14A-135-GR.lua b/resources/customized_payloads/F-14A-135-GR.lua index 9926af41..e50d9798 100644 --- a/resources/customized_payloads/F-14A-135-GR.lua +++ b/resources/customized_payloads/F-14A-135-GR.lua @@ -98,102 +98,6 @@ local unitPayloads = { }, }, [3] = { - ["name"] = "SEAD", - ["pylons"] = { - [1] = { - ["CLSID"] = "{LAU-138 wtip - AIM-9M}", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "{LAU-138 wtip - AIM-9M}", - ["num"] = 1, - }, - [3] = { - ["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}", - ["num"] = 9, - }, - [4] = { - ["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}", - ["num"] = 2, - }, - [5] = { - ["CLSID"] = "{F14-300gal}", - ["num"] = 8, - }, - [6] = { - ["CLSID"] = "{F14-300gal}", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "{BRU3242_ADM141}", - ["num"] = 7, - }, - [8] = { - ["CLSID"] = "{BRU3242_ADM141}", - ["num"] = 4, - }, - [9] = { - ["CLSID"] = "{BRU3242_ADM141}", - ["num"] = 6, - }, - [10] = { - ["CLSID"] = "{BRU3242_ADM141}", - ["num"] = 5, - }, - }, - ["tasks"] = { - [1] = 10, - }, - }, - [4] = { - ["name"] = "DEAD", - ["pylons"] = { - [1] = { - ["CLSID"] = "{LAU-138 wtip - AIM-9M}", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "{LAU-138 wtip - AIM-9M}", - ["num"] = 1, - }, - [3] = { - ["CLSID"] = "{F14-LANTIRN-TP}", - ["num"] = 9, - }, - [4] = { - ["CLSID"] = "{PHXBRU3242_2*LAU10 LS}", - ["num"] = 2, - }, - [5] = { - ["CLSID"] = "{F14-300gal}", - ["num"] = 8, - }, - [6] = { - ["CLSID"] = "{F14-300gal}", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "{BRU-32 GBU-12}", - ["num"] = 7, - }, - [8] = { - ["CLSID"] = "{BRU-32 GBU-12}", - ["num"] = 4, - }, - [9] = { - ["CLSID"] = "{BRU-32 GBU-12}", - ["num"] = 6, - }, - [10] = { - ["CLSID"] = "{BRU-32 GBU-12}", - ["num"] = 5, - }, - }, - ["tasks"] = { - [1] = 10, - }, - }, - [5] = { ["name"] = "STRIKE", ["pylons"] = { [1] = { @@ -241,7 +145,7 @@ local unitPayloads = { [1] = 10, }, }, - [6] = { + [4] = { ["name"] = "BAI", ["pylons"] = { [1] = { @@ -289,7 +193,7 @@ local unitPayloads = { [1] = 10, }, }, - [7] = { + [5] = { ["name"] = "ANTISHIP", ["pylons"] = { [1] = { @@ -337,6 +241,103 @@ local unitPayloads = { [1] = 10, }, }, + [6] = { + ["name"] = "Liberation DEAD", + ["pylons"] = { + [1] = { + ["CLSID"] = "{LAU-138 wtip - AIM-9M}", + ["num"] = 10, + }, + [2] = { + ["CLSID"] = "{LAU-138 wtip - AIM-9M}", + ["num"] = 1, + }, + [3] = { + ["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}", + ["num"] = 2, + }, + [4] = { + ["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}", + ["num"] = 9, + }, + [5] = { + ["CLSID"] = "{F14-300gal}", + ["num"] = 8, + }, + [6] = { + ["CLSID"] = "{F14-300gal}", + ["num"] = 3, + }, + [7] = { + ["CLSID"] = "{MAK79_MK82 4}", + ["num"] = 7, + }, + [8] = { + ["CLSID"] = "{MAK79_MK82 3R}", + ["num"] = 6, + }, + [9] = { + ["CLSID"] = "{MAK79_MK82 3L}", + ["num"] = 5, + }, + [10] = { + ["CLSID"] = "{MAK79_MK82 4}", + ["num"] = 4, + }, + }, + ["tasks"] = { + [1] = 31, + }, + }, + [7] = { + ["displayName"] = "Liberation SEAD", + ["name"] = "Liberation SEAD", + ["pylons"] = { + [1] = { + ["CLSID"] = "{LAU-138 wtip - AIM-9M}", + ["num"] = 10, + }, + [2] = { + ["CLSID"] = "{LAU-138 wtip - AIM-9M}", + ["num"] = 1, + }, + [3] = { + ["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}", + ["num"] = 2, + }, + [4] = { + ["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}", + ["num"] = 9, + }, + [5] = { + ["CLSID"] = "{F14-300gal}", + ["num"] = 8, + }, + [6] = { + ["CLSID"] = "{F14-300gal}", + ["num"] = 3, + }, + [7] = { + ["CLSID"] = "{BRU3242_ADM141}", + ["num"] = 7, + }, + [8] = { + ["CLSID"] = "{BRU3242_ADM141}", + ["num"] = 6, + }, + [9] = { + ["CLSID"] = "{BRU3242_ADM141}", + ["num"] = 5, + }, + [10] = { + ["CLSID"] = "{BRU3242_ADM141}", + ["num"] = 4, + }, + }, + ["tasks"] = { + [1] = 31, + }, + }, }, ["unitType"] = "F-14A-135-GR", } diff --git a/resources/customized_payloads/F-14B.lua b/resources/customized_payloads/F-14B.lua index 64cccde5..83391742 100644 --- a/resources/customized_payloads/F-14B.lua +++ b/resources/customized_payloads/F-14B.lua @@ -155,57 +155,6 @@ local unitPayloads = { }, }, [4] = { - ["name"] = "SEAD", - ["pylons"] = { - [1] = { - ["CLSID"] = "{LAU-138 wtip - AIM-9M}", - ["num"] = 10, - }, - [2] = { - ["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}", - ["num"] = 9, - }, - [3] = { - ["CLSID"] = "{F14-300gal}", - ["num"] = 8, - }, - [4] = { - ["CLSID"] = "{BRU3242_ADM141}", - ["num"] = 7, - }, - [5] = { - ["CLSID"] = "{BRU-32 GBU-12}", - ["num"] = 6, - }, - [6] = { - ["CLSID"] = "{BRU-32 GBU-12}", - ["num"] = 5, - }, - [7] = { - ["CLSID"] = "{BRU3242_ADM141}", - ["num"] = 4, - }, - [8] = { - ["CLSID"] = "{F14-300gal}", - ["num"] = 3, - }, - [9] = { - ["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}", - ["num"] = 2, - }, - [10] = { - ["CLSID"] = "{LAU-138 wtip - AIM-9M}", - ["num"] = 1, - }, - }, - ["tasks"] = { - [1] = 10, - [2] = 11, - [3] = 18, - [4] = 19, - }, - }, - [5] = { ["name"] = "ANTISHIP", ["pylons"] = { [1] = { @@ -256,6 +205,103 @@ local unitPayloads = { [4] = 19, }, }, + [5] = { + ["name"] = "Liberation DEAD", + ["pylons"] = { + [1] = { + ["CLSID"] = "{LAU-138 wtip - AIM-9M}", + ["num"] = 10, + }, + [2] = { + ["CLSID"] = "{LAU-138 wtip - AIM-9M}", + ["num"] = 1, + }, + [3] = { + ["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}", + ["num"] = 2, + }, + [4] = { + ["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}", + ["num"] = 9, + }, + [5] = { + ["CLSID"] = "{F14-300gal}", + ["num"] = 8, + }, + [6] = { + ["CLSID"] = "{F14-300gal}", + ["num"] = 3, + }, + [7] = { + ["CLSID"] = "{MAK79_MK82 4}", + ["num"] = 7, + }, + [8] = { + ["CLSID"] = "{MAK79_MK82 3R}", + ["num"] = 6, + }, + [9] = { + ["CLSID"] = "{MAK79_MK82 3L}", + ["num"] = 5, + }, + [10] = { + ["CLSID"] = "{MAK79_MK82 4}", + ["num"] = 4, + }, + }, + ["tasks"] = { + [1] = 31, + }, + }, + [6] = { + ["displayName"] = "Liberation SEAD", + ["name"] = "Liberation SEAD", + ["pylons"] = { + [1] = { + ["CLSID"] = "{LAU-138 wtip - AIM-9M}", + ["num"] = 10, + }, + [2] = { + ["CLSID"] = "{LAU-138 wtip - AIM-9M}", + ["num"] = 1, + }, + [3] = { + ["CLSID"] = "{SHOULDER AIM_54C_Mk47 L}", + ["num"] = 2, + }, + [4] = { + ["CLSID"] = "{SHOULDER AIM_54C_Mk47 R}", + ["num"] = 9, + }, + [5] = { + ["CLSID"] = "{F14-300gal}", + ["num"] = 8, + }, + [6] = { + ["CLSID"] = "{F14-300gal}", + ["num"] = 3, + }, + [7] = { + ["CLSID"] = "{BRU3242_ADM141}", + ["num"] = 7, + }, + [8] = { + ["CLSID"] = "{BRU3242_ADM141}", + ["num"] = 6, + }, + [9] = { + ["CLSID"] = "{BRU3242_ADM141}", + ["num"] = 5, + }, + [10] = { + ["CLSID"] = "{BRU3242_ADM141}", + ["num"] = 4, + }, + }, + ["tasks"] = { + [1] = 31, + }, + }, }, ["unitType"] = "F-14B", } diff --git a/resources/customized_payloads/F-15E.lua b/resources/customized_payloads/F-15E.lua index 77955c5f..2f71ae0f 100644 --- a/resources/customized_payloads/F-15E.lua +++ b/resources/customized_payloads/F-15E.lua @@ -2,71 +2,88 @@ local unitPayloads = { ["name"] = "F-15E", ["payloads"] = { [1] = { - ["name"] = "CAS", + ["displayName"] = "Liberation CAS", + ["name"] = "Liberation CAS", ["pylons"] = { [1] = { - ["CLSID"] = "{C8E06185-7CD6-4C90-959F-044679E90751}", - ["num"] = 1, + ["CLSID"] = "{444BA8AE-82A7-4345-842E-76154EFCCA46}", + ["num"] = 18, }, [2] = { + ["CLSID"] = "{444BA8AE-82A7-4345-842E-76154EFCCA46}", + ["num"] = 2, + }, + [3] = { + ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", + ["num"] = 1, + }, + [4] = { ["CLSID"] = "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}", ["num"] = 3, }, - [3] = { - ["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}", - ["num"] = 4, - }, - [4] = { - ["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}", - ["num"] = 6, - }, [5] = { - ["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}", - ["num"] = 7, + ["CLSID"] = "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}", + ["num"] = 17, }, [6] = { - ["CLSID"] = "{GBU-38}", - ["num"] = 9, + ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", + ["num"] = 19, }, [7] = { ["CLSID"] = "{E1F29B21-F291-4589-9FD8-3272EEC69506}", ["num"] = 10, }, [8] = { - ["CLSID"] = "{GBU-38}", + ["CLSID"] = "{CBU_105}", ["num"] = 11, }, [9] = { - ["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}", - ["num"] = 13, + ["CLSID"] = "{CBU_105}", + ["num"] = 9, }, [10] = { - ["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}", - ["num"] = 14, + ["CLSID"] = "{CBU_105}", + ["num"] = 8, }, [11] = { - ["CLSID"] = "{AB8B8299-F1CC-4359-89B5-2172E0CF4A5A}", - ["num"] = 16, + ["CLSID"] = "{CBU_105}", + ["num"] = 7, }, [12] = { - ["CLSID"] = "{C8E06185-7CD6-4C90-959F-044679E90751}", - ["num"] = 19, + ["CLSID"] = "{CBU_105}", + ["num"] = 12, }, [13] = { - ["CLSID"] = "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}", - ["num"] = 17, + ["CLSID"] = "{CBU_105}", + ["num"] = 13, }, [14] = { - ["CLSID"] = "{444BA8AE-82A7-4345-842E-76154EFCCA46}", - ["num"] = 18, + ["CLSID"] = "{Mk82AIR}", + ["num"] = 6, }, [15] = { - ["CLSID"] = "{444BA8AE-82A7-4345-842E-76154EFCCA46}", - ["num"] = 2, + ["CLSID"] = "{Mk82AIR}", + ["num"] = 5, + }, + [16] = { + ["CLSID"] = "{Mk82AIR}", + ["num"] = 4, + }, + [17] = { + ["CLSID"] = "{Mk82AIR}", + ["num"] = 14, + }, + [18] = { + ["CLSID"] = "{Mk82AIR}", + ["num"] = 15, + }, + [19] = { + ["CLSID"] = "{Mk82AIR}", + ["num"] = 16, }, }, ["tasks"] = { - [1] = 32, + [1] = 31, }, }, [2] = { diff --git a/resources/customized_payloads/F-16C_50.lua b/resources/customized_payloads/F-16C_50.lua index 285bccf3..8d9b3fbf 100644 --- a/resources/customized_payloads/F-16C_50.lua +++ b/resources/customized_payloads/F-16C_50.lua @@ -5,7 +5,7 @@ local unitPayloads = { ["name"] = "CAS", ["pylons"] = { [1] = { - ["CLSID"] = "{8A0BE8AE-58D4-4572-9263-3144C0D06364}", + ["CLSID"] = "", ["num"] = 5, }, [2] = { @@ -80,7 +80,7 @@ local unitPayloads = { ["num"] = 11, }, [8] = { - ["CLSID"] = "{8A0BE8AE-58D4-4572-9263-3144C0D06364}", + ["CLSID"] = "", ["num"] = 5, }, }, @@ -91,7 +91,7 @@ local unitPayloads = { ["name"] = "CAP", ["pylons"] = { [1] = { - ["CLSID"] = "{8A0BE8AE-58D4-4572-9263-3144C0D06364}", + ["CLSID"] = "", ["num"] = 5, }, [2] = { @@ -166,7 +166,7 @@ local unitPayloads = { ["num"] = 1, }, [9] = { - ["CLSID"] = "{8A0BE8AE-58D4-4572-9263-3144C0D06364}", + ["CLSID"] = "", ["num"] = 5, }, [10] = { @@ -197,7 +197,7 @@ local unitPayloads = { ["num"] = 3, }, [5] = { - ["CLSID"] = "{8A0BE8AE-58D4-4572-9263-3144C0D06364}", + ["CLSID"] = "", ["num"] = 5, }, [6] = { @@ -220,6 +220,55 @@ local unitPayloads = { ["tasks"] = { }, }, + [6] = { + ["displayName"] = "Liberation DEAD", + ["name"] = "Liberation DEAD", + ["pylons"] = { + [1] = { + ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", + ["num"] = 1, + }, + [2] = { + ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", + ["num"] = 2, + }, + [3] = { + ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", + ["num"] = 9, + }, + [4] = { + ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", + ["num"] = 8, + }, + [5] = { + ["CLSID"] = "{5335D97A-35A5-4643-9D9B-026C75961E52}", + ["num"] = 7, + }, + [6] = { + ["CLSID"] = "{F376DBEE-4CAE-41BA-ADD9-B2910AC95DEC}", + ["num"] = 6, + }, + [7] = { + ["CLSID"] = "{5335D97A-35A5-4643-9D9B-026C75961E52}", + ["num"] = 3, + }, + [8] = { + ["CLSID"] = "{F376DBEE-4CAE-41BA-ADD9-B2910AC95DEC}", + ["num"] = 4, + }, + [9] = { + ["CLSID"] = "{A111396E-D3E8-4b9c-8AC9-2432489304D5}", + ["num"] = 11, + }, + [10] = { + ["CLSID"] = "", + ["num"] = 5, + }, + }, + ["tasks"] = { + [1] = 31, + }, + }, }, ["unitType"] = "F-16C_50", } diff --git a/resources/customized_payloads/FA-18C_hornet.lua b/resources/customized_payloads/FA-18C_hornet.lua index f3020cef..3865947f 100644 --- a/resources/customized_payloads/FA-18C_hornet.lua +++ b/resources/customized_payloads/FA-18C_hornet.lua @@ -2,39 +2,39 @@ local unitPayloads = { ["name"] = "FA-18C_hornet", ["payloads"] = { [1] = { - ["name"] = "CAS MAVERICK F", + ["name"] = "Liberation BARCAP", ["pylons"] = { [1] = { - ["CLSID"] = "LAU_117_AGM_65F", - ["num"] = 7, + ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", + ["num"] = 9, }, [2] = { - ["CLSID"] = "LAU_117_AGM_65F", - ["num"] = 8, - }, - [3] = { - ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", - ["num"] = 6, - }, - [4] = { - ["CLSID"] = "{AN_ASQ_228}", - ["num"] = 4, - }, - [5] = { - ["CLSID"] = "LAU_117_AGM_65F", - ["num"] = 3, - }, - [6] = { - ["CLSID"] = "LAU_117_AGM_65F", - ["num"] = 2, - }, - [7] = { ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", ["num"] = 1, }, + [3] = { + ["CLSID"] = "{FPU_8A_FUEL_TANK}", + ["num"] = 3, + }, + [4] = { + ["CLSID"] = "{FPU_8A_FUEL_TANK}", + ["num"] = 7, + }, + [5] = { + ["CLSID"] = "LAU-115_2*LAU-127_AIM-120C", + ["num"] = 8, + }, + [6] = { + ["CLSID"] = "LAU-115_2*LAU-127_AIM-120C", + ["num"] = 2, + }, + [7] = { + ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", + ["num"] = 6, + }, [8] = { - ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", - ["num"] = 9, + ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", + ["num"] = 4, }, }, ["tasks"] = { @@ -42,90 +42,50 @@ local unitPayloads = { }, }, [2] = { - ["name"] = "CAS MAVERICK E", + ["name"] = "Liberation CAS", ["pylons"] = { [1] = { - ["CLSID"] = "{F16A4DE0-116C-4A71-97F0-2CF85B0313EC}", - ["num"] = 7, + ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", + ["num"] = 9, }, [2] = { - ["CLSID"] = "{F16A4DE0-116C-4A71-97F0-2CF85B0313EC}", - ["num"] = 8, + ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", + ["num"] = 1, }, [3] = { - ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", - ["num"] = 6, + ["CLSID"] = "LAU_117_AGM_65F", + ["num"] = 2, }, [4] = { + ["CLSID"] = "LAU_117_AGM_65F", + ["num"] = 8, + }, + [5] = { + ["CLSID"] = "LAU_117_AGM_65F", + ["num"] = 7, + }, + [6] = { + ["CLSID"] = "LAU_117_AGM_65F", + ["num"] = 3, + }, + [7] = { ["CLSID"] = "{AN_ASQ_228}", ["num"] = 4, }, - [5] = { - ["CLSID"] = "{F16A4DE0-116C-4A71-97F0-2CF85B0313EC}", - ["num"] = 3, - }, - [6] = { - ["CLSID"] = "{F16A4DE0-116C-4A71-97F0-2CF85B0313EC}", - ["num"] = 2, - }, - [7] = { - ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", - ["num"] = 1, - }, [8] = { - ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", - ["num"] = 9, - }, - }, - ["tasks"] = { - [1] = 11, - }, - }, - [3] = { - ["name"] = "CAP HEAVY", - ["pylons"] = { - [1] = { - ["CLSID"] = "LAU-115_2*LAU-127_AIM-120C", - ["num"] = 7, - }, - [2] = { - ["CLSID"] = "LAU-115_2*LAU-127_AIM-120C", - ["num"] = 8, - }, - [3] = { ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", ["num"] = 6, }, - [4] = { - ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", - ["num"] = 4, - }, - [5] = { + [9] = { ["CLSID"] = "{FPU_8A_FUEL_TANK}", ["num"] = 5, }, - [6] = { - ["CLSID"] = "LAU-115_2*LAU-127_AIM-120C", - ["num"] = 3, - }, - [7] = { - ["CLSID"] = "LAU-115_2*LAU-127_AIM-120C", - ["num"] = 2, - }, - [8] = { - ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", - ["num"] = 1, - }, - [9] = { - ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", - ["num"] = 9, - }, }, ["tasks"] = { - [1] = 11, + [1] = 31, }, }, - [4] = { + [3] = { ["name"] = "STRIKE", ["pylons"] = { [1] = { @@ -165,7 +125,7 @@ local unitPayloads = { [1] = 11, }, }, - [5] = { + [4] = { ["name"] = "ANTISHIP", ["pylons"] = { [1] = { @@ -205,16 +165,17 @@ local unitPayloads = { [1] = 11, }, }, - [6] = { - ["name"] = "SEAD", + [5] = { + ["displayName"] = "Liberation SEAD", + ["name"] = "Liberation SEAD", ["pylons"] = { [1] = { - ["CLSID"] = "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}", - ["num"] = 7, + ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", + ["num"] = 9, }, [2] = { - ["CLSID"] = "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}", - ["num"] = 8, + ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", + ["num"] = 1, }, [3] = { ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", @@ -226,30 +187,26 @@ local unitPayloads = { }, [5] = { ["CLSID"] = "{FPU_8A_FUEL_TANK}", - ["num"] = 5, + ["num"] = 3, }, [6] = { - ["CLSID"] = "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}", - ["num"] = 3, + ["CLSID"] = "{FPU_8A_FUEL_TANK}", + ["num"] = 7, }, [7] = { ["CLSID"] = "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}", ["num"] = 2, }, [8] = { - ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", - ["num"] = 1, - }, - [9] = { - ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", - ["num"] = 9, + ["CLSID"] = "{B06DD79A-F21E-4EB9-BD9D-AB3844618C93}", + ["num"] = 8, }, }, ["tasks"] = { - [1] = 11, + [1] = 29, }, }, - [7] = { + [6] = { ["name"] = "RUNWAY_ATTACK", ["pylons"] = { [1] = { @@ -293,6 +250,46 @@ local unitPayloads = { [1] = 34, }, }, + [7] = { + ["name"] = "Liberation DEAD", + ["pylons"] = { + [1] = { + ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", + ["num"] = 9, + }, + [2] = { + ["CLSID"] = "{5CE2FF2A-645A-4197-B48D-8720AC69394F}", + ["num"] = 1, + }, + [3] = { + ["CLSID"] = "{40EF17B7-F508-45de-8566-6FFECC0C1AB8}", + ["num"] = 6, + }, + [4] = { + ["CLSID"] = "{FPU_8A_FUEL_TANK}", + ["num"] = 7, + }, + [5] = { + ["CLSID"] = "{FPU_8A_FUEL_TANK}", + ["num"] = 3, + }, + [6] = { + ["CLSID"] = "{BRU55_2*AGM-154A}", + ["num"] = 8, + }, + [7] = { + ["CLSID"] = "{BRU55_2*AGM-154A}", + ["num"] = 2, + }, + [8] = { + ["CLSID"] = "{AN_ASQ_228}", + ["num"] = 4, + }, + }, + ["tasks"] = { + [1] = 11, + }, + }, }, ["tasks"] = { }, diff --git a/resources/customized_payloads/JAS39Gripen.lua b/resources/customized_payloads/JAS39Gripen.lua new file mode 100644 index 00000000..8f963be7 --- /dev/null +++ b/resources/customized_payloads/JAS39Gripen.lua @@ -0,0 +1,54 @@ +local unitPayloads = { + ["name"] = "JAS39Gripen", + ["payloads"] = { + [1] = { + ["displayName"] = "CAP", + ["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", + ["num"] = 8, + }, + [6] = { + ["CLSID"] = "JAS_Meteor", + ["num"] = 3, + }, + [7] = { + ["CLSID"] = "JAS_TANK1100", + ["num"] = 5, + }, + [8] = { + ["CLSID"] = "{44EE8698-89F9-48EE-AF36-5FD31896A82F}", + ["num"] = 6, + }, + [9] = { + ["CLSID"] = "{0519A264-0AB6-11d6-9193-00A0249B6F00}", + ["num"] = 4, + }, + }, + ["tasks"] = { + [1] = 11, + }, + }, + }, + ["tasks"] = { + }, + ["unitType"] = "JAS39Gripen", +} +return unitPayloads diff --git a/resources/customized_payloads/JAS39Gripen_AG.lua b/resources/customized_payloads/JAS39Gripen_AG.lua new file mode 100644 index 00000000..db687a8e --- /dev/null +++ b/resources/customized_payloads/JAS39Gripen_AG.lua @@ -0,0 +1,291 @@ +local unitPayloads = { + ["name"] = "JAS39Gripen_AG", + ["payloads"] = { + [1] = { + ["displayName"] = "ANTISHIP", + ["name"] = "ANTISHIP", + ["pylons"] = { + [1] = { + ["CLSID"] = "JAS_IRIS-T", + ["num"] = 10, + }, + [2] = { + ["CLSID"] = "JAS_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", + ["num"] = 8, + }, + [8] = { + ["CLSID"] = "JAS_RB15F", + ["num"] = 2, + }, + [9] = { + ["CLSID"] = "JAS_RB15F", + ["num"] = 9, + }, + }, + ["tasks"] = { + [1] = 19, + }, + }, + [2] = { + ["displayName"] = "SEAD", + ["name"] = "SEAD", + ["pylons"] = { + [1] = { + ["CLSID"] = "JAS_IRIS-T", + ["num"] = 10, + }, + [2] = { + ["CLSID"] = "JAS_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", + ["num"] = 8, + }, + [8] = { + ["CLSID"] = "JAS_MAR-1", + ["num"] = 2, + }, + [9] = { + ["CLSID"] = "JAS_MAR-1", + ["num"] = 9, + }, + }, + ["tasks"] = { + [1] = 19, + }, + }, + [3] = { + ["displayName"] = "DEAD", + ["name"] = "DEAD", + ["pylons"] = { + [1] = { + ["CLSID"] = "JAS_IRIS-T", + ["num"] = 10, + }, + [2] = { + ["CLSID"] = "JAS_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_Stormshadow", + ["num"] = 3, + }, + [7] = { + ["CLSID"] = "JAS_Stormshadow", + ["num"] = 8, + }, + [8] = { + ["CLSID"] = "JAS_MAR-1", + ["num"] = 2, + }, + [9] = { + ["CLSID"] = "JAS_MAR-1", + ["num"] = 9, + }, + }, + ["tasks"] = { + [1] = 19, + }, + }, + [4] = { + ["displayName"] = "CAS", + ["name"] = "CAS", + ["pylons"] = { + [1] = { + ["CLSID"] = "JAS_IRIS-T", + ["num"] = 10, + }, + [2] = { + ["CLSID"] = "JAS_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", + ["num"] = 8, + }, + [8] = { + ["CLSID"] = "JAS_BRIMSTONE", + ["num"] = 2, + }, + [9] = { + ["CLSID"] = "JAS_BRIMSTONE", + ["num"] = 9, + }, + [10] = { + ["CLSID"] = "JAS_Litening", + ["num"] = 7, + }, + }, + ["tasks"] = { + [1] = 19, + }, + }, + [5] = { + ["displayName"] = "STRIKE", + ["name"] = "STRIKE", + ["pylons"] = { + [1] = { + ["CLSID"] = "JAS_IRIS-T", + ["num"] = 10, + }, + [2] = { + ["CLSID"] = "JAS_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", + ["num"] = 8, + }, + [8] = { + ["CLSID"] = "JAS_GBU49_TV", + ["num"] = 2, + }, + [9] = { + ["CLSID"] = "JAS_GBU49_TV", + ["num"] = 9, + }, + [10] = { + ["CLSID"] = "JAS_Litening", + ["num"] = 7, + }, + }, + ["tasks"] = { + [1] = 19, + }, + }, + [6] = { + ["displayName"] = "OCA", + ["name"] = "OCA", + ["pylons"] = { + [1] = { + ["CLSID"] = "JAS_IRIS-T", + ["num"] = 10, + }, + [2] = { + ["CLSID"] = "JAS_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", + ["num"] = 8, + }, + [8] = { + ["CLSID"] = "JAS_ARAKM70BHE", + ["num"] = 2, + }, + [9] = { + ["CLSID"] = "JAS_ARAKM70BHE", + ["num"] = 9, + }, + [10] = { + ["CLSID"] = "JAS_Litening", + ["num"] = 7, + }, + }, + ["tasks"] = { + [1] = 19, + }, + }, + }, + ["tasks"] = { + }, + ["unitType"] = "JAS39Gripen_AG", +} +return unitPayloads diff --git a/resources/customized_payloads/Ju-88A4.lua b/resources/customized_payloads/Ju-88A4.lua index 6c024719..cdd5ee78 100644 --- a/resources/customized_payloads/Ju-88A4.lua +++ b/resources/customized_payloads/Ju-88A4.lua @@ -20,13 +20,21 @@ local unitPayloads = { [2] = { ["name"] = "CAS", ["pylons"] = { + [1] = { + ["CLSID"] = "SC_501_SC500", + ["num"] = 3, + }, + [2] = { + ["CLSID"] = "SC_501_SC500", + ["num"] = 1, + }, }, ["tasks"] = { [1] = 32, }, }, [3] = { - ["name"] = "STRIKE", + ["name"] = "CAP", ["pylons"] = { }, ["tasks"] = { @@ -34,8 +42,67 @@ local unitPayloads = { }, }, [4] = { - ["name"] = "CAP", + ["name"] = "STRIKE", ["pylons"] = { + [1] = { + ["CLSID"] = "SC_501_SC500", + ["num"] = 3, + }, + [2] = { + ["CLSID"] = "SC_501_SC500", + ["num"] = 1, + }, + }, + ["tasks"] = { + [1] = 32, + }, + }, + [5] = { + ["displayName"] = "RUNWAY_ATTACK", + ["name"] = "RUNWAY_ATTACK", + ["pylons"] = { + [1] = { + ["CLSID"] = "SC_501_SC500", + ["num"] = 3, + }, + [2] = { + ["CLSID"] = "SC_501_SC500", + ["num"] = 1, + }, + }, + ["tasks"] = { + [1] = 32, + }, + }, + [6] = { + ["displayName"] = "SEAD", + ["name"] = "SEAD", + ["pylons"] = { + [1] = { + ["CLSID"] = "SC_501_SC500", + ["num"] = 3, + }, + [2] = { + ["CLSID"] = "SC_501_SC500", + ["num"] = 1, + }, + }, + ["tasks"] = { + [1] = 32, + }, + }, + [7] = { + ["displayName"] = "DEAD", + ["name"] = "DEAD", + ["pylons"] = { + [1] = { + ["CLSID"] = "SC_501_SC500", + ["num"] = 3, + }, + [2] = { + ["CLSID"] = "SC_501_SC500", + ["num"] = 1, + }, }, ["tasks"] = { [1] = 32, diff --git a/resources/factions/Israel-USN_2005_Allied_Sword.json b/resources/factions/Israel-USN_2005_Allied_Sword.json new file mode 100644 index 00000000..ee7d8b67 --- /dev/null +++ b/resources/factions/Israel-USN_2005_Allied_Sword.json @@ -0,0 +1,109 @@ +{ + "country": "Israel", + "name": "Israel-USN 2005 (Allied Sword)", + "authors": "Fuzzle", + "description": "

A joint US Navy/Israeli modern faction for use with the Operation Allied Sword scenario.

", + "aircrafts": [ + "F_4E", + "F_15C", + "F_15E", + "F_16C_50", + "F_14B", + "FA_18C_hornet", + "AV8BNA", + "AH_1W", + "AH_64D", + "S_3B", + "SH_60B", + "UH_1H" + ], + "awacs": [ + "E_2C" + ], + "tankers": [ + "KC_135", + "KC130", + "S_3B_Tanker" + ], + "frontline_units": [ + "APC_M113", + "APC_HMMWV__Scout", + "ATGM_HMMWV", + "MBT_Merkava_IV", + "SPAAA_Vulcan_M163" + ], + "artillery_units": [ + "SPH_M109_Paladin_155mm", + "MLRS_M270_227mm" + ], + "logistics_units": [ + "Truck_M818_6x6" + ], + "infantry_units": [ + "Infantry_M4", + "Infantry_M249", + "MANPADS_Stinger" + ], + "air_defenses": [ + "ChaparralGenerator", + "HawkGenerator", + "VulcanGenerator", + "PatriotGenerator" + ], + "ewrs": [ + "HawkEwrGenerator" + ], + "aircraft_carrier": [ + "CVN_74_John_C__Stennis" + ], + "helicopter_carrier": [ + "LHA_1_Tarawa" + ], + "destroyers": [ + "DDG_Arleigh_Burke_IIa" + ], + "cruisers": [ + "CG_Ticonderoga" + ], + "requirements": { + }, + "carrier_names": [ + "CVN-71 Theodore Roosevelt", + "CVN-72 Abraham Lincoln", + "CVN-73 George Washington", + "CVN-74 John C. Stennis", + "CVN-75 Harry S. Truman" + ], + "helicopter_carrier_names": [ + "LHA-1 Tarawa", + "LHA-2 Saipan", + "LHA-3 Belleau Wood", + "LHA-4 Nassau", + "LHA-5 Peleliu" + ], + "navy_generators": [ + "ArleighBurkeGroupGenerator", + "OliverHazardPerryGroupGenerator" + + ], + "has_jtac": true, + "jtac_unit": "MQ_9_Reaper", + "doctrine": "modern", + "liveries_overrides": { + "F_14B": [ + "VF-142 Ghostriders" + ], + "FA_18C_hornet": [ + "VMFA-251 high visibility" + ], + "AV8BNA": [ + "VMAT-542" + ], + "AH_1W": [ + "Marines" + ], + "UH_1H": [ + "US NAVY" + ] + } +} diff --git a/resources/factions/NATO_Desert_Storm.json b/resources/factions/NATO_Desert_Storm.json index 7b9743b9..0c02a3d1 100644 --- a/resources/factions/NATO_Desert_Storm.json +++ b/resources/factions/NATO_Desert_Storm.json @@ -40,14 +40,15 @@ "IFV_M2A2_Bradley", "IFV_M1126_Stryker_ICV", "IFV_LAV_25", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV", "APC_TPz_Fuchs", "IFV_Warrior", "MBT_Challenger_II", "MBT_M60A3_Patton", "SPG_Stryker_MGS", - "SAM_Avenger__Stinger" + "SAM_Avenger__Stinger", + "ATGM_VAB_Mephisto" ], "artillery_units": [ "MLRS_M270_227mm", diff --git a/resources/factions/Syria-Lebanon_2005_Allied_Sword.json b/resources/factions/Syria-Lebanon_2005_Allied_Sword.json new file mode 100644 index 00000000..49c41677 --- /dev/null +++ b/resources/factions/Syria-Lebanon_2005_Allied_Sword.json @@ -0,0 +1,105 @@ +{ + "country": "Combined Joint Task Forces Red", + "name": "Syria-Lebanon 2005 (Allied Sword)", + "authors": "Fuzzle", + "description": "

Syria-Lebanon alliance in a modern setting with several imported Russian assets. Designed for use with the Allied Sword scenario.

", + "aircrafts": [ + "MiG_23MLD", + "MiG_25PD", + "MiG_29S", + "Su_17M4", + "Su_24M", + "Su_30", + "Su_34", + "L_39ZA", + "Tu_22M3", + "Mi_24V", + "Mi_8MT", + "SA342M", + "SA342L" + ], + "awacs": [ + "A_50" + ], + "tankers": [ + "IL_78M" + ], + "frontline_units": [ + "IFV_BMP_1", + "IFV_BMP_2", + "APC_BTR_80", + "IFV_BRDM_2", + "APC_MTLB", + "APC_Cobra__Scout", + "MBT_T_55", + "MBT_T_72B", + "MBT_T_90", + "SPAAA_ZSU_57_2" + ], + "artillery_units": [ + "MLRS_BM_27_Uragan_220mm", + "SPH_2S9_Nona_120mm_M", + "MLRS_BM_21_Grad_122mm", + "SPH_2S1_Gvozdika_122mm" + ], + "logistics_units": [ + "Truck_Ural_375", + "LUV_UAZ_469_Jeep" + ], + "infantry_units": [ + "Paratrooper_AKS", + "Infantry_AK_74_Rus", + "Paratrooper_RPG_16", + "MANPADS_SA_18_Igla_S_Grouse" + ], + "air_defenses": [ + "ColdWarFlakGenerator", + "SA2Generator", + "SA3Generator", + "SA6Generator", + "SA8Generator", + "SA9Generator", + "SA10Generator", + "SA11Generator", + "SA13Generator", + "SA19Generator", + "ZSU23Generator", + "ZU23Generator", + "ZU23UralGenerator", + "ZSU57Generator" + ], + "ewrs": [ + "BoxSpringGenerator", + "TallRackGenerator" + ], + "missiles": [ + "ScudGenerator" + ], + "missiles_group_count": 2, + "coastal_defenses": [ + "SilkwormGenerator" + ], + "coastal_group_count": 4, + "aircraft_carrier": [ + ], + "helicopter_carrier": [ + ], + "helicopter_carrier_names": [ + ], + "destroyers": [ + "Frigate_1135M_Rezky", + "Corvette_1241_1_Molniya" + ], + "cruisers": [ + ], + "requirements": {}, + "carrier_names": [ + ], + "coastal_group_count": 8, + "navy_generators": [ + "GrishaGroupGenerator", + "MolniyaGroupGenerator", + "RussianNavyGroupGenerator", + "LaCombattanteIIGroupGenerator" + ] +} diff --git a/resources/factions/allies_1940.json b/resources/factions/allies_1940.json new file mode 100644 index 00000000..1d590b00 --- /dev/null +++ b/resources/factions/allies_1940.json @@ -0,0 +1,55 @@ +{ + "country": "UK", + "name": "Allies 1940", + "authors": "Khopa", + "description": "

A generic WW2 ally factions for 1940 Battle of France or Battle of England.

", + "aircrafts": [ + "SpitfireLFMkIX", + "SpitfireLFMkIXCW", + "A_20G" + ], + "frontline_units": [ + "APC_M2A1_Halftrack", + "HIT_Churchill_VII", + "Car_Daimler_Armored", + "LT_Mk_VII_Tetrarch", + "AAA_QF_3_7", + "AAA_Bofors_40mm" + ], + "artillery_units": [ + ], + "logistics_units": [ + "Truck_Bedford", + "Truck_GMC_Jimmy_6x6_Truck" + ], + "infantry_units": [ + "Infantry_SMLE_No_4_Mk_1", + "Infantry_M1_Garand" + ], + "air_defenses": [ + "AllyWW2FlakGenerator", + "BoforsGenerator" + ], + "aircraft_carrier": [ + ], + "helicopter_carrier": [ + ], + "destroyers": [ + ], + "cruisers": [ + ], + "requirements": { + "WW2 Asset Pack": "https://www.digitalcombatsimulator.com/en/products/other/wwii_assets_pack/" + }, + "carrier_names": [ + ], + "helicopter_carrier_names": [ + ], + "navy_generators": [ + "WW2LSTGroupGenerator" + ], + "navy_group_count": 1, + "has_jtac": false, + "doctrine": "ww2", + "building_set": "ww2ally" +} diff --git a/resources/factions/allies_1944.json b/resources/factions/allies_1944.json index c8cc8d30..98f2a5ec 100644 --- a/resources/factions/allies_1944.json +++ b/resources/factions/allies_1944.json @@ -16,7 +16,7 @@ ], "frontline_units": [ "MT_M4A4_Sherman_Firefly", - "MT_M4_Sherman", + "Tk_M4_Sherman", "APC_M2A1_Halftrack", "CT_Cromwell_IV", "CT_Centaur_IV", @@ -26,7 +26,7 @@ "Car_Daimler_Armored", "LT_Mk_VII_Tetrarch", "AAA_QF_3_7", - "AAA_40mm_Bofors" + "AAA_Bofors_40mm" ], "artillery_units": [ "SPG_M12_GMC_155mm" diff --git a/resources/factions/allies_1944_free.json b/resources/factions/allies_1944_free.json index 0fce2438..7e4e7fc9 100644 --- a/resources/factions/allies_1944_free.json +++ b/resources/factions/allies_1944_free.json @@ -14,9 +14,9 @@ "A_20G" ], "frontline_units": [ - "MT_M4_Sherman", + "Tk_M4_Sherman", "APC_M2A1_Halftrack", - "AAA_40mm_Bofors" + "AAA_Bofors_40mm" ], "artillery_units": [ ], diff --git a/resources/factions/australia_2005.json b/resources/factions/australia_2005.json index 00cec1de..efca64d4 100644 --- a/resources/factions/australia_2005.json +++ b/resources/factions/australia_2005.json @@ -61,5 +61,11 @@ "ArleighBurkeGroupGenerator" ], "has_jtac": true, - "jtac_unit": "MQ_9_Reaper" + "jtac_unit": "MQ_9_Reaper", + "liveries_overrides": { + "FA_18C_hornet": [ + "Australian 75th Squadron", + "Australian 77th Squadron" + ] + } } diff --git a/resources/factions/australia_2005_c130.json b/resources/factions/australia_2005_c130.json new file mode 100644 index 00000000..ae5ecb18 --- /dev/null +++ b/resources/factions/australia_2005_c130.json @@ -0,0 +1,74 @@ +{ + "country": "Australia", + "name": "Australia 2005 (With C-130)", + "authors": "Khopa, SpaceEnthusiast", + "description": "

The Australian army in 2005.

Some units might not be accurate, but were picked to represent at best this army.

", + "aircrafts": [ + "FA_18C_hornet", + "UH_1H", + "SH_60B", + "AH_1W", + "Hercules" + ], + "awacs": [ + "E_3A" + ], + "tankers": [ + "KC_135", + "KC130" + ], + "frontline_units": [ + "MBT_M1A2_Abrams", + "MBT_Leopard_1A3", + "APC_M113", + "IFV_LAV_25", + "IFV_Warrior" + ], + "artillery_units": [ + ], + "logistics_units": [ + "Truck_M818_6x6" + ], + "infantry_units": [ + "Infantry_M4", + "Infantry_M249", + "MANPADS_Stinger" + ], + "air_defenses": [ + "HawkGenerator", + "RapierGenerator" + ], + "ewrs": [ + "HawkEwrGenerator" + ], + "aircraft_carrier": [ + ], + "helicopter_carrier": [ + "LHA_1_Tarawa" + ], + "destroyers": [ + "DDG_Arleigh_Burke_IIa" + ], + "cruisers": [ + ], + "requirements": { + "C-130J-30 Super Hercules Mod by Anubis": "https://forums.eagle.ru/topic/252075-dcs-super-hercules-mod-by-anubis/" + }, + "carrier_names": [ + ], + "helicopter_carrier_names": [ + "HMAS Canberra", + "HMAS Adelaide" + ], + "navy_generators": [ + "ArleighBurkeGroupGenerator" + ], + "has_jtac": true, + "jtac_unit": "MQ_9_Reaper", + "liveries_overrides": { + "FA_18C_hornet": [ + "Australian 75th Squadron", + "Australian 77th Squadron" + ] + } +} diff --git a/resources/factions/bluefor_modern.json b/resources/factions/bluefor_modern.json index 81e93e6e..a07ba747 100644 --- a/resources/factions/bluefor_modern.json +++ b/resources/factions/bluefor_modern.json @@ -37,16 +37,17 @@ ], "frontline_units": [ "MBT_M1A2_Abrams", - "MBT_Leopard_2", + "MBT_Leopard_2A6M", "MBT_Merkava_IV", "ATGM_Stryker", "IFV_M2A2_Bradley", "IFV_Marder", "IFV_LAV_25", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV", "SAM_Linebacker___Bradley_M6", - "SAM_Avenger__Stinger" + "SAM_Avenger__Stinger", + "ATGM_VAB_Mephisto" ], "artillery_units": [ "MLRS_M270_227mm", diff --git a/resources/factions/canada_2005.json b/resources/factions/canada_2005.json index 0775dff5..6ef74e66 100644 --- a/resources/factions/canada_2005.json +++ b/resources/factions/canada_2005.json @@ -3,6 +3,7 @@ "name": "Canada 2005", "authors": "Khopa", "description": "

Canada in the 2000s, an F/A-18C Hornet focused faction.

", + "locales": ["en_US", "fr_CA"], "aircrafts": [ "FA_18C_hornet", "UH_1H", @@ -17,7 +18,8 @@ ], "frontline_units": [ "MBT_Leopard_1A3", - "MBT_Leopard_2", + "MBT_Leopard_2A4", + "MBT_Leopard_2A6M", "IFV_LAV_25", "APC_M113", "IFV_Warrior", @@ -59,5 +61,11 @@ "ArleighBurkeGroupGenerator" ], "has_jtac": true, - "jtac_unit": "MQ_9_Reaper" + "jtac_unit": "MQ_9_Reaper", + "liveries_overrides": { + "FA_18C_hornet": [ + "Canada 409th Squadron", + "Canada 425th Squadron" + ] + } } diff --git a/resources/factions/canada_2005_c130.json b/resources/factions/canada_2005_c130.json new file mode 100644 index 00000000..6f1b7657 --- /dev/null +++ b/resources/factions/canada_2005_c130.json @@ -0,0 +1,77 @@ +{ + "country": "Canada", + "name": "Canada 2005 (With C-130)", + "authors": "Khopa, SpaceEnthusiast", + "description": "

Canada in the 2000s, an F/A-18C Hornet focused faction.

", + "locales": ["en_US", "fr_CA"], + "aircrafts": [ + "FA_18C_hornet", + "UH_1H", + "AH_1W", + "Hercules" + ], + "awacs": [ + "E_3A" + ], + "tankers": [ + "KC_135", + "KC130" + ], + "frontline_units": [ + "MBT_Leopard_1A3", + "MBT_Leopard_2A4", + "MBT_Leopard_2A6M", + "IFV_LAV_25", + "APC_M113", + "IFV_Warrior", + "SAM_Avenger__Stinger" + ], + "artillery_units": [ + ], + "logistics_units": [ + "Truck_M818_6x6" + ], + "infantry_units": [ + "Infantry_M4", + "Infantry_M249", + "MANPADS_Stinger" + ], + "air_defenses": [ + "AvengerGenerator", + "HawkGenerator" + ], + "ewrs": [ + "HawkEwrGenerator" + ], + "aircraft_carrier": [ + ], + "helicopter_carrier": [ + ], + "destroyers": [ + "DDG_Arleigh_Burke_IIa" + ], + "cruisers": [ + "CG_Ticonderoga" + ], + "requirements": { + "C-130J-30 Super Hercules Mod by Anubis": "https://forums.eagle.ru/topic/252075-dcs-super-hercules-mod-by-anubis/" + }, + "carrier_names": [ + ], + "helicopter_carrier_names": [ + ], + "navy_generators": [ + "ArleighBurkeGroupGenerator" + ], + "has_jtac": true, + "jtac_unit": "MQ_9_Reaper", + "liveries_overrides": { + "FA_18C_hornet": [ + "Canada 409th Squadron", + "Canada 425th Squadron" + ], + "Hercules": [ + "Royal Canadian AF CC-130J" + ] + } +} diff --git a/resources/factions/china_2010.json b/resources/factions/china_2010.json index 1f47c779..740b96d2 100644 --- a/resources/factions/china_2010.json +++ b/resources/factions/china_2010.json @@ -3,6 +3,7 @@ "name": "China 2010", "authors": "Khopa", "description": "

China in the late 2000s, early 2010s.

", + "locales": ["zh_CN"], "aircrafts": [ "MiG_21Bis", "Su_30", @@ -28,7 +29,7 @@ "artillery_units": [ "MLRS_9A52_Smerch_HE_300mm", "PLZ_05", - "SPH_2S9_Nona_120mm_M" + "SPM_2S9_Nona_120mm_M" ], "logistics_units": [ "Truck_Ural_375", diff --git a/resources/factions/dprk_1950_fictional.json b/resources/factions/dprk_1950_fictional.json index 29251c1b..428212d9 100644 --- a/resources/factions/dprk_1950_fictional.json +++ b/resources/factions/dprk_1950_fictional.json @@ -15,7 +15,7 @@ "tankers": [ ], "frontline_units": [ - "IFV_BRDM_2", + "Scout_BRDM_2", "Grad_MRL_FDDM__FC", "APC_MTLB", "MBT_T_55", diff --git a/resources/factions/france_1985_frenchpack.json b/resources/factions/france_1985_frenchpack.json index 86bad8bc..3d30195d 100644 --- a/resources/factions/france_1985_frenchpack.json +++ b/resources/factions/france_1985_frenchpack.json @@ -3,6 +3,7 @@ "name": "France 1985 (Frenchpack)", "authors": "Colonel Panic", "description": "

1980s French equipment using FrenchPack.

", + "locales": ["fr_FR"], "doctrine": "coldwar", "aircrafts": [ "M_2000C", @@ -23,13 +24,10 @@ "ERC_90", "TRM_2000_PAMELA", "VAB__50", - "VAB_MEPHISTO", - "VAB_T20_13", + "ATGM_VAB_Mephisto", "VAB_T20_13", "VBL__50", "VBL_AANF1", - "VBAE_CRAB", - "VBAE_CRAB_MMP", "AMX_30B2", "SAM_Roland_ADS" ], diff --git a/resources/factions/france_1995.json b/resources/factions/france_1995.json index 30d3e440..5278dd32 100644 --- a/resources/factions/france_1995.json +++ b/resources/factions/france_1995.json @@ -3,6 +3,7 @@ "name": "France 1995", "authors": "Khopa", "description": "

France in the late 90s before Rafale introduction. A Mirage-2000 centric faction choice.

", + "locales": ["fr_FR"], "aircrafts": [ "M_2000C", "Mirage_2000_5", @@ -20,11 +21,9 @@ "frontline_units": [ "MBT_Leclerc", "APC_TPz_Fuchs", - "APC_Cobra__Scout", - "ATGM_Stryker", + "Scout_Cobra", "IFV_LAV_25", - "APC_HMMWV__Scout", - "ATGM_HMMWV", + "ATGM_VAB_Mephisto", "SAM_Roland_ADS" ], "artillery_units": [ diff --git a/resources/factions/france_2005_frenchpack.json b/resources/factions/france_2005_frenchpack.json index bae4fbf3..2cfcff15 100644 --- a/resources/factions/france_2005_frenchpack.json +++ b/resources/factions/france_2005_frenchpack.json @@ -3,6 +3,7 @@ "name": "France 2005 (Frenchpack)", "authors": "HerrTom", "description": "

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

", + "locales": ["fr_FR"], "aircrafts": [ "M_2000C", "Mirage_2000_5", @@ -24,7 +25,6 @@ "ERC_90", "TRM_2000_PAMELA", "VAB__50", - "VAB_MEPHISTO", "VAB_T20_13", "VAB_T20_13", "VBL__50", @@ -33,7 +33,8 @@ "VBAE_CRAB_MMP", "AMX_30B2", "Leclerc_Serie_XXI", - "SAM_Roland_ADS" + "SAM_Roland_ADS", + "ATGM_VAB_Mephisto" ], "artillery_units": [ "MLRS_M270_227mm", diff --git a/resources/factions/gdr_1985.json b/resources/factions/gdr_1985.json index 9eee25a1..e68897bd 100644 --- a/resources/factions/gdr_1985.json +++ b/resources/factions/gdr_1985.json @@ -3,6 +3,7 @@ "name": "German Democratic Republic 1985", "authors": "Colonel Panic", "description": "

The German Democratic Republic in 1985.

", + "locales": ["de_DE"], "doctrine": "coldwar", "aircrafts": [ "MiG_21Bis", @@ -17,7 +18,7 @@ "frontline_units": [ "IFV_BMP_1", "IFV_BMP_2", - "IFV_BRDM_2", + "Scout_BRDM_2", "APC_MTLB", "MBT_T_55", "MBT_T_72B" diff --git a/resources/factions/georgia_2008.json b/resources/factions/georgia_2008.json index b1250008..f1937456 100644 --- a/resources/factions/georgia_2008.json +++ b/resources/factions/georgia_2008.json @@ -13,7 +13,7 @@ "frontline_units": [ "APC_BTR_80", "APC_MTLB", - "APC_Cobra__Scout", + "Scout_Cobra", "IFV_BMP_1", "IFV_BMP_2", "MBT_T_72B", @@ -45,6 +45,9 @@ "ZU23Generator", "ZU23UralGenerator" ], + "navy_generators": [ + "LaCombattanteIIGroupGenerator" + ], "requirements": {}, "has_jtac": true, "jtac_unit": "MQ_9_Reaper" diff --git a/resources/factions/germany_1940.json b/resources/factions/germany_1940.json new file mode 100644 index 00000000..b18211c8 --- /dev/null +++ b/resources/factions/germany_1940.json @@ -0,0 +1,56 @@ +{ + "country": "Third Reich", + "name": "Germany 1940", + "authors": "Khopa", + "description": "

Germany 1940, Early german faction for Battle of France, or Battle of England.

", + "locales": ["de_DE"], + "aircrafts": [ + "FW_190A8", + "FW_190D9", + "Bf_109K_4", + "Ju_88A4" + ], + "frontline_units": [ + "Tk_PzIV_H", + "APC_Sd_Kfz_251_Halftrack", + "IFV_Sd_Kfz_234_2_Puma", + "AAA_8_8cm_Flak_18" + ], + "artillery_units": [ + ], + "logistics_units": [ + "Truck_Opel_Blitz", + "LUV_Kubelwagen_82", + "Carrier_Sd_Kfz_7_Tractor", + "LUV_Kettenrad" + ], + "infantry_units": [ + "Infantry_Mauser_98" + ], + "air_defenses": [ + "FlakGenerator" + ], + "aircraft_carrier": [ + ], + "helicopter_carrier": [ + ], + "destroyers": [ + ], + "cruisers": [ + ], + "requirements": { + "WW2 Asset Pack": "https://www.digitalcombatsimulator.com/en/products/other/wwii_assets_pack/" + }, + "carrier_names": [ + ], + "helicopter_carrier_names": [ + ], + "navy_generators": [ + "UBoatGroupGenerator", + "SchnellbootGroupGenerator" + ], + "navy_group_count": 2, + "has_jtac": false, + "doctrine": "ww2", + "building_set": "ww2germany" +} diff --git a/resources/factions/germany_1942.json b/resources/factions/germany_1942.json index 927152d5..ee8e1a03 100644 --- a/resources/factions/germany_1942.json +++ b/resources/factions/germany_1942.json @@ -3,6 +3,7 @@ "name": "Germany 1942", "authors": "Khopa", "description": "

Germany 1942, is a faction that does not use the late war german units such as the Tiger tank, so it's a bit easier to perform CAS against them.

", + "locales": ["de_DE"], "aircrafts": [ "FW_190A8", "FW_190D9", @@ -10,14 +11,14 @@ "Ju_88A4" ], "frontline_units": [ - "MT_PzIV_H", + "Tk_PzIV_H", "APC_Sd_Kfz_251_Halftrack", "IFV_Sd_Kfz_234_2_Puma", "SPG_Jagdpanzer_IV", - "AAA_8_8cm_Flak_18" + "AAA_8_8cm_Flak_18", + "SPG_Sturmpanzer_IV_Brummbar" ], "artillery_units": [ - "SPG_Sturmpanzer_IV_Brummbar" ], "logistics_units": [ "Truck_Opel_Blitz", diff --git a/resources/factions/germany_1944.json b/resources/factions/germany_1944.json index a607adfa..f9b68cc9 100644 --- a/resources/factions/germany_1944.json +++ b/resources/factions/germany_1944.json @@ -3,6 +3,7 @@ "name": "Germany 1944", "authors": "Khopa", "description": "

Late war Germany with access to all the late-war ground units such as the Tiger and Tiger II tanks.

", + "locales": ["de_DE"], "aircrafts": [ "FW_190A8", "FW_190D9", @@ -11,7 +12,7 @@ ], "frontline_units": [ "MT_Pz_Kpfw_V_Panther_Ausf_G", - "MT_PzIV_H", + "Tk_PzIV_H", "HT_Pz_Kpfw_VI_Tiger_I", "HT_Pz_Kpfw_VI_Ausf__B_Tiger_II", "APC_Sd_Kfz_251_Halftrack", @@ -22,10 +23,10 @@ "SPG_StuG_III_Ausf__G", "SPG_StuG_IV", "AAA_8_8cm_Flak_18", - "AAA_8_8cm_Flak_41" + "AAA_8_8cm_Flak_41", + "SPG_Sturmpanzer_IV_Brummbar" ], "artillery_units": [ - "SPG_Sturmpanzer_IV_Brummbar" ], "logistics_units": [ "Truck_Opel_Blitz", diff --git a/resources/factions/germany_1944_free.json b/resources/factions/germany_1944_free.json index 6145a374..676eae2f 100644 --- a/resources/factions/germany_1944_free.json +++ b/resources/factions/germany_1944_free.json @@ -3,13 +3,14 @@ "name": "Germany 1944 (Free)", "authors": "Khopa", "description": "

WW2 germany faction that does not require the WW2 asset pack.

", + "locales": ["de_DE"], "aircrafts": [ "FW_190A8", "FW_190D9", "Bf_109K_4" ], "frontline_units": [ - "MT_PzIV_H", + "Tk_PzIV_H", "APC_Sd_Kfz_251_Halftrack", "AAA_8_8cm_Flak_18" ], diff --git a/resources/factions/germany_1990.json b/resources/factions/germany_1990.json index 0190d101..f1368613 100644 --- a/resources/factions/germany_1990.json +++ b/resources/factions/germany_1990.json @@ -3,6 +3,7 @@ "name": "Germany 1990", "authors": "Khopa", "description": "

1990s reunited Germany.

", + "locales": ["de_DE"], "aircrafts": [ "MiG_29G", "Tornado_IDS", @@ -21,7 +22,7 @@ "frontline_units": [ "APC_TPz_Fuchs", "MBT_Leopard_1A3", - "MBT_Leopard_2", + "MBT_Leopard_2A4", "IFV_Marder", "SPAAA_Gepard" ], @@ -61,7 +62,8 @@ "helicopter_carrier_names": [ ], "navy_generators": [ - "OliverHazardPerryGroupGenerator" + "OliverHazardPerryGroupGenerator", + "LaCombattanteIIGroupGenerator" ], "has_jtac": true, "jtac_unit": "MQ_9_Reaper" diff --git a/resources/factions/greece_2005.json b/resources/factions/greece_2005.json index a390525a..0356a339 100644 --- a/resources/factions/greece_2005.json +++ b/resources/factions/greece_2005.json @@ -3,6 +3,7 @@ "name": "Greece 2005", "authors": "Malakhit", "description": "

Hellenic army in the mid/late 2000s.

", + "locales": ["el_GR"], "aircrafts": [ "F_16C_50", "F_4E", @@ -15,10 +16,10 @@ "KC130" ], "frontline_units": [ - "MBT_Leopard_2", + "MBT_Leopard_2A4", "MBT_Leopard_1A3", "MBT_M60A3_Patton", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV", "APC_M113", "IFV_BMP_1" @@ -46,6 +47,9 @@ "HawkEwrGenerator", "FlatFaceGenerator" ], + "navy_generators": [ + "LaCombattanteIIGroupGenerator" + ], "has_jtac": true, "jtac_unit": "MQ_9_Reaper" -} \ No newline at end of file +} diff --git a/resources/factions/india_2010.json b/resources/factions/india_2010.json index 8ea8c8c9..b91aab32 100644 --- a/resources/factions/india_2010.json +++ b/resources/factions/india_2010.json @@ -3,6 +3,7 @@ "name": "India 2010", "authors": "Khopa", "description": "

Indian faction in the late 2000s.

", + "locales": ["en_IN"], "aircrafts": [ "Mirage_2000_5", "M_2000C", @@ -26,7 +27,7 @@ "SAM_SA_19_Tunguska_Grison" ], "artillery_units": [ - "MLRS_BM_27_Uragan_220mm", + "MLRS_9K57_Uragan_BM_27_220mm", "SPH_2S19_Msta_152mm" ], "logistics_units": [ diff --git a/resources/factions/insurgents.json b/resources/factions/insurgents.json index 773ea181..cbf7b3b3 100644 --- a/resources/factions/insurgents.json +++ b/resources/factions/insurgents.json @@ -6,9 +6,10 @@ "aircrafts": [ ], "frontline_units": [ - "APC_Cobra__Scout", + "Scout_Cobra", "APC_MTLB", - "IFV_BRDM_2", + "Scout_BRDM_2", + "LT_PT_76", "SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375" ], "artillery_units": [ diff --git a/resources/factions/insurgents_hard.json b/resources/factions/insurgents_hard.json index 086385cd..14825184 100644 --- a/resources/factions/insurgents_hard.json +++ b/resources/factions/insurgents_hard.json @@ -7,10 +7,11 @@ ], "frontline_units": [ "ATGM_HMMWV", - "APC_HMMWV__Scout", - "IFV_BRDM_2", + "Scout_HMMWV", + "Scout_BRDM_2", "APC_BTR_80", "APC_BTR_RD", + "LT_PT_76", "IFV_BMP_1", "MBT_T_55", "SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375", diff --git a/resources/factions/iran_1988.json b/resources/factions/iran_1988.json index 21fa2707..e21e1b61 100644 --- a/resources/factions/iran_1988.json +++ b/resources/factions/iran_1988.json @@ -3,6 +3,7 @@ "name": "Iran 1988", "authors": "Malakhit", "description": "

Iran at the end of the Iran-Iraq war

", + "locales": ["fa_IR"], "aircrafts": [ "MiG_21Bis", "F_4E", @@ -20,6 +21,7 @@ "APC_M113", "APC_BTR_80", "MBT_M60A3_Patton", + "MBT_Chieftain_Mk_3", "IFV_BMP_1", "SPAAA_ZSU_23_4_Shilka_Gun_Dish", "SPAAA_ZSU_57_2", @@ -75,7 +77,7 @@ ], "coastal_group_count": 2, "navy_generators": [ - "GrishaGroupGenerator", + "LaCombattanteIIGroupGenerator", "MolniyaGroupGenerator" ], "has_jtac": true, diff --git a/resources/factions/iran_2015.json b/resources/factions/iran_2015.json index 15a0771e..f6c8cdd7 100644 --- a/resources/factions/iran_2015.json +++ b/resources/factions/iran_2015.json @@ -3,6 +3,7 @@ "name": "Iran 2015", "authors": "Khopa", "description": "

Iranian 2010s faction

", + "locales": ["fa_IR"], "aircrafts": [ "MiG_21Bis", "MiG_29A", @@ -26,6 +27,7 @@ "APC_M113", "APC_BTR_80", "MBT_M60A3_Patton", + "MBT_Chieftain_Mk_3", "IFV_BMP_1", "MBT_T_72B", "SPAAA_ZSU_23_4_Shilka_Gun_Dish", @@ -85,7 +87,8 @@ "coastal_group_count": 3, "navy_generators": [ "GrishaGroupGenerator", - "MolniyaGroupGenerator" + "MolniyaGroupGenerator", + "LaCombattanteIIGroupGenerator" ], "has_jtac": true, "jtac_unit": "MQ_9_Reaper" diff --git a/resources/factions/iraq_1991.json b/resources/factions/iraq_1991.json index 25376a66..ecb9579d 100644 --- a/resources/factions/iraq_1991.json +++ b/resources/factions/iraq_1991.json @@ -29,8 +29,10 @@ "APC_MTLB", "MBT_T_55", "MBT_T_72B", + "MBT_Chieftain_Mk_3", "APC_BTR_80", - "IFV_BRDM_2", + "Scout_BRDM_2", + "LT_PT_76", "SPH_2S1_Gvozdika_122mm", "SPAAA_ZSU_57_2", "SPAAA_ZSU_23_4_Shilka_Gun_Dish" diff --git a/resources/factions/israel_1948.json b/resources/factions/israel_1948.json index 4d81443e..e6282682 100644 --- a/resources/factions/israel_1948.json +++ b/resources/factions/israel_1948.json @@ -3,6 +3,7 @@ "name": "Israel 1948", "authors": "Khopa", "description": "

Israel during the 1948 Arab-Israeli war.

", + "locales": ["he_IL"], "aircrafts": [ "SpitfireLFMkIXCW", "SpitfireLFMkIX", @@ -14,9 +15,9 @@ "frontline_units": [ "MT_M4A4_Sherman_Firefly", "APC_M2A1_Halftrack", - "MT_M4_Sherman", + "Tk_M4_Sherman", "Car_M8_Greyhound_Armored", - "AAA_40mm_Bofors" + "AAA_Bofors_40mm" ], "artillery_units": [ ], diff --git a/resources/factions/israel_1973.json b/resources/factions/israel_1973.json index da572305..6b852dd9 100644 --- a/resources/factions/israel_1973.json +++ b/resources/factions/israel_1973.json @@ -3,6 +3,7 @@ "name": "Israel 1973", "authors": "Khopa", "description": "

Israel during the 1973 Yom Kippur War.

", + "locales": ["he_IL"], "aircrafts": [ "F_4E", "A_4E_C", @@ -16,7 +17,7 @@ "KC130" ], "frontline_units": [ - "MT_M4_Sherman", + "Tk_M4_Sherman", "APC_M2A1_Halftrack", "MBT_M60A3_Patton", "APC_M113", diff --git a/resources/factions/israel_1982.json b/resources/factions/israel_1982.json index a938461a..2d8323d0 100644 --- a/resources/factions/israel_1982.json +++ b/resources/factions/israel_1982.json @@ -3,6 +3,7 @@ "name": "Israel 1982", "authors": "Khopa", "description": "

Israel during the 1982 war with Lebanon.

", + "locales": ["he_IL"], "aircrafts": [ "F_4E", "A_4E_C", diff --git a/resources/factions/israel_2000.json b/resources/factions/israel_2000.json index 5d8d5b60..57d2c842 100644 --- a/resources/factions/israel_2000.json +++ b/resources/factions/israel_2000.json @@ -3,6 +3,7 @@ "name": "Israel 2000", "authors": "Khopa", "description": "

Modern Israeli faction.

", + "locales": ["he_IL"], "aircrafts": [ "F_4E", "F_15C", @@ -21,7 +22,7 @@ ], "frontline_units": [ "APC_M113", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV", "MBT_Merkava_IV", "SPAAA_Vulcan_M163" diff --git a/resources/factions/israel_2012.json b/resources/factions/israel_2012.json new file mode 100644 index 00000000..19512551 --- /dev/null +++ b/resources/factions/israel_2012.json @@ -0,0 +1,100 @@ +{ + "country": "Israel", + "name": "Israel 2012'ish", + "authors": "Headiii", + "description": "

A more modern Israeli faction with fictional Imports.

", + "locales": ["he_IL"], + "aircrafts": [ + "A_10C", + "F_15C", + "F_15E", + "F_16C_50", + "FA_18C_hornet", + "M_2000C", + "UH_1H", + "AH_64D" + ], + "awacs": [ + "E_2C" + ], + "tankers": [ + "KC_135", + "KC130" + ], + "frontline_units": [ + "APC_M113", + "Scout_HMMWV", + "ATGM_HMMWV", + "MBT_Merkava_IV", + "SPAAA_Vulcan_M163" + ], + "artillery_units": [ + "SPH_M109_Paladin_155mm", + "MLRS_M270_227mm" + ], + "logistics_units": [ + "Truck_M818_6x6" + ], + "infantry_units": [ + "Infantry_M4", + "Infantry_M249", + "MANPADS_Stinger" + ], + "air_defenses": [ + "ChaparralGenerator", + "HawkGenerator", + "PatriotGenerator", + "VulcanGenerator" + ], + "ewrs": [ + "HawkEwrGenerator" + ], + "aircraft_carrier": [ + ], + "helicopter_carrier": [ + ], + "destroyers": [ + ], + "cruisers": [ + ], + "requirements": { + }, + "carrier_names": [ + ], + "helicopter_carrier_names": [ + ], + "navy_generators": [ + "ArleighBurkeGroupGenerator" + ], + "has_jtac": true, + "jtac_unit": "MQ_9_Reaper", + "liveries_overrides": { + "A_10C": [ + "Fictional Israel 115 Sqn Flying Dragon" + ], + "F_15C": [ + "390th Fighter SQN" + ], + "F_15E":[ + "IDF No 69 Hammers Squadron" + ], + "F_16C_50": [ + "IAF_101st_squadron", + "IAF_110th_Squadron", + "IAF_115th_Aggressors_Squadron", + "IAF_117th_Squadron" + ], + "UH_1H": [ + "Israel Army" + ], + "AH_64D":[ + "ah-64_d_isr" + ], + "FA_18C_hornet": [ + "Fictional Israel Air Force" + ], + "M_2000C": [ + "UAE Air Force" + ] + } +} diff --git a/resources/factions/italy_1990.json b/resources/factions/italy_1990.json index cacea593..20618885 100644 --- a/resources/factions/italy_1990.json +++ b/resources/factions/italy_1990.json @@ -3,6 +3,7 @@ "name": "Italy 1990", "authors": "Khopa", "description": "

Italy in the 90s.

", + "locales": ["it_IT"], "aircrafts": [ "Tornado_IDS", "AV8BNA", diff --git a/resources/factions/italy_1990_mb339.json b/resources/factions/italy_1990_mb339.json index f658078b..7700704e 100644 --- a/resources/factions/italy_1990_mb339.json +++ b/resources/factions/italy_1990_mb339.json @@ -3,6 +3,7 @@ "name": "Italy 1990 (With MB339)", "authors": "Khopa", "description": "

Italy in the 90s, with the MB339 mod.

", + "locales": ["it_IT"], "aircrafts": [ "Tornado_IDS", "AV8BNA", diff --git a/resources/factions/japan_2005.json b/resources/factions/japan_2005.json index 892e1ed7..7d860b05 100644 --- a/resources/factions/japan_2005.json +++ b/resources/factions/japan_2005.json @@ -3,6 +3,7 @@ "name": "Japan 2005", "authors": "Khopa", "description": "

Japanese self defense force, F-15C standing as F-15J, and F-16 as Mitsubishi F-2.

Ground units were also chosen to fit the existing vehicles of the japanese forces

", + "locales": ["ja_JP"], "aircrafts": [ "F_15C", "F_16C_50", @@ -23,7 +24,7 @@ "IFV_Marder", "APC_TPz_Fuchs", "IFV_LAV_25", - "APC_HMMWV__Scout", + "Scout_HMMWV", "SPAAA_Gepard" ], "artillery_units": [ diff --git a/resources/factions/libya_2011.json b/resources/factions/libya_2011.json index 10ef96a2..cf6b45cd 100644 --- a/resources/factions/libya_2011.json +++ b/resources/factions/libya_2011.json @@ -18,9 +18,10 @@ ], "frontline_units": [ "IFV_BMP_1", - "IFV_BRDM_2", + "Scout_BRDM_2", "MBT_T_72B", "MBT_T_55", + "LT_PT_76", "SPAAA_ZSU_23_4_Shilka_Gun_Dish", "SAM_SA_8_Osa_Gecko_TEL" ], @@ -71,6 +72,6 @@ "carrier_names": [ ], "navy_generators": [ - "GrishaGroupGenerator", "MolniyaGroupGenerator" + "GrishaGroupGenerator", "MolniyaGroupGenerator", "LaCombattanteIIGroupGenerator" ] } diff --git a/resources/factions/netherlands_1990.json b/resources/factions/netherlands_1990.json index cf4735f9..feb9d43e 100644 --- a/resources/factions/netherlands_1990.json +++ b/resources/factions/netherlands_1990.json @@ -3,6 +3,7 @@ "name": "Netherlands 1990", "authors": "Khopa", "description": "

Netherlands forces in the 90s.

", + "locales": ["nl_NL"], "aircrafts": [ "F_16C_50", "F_5E_3", diff --git a/resources/factions/north_korea_2000.json b/resources/factions/north_korea_2000.json index 565ab250..ffd01032 100644 --- a/resources/factions/north_korea_2000.json +++ b/resources/factions/north_korea_2000.json @@ -19,18 +19,19 @@ "IL_78M" ], "frontline_units": [ - "IFV_BRDM_2", + "Scout_BRDM_2", "APC_BTR_80", "IFV_BMP_1", "MBT_T_55", "MBT_T_72B", "MBT_T_80U", + "LT_PT_76", "SPAAA_ZSU_57_2", "SAM_SA_9_Strela_1_Gaskin_TEL" ], "artillery_units": [ "MLRS_BM_21_Grad_122mm", - "MLRS_BM_27_Uragan_220mm", + "MLRS_9K57_Uragan_BM_27_220mm", "SPH_2S19_Msta_152mm" ], "logistics_units": [ diff --git a/resources/factions/pakistan_2015.json b/resources/factions/pakistan_2015.json index 1c8c7ef7..c8e2b4b3 100644 --- a/resources/factions/pakistan_2015.json +++ b/resources/factions/pakistan_2015.json @@ -28,7 +28,7 @@ ], "artillery_units": [ "MLRS_9A52_Smerch_HE_300mm", - "SPH_2S9_Nona_120mm_M" + "SPM_2S9_Nona_120mm_M" ], "logistics_units": [ "Truck_Ural_375", diff --git a/resources/factions/pmc_russian.json b/resources/factions/pmc_russian.json index aa273736..14061dd4 100644 --- a/resources/factions/pmc_russian.json +++ b/resources/factions/pmc_russian.json @@ -3,6 +3,7 @@ "name": "Private Military Company - Russian", "authors": "Khopa", "description": "

A private military company using Russian units.

", + "locales": ["ru_RU"], "aircrafts": [ "L_39ZA", "Mi_8MT", @@ -10,9 +11,9 @@ "Ka_50" ], "frontline_units": [ - "APC_Cobra__Scout", + "Scout_Cobra", "APC_BTR_80", - "IFV_BRDM_2", + "Scout_BRDM_2", "SAM_SA_13_Strela_10M3_Gopher_TEL" ], "artillery_units": [ diff --git a/resources/factions/pmc_us.json b/resources/factions/pmc_us.json index 293a4b9d..8957f5db 100644 --- a/resources/factions/pmc_us.json +++ b/resources/factions/pmc_us.json @@ -10,7 +10,7 @@ "SA342M" ], "frontline_units": [ - "APC_HMMWV__Scout", + "Scout_HMMWV", "IFV_Warrior", "IFV_LAV_25", "SAM_Avenger__Stinger" diff --git a/resources/factions/pmc_us_with_mb339.json b/resources/factions/pmc_us_with_mb339.json index 8ffdf832..1e9867d5 100644 --- a/resources/factions/pmc_us_with_mb339.json +++ b/resources/factions/pmc_us_with_mb339.json @@ -11,7 +11,7 @@ "SA342M" ], "frontline_units": [ - "APC_HMMWV__Scout", + "Scout_HMMWV", "IFV_Warrior", "IFV_LAV_25", "SAM_Avenger__Stinger" diff --git a/resources/factions/poland_2010.json b/resources/factions/poland_2010.json index 35256cfc..d37e55dc 100644 --- a/resources/factions/poland_2010.json +++ b/resources/factions/poland_2010.json @@ -3,6 +3,7 @@ "name": "Poland 2010", "authors": "Malakhit", "description": "

Polish army in the 2010s.

", + "locales": ["pl_PL"], "aircrafts": [ "Su_17M4", "MiG_29A", @@ -13,13 +14,13 @@ "KC130" ], "frontline_units": [ - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV", "IFV_M1126_Stryker_ICV", - "IFV_BRDM_2", + "Scout_BRDM_2", "IFV_BMP_1", "APC_MTLB", - "MBT_Leopard_2", + "MBT_Leopard_2A6M", "MBT_T_72B3" ], "artillery_units": [ diff --git a/resources/factions/redfor_china_2010.json b/resources/factions/redfor_china_2010.json index c7b1fe0d..9acd9203 100644 --- a/resources/factions/redfor_china_2010.json +++ b/resources/factions/redfor_china_2010.json @@ -31,7 +31,7 @@ ], "frontline_units": [ "APC_BTR_80", - "APC_BTR_82A", + "IFV_BTR_82A", "HQ_7_Self_Propelled_LN", "IFV_BMP_1", "IFV_BMP_2", @@ -46,8 +46,8 @@ ], "artillery_units": [ "MLRS_9A52_Smerch_HE_300mm", - "MLRS_BM_27_Uragan_220mm", - "SPH_2S9_Nona_120mm_M", + "MLRS_9K57_Uragan_BM_27_220mm", + "SPM_2S9_Nona_120mm_M", "SPH_2S19_Msta_152mm" ], "logistics_units": [ diff --git a/resources/factions/redfor_russia_2010.json b/resources/factions/redfor_russia_2010.json index e195e8d5..1703388b 100644 --- a/resources/factions/redfor_russia_2010.json +++ b/resources/factions/redfor_russia_2010.json @@ -3,6 +3,7 @@ "name": "Redfor (Russia) 2010", "authors": "Robert Peary", "description": "

Combined Redfor under Russia's leadership in the late 2000s, early 2010s.

", + "locales": ["ru_RU"], "aircrafts": [ "J_11A", "JF_17", @@ -31,7 +32,7 @@ ], "frontline_units": [ "APC_BTR_80", - "APC_BTR_82A", + "IFV_BTR_82A", "HQ_7_Self_Propelled_LN", "IFV_BMP_1", "IFV_BMP_2", @@ -46,8 +47,8 @@ ], "artillery_units": [ "MLRS_9A52_Smerch_HE_300mm", - "MLRS_BM_27_Uragan_220mm", - "SPH_2S9_Nona_120mm_M", + "MLRS_9K57_Uragan_BM_27_220mm", + "SPM_2S9_Nona_120mm_M", "SPH_2S19_Msta_152mm" ], "logistics_units": [ @@ -104,7 +105,7 @@ ], "requirements": {}, "carrier_names": [ - "Admiral Kuznetov" + "Admiral Kuznetsov" ], "navy_generators": [ "RussianNavyGroupGenerator", diff --git a/resources/factions/russia_1955.json b/resources/factions/russia_1955.json index a3964056..ec107246 100644 --- a/resources/factions/russia_1955.json +++ b/resources/factions/russia_1955.json @@ -3,6 +3,7 @@ "name": "Russia 1955", "authors": "Khopa", "description": "

Soviet army around 1955, during the Korean War

", + "locales": ["ru_RU"], "aircrafts": [ "MiG_15bis" ], @@ -13,10 +14,11 @@ "IL_78M" ], "frontline_units": [ - "IFV_BRDM_2", + "Scout_BRDM_2", "Grad_MRL_FDDM__FC", "APC_MTLB", "MBT_T_55", + "LT_PT_76", "SPAAA_ZU_23_2_Mounted_Ural_375", "AAA_8_8cm_Flak_18", "AAA_S_60_57mm" diff --git a/resources/factions/russia_1965.json b/resources/factions/russia_1965.json index 49b21bcc..cd506c36 100644 --- a/resources/factions/russia_1965.json +++ b/resources/factions/russia_1965.json @@ -3,6 +3,7 @@ "name": "Russia 1965", "authors": "Khopa", "description": "

Soviet army in the 60s, ideal to fly the Mig-19 or the Mig-21.

", + "locales": ["ru_RU"], "aircrafts": [ "MiG_15bis", "MiG_19P", @@ -17,11 +18,12 @@ "IL_78M" ], "frontline_units": [ - "IFV_BRDM_2", + "Scout_BRDM_2", "APC_BTR_80", "APC_BTR_RD", "IFV_BMD_1", "IFV_BMP_1", + "LT_PT_76", "MBT_T_55", "SPAAA_ZU_23_2_Mounted_Ural_375", "SPAAA_ZSU_57_2", diff --git a/resources/factions/russia_1970_limited_air.json b/resources/factions/russia_1970_limited_air.json index 5ab6a703..735c2a88 100644 --- a/resources/factions/russia_1970_limited_air.json +++ b/resources/factions/russia_1970_limited_air.json @@ -3,6 +3,7 @@ "name": "Russia 1970 Limited Air", "authors": "Starfire", "description": "

1970 Soviet Russia, with limited air units intended for a Viggen campaign.

", + "locales": ["ru_RU"], "aircrafts": [ "MiG_19P", "MiG_21Bis", @@ -10,16 +11,17 @@ "Mi_24V" ], "frontline_units": [ - "IFV_BRDM_2", + "Scout_BRDM_2", "APC_BTR_80", "IFV_BMD_1", "IFV_BMP_1", + "LT_PT_76", "MBT_T_55", "SPAAA_ZSU_57_2" ], "artillery_units": [ "MLRS_BM_21_Grad_122mm", - "SPH_2S9_Nona_120mm_M", + "SPM_2S9_Nona_120mm_M", "SPH_2S1_Gvozdika_122mm" ], "logistics_units": [ diff --git a/resources/factions/russia_1975.json b/resources/factions/russia_1975.json index d931e313..387bebf2 100644 --- a/resources/factions/russia_1975.json +++ b/resources/factions/russia_1975.json @@ -3,6 +3,7 @@ "name": "Russia 1975", "authors": "Khopa", "description": "

Soviet army in the late 70s, using their prototype Mig-29A.

", + "locales": ["ru_RU"], "aircrafts": [ "MiG_21Bis", "MiG_23MLD", @@ -23,16 +24,17 @@ "IL_78M" ], "frontline_units": [ - "IFV_BRDM_2", + "Scout_BRDM_2", "APC_BTR_80", "IFV_BMD_1", "IFV_BMP_1", + "LT_PT_76", "MBT_T_55", "SAM_SA_8_Osa_Gecko_TEL" ], "artillery_units": [ "MLRS_BM_21_Grad_122mm", - "SPH_2S9_Nona_120mm_M", + "SPM_2S9_Nona_120mm_M", "SPH_2S1_Gvozdika_122mm" ], "logistics_units": [ diff --git a/resources/factions/russia_1990.json b/resources/factions/russia_1990.json index 4237b95c..7f0f561f 100644 --- a/resources/factions/russia_1990.json +++ b/resources/factions/russia_1990.json @@ -3,6 +3,7 @@ "name": "Russia 1990", "authors": "Khopa", "description": "

Soviet/Russian army in the 90s.

", + "locales": ["ru_RU"], "aircrafts": [ "MiG_25PD", "MiG_29S", @@ -35,7 +36,7 @@ "SAM_SA_13_Strela_10M3_Gopher_TEL" ], "artillery_units": [ - "MLRS_BM_27_Uragan_220mm", + "MLRS_9K57_Uragan_BM_27_220mm", "SPH_2S19_Msta_152mm" ], "logistics_units": [ @@ -85,7 +86,7 @@ ], "requirements": {}, "carrier_names": [ - "Admiral Kuznetov", + "Admiral Kuznetsov", "Admiral Gorshkov" ], "navy_generators": [ diff --git a/resources/factions/russia_2010.json b/resources/factions/russia_2010.json index 1d9ccf97..624ced64 100644 --- a/resources/factions/russia_2010.json +++ b/resources/factions/russia_2010.json @@ -3,6 +3,7 @@ "name": "Russia 2010", "authors": "Khopa", "description": "

Russian army in the early 2010s.

", + "locales": ["ru_RU"], "aircrafts": [ "MiG_29S", "MiG_31", @@ -34,14 +35,14 @@ "IFV_BMP_2", "IFV_BMP_3", "APC_BTR_80", - "APC_BTR_82A", + "IFV_BTR_82A", "MBT_T_90", "MBT_T_80U", "MBT_T_72B3", "SAM_SA_19_Tunguska_Grison" ], "artillery_units": [ - "MLRS_BM_27_Uragan_220mm", + "MLRS_9K57_Uragan_BM_27_220mm", "SPH_2S19_Msta_152mm" ], "logistics_units": [ @@ -88,7 +89,7 @@ ], "requirements": {}, "carrier_names": [ - "Admiral Kuznetov" + "Admiral Kuznetsov" ], "navy_generators": [ "RussianNavyGroupGenerator" diff --git a/resources/factions/russia_2010_hds.json b/resources/factions/russia_2010_hds.json index 751bf335..62eb94bf 100644 --- a/resources/factions/russia_2010_hds.json +++ b/resources/factions/russia_2010_hds.json @@ -3,6 +3,7 @@ "name": "Russia 2010 (High Digit SAMs)", "authors": "Khopa", "description": "

Russian army in the early 2010s, featuring the High Digit SAMs mod units.

", + "locales": ["ru_RU"], "aircrafts": [ "MiG_29S", "MiG_31", @@ -34,14 +35,14 @@ "IFV_BMP_2", "IFV_BMP_3", "APC_BTR_80", - "APC_BTR_82A", + "IFV_BTR_82A", "MBT_T_90", "MBT_T_80U", "MBT_T_72B3", "SAM_SA_19_Tunguska_Grison" ], "artillery_units": [ - "MLRS_BM_27_Uragan_220mm", + "MLRS_9K57_Uragan_BM_27_220mm", "SPH_2S19_Msta_152mm" ], "logistics_units": [ @@ -85,7 +86,7 @@ ], "requirements": { "High Digit SAMs": "https://github.com/Auranis/HighDigitSAMs/releases"}, "carrier_names": [ - "Admiral Kuznetov" + "Admiral Kuznetsov" ], "navy_generators": [ "RussianNavyGroupGenerator" diff --git a/resources/factions/russia_2020.json b/resources/factions/russia_2020.json index 9f134ebe..8d437dbf 100644 --- a/resources/factions/russia_2020.json +++ b/resources/factions/russia_2020.json @@ -3,6 +3,7 @@ "name": "Russia 2020 (Modded)", "authors": "Khopa", "description": "

Russia in 2020, using the Su-57 mod by Cubanace.

", + "locales": ["ru_RU"], "aircrafts": [ "MiG_29S", "MiG_31", @@ -39,7 +40,7 @@ "SAM_SA_19_Tunguska_Grison" ], "artillery_units": [ - "MLRS_BM_27_Uragan_220mm", + "MLRS_9K57_Uragan_BM_27_220mm", "SPH_2S19_Msta_152mm" ], "logistics_units": [ @@ -86,7 +87,7 @@ "SU-57 Felon By CubanAce Simulations": "https://www.digitalcombatsimulator.com/fr/files/2539621/" }, "carrier_names": [ - "Admiral Kuznetov" + "Admiral Kuznetsov" ], "navy_generators": [ "RussianNavyGroupGenerator" diff --git a/resources/factions/soviet_union_1943.json b/resources/factions/soviet_union_1943.json index 3be02dd0..f47687b2 100644 --- a/resources/factions/soviet_union_1943.json +++ b/resources/factions/soviet_union_1943.json @@ -3,16 +3,17 @@ "name": "Soviet Union 1943", "authors": "Khopa", "description": "

Soviet Union in 1943. Featuring the I16, and using some allies units to represent either lend leased vehicles or soviet equivalent vehicles. BM-21 is used to represent BM-13

", + "locales": ["ru_RU"], "aircrafts": [ "SpitfireLFMkIX", "I_16" ], "frontline_units": [ - "MT_M4_Sherman", + "Tk_M4_Sherman", "APC_M2A1_Halftrack", "Car_Daimler_Armored", "LT_Mk_VII_Tetrarch", - "AAA_40mm_Bofors" + "AAA_Bofors_40mm" ], "artillery_units": [ "MLRS_BM_21_Grad_122mm" diff --git a/resources/factions/spain_1990.json b/resources/factions/spain_1990.json index 344ea866..728f2384 100644 --- a/resources/factions/spain_1990.json +++ b/resources/factions/spain_1990.json @@ -3,6 +3,7 @@ "name": "Spain 1990", "authors": "Khopa", "description": "

Spain in the 90s

", + "locales": ["es_ES"], "aircrafts": [ "FA_18C_hornet", "AV8BNA", @@ -19,7 +20,7 @@ ], "frontline_units": [ "MBT_M60A3_Patton", - "MBT_Leopard_2", + "MBT_Leopard_2A4", "APC_M113", "SAM_Avenger__Stinger" ], @@ -64,5 +65,22 @@ "OliverHazardPerryGroupGenerator" ], "has_jtac": true, - "jtac_unit": "MQ_9_Reaper" + "jtac_unit": "MQ_9_Reaper", + "liveries_overrides": { + "FA_18C_hornet": [ + "Spain 111th Escuadron C.15-73", + "Spain 111th Escuadron C.15-88", + "Spain 121th Escuadron C.15-45", + "Spain 121th Escuadron C.15-50", + "Spain 121th Escuadron C.15-60", + "Spain 151th Escuadron C.15-14", + "Spain 151th Escuadron C.15-18", + "Spain 151th Escuadron C.15-23", + "Spain 151th Escuadron C.15-24", + "Spain 211th Escuadron C.15-76", + "Spain 211th Escuadron C.15-77", + "Spain 462th Escuadron C.15-79", + "Spain 462th Escuadron C.15-90" + ] + } } diff --git a/resources/factions/sweden_1970.json b/resources/factions/sweden_1970.json index 30bcdcb7..18554128 100644 --- a/resources/factions/sweden_1970.json +++ b/resources/factions/sweden_1970.json @@ -3,6 +3,7 @@ "name": "Sweden 1970s Alternate Universe", "authors": "Starfire", "description": "

Sweden 1970

Since we do not yet have Heatblur's AI Draken, this faction includes the Mirage 2000C in order to provide Sweden with some form of A2A capability.

", + "locales": ["sv_SE"], "aircrafts": [ "AJS37", "M_2000C", @@ -17,7 +18,7 @@ ], "frontline_units": [ "IFV_Warrior", - "MBT_Leopard_2", + "MBT_Leopard_2A4", "IFV_M1126_Stryker_ICV", "SAM_Chaparral_M48" ], diff --git a/resources/factions/sweden_1990.json b/resources/factions/sweden_1990.json index 2ea024b9..18da3260 100644 --- a/resources/factions/sweden_1990.json +++ b/resources/factions/sweden_1990.json @@ -3,6 +3,7 @@ "name": "Sweden 1990", "authors": "Khopa", "description": "

Sweden in the 90s.

Note : Since we're missing the Draken and the Air-to-Air variant of the Viggen, this faction will struggle in air-to-air scenarios.

", + "locales": ["sv_SE"], "aircrafts": [ "AJS37", "UH_1H" @@ -16,7 +17,7 @@ ], "frontline_units": [ "IFV_Warrior", - "MBT_Leopard_2", + "MBT_Leopard_2A4", "IFV_M1126_Stryker_ICV", "SAM_Avenger__Stinger" ], diff --git a/resources/factions/sweden_2002_with_gripen.json b/resources/factions/sweden_2002_with_gripen.json new file mode 100644 index 00000000..25af4e24 --- /dev/null +++ b/resources/factions/sweden_2002_with_gripen.json @@ -0,0 +1,51 @@ +{ + "country": "Sweden", + "name": "Sweden 2002", + "authors": "Khopa (updated with Gripen by bgreman)", + "description": "

Sweden in 2002 after the addition of the Gripen-C.

", + "locales": ["sv_SE"], + "aircrafts": [ + "AJS37", + "JAS39Gripen", + "JAS39Gripen_AG", + "UH_1H" + ], + "awacs": [ + "E_3A" + ], + "tankers": [ + "KC_135", + "KC130" + ], + "frontline_units": [ + "IFV_Warrior", + "MBT_Leopard_2A4", + "IFV_M1126_Stryker_ICV", + "SAM_Avenger__Stinger" + ], + "artillery_units": [ + ], + "logistics_units": [ + "Truck_M818_6x6" + ], + "infantry_units": [ + "Infantry_M4", + "Infantry_M249", + "MANPADS_Stinger" + ], + "air_defenses": [ + "AvengerGenerator", + "HawkGenerator" + ], + "ewrs": [ + "HawkEwrGenerator" + ], + "navy_generators": [ + "OliverHazardPerryGroupGenerator" + ], + "requirements": { + "JAS39 Gripen Mod by Community": "https://github.com/whisky-actual/Community-JAS-39-C" + }, + "has_jtac": true, + "jtac_unit": "MQ_9_Reaper" +} diff --git a/resources/factions/syria_1948.json b/resources/factions/syria_1948.json index 79cd7438..ad05776d 100644 --- a/resources/factions/syria_1948.json +++ b/resources/factions/syria_1948.json @@ -10,9 +10,9 @@ "frontline_units": [ "IFV_Sd_Kfz_234_2_Puma", "APC_Sd_Kfz_251_Halftrack", - "MT_PzIV_H", - "MT_M4_Sherman", - "AAA_40mm_Bofors" + "Tk_PzIV_H", + "Tk_M4_Sherman", + "AAA_Bofors_40mm" ], "artillery_units": [ ], diff --git a/resources/factions/syria_1967.json b/resources/factions/syria_1967.json index b2e93159..cab0ceb8 100644 --- a/resources/factions/syria_1967.json +++ b/resources/factions/syria_1967.json @@ -17,9 +17,10 @@ "IL_78M" ], "frontline_units": [ - "IFV_BRDM_2", - "MT_PzIV_H", + "Scout_BRDM_2", + "Tk_PzIV_H", "MBT_T_55", + "LT_PT_76", "SPAAA_ZU_23_2_Mounted_Ural_375", "SPAAA_ZSU_57_2", "AAA_S_60_57mm" diff --git a/resources/factions/syria_1967_with_ww2_weapons.json b/resources/factions/syria_1967_with_ww2_weapons.json index 34fbaf1d..cda98f1d 100644 --- a/resources/factions/syria_1967_with_ww2_weapons.json +++ b/resources/factions/syria_1967_with_ww2_weapons.json @@ -18,9 +18,10 @@ "IL_78M" ], "frontline_units": [ - "IFV_BRDM_2", + "Scout_BRDM_2", "MBT_T_55", - "MT_PzIV_H", + "LT_PT_76", + "Tk_PzIV_H", "SPG_StuG_III_Ausf__G", "SPG_Jagdpanzer_IV", "SPAAA_ZSU_57_2", diff --git a/resources/factions/syria_1973.json b/resources/factions/syria_1973.json index 302283fe..f5467de4 100644 --- a/resources/factions/syria_1973.json +++ b/resources/factions/syria_1973.json @@ -20,6 +20,7 @@ "IFV_BMP_1", "APC_MTLB", "MBT_T_55", + "LT_PT_76", "SPAAA_ZU_23_2_Mounted_Ural_375", "SPAAA_ZSU_57_2", "AAA_S_60_57mm" diff --git a/resources/factions/syria_1982.json b/resources/factions/syria_1982.json index d8beda8e..022fe6ea 100644 --- a/resources/factions/syria_1982.json +++ b/resources/factions/syria_1982.json @@ -21,6 +21,7 @@ "IFV_BMP_1", "APC_MTLB", "MBT_T_55", + "LT_PT_76", "MBT_T_72B", "SPAAA_ZU_23_2_Mounted_Ural_375", "SPAAA_ZSU_57_2", diff --git a/resources/factions/syria_2011.json b/resources/factions/syria_2011.json index ae2211a5..c5a3e375 100644 --- a/resources/factions/syria_2011.json +++ b/resources/factions/syria_2011.json @@ -26,17 +26,18 @@ "IFV_BMP_1", "IFV_BMP_2", "APC_BTR_80", - "IFV_BRDM_2", + "Scout_BRDM_2", + "LT_PT_76", "APC_MTLB", - "APC_Cobra__Scout", + "Scout_Cobra", "MBT_T_55", "MBT_T_72B", "MBT_T_90", "SPAAA_ZSU_57_2" ], "artillery_units": [ - "MLRS_BM_27_Uragan_220mm", - "SPH_2S9_Nona_120mm_M", + "MLRS_9K57_Uragan_BM_27_220mm", + "SPM_2S9_Nona_120mm_M", "MLRS_BM_21_Grad_122mm", "SPH_2S1_Gvozdika_122mm" ], diff --git a/resources/factions/syria_2012.json b/resources/factions/syria_2012.json new file mode 100644 index 00000000..f5c5f3cb --- /dev/null +++ b/resources/factions/syria_2012.json @@ -0,0 +1,96 @@ +{ + "country": "Combined Joint Task Forces Red", + "name": "Syria 2012'ish", + "authors": "Headiii", + "description": "

Syrian Army with more modern Imports and supported by a Russian Expeditionary Force.

", + "aircrafts": [ + "MiG_23MLD", + "MiG_25PD", + "MiG_29S", + "Su_24M", + "Su_25", + "Su_30", + "Su_34", + "L_39ZA", + "Mi_24V", + "Mi_8MT", + "SA342M", + "SA342L" + ], + "awacs": [ + "A_50" + ], + "tankers": [ + "IL_78M" + ], + "frontline_units": [ + "IFV_BMP_1", + "IFV_BMP_2", + "APC_BTR_80", + "Scout_BRDM_2", + "LT_PT_76", + "APC_MTLB", + "Scout_Cobra", + "MBT_T_55", + "MBT_T_72B", + "MBT_T_90", + "SPAAA_ZSU_57_2" + ], + "artillery_units": [ + "MLRS_9K57_Uragan_BM_27_220mm", + "SPM_2S9_Nona_120mm_M", + "MLRS_BM_21_Grad_122mm", + "SPH_2S1_Gvozdika_122mm" + ], + "logistics_units": [ + "Truck_Ural_375", + "LUV_UAZ_469_Jeep" + ], + "infantry_units": [ + "Paratrooper_AKS", + "Infantry_AK_74_Rus", + "Paratrooper_RPG_16", + "MANPADS_SA_18_Igla_S_Grouse" + ], + "air_defenses": [ + "ColdWarFlakGenerator", + "SA2Generator", + "SA3Generator", + "SA6Generator", + "SA8Generator", + "SA8Generator", + "SA9Generator", + "SA10Generator", + "SA11Generator", + "SA13Generator", + "SA19Generator", + "ZSU23Generator", + "ZU23Generator", + "ZU23UralGenerator" + ], + "ewrs": [ + "BoxSpringGenerator", + "TallRackGenerator" + ], + "missiles": [ + "ScudGenerator" + ], + "missiles_group_count": 1, + "aircraft_carrier": [ + ], + "helicopter_carrier": [ + ], + "helicopter_carrier_names": [ + ], + "destroyers": [ + ], + "cruisers": [ + ], + "requirements": {}, + "carrier_names": [ + ], + "navy_generators": [ + "GrishaGroupGenerator", + "MolniyaGroupGenerator" + ] +} diff --git a/resources/factions/turkey_2005.json b/resources/factions/turkey_2005.json index f5c7847c..16343880 100644 --- a/resources/factions/turkey_2005.json +++ b/resources/factions/turkey_2005.json @@ -3,6 +3,7 @@ "name": "Turkey 2005", "authors": "Khopa", "description": "

Turkish army in the mid/late 2000s.

", + "locales": ["tr_TR"], "aircrafts": [ "F_16C_50", "F_4E", @@ -17,15 +18,15 @@ "KC130" ], "frontline_units": [ - "MBT_Leopard_2", + "MBT_Leopard_2A4_Trs", "MBT_Leopard_1A3", "MBT_M60A3_Patton", - "APC_Cobra__Scout", + "Scout_Cobra", "APC_BTR_80", "SAM_Avenger__Stinger" ], "artillery_units": [ - "SPH_M109_Paladin_155mm" + "SPH_T155_Firtina_155mm" ], "logistics_units": [ "Truck_M818_6x6" diff --git a/resources/factions/uae_2015.json b/resources/factions/uae_2015.json new file mode 100644 index 00000000..10bc290c --- /dev/null +++ b/resources/factions/uae_2015.json @@ -0,0 +1,54 @@ +{ + "country": "United Arab Emirates", + "name": "United Arab Emirates 2015", + "authors": "Khopa", + "description": "

UAE army in the 2010s.

", + "aircrafts": [ + "AH_64D", + "C_130", + "C_17A", + "CH_47D", + "F_16C_50", + "M_2000C", + "Mirage_2000_5", + "UH_60A" + ], + "awacs": [ + "E_3A" + ], + "tankers": [ + "KC_135", + "KC130" + ], + "frontline_units": [ + "MBT_Leclerc", + "APC_TPz_Fuchs", + "IFV_BMP_3" + ], + "artillery_units": [ + ], + "logistics_units": [ + "Truck_M818_6x6" + ], + "infantry_units": [ + "Infantry_M4", + "Infantry_M249", + "MANPADS_SA_18_Igla_S_Grouse" + ], + "air_defenses": [ + "HawkGenerator", + "PatriotGenerator", + "RapierGenerator" + ], + "ewrs": [ + "PatriotEwrGenerator" + ], + "requirements": {}, + "carrier_names": [ + ], + "navy_generators": [ + "OliverHazardPerryGroupGenerator" + ], + "has_jtac": true, + "jtac_unit": "WingLoong_I" +} diff --git a/resources/factions/uk_1944.json b/resources/factions/uk_1944.json index 32f44303..2a1aa650 100644 --- a/resources/factions/uk_1944.json +++ b/resources/factions/uk_1944.json @@ -3,6 +3,7 @@ "name": "United Kingdom 1944", "authors": "Khopa", "description": "

United Kingdom army in 1944.

", + "locales": ["en_UK"], "aircrafts": [ "P_51D", "P_51D_30_NA", @@ -16,14 +17,14 @@ ], "frontline_units": [ "MT_M4A4_Sherman_Firefly", - "MT_M4_Sherman", + "Tk_M4_Sherman", "APC_M2A1_Halftrack", "CT_Cromwell_IV", "CT_Centaur_IV", "HIT_Churchill_VII", "Car_Daimler_Armored", "LT_Mk_VII_Tetrarch", - "AAA_40mm_Bofors" + "AAA_Bofors_40mm" ], "artillery_units": [ ], diff --git a/resources/factions/uk_1990.json b/resources/factions/uk_1990.json index 9f88bb79..92b386ca 100644 --- a/resources/factions/uk_1990.json +++ b/resources/factions/uk_1990.json @@ -3,6 +3,7 @@ "name": "United Kingdom 1990", "authors": "Khopa", "description": "

United Kingdom Army in the 1990s.

", + "locales": ["en_UK"], "aircrafts": [ "Tornado_GR4", "AV8BNA", @@ -19,8 +20,9 @@ ], "frontline_units": [ "MBT_Challenger_II", + "MBT_Chieftain_Mk_3", "IFV_Warrior", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV", "SAM_Avenger__Stinger" ], @@ -71,4 +73,4 @@ ], "has_jtac": true, "jtac_unit": "MQ_9_Reaper" -} \ No newline at end of file +} diff --git a/resources/factions/ukraine_2010.json b/resources/factions/ukraine_2010.json index 9ecceb81..644b9799 100644 --- a/resources/factions/ukraine_2010.json +++ b/resources/factions/ukraine_2010.json @@ -20,7 +20,7 @@ "IL_78M" ], "frontline_units": [ - "APC_HMMWV__Scout", + "Scout_HMMWV", "IFV_BMP_3", "IFV_BMP_2", "APC_BTR_80", @@ -61,7 +61,7 @@ ], "requirements": {}, "carrier_names": [ - "Admiral Kuznetov", + "Admiral Kuznetsov", "Admiral Gorshkov" ], "navy_generators": [ diff --git a/resources/factions/unc_1950_fictional.json b/resources/factions/unc_1950_fictional.json index dae56d1f..7651897b 100644 --- a/resources/factions/unc_1950_fictional.json +++ b/resources/factions/unc_1950_fictional.json @@ -12,11 +12,11 @@ "P_47D_40" ], "frontline_units": [ - "MT_M4_Sherman", + "Tk_M4_Sherman", "MBT_M60A3_Patton", "APC_M2A1_Halftrack", "Car_M8_Greyhound_Armored", - "AAA_40mm_Bofors" + "AAA_Bofors_40mm" ], "artillery_units": [ "SPG_M12_GMC_155mm" @@ -38,4 +38,4 @@ "requirements": { "WW2 Asset Pack": "https://www.digitalcombatsimulator.com/en/products/other/wwii_assets_pack/" } -} \ No newline at end of file +} diff --git a/resources/factions/us_aggressors.json b/resources/factions/us_aggressors.json index ef43854b..38bab3fe 100644 --- a/resources/factions/us_aggressors.json +++ b/resources/factions/us_aggressors.json @@ -3,6 +3,7 @@ "name": "USAF Aggressors", "authors": "Khopa", "description": "

USAF aggresors.

", + "locales": ["en_US"], "aircrafts": [ "F_15C", "F_15E", @@ -31,11 +32,11 @@ ], "frontline_units": [ "MBT_M1A2_Abrams", - "MBT_Leopard_2", + "MBT_Leopard_2A4", "ATGM_Stryker", "IFV_M2A2_Bradley", "IFV_LAV_25", - "APC_HMMWV__Scout", + "Scout_HMMWV", "SAM_Avenger__Stinger" ], "artillery_units": [ @@ -82,4 +83,4 @@ "vf-74 adversary" ] } -} \ No newline at end of file +} diff --git a/resources/factions/usa_1944.json b/resources/factions/usa_1944.json index 7b846324..107051dd 100644 --- a/resources/factions/usa_1944.json +++ b/resources/factions/usa_1944.json @@ -3,6 +3,7 @@ "name": "USA 1944", "authors": "Khopa", "description": "

US army in 1944, western front.

", + "locales": ["en_US"], "aircrafts": [ "P_51D", "P_51D_30_NA", @@ -15,7 +16,7 @@ ], "frontline_units": [ "MT_M4A4_Sherman_Firefly", - "MT_M4_Sherman", + "Tk_M4_Sherman", "APC_M2A1_Halftrack", "Car_M8_Greyhound_Armored", "SPG_M10_GMC", diff --git a/resources/factions/usa_1955.json b/resources/factions/usa_1955.json index df202d6a..cb767293 100644 --- a/resources/factions/usa_1955.json +++ b/resources/factions/usa_1955.json @@ -3,17 +3,19 @@ "name": "USA 1955", "authors": "Khopa", "description": "

US army in the 50s, circa Korean War.

", + "locales": ["en_US"], "aircrafts": [ + "B_52H", + "C_130", "F_86F_Sabre", "P_51D", - "P_51D_30_NA", - "B_52H" + "P_51D_30_NA" ], "frontline_units": [ - "MT_M4_Sherman", + "Tk_M4_Sherman", "MBT_M60A3_Patton", "APC_M2A1_Halftrack", - "AAA_40mm_Bofors" + "AAA_Bofors_40mm" ], "artillery_units": [ "SPG_M12_GMC_155mm" @@ -33,4 +35,4 @@ "requirements": { "WW2 Asset Pack": "https://www.digitalcombatsimulator.com/en/products/other/wwii_assets_pack/" } -} \ No newline at end of file +} diff --git a/resources/factions/usa_1960.json b/resources/factions/usa_1960.json index 0548300f..f15a8685 100644 --- a/resources/factions/usa_1960.json +++ b/resources/factions/usa_1960.json @@ -3,11 +3,13 @@ "name": "USA 1960", "authors": "Khopa", "description": "

US army in the 60s, pre-Vietnam war.

", + "locales": ["en_US"], "aircrafts": [ + "B_52H", + "C_130", "F_86F_Sabre", "P_51D", "P_51D_30_NA", - "B_52H", "UH_1H" ], "frontline_units": [ diff --git a/resources/factions/usa_1965.json b/resources/factions/usa_1965.json index e252c371..b0f08587 100644 --- a/resources/factions/usa_1965.json +++ b/resources/factions/usa_1965.json @@ -3,10 +3,13 @@ "name": "USA 1965", "authors": "Khopa", "description": "

US army in the late 60s, during Vietnam war.

", + "locales": ["en_US"], "aircrafts": [ - "F_5E_3", - "F_4E", "B_52H", + "CH_47D", + "C_130", + "F_4E", + "F_5E_3", "UH_1H" ], "awacs": [ diff --git a/resources/factions/usa_1975.json b/resources/factions/usa_1975.json index c1e894bb..fc8f71cf 100644 --- a/resources/factions/usa_1975.json +++ b/resources/factions/usa_1975.json @@ -3,12 +3,16 @@ "name": "USA 1975", "authors": "Khopa", "description": "

US army in the 70s at the end of the war in Vietnam.

", + "locales": ["en_US"], "aircrafts": [ - "F_5E_3", - "F_4E", - "F_14A_135_GR", - "S_3B", "B_52H", + "CH_47D", + "CH_53E", + "C_130", + "F_14A_135_GR", + "F_4E", + "F_5E_3", + "S_3B", "UH_1H" ], "awacs": [ diff --git a/resources/factions/usa_1990.json b/resources/factions/usa_1990.json index b9c203ea..0a9d8913 100644 --- a/resources/factions/usa_1990.json +++ b/resources/factions/usa_1990.json @@ -3,22 +3,28 @@ "name": "USA 1990", "authors": "Khopa", "description": "

US army in the 90s, Gulf War/Desert Storm.

", + "locales": ["en_US"], "aircrafts": [ - "F_15C", - "F_15E", + "AH_64A", + "AV8BNA", + "A_10A", + "B_1B", + "B_52H", + "CH_47D", + "CH_53E", + "C_130", + "FA_18C_hornet", + "F_117A", "F_14A_135_GR", "F_14B", - "FA_18C_hornet", + "F_15C", + "F_15E", "F_16C_50", - "A_10A", - "AV8BNA", - "UH_1H", - "S_3B", "SH_60B", - "AH_64A", - "B_52H", - "B_1B", - "F_117A" + "SH_60B", + "S_3B", + "UH_1H", + "UH_60A" ], "awacs": [ "E_3A", @@ -34,7 +40,7 @@ "IFV_M1126_Stryker_ICV", "IFV_M2A2_Bradley", "IFV_LAV_25", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV", "SAM_Avenger__Stinger" ], @@ -114,4 +120,4 @@ "VMFA-323" ] } -} \ No newline at end of file +} diff --git a/resources/factions/usa_2005.json b/resources/factions/usa_2005.json index 50255813..f516f47d 100644 --- a/resources/factions/usa_2005.json +++ b/resources/factions/usa_2005.json @@ -3,22 +3,28 @@ "name": "USA 2005", "authors": "Khopa", "description": "

USA in the 2000s.

", + "locales": ["en_US"], "aircrafts": [ - "F_15C", - "F_15E", - "F_14B", - "F_117A", - "FA_18C_hornet", - "F_16C_50", + "AH_64D", + "AV8BNA", "A_10C", "A_10C_2", - "AV8BNA", - "UH_1H", - "S_3B", - "SH_60B", - "AH_64D", + "B_1B", "B_52H", - "B_1B" + "CH_47D", + "CH_53E", + "C_130", + "C_17A", + "FA_18C_hornet", + "F_117A", + "F_14B", + "F_15C", + "F_15E", + "F_16C_50", + "SH_60B", + "S_3B", + "UH_1H", + "UH_60A" ], "awacs": [ "E_3A", @@ -34,7 +40,7 @@ "IFV_M1126_Stryker_ICV", "IFV_M2A2_Bradley", "IFV_LAV_25", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV", "SAM_Avenger__Stinger", "SAM_Linebacker___Bradley_M6", @@ -114,4 +120,4 @@ "VMFA-323" ] } -} \ No newline at end of file +} diff --git a/resources/factions/usa_2005_c130.json b/resources/factions/usa_2005_c130.json index 9546bd55..8cfbcbb5 100644 --- a/resources/factions/usa_2005_c130.json +++ b/resources/factions/usa_2005_c130.json @@ -3,23 +3,29 @@ "name": "USA 2005 (With C-130)", "authors": "Khopa", "description": "

USA in the 2000s.

", + "locales": ["en_US"], "aircrafts": [ - "F_15C", - "F_15E", - "F_14B", - "FA_18C_hornet", - "F_16C_50", + "AH_64D", + "AV8BNA", "A_10C", "A_10C_2", - "AV8BNA", - "UH_1H", - "S_3B", - "SH_60B", - "AH_64D", - "B_52H", "B_1B", + "B_52H", + "CH_47D", + "CH_53E", + "C_130", + "C_17A", + "FA_18C_hornet", "F_117A", - "Hercules" + "F_14B", + "F_15C", + "F_15E", + "F_16C_50", + "Hercules", + "SH_60B", + "S_3B", + "UH_1H", + "UH_60A" ], "awacs": [ "E_3A", @@ -35,7 +41,7 @@ "IFV_M1126_Stryker_ICV", "IFV_M2A2_Bradley", "IFV_LAV_25", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV", "SAM_Avenger__Stinger", "SAM_Linebacker___Bradley_M6" @@ -116,4 +122,4 @@ "VMFA-323" ] } -} \ No newline at end of file +} diff --git a/resources/factions/usa_2005_modded.json b/resources/factions/usa_2005_modded.json index cc8ca114..b791d905 100644 --- a/resources/factions/usa_2005_modded.json +++ b/resources/factions/usa_2005_modded.json @@ -3,6 +3,7 @@ "name": "USA 2005 Modded", "authors": "Khopa", "description": "

USA 2005 with the Raptor mod, with the F-22 mod by Grinelli Designs.

", + "locales": ["en_US"], "aircrafts": [ "F_15C", "F_15E", @@ -35,7 +36,7 @@ "IFV_M1126_Stryker_ICV", "IFV_M2A2_Bradley", "IFV_LAV_25", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV", "SAM_Avenger__Stinger", "SAM_Linebacker___Bradley_M6" @@ -116,4 +117,4 @@ "VMFA-323" ] } -} \ No newline at end of file +} diff --git a/resources/factions/usn_1985.json b/resources/factions/usn_1985.json index 0f65fcb6..ee7cb9f1 100644 --- a/resources/factions/usn_1985.json +++ b/resources/factions/usn_1985.json @@ -3,6 +3,7 @@ "name": "US Navy 1985", "authors": "HerrTom", "description": "

Highway to the Danger Zone! For Tomcat lovers.

", + "locales": ["en_US"], "aircrafts": [ "AH_1W", "F_4E", @@ -21,7 +22,7 @@ "frontline_units": [ "MBT_M60A3_Patton", "APC_M113", - "APC_HMMWV__Scout", + "Scout_HMMWV", "SPAAA_Vulcan_M163" ], "artillery_units": [ @@ -87,4 +88,4 @@ "VFA-97" ] } -} \ No newline at end of file +} diff --git a/resources/mizdata/caucasus/Anapa-Vityazevo.miz b/resources/mizdata/caucasus/Anapa-Vityazevo.miz deleted file mode 100644 index 6382b0bd..00000000 Binary files a/resources/mizdata/caucasus/Anapa-Vityazevo.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Batumi.miz b/resources/mizdata/caucasus/Batumi.miz deleted file mode 100644 index ac262ee8..00000000 Binary files a/resources/mizdata/caucasus/Batumi.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Beslan.miz b/resources/mizdata/caucasus/Beslan.miz deleted file mode 100644 index 238fa1c1..00000000 Binary files a/resources/mizdata/caucasus/Beslan.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Gelendzhik.miz b/resources/mizdata/caucasus/Gelendzhik.miz deleted file mode 100644 index 2c914465..00000000 Binary files a/resources/mizdata/caucasus/Gelendzhik.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Gudauta.miz b/resources/mizdata/caucasus/Gudauta.miz deleted file mode 100644 index 1411c3f3..00000000 Binary files a/resources/mizdata/caucasus/Gudauta.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Kobuleti.miz b/resources/mizdata/caucasus/Kobuleti.miz deleted file mode 100644 index 0c917e55..00000000 Binary files a/resources/mizdata/caucasus/Kobuleti.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Krasnodar-Center.miz b/resources/mizdata/caucasus/Krasnodar-Center.miz deleted file mode 100644 index f25e54a6..00000000 Binary files a/resources/mizdata/caucasus/Krasnodar-Center.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Krasnodar-Pashkovsky.miz b/resources/mizdata/caucasus/Krasnodar-Pashkovsky.miz deleted file mode 100644 index f2bd0f4f..00000000 Binary files a/resources/mizdata/caucasus/Krasnodar-Pashkovsky.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Krymsk.miz b/resources/mizdata/caucasus/Krymsk.miz deleted file mode 100644 index 3359c2b8..00000000 Binary files a/resources/mizdata/caucasus/Krymsk.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Kutaisi.miz b/resources/mizdata/caucasus/Kutaisi.miz deleted file mode 100644 index ff860fec..00000000 Binary files a/resources/mizdata/caucasus/Kutaisi.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Maykop-Khanskaya.miz b/resources/mizdata/caucasus/Maykop-Khanskaya.miz deleted file mode 100644 index 92f139ee..00000000 Binary files a/resources/mizdata/caucasus/Maykop-Khanskaya.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Mineralnye Vody.miz b/resources/mizdata/caucasus/Mineralnye Vody.miz deleted file mode 100644 index cd84551b..00000000 Binary files a/resources/mizdata/caucasus/Mineralnye Vody.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Mozdok.miz b/resources/mizdata/caucasus/Mozdok.miz deleted file mode 100644 index 1e19d13a..00000000 Binary files a/resources/mizdata/caucasus/Mozdok.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Nalchik.miz b/resources/mizdata/caucasus/Nalchik.miz deleted file mode 100644 index f29c44b0..00000000 Binary files a/resources/mizdata/caucasus/Nalchik.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Novorossiysk.miz b/resources/mizdata/caucasus/Novorossiysk.miz deleted file mode 100644 index 49076868..00000000 Binary files a/resources/mizdata/caucasus/Novorossiysk.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Senaki-Kolkhi.miz b/resources/mizdata/caucasus/Senaki-Kolkhi.miz deleted file mode 100644 index 63d6ff92..00000000 Binary files a/resources/mizdata/caucasus/Senaki-Kolkhi.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Sochi-Adler.miz b/resources/mizdata/caucasus/Sochi-Adler.miz deleted file mode 100644 index c93339fc..00000000 Binary files a/resources/mizdata/caucasus/Sochi-Adler.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Sukhumi-Babushara.miz b/resources/mizdata/caucasus/Sukhumi-Babushara.miz deleted file mode 100644 index 9677cefe..00000000 Binary files a/resources/mizdata/caucasus/Sukhumi-Babushara.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/Vaziani.miz b/resources/mizdata/caucasus/Vaziani.miz deleted file mode 100644 index beb84168..00000000 Binary files a/resources/mizdata/caucasus/Vaziani.miz and /dev/null differ diff --git a/resources/mizdata/caucasus/caucusus_frontline.miz b/resources/mizdata/caucasus/caucusus_frontline.miz deleted file mode 100644 index 53ca0e0f..00000000 Binary files a/resources/mizdata/caucasus/caucusus_frontline.miz and /dev/null differ diff --git a/resources/mizdata/persiangulf/Al Ain International Airport.miz b/resources/mizdata/persiangulf/Al Ain International Airport.miz deleted file mode 100644 index 810f6349..00000000 Binary files a/resources/mizdata/persiangulf/Al Ain International Airport.miz and /dev/null differ diff --git a/resources/mizdata/persiangulf/Fujairah.miz b/resources/mizdata/persiangulf/Fujairah.miz deleted file mode 100644 index a2e48ea8..00000000 Binary files a/resources/mizdata/persiangulf/Fujairah.miz and /dev/null differ diff --git a/resources/mizdata/syria/Eyn Shemer.miz b/resources/mizdata/syria/Eyn Shemer.miz deleted file mode 100644 index c29451c1..00000000 Binary files a/resources/mizdata/syria/Eyn Shemer.miz and /dev/null differ diff --git a/resources/mizdata/syria/Khalkhalah Defenses.miz b/resources/mizdata/syria/Khalkhalah Defenses.miz deleted file mode 100644 index 7584826b..00000000 Binary files a/resources/mizdata/syria/Khalkhalah Defenses.miz and /dev/null differ diff --git a/resources/mizdata/syria/King Hussein Air College.miz b/resources/mizdata/syria/King Hussein Air College.miz deleted file mode 100644 index 6998a4ac..00000000 Binary files a/resources/mizdata/syria/King Hussein Air College.miz and /dev/null differ diff --git a/resources/mizdata/thechannel/Abbeville Drucat.miz b/resources/mizdata/thechannel/Abbeville Drucat.miz deleted file mode 100644 index aa609d0d..00000000 Binary files a/resources/mizdata/thechannel/Abbeville Drucat.miz and /dev/null differ diff --git a/resources/mizdata/thechannel/Detling.miz b/resources/mizdata/thechannel/Detling.miz deleted file mode 100644 index d4cecfbb..00000000 Binary files a/resources/mizdata/thechannel/Detling.miz and /dev/null differ diff --git a/resources/mizdata/thechannel/Dunkirk Mardyck.miz b/resources/mizdata/thechannel/Dunkirk Mardyck.miz deleted file mode 100644 index 1c86c3fc..00000000 Binary files a/resources/mizdata/thechannel/Dunkirk Mardyck.miz and /dev/null differ diff --git a/resources/mizdata/thechannel/Hawkinge.miz b/resources/mizdata/thechannel/Hawkinge.miz deleted file mode 100644 index 29981845..00000000 Binary files a/resources/mizdata/thechannel/Hawkinge.miz and /dev/null differ diff --git a/resources/mizdata/thechannel/High Halden.miz b/resources/mizdata/thechannel/High Halden.miz deleted file mode 100644 index 9cf4d23e..00000000 Binary files a/resources/mizdata/thechannel/High Halden.miz and /dev/null differ diff --git a/resources/mizdata/thechannel/Lympne.miz b/resources/mizdata/thechannel/Lympne.miz deleted file mode 100644 index 58e55610..00000000 Binary files a/resources/mizdata/thechannel/Lympne.miz and /dev/null differ diff --git a/resources/mizdata/thechannel/Manston.miz b/resources/mizdata/thechannel/Manston.miz deleted file mode 100644 index ea7b9fda..00000000 Binary files a/resources/mizdata/thechannel/Manston.miz and /dev/null differ diff --git a/resources/mizdata/thechannel/Merville Calonne.miz b/resources/mizdata/thechannel/Merville Calonne.miz deleted file mode 100644 index e0be3b8a..00000000 Binary files a/resources/mizdata/thechannel/Merville Calonne.miz and /dev/null differ diff --git a/resources/mizdata/thechannel/Saint Omer Longuenesse.miz b/resources/mizdata/thechannel/Saint Omer Longuenesse.miz deleted file mode 100644 index 7b3daa77..00000000 Binary files a/resources/mizdata/thechannel/Saint Omer Longuenesse.miz and /dev/null differ diff --git a/resources/plugins/jtacautolase/jtacautolase-config.lua b/resources/plugins/jtacautolase/jtacautolase-config.lua index 38d02911..c8ad8301 100644 --- a/resources/plugins/jtacautolase/jtacautolase-config.lua +++ b/resources/plugins/jtacautolase/jtacautolase-config.lua @@ -2,7 +2,7 @@ -- configuration file for the JTAC Autolase framework -- -- This configuration is tailored for a mission generated by DCS Liberation --- see https://github.com/Khopa/dcs_liberation +-- see https://github.com/dcs-liberation/dcs_liberation ------------------------------------------------------------------------------------------------------------------------------------------------------------- -- JTACAutolase plugin - configuration diff --git a/resources/plugins/lotatc/LotAtcExport-config.lua b/resources/plugins/lotatc/LotAtcExport-config.lua index 494be5f1..f7b0d876 100644 --- a/resources/plugins/lotatc/LotAtcExport-config.lua +++ b/resources/plugins/lotatc/LotAtcExport-config.lua @@ -2,7 +2,7 @@ -- configuration file for the LotATC Export script -- -- This configuration is tailored for a mission generated by DCS Liberation --- see https://github.com/Khopa/dcs_liberation +-- see https://github.com/dcs-liberation/dcs_liberation ------------------------------------------------------------------------------------------------------------------------------------------------------------- -- LotATC Export plugin - configuration diff --git a/resources/plugins/skynetiads/skynet-iads-compiled.lua b/resources/plugins/skynetiads/skynet-iads-compiled.lua index 7b7939a4..b13f1bb5 100644 --- a/resources/plugins/skynetiads/skynet-iads-compiled.lua +++ b/resources/plugins/skynetiads/skynet-iads-compiled.lua @@ -1,4 +1,4 @@ -env.info("--- SKYNET VERSION: 2.0.1 | BUILD TIME: 04.01.2021 0706Z ---") +env.info("--- SKYNET VERSION: 2.1.0 | BUILD TIME: 27.03.2021 2125Z ---") do --this file contains the required units per sam type samTypesDB = { @@ -387,6 +387,20 @@ end do -- this file contains the definitions for the HightDigitSAMSs: https://github.com/Auranis/HighDigitSAMs +--EW radars used in multiple SAM systems: + +s300PMU164N6Esr = { + ['name'] = { + ['NATO'] = 'Big Bird', + }, +} + +s300PMU140B6MDsr = { + ['name'] = { + ['NATO'] = 'Clam Shell', + }, +} + --[[ units in SA-10 group Gargoyle: 2020-12-10 18:27:27.050 INFO SCRIPTING: S-300PMU1 54K6 cp 2020-12-10 18:27:27.050 INFO SCRIPTING: S-300PMU1 5P85CE ln @@ -399,21 +413,36 @@ do samTypesDB['S-300PMU1'] = { ['type'] = 'complex', ['searchRadar'] = { - ['S-300PMU1 40B6MD sr'] = { + ['S-300PMU1 40B6MD sr'] = s300PMU140B6MDsr, + ['S-300PMU1 64N6E sr'] = s300PMU164N6Esr, + + ['S-300PS 40B6MD sr'] = { ['name'] = { - ['NATO'] = 'Clam Shell', + ['NATO'] = '', }, }, - ['S-300PMU1 64N6E sr'] = { + ['S-300PS 64H6E sr'] = { ['name'] = { - ['NATO'] = 'Big Bird', + ['NATO'] = '', }, }, }, ['trackingRadar'] = { ['S-300PMU1 40B6M tr'] = { + ['name'] = { + ['NATO'] = 'Grave Stone', + }, }, ['S-300PMU1 30N6E tr'] = { + ['name'] = { + ['NATO'] = 'Flap Lid', + }, + + }, + ['S-300PS 40B6M tr'] = { + ['name'] = { + ['NATO'] = '', + }, }, }, ['misc'] = { @@ -441,10 +470,6 @@ samTypesDB['S-300PMU1'] = { 2020-12-11 16:40:52.072 INFO SCRIPTING: S-300VM 9S32ME tr 2020-12-11 16:40:52.072 INFO SCRIPTING: S-300VM 9S457ME cp -According to wikipedia: -dem 9A83-Startfahrzeug die Bezeichnung SA-12A Gladiator zu geben; das größere 9A82-Startfahrzeug erhielt die Bezeichnung SA-12B Giant. -9A83ME -> SA-23A Gladiator -9A82ME -> SA-23B Giant ]]-- samTypesDB['S-300VM'] = { ['type'] = 'complex', @@ -476,7 +501,7 @@ samTypesDB['S-300VM'] = { }, }, ['name'] = { - ['NATO'] = 'SA-23 Gladiator/Giant' + ['NATO'] = 'SA-23 Antey-2500' }, ['harm_detection_chance'] = 90 } @@ -494,6 +519,9 @@ samTypesDB['S-300PS'] = { ['type'] = 'complex', ['searchRadar'] = { ['S-300PS SA-10B 40B6MD MAST sr'] = { + ['name'] = { + ['NATO'] = 'Clam Shell', + }, }, ['S-300PS 64H6E TRAILER sr'] = { }, @@ -503,6 +531,12 @@ samTypesDB['S-300PS'] = { }, ['S-300PS SA-10B 40B6M MAST tr'] = { }, + ['S-300PS 40B6M tr'] = { + }, + ['S-300PMU1 40B6M tr'] = { + }, + ['S-300PMU1 30N6E tr'] = { + }, }, ['misc'] = { ['S-300PS SA-10B 54K6 cp'] = { @@ -546,6 +580,9 @@ samTypesDB['Buk-M2'] = { ['type'] = 'complex', ['searchRadar'] = { ['SA-11 Buk SR 9S18M1'] = { + ['name'] = { + ['NATO'] = 'Snow Drift', + }, }, }, ['launchers'] = { @@ -581,6 +618,106 @@ New launcher for the SA-2 complex: HQ_2_Guideline_LN local s125launchers = samTypesDB['S-75']['launchers'] s125launchers['HQ_2_Guideline_LN'] = {} +--[[ +SA-12 Gladiator / Giant: +2021-03-19 21:24:22.620 INFO SCRIPTING: S-300V 9S15 sr +2021-03-19 21:24:22.620 INFO SCRIPTING: S-300V 9S19 sr +2021-03-19 21:24:22.620 INFO SCRIPTING: S-300V 9S32 tr +2021-03-19 21:24:22.620 INFO SCRIPTING: S-300V 9S457 cp +2021-03-19 21:24:22.620 INFO SCRIPTING: S-300V 9A83 ln +2021-03-19 21:24:22.620 INFO SCRIPTING: S-300V 9A82 ln +--]] +samTypesDB['S-300V'] = { + ['type'] = 'complex', + ['searchRadar'] = { + ['S-300V 9S15 sr'] = { + ['name'] = { + ['NATO'] = 'Bill Board', + }, + }, + ['S-300V 9S19 sr'] = { + ['name'] = { + ['NATO'] = 'High Screen', + }, + }, + }, + ['trackingRadar'] = { + ['S-300V 9S32 tr'] = { + ['NATO'] = 'Grill Pan', + }, + }, + ['misc'] = { + ['S-300V 9S457 cp'] = { + ['required'] = true, + }, + }, + ['launchers'] = { + ['S-300V 9A83 ln'] = { + }, + ['S-300V 9A82 ln'] = { + }, + }, + ['name'] = { + ['NATO'] = 'SA-12 Gladiator/Giant' + }, + ['harm_detection_chance'] = 90 +} + +--[[ +SA-20B Gargoyle B: + +2021-03-25 19:15:02.135 INFO SCRIPTING: S-300PMU2 64H6E2 sr +2021-03-25 19:15:02.135 INFO SCRIPTING: S-300PMU2 92H6E tr +2021-03-25 19:15:02.135 INFO SCRIPTING: S-300PMU2 5P85SE2 ln +2021-03-25 19:15:02.135 INFO SCRIPTING: S-300PMU2 54K6E2 cp +--]] + +samTypesDB['S-300PMU2'] = { + ['type'] = 'complex', + ['searchRadar'] = { + ['S-300PMU2 64H6E2 sr'] = { + ['name'] = { + ['NATO'] = '', + }, + }, + ['S-300PMU1 40B6MD sr'] = s300PMU140B6MDsr, + ['S-300PMU1 64N6E sr'] = s300PMU164N6Esr, + + ['S-300PS 40B6MD sr'] = { + ['name'] = { + ['NATO'] = '', + }, + }, + ['S-300PS 64H6E sr'] = { + ['name'] = { + ['NATO'] = '', + }, + }, + }, + ['trackingRadar'] = { + ['S-300PMU2 92H6E tr'] = { + }, + ['S-300PS 40B6M tr'] = { + }, + ['S-300PMU1 40B6M tr'] = { + }, + ['S-300PMU1 30N6E tr'] = { + }, + }, + ['misc'] = { + ['S-300PMU2 54K6E2 cp'] = { + ['required'] = true, + }, + }, + ['launchers'] = { + ['S-300PMU2 5P85SE2 ln'] = { + }, + }, + ['name'] = { + ['NATO'] = 'SA-20B Gargoyle B' + }, + ['harm_detection_chance'] = 90 +} end @@ -2545,6 +2682,10 @@ end function SkynetIADSAbstractRadarElement.finishHarmDefence(self) mist.removeFunction(self.harmSilenceID) self.harmSilenceID = nil + + if ( self:getAutonomousState() == true ) then + self:goAutonomous() + end end function SkynetIADSAbstractRadarElement:getDetectedTargets() @@ -2869,6 +3010,7 @@ function SkynetIADSEWRadar:setupElements() for typeName, dataType in pairs(SkynetIADS.database) do for entry, unitData in pairs(dataType) do if entry == 'searchRadar' then + --buildSingleUnit checks to make sure the EW radar is defined in the Skynet database. If it is not, self.searchRadars will be 0 so no ew radar will be added self:buildSingleUnit(unit, SkynetIADSSAMSearchRadar, self.searchRadars, unitData) if #self.searchRadars > 0 then local harmDetection = dataType['harm_detection_chance'] diff --git a/resources/plugins/skynetiads/skynetiads-config.lua b/resources/plugins/skynetiads/skynetiads-config.lua index fafa8104..aa0ce992 100644 --- a/resources/plugins/skynetiads/skynetiads-config.lua +++ b/resources/plugins/skynetiads/skynetiads-config.lua @@ -3,7 +3,7 @@ -- see https://github.com/walder/Skynet-IADS -- -- This configuration is tailored for a mission generated by DCS Liberation --- see https://github.com/Khopa/dcs_liberation +-- see https://github.com/dcs-liberation/dcs_liberation ------------------------------------------------------------------------------------------------------------------------------------------------------------- -- Skynet-IADS plugin - configuration diff --git a/resources/plugins/splashdamage/SplashDamage.lua b/resources/plugins/splashdamage/SplashDamage.lua deleted file mode 100644 index 442cf004..00000000 --- a/resources/plugins/splashdamage/SplashDamage.lua +++ /dev/null @@ -1,228 +0,0 @@ ---[[ - -2 October 2020 -FrozenDroid: -- Added error handling to all event handler and scheduled functions. Lua script errors can no longer bring the server down. -- Added some extra checks to which weapons to handle, make sure they actually have a warhead (how come S-8KOM's don't have a warhead field...?) -28 October 2020 -FrozenDroid: -- Uncommented error logging, actually made it an error log which shows a message box on error. -- Fixed the too restrictive weapon filter (took out the HE warhead requirement) ---]] - - -local weaponDamageEnable = 1 -local killRangeMultiplier = 0.3 -local staticDamageRangeMultiplier = 0.1 -local stunRangeMultiplier = 1.0 - -local suppressedGroups = {} -local tracked_weapons = {} -local USearchArray = {} -WpnHandler = {} - -local function getDistance(point1, point2) - - local x1 = point1.x - local y1 = point1.y - local z1 = point1.z - local x2 = point2.x - local y2 = point2.y - local z2 = point2.z - local dX = math.abs(x1-x2) - local dZ = math.abs(z1-z2) - local distance = math.sqrt(dX*dX + dZ*dZ) - - return distance - -end - -local function getDistance3D(point1, point2) - - local x1 = point1.x - local y1 = point1.y - local z1 = point1.z - local x2 = point2.x - local y2 = point2.y - local z2 = point2.z - - local dX = math.abs(x1-x2) - local dY = math.abs(y1-y2) - local dZ = math.abs(z1-z2) - local distance = math.sqrt(dX*dX + dZ*dZ + dY*dY) - - return distance - -end - -local function suppress(suppArray) - - suppressedGroups[suppArray[1]:getName()] = {["SuppGroup"] = suppArray[1], ["SuppTime"] = suppArray[2]} - if suppArray[1]:getController() then - suppArray[1]:getController():setOnOff(false) --- env.info("Group: "..suppArray[1]:getName().." suppressed") - end - -end - -local function unSuppress(unSuppGroup) --- env.info("In unSuppress") - if unSuppGroup:isExist() and unSuppGroup:getController() then - unSuppGroup:getController():setOnOff(true) - -- env.info("Got controller") - -- env.info("Suppressed group removed from table") - suppressedGroups[unSuppGroup:getName()] = nil - end -end - -local function ifFoundS(foundItem, impactPoint) --- trigger.action.outText("Found Static", 10) --- env.info("Found static in kill range") - local point1 = foundItem:getPoint() - point1.y = point1.y + 2 - local point2 = impactPoint - point2.y = point2.y + 2 - if land.isVisible(point1, point2) == true then --- env.info("Static"..foundItem:getID().. "Destroyed by script") - trigger.action.explosion(point1, 5) - end -end - -local function ifFoundU(foundItem, USearchArray) - --- env.info("Found Unit") - local point1 = foundItem:getPoint() --- env.info("Got point") - point1.y = point1.y + 5 - local point2 = USearchArray.point - point2.y = point2.y + 5 - if land.isVisible(point1, point2) == true then --- env.info("is visible LOS") - local distanceFrom = getDistance(point1, point2) --- env.info("got distance: "..distanceFrom) - if distanceFrom < USearchArray.exMass*killRangeMultiplier then - trigger.action.explosion(foundItem:getPoint(), 1) - -- env.info("Unit: "..foundItem:getName().." was destroyed by script") - -- else - -- local suppTimer = math.random(30,100) - -- local suppArray = {foundItem:getGroup(), suppTimer} - -- suppress(suppArray) - end - end - -end - -local function track_wpns() --- env.info("Weapon Track Start") - for wpn_id_, wpnData in pairs(tracked_weapons) do - - if wpnData.wpn:isExist() then -- just update position and direction. - wpnData.pos = wpnData.wpn:getPosition().p - wpnData.dir = wpnData.wpn:getPosition().x - wpnData.exMass = wpnData.wpn:getDesc().warhead.explosiveMass - --wpnData.lastIP = land.getIP(wpnData.pos, wpnData.dir, 50) - else -- wpn no longer exists, must be dead. --- trigger.action.outText("Weapon impacted, mass of weapon warhead is " .. wpnData.exMass, 2) - local ip = land.getIP(wpnData.pos, wpnData.dir, 20) -- terrain intersection point with weapon's nose. Only search out 20 meters though. - local impactPoint - if not ip then -- use last calculated IP - impactPoint = wpnData.pos --- trigger.action.outText("Impact Point:\nPos X: " .. impactPoint.x .. "\nPos Z: " .. impactPoint.z, 2) - else -- use intersection point - impactPoint = ip --- trigger.action.outText("Impact Point:\nPos X: " .. impactPoint.x .. "\nPos Z: " .. impactPoint.z, 2) - end - local staticRadius = wpnData.exMass*staticDamageRangeMultiplier --- trigger.action.outText("Static Radius :"..staticRadius, 10) - local VolS = - { - id = world.VolumeType.SPHERE, - params = - { - point = impactPoint, - radius = staticRadius - } - } - local VolU = - { - id = world.VolumeType.SPHERE, - params = - { - point = impactPoint, - radius = wpnData.exMass*stunRangeMultiplier - } - } --- env.info("Static search radius: " ..wpnData.exMass*staticDamageRangeMultiplier) --- env.warning("Begin Search") --- trigger.action.outText("Beginning Searches", 10) - world.searchObjects(Object.Category.STATIC, VolS, ifFoundS,impactPoint) - USearchArray = {["point"] = impactPoint, ["exMass"] = wpnData.exMass} - world.searchObjects(Object.Category.UNIT, VolU, ifFoundU, USearchArray) --- env.warning("Finished Search") - tracked_weapons[wpn_id_] = nil -- remove from tracked weapons first. - end - end --- env.info("Weapon Track End") -end - -local function checkSuppression() --- env.info("Checking suppression") - for i, group in pairs(suppressedGroups) do --- env.info("Check group exists, #".. i) - if group.SuppGroup:isExist() then --- env.info("It does") - group.SuppTime = group.SuppTime - 10 - if group.SuppTime < 1 then --- env.info("SuppTime < 1") - unSuppress(group.SuppGroup) - end - else - suppressedGroups[group.SuppGroup:getName()] = nil - end - end --- env.info("Ending suppression check") -end - -function onWpnEvent(event) - if event.id == world.event.S_EVENT_SHOT then - if event.weapon then - local ordnance = event.weapon - local weapon_desc = ordnance:getDesc() - if (weapon_desc.category == 3 or weapon_desc.category == 2 or weapon_desc.category == 1) and not (weapon_desc.missileCategory == 1 or weapon_desc.missileCategory == 2 or weapon_desc.missileCategory == 3) and weapon_desc.warhead and weapon_desc.warhead.explosiveMass and event.initiator then - tracked_weapons[event.weapon.id_] = { wpn = ordnance, init = event.initiator:getName(), pos = ordnance:getPoint(), dir = ordnance:getPosition().x, exMass = weapon_desc.warhead.explosiveMass } --- env.info("Tracking " .. event.initiator:getName()) - end - end - end -end - -function WpnHandler:onEvent(event) - protectedCall(onWpnEvent, event) -end - -function protectedCall(...) - local status, retval = pcall(...) - if not status then - env.error("Splash damage script error... gracefully caught! " .. retval, true) - end -end - -if (weaponDamageEnable == 1) then - timer.scheduleFunction(function() - protectedCall(track_wpns) - return timer.getTime() + 1 - end, - {}, - timer.getTime() + 0.5 - ) - - timer.scheduleFunction(function() - protectedCall(checkSuppression) - return timer.getTime() + 1 - end, - {}, - timer.getTime() + 10 - ) - - world.addEventHandler(WpnHandler) -end diff --git a/resources/plugins/splashdamage/Weapons_Damage_Updated.lua b/resources/plugins/splashdamage/Weapons_Damage_Updated.lua new file mode 100644 index 00000000..3daaafee --- /dev/null +++ b/resources/plugins/splashdamage/Weapons_Damage_Updated.lua @@ -0,0 +1,209 @@ + + +--[[ +2 October 2020 +FrozenDroid: +- Added error handling to all event handler and scheduled functions. Lua script errors can no longer bring the server down. +- Added some extra checks to which weapons to handle, make sure they actually have a warhead (how come S-8KOM's don't have a warhead field...?) +28 October 2020 +FrozenDroid: +- Uncommented error logging, actually made it an error log which shows a message box on error. +- Fixed the too restrictive weapon filter (took out the HE warhead requirement) +--]] + +explTable = { + ["FAB_100"] = 45, + ["FAB_250"] = 100, + ["FAB_250M54TU"]= 100, + ["FAB_500"] = 213, + ["FAB_1500"] = 675, + ["BetAB_500"] = 98, + ["BetAB_500ShP"]= 107, + ["KH-66_Grom"] = 108, + ["M_117"] = 201, + ["Mk_81"] = 60, + ["Mk_82"] = 118, + ["AN_M64"] = 121, + ["Mk_83"] = 274, + ["Mk_84"] = 582, + ["MK_82AIR"] = 118, + ["MK_82SNAKEYE"]= 118, + ["GBU_10"] = 582, + ["GBU_12"] = 118, + ["GBU_16"] = 274, + ["KAB_1500Kr"] = 675, + ["KAB_500Kr"] = 213, + ["KAB_500"] = 213, + ["GBU_31"] = 582, + ["GBU_31_V_3B"] = 582, + ["GBU_31_V_2B"] = 582, + ["GBU_31_V_4B"] = 582, + ["GBU_32_V_2B"] = 202, + ["GBU_38"] = 118, + ["AGM_62"] = 400, + ["GBU_24"] = 582, + ["X_23"] = 111, + ["X_23L"] = 111, + ["X_28"] = 160, + ["X_25ML"] = 89, + ["X_25MP"] = 89, + ["X_25MR"] = 140, + ["X_58"] = 140, + ["X_29L"] = 320, + ["X_29T"] = 320, + ["X_29TE"] = 320, + ["AGM_84E"] = 488, + ["AGM_88C"] = 89, + ["AGM_122"] = 15, + ["AGM_123"] = 274, + ["AGM_130"] = 582, + ["AGM_119"] = 176, + ["AGM_154C"] = 305, + ["S-24A"] = 24, + --["S-24B"] = 123, + ["S-25OF"] = 194, + ["S-25OFM"] = 150, + ["S-25O"] = 150, + ["S_25L"] = 190, + ["S-5M"] = 1, + ["C_8"] = 4, + ["C_8OFP2"] = 3, + ["C_13"] = 21, + ["C_24"] = 123, + ["C_25"] = 151, + ["HYDRA_70M15"] = 2, + ["Zuni_127"] = 5, + ["ARAKM70BHE"] = 4, + ["BR_500"] = 118, + ["Rb 05A"] = 217, + ["HEBOMB"] = 40, + ["HEBOMBD"] = 40, + ["MK-81SE"] = 60, + ["AN-M57"] = 56, + ["AN-M64"] = 180, + ["AN-M65"] = 295, + ["AN-M66A2"] = 536, +} + +local weaponDamageEnable = 1 +WpnHandler = {} +tracked_weapons = {} +refreshRate = 0.1 + +local function getDistance(point1, point2) + local x1 = point1.x + local y1 = point1.y + local z1 = point1.z + local x2 = point2.x + local y2 = point2.y + local z2 = point2.z + local dX = math.abs(x1-x2) + local dZ = math.abs(z1-z2) + local distance = math.sqrt(dX*dX + dZ*dZ) + return distance +end + +local function getDistance3D(point1, point2) + local x1 = point1.x + local y1 = point1.y + local z1 = point1.z + local x2 = point2.x + local y2 = point2.y + local z2 = point2.z + local dX = math.abs(x1-x2) + local dY = math.abs(y1-y2) + local dZ = math.abs(z1-z2) + local distance = math.sqrt(dX*dX + dZ*dZ + dY*dY) + return distance +end + +local function vec3Mag(speedVec) + + mag = speedVec.x*speedVec.x + speedVec.y*speedVec.y+speedVec.z*speedVec.z + mag = math.sqrt(mag) + --trigger.action.outText("X = " .. speedVec.x ..", y = " .. speedVec.y .. ", z = "..speedVec.z, 10) + --trigger.action.outText("Speed = " .. mag, 1) + return mag + +end + +local function lookahead(speedVec) + + speed = vec3Mag(speedVec) + dist = speed * refreshRate * 1.5 + return dist + +end + +local function track_wpns() +-- env.info("Weapon Track Start") + for wpn_id_, wpnData in pairs(tracked_weapons) do + if wpnData.wpn:isExist() then -- just update speed, position and direction. + wpnData.pos = wpnData.wpn:getPosition().p + wpnData.dir = wpnData.wpn:getPosition().x + wpnData.speed = wpnData.wpn:getVelocity() + --wpnData.lastIP = land.getIP(wpnData.pos, wpnData.dir, 50) + else -- wpn no longer exists, must be dead. +-- trigger.action.outText("Weapon impacted, mass of weapon warhead is " .. wpnData.exMass, 2) + local ip = land.getIP(wpnData.pos, wpnData.dir, lookahead(wpnData.speed)) -- terrain intersection point with weapon's nose. Only search out 20 meters though. + local impactPoint + if not ip then -- use last calculated IP + impactPoint = wpnData.pos + -- trigger.action.outText("Impact Point:\nPos X: " .. impactPoint.x .. "\nPos Z: " .. impactPoint.z, 2) + else -- use intersection point + impactPoint = ip + -- trigger.action.outText("Impact Point:\nPos X: " .. impactPoint.x .. "\nPos Z: " .. impactPoint.z, 2) + end + --env.info("Weapon is gone") -- Got to here -- + --trigger.action.outText("Weapon Type was: ".. wpnData.name, 20) + if explTable[wpnData.name] then + --env.info("triggered explosion size: "..explTable[wpnData.name]) + trigger.action.explosion(impactPoint, explTable[wpnData.name]) + --trigger.action.smoke(impactPoint, 0) + end + tracked_weapons[wpn_id_] = nil -- remove from tracked weapons first. + end + end +-- env.info("Weapon Track End") +end + +function onWpnEvent(event) + if event.id == world.event.S_EVENT_SHOT then + if event.weapon then + local ordnance = event.weapon + local weapon_desc = ordnance:getDesc() + if (weapon_desc.category ~= 0) and event.initiator then + if (weapon_desc.category == 1) then + if (weapon_desc.MissileCategory ~= 1 and weapon_desc.MissileCategory ~= 2) then + tracked_weapons[event.weapon.id_] = { wpn = ordnance, init = event.initiator:getName(), pos = ordnance:getPoint(), dir = ordnance:getPosition().x, name = ordnance:getTypeName(), speed = ordnance:getVelocity() } + end + else + tracked_weapons[event.weapon.id_] = { wpn = ordnance, init = event.initiator:getName(), pos = ordnance:getPoint(), dir = ordnance:getPosition().x, name = ordnance:getTypeName(), speed = ordnance:getVelocity() } + end + end + end + end +end + +local function protectedCall(...) + local status, retval = pcall(...) + if not status then + env.warning("Splash damage script error... gracefully caught! " .. retval, true) + end +end + + +function WpnHandler:onEvent(event) + protectedCall(onWpnEvent, event) +end + +if (weaponDamageEnable == 1) then + timer.scheduleFunction(function() + protectedCall(track_wpns) + return timer.getTime() + refreshRate + end, + {}, + timer.getTime() + refreshRate + ) + world.addEventHandler(WpnHandler) +end diff --git a/resources/plugins/splashdamage/plugin.json b/resources/plugins/splashdamage/plugin.json index b0953d62..637bf5f9 100644 --- a/resources/plugins/splashdamage/plugin.json +++ b/resources/plugins/splashdamage/plugin.json @@ -4,9 +4,9 @@ "specificOptions": [], "scriptsWorkOrders": [ { - "file": "SplashDamage.lua", + "file": "Weapons_Damage_Updated.lua", "mnemonic": "Splash Damage" } ], "configurationWorkOrders": [] -} \ No newline at end of file +} diff --git a/resources/squadrons/globemaster/15th-Airlift.yaml b/resources/squadrons/globemaster/15th-Airlift.yaml new file mode 100644 index 00000000..510352f6 --- /dev/null +++ b/resources/squadrons/globemaster/15th-Airlift.yaml @@ -0,0 +1,8 @@ +--- +name: 15th Airlift Squadron +nickname: Global Eagles +country: USA +role: Airlift +aircraft: C-17A +mission_types: + - Transport diff --git a/resources/squadrons/hornet/VFA-113.yaml b/resources/squadrons/hornet/VFA-113.yaml new file mode 100644 index 00000000..d895e982 --- /dev/null +++ b/resources/squadrons/hornet/VFA-113.yaml @@ -0,0 +1,22 @@ +--- +name: VFA-113 +nickname: Stingers +country: USA +role: Strike Fighter +aircraft: FA-18C_hornet +livery: VFA-113 +mission_types: + - Anti-ship + - BAI + - BARCAP + - CAS + - DEAD + - Escort + - Intercept + - OCA/Aircraft + - OCA/Runway + - SEAD + - SEAD Escort + - Strike + - Fighter sweep + - TARCAP diff --git a/resources/squadrons/hornet/VFA-192.yaml b/resources/squadrons/hornet/VFA-192.yaml new file mode 100644 index 00000000..3e3f161a --- /dev/null +++ b/resources/squadrons/hornet/VFA-192.yaml @@ -0,0 +1,22 @@ +--- +name: VFA-192 +nickname: Golden Dragons +country: USA +role: Strike Fighter +aircraft: FA-18C_hornet +livery: VFA-192 +mission_types: + - Anti-ship + - BAI + - BARCAP + - CAS + - DEAD + - Escort + - Intercept + - OCA/Aircraft + - OCA/Runway + - SEAD + - SEAD Escort + - Strike + - Fighter sweep + - TARCAP diff --git a/resources/stylesheets/style-dcs.css b/resources/stylesheets/style-dcs.css index 22945244..43aabda5 100644 --- a/resources/stylesheets/style-dcs.css +++ b/resources/stylesheets/style-dcs.css @@ -498,7 +498,6 @@ QHeaderView::section { background: #4B5B74; padding: 4px; border-style: none; - border-bottom: 1px solid #1D2731; } QHeaderView::section:horizontal @@ -515,11 +514,6 @@ QHeaderView::section:vertical background: #4B5B74; } -QTableWidget { - gridline-color: red; - background: #4B5B74; -} - QTableView QTableCornerButton::section { background: #4B5B74; } diff --git a/resources/tools/coord_export.lua b/resources/tools/coord_export.lua new file mode 100644 index 00000000..c5542522 --- /dev/null +++ b/resources/tools/coord_export.lua @@ -0,0 +1,38 @@ +local function dump_coords() + local coordinates = {} + local bases = world.getAirbases() + for i = 1, #bases do + local base = bases[i] + point = Airbase.getPoint(base) + lat, lon, alt = coord.LOtoLL(point) + coordinates[Airbase.getName(base)] = { + ["point"] = point, + ["LL"] = { + ["lat"] = lat, + ["lon"] = lon, + ["alt"] = alt, + }, + } + end + + zero = { + ["x"] = 0, + ["y"] = 0, + ["z"] = 0, + } + lat, lon, alt = coord.LOtoLL(zero) + coordinates["zero"] = { + ["point"] = zero, + ["LL"] = { + ["lat"] = lat, + ["lon"] = lon, + ["alt"] = alt, + }, + } + + local fp = io.open(lfs.writedir() .. "\\coords.json", 'w') + fp:write(json:encode(coordinates)) + fp:close() +end + +dump_coords() \ No newline at end of file diff --git a/resources/tools/export_coordinates.py b/resources/tools/export_coordinates.py new file mode 100644 index 00000000..d3605238 --- /dev/null +++ b/resources/tools/export_coordinates.py @@ -0,0 +1,255 @@ +"""Command line tool for exporting coordinates from DCS to derive projection data. + +DCS X/Z coordinates are meter-scale projections of a transverse mercator grid. The +projection has a few required parameters: + +1. Scale factor. Is 0.9996 for most regions: + https://proj.org/operations/projections/tmerc.html. +2. Central meridian of the projection. Easily guessed because there are only 60 UTM + zones and one of those is always used. +3. A false easting and northing (offsets from UTM's center point to DCS's). These aren't + easily guessed, but can be computed by using an offset of 0 and finding the error of + projecting the 0 point from DCS. + +This tool creates a mission that will dump the lat/lon and x/z coordinates of the 0/0 +point and also every airport in the given theater. The data for the zero point is used +to compute the false easting and northing for the map. The data for each airport is used +to test the projection for errors. + +The resulting data is exported to game/theater/.py as a TransverseMercator object. +""" +from __future__ import annotations + +import argparse +import json +import math +import sys +import textwrap +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict + +from dcs import Mission +from dcs.action import DoScriptFile +from dcs.terrain.caucasus import Caucasus +from dcs.terrain.nevada import Nevada +from dcs.terrain.normandy import Normandy +from dcs.terrain.persiangulf import PersianGulf +from dcs.terrain.syria import Syria +from dcs.terrain.terrain import Terrain +from dcs.terrain.thechannel import TheChannel +from dcs.triggers import TriggerStart +from pyproj import CRS, Transformer + +from game import persistency +from game.theater.projections import TransverseMercator +from qt_ui import liberation_install + +THIS_DIR = Path(__file__).resolve().parent +JSON_LUA = THIS_DIR.parent / "plugins/base/json.lua" +EXPORT_LUA = THIS_DIR / "coord_export.lua" +SAVE_DIR = THIS_DIR.parent / "coordinate_reference" + + +ARG_TO_TERRAIN_MAP = { + "caucasus": Caucasus(), + "nevada": Nevada(), + "normandy": Normandy(), + "persiangulf": PersianGulf(), + "thechannel": TheChannel(), + "syria": Syria(), +} + +# https://gisgeography.com/central-meridian/ +# UTM zones determined by guess and check. There are only a handful in the region for +# each map and getting the wrong one will be flagged with errors when processing. +CENTRAL_MERIDIANS = { + "caucasus": 33, + "nevada": -117, + "normandy": -3, + "persiangulf": 57, + "thechannel": 3, + "syria": 39, +} + + +@dataclass(frozen=True) +class Coordinates: + x: float + y: float + z: float + + latitude: float + longitude: float + altitude: float + + @classmethod + def from_json(cls, data: Dict[str, Any]) -> Coordinates: + return cls( + x=data["point"]["x"], + y=data["point"]["y"], + z=data["point"]["z"], + latitude=data["LL"]["lat"], + longitude=data["LL"]["lon"], + altitude=data["LL"]["alt"], + ) + + +def create_mission(terrain: Terrain) -> Path: + m = Mission(terrain) + + json_trigger = TriggerStart(comment=f"Load JSON") + json_lua = m.map_resource.add_resource_file(JSON_LUA) + json_trigger.add_action(DoScriptFile(json_lua)) + m.triggerrules.triggers.append(json_trigger) + + export_trigger = TriggerStart(comment=f"Load coordinate export") + export_lua = m.map_resource.add_resource_file(EXPORT_LUA) + export_trigger.add_action(DoScriptFile(export_lua)) + m.triggerrules.triggers.append(export_trigger) + + mission_path = persistency.mission_path_for(f"export_{terrain.name.lower()}.miz") + m.save(mission_path) + return Path(mission_path) + + +def load_coordinate_data(data: Dict[str, Any]) -> Dict[str, Coordinates]: + airbases = {} + for name, coord_data in data.items(): + airbases[name] = Coordinates.from_json(coord_data) + return airbases + + +def test_for_errors( + name: str, + lat_lon_to_x_z: Transformer, + x_z_to_lat_lon: Transformer, + coords: Coordinates, +) -> bool: + errors = False + + x, z = lat_lon_to_x_z.transform(coords.latitude, coords.longitude) + if not math.isclose(x, coords.x) or not math.isclose(z, coords.z): + error_x = x - coords.x + error_z = z - coords.z + error_pct_x = error_x / coords.x * 100 + error_pct_z = error_z / coords.z * 100 + print(f"{name} has error of {error_pct_x}% {error_pct_z}%") + errors = True + + lat, lon = x_z_to_lat_lon.transform(coords.x, coords.z) + if not math.isclose(lat, coords.latitude) or not math.isclose( + lon, coords.longitude + ): + error_lat = lat - coords.latitude + error_lon = lon - coords.longitude + error_pct_lon = error_lat / coords.latitude * 100 + error_pct_lat = error_lon / coords.longitude * 100 + print(f"{name} has error of {error_pct_lat}% {error_pct_lon}%") + errors = True + + return errors + + +def test_parameters( + airbases: Dict[str, Coordinates], parameters: TransverseMercator +) -> bool: + errors = False + wgs84 = CRS("WGS84") + crs = parameters.to_crs() + lat_lon_to_x_z = Transformer.from_crs(wgs84, crs) + x_z_to_lat_lon = Transformer.from_crs(crs, wgs84) + for name, coords in airbases.items(): + if name == "zero": + continue + if test_for_errors(name, lat_lon_to_x_z, x_z_to_lat_lon, coords): + errors = True + return errors + + +def compute_tmerc_parameters( + coordinates_file: Path, terrain: str +) -> TransverseMercator: + + data = json.loads(coordinates_file.read_text()) + airbases = load_coordinate_data(data) + wgs84 = CRS("WGS84") + + # Creates a transformer with 0 for the false easting and northing, but otherwise has + # the correct parameters. We'll use this to transform the zero point from the + # mission to calculate the error from the actual zero point to determine the correct + # false easting and northing. + bad = TransverseMercator( + central_meridian=CENTRAL_MERIDIANS[terrain], + false_easting=0, + false_northing=0, + scale_factor=0.9996, + ).to_crs() + zero_finder = Transformer.from_crs(wgs84, bad) + z, x = zero_finder.transform(airbases["zero"].latitude, airbases["zero"].longitude) + + parameters = TransverseMercator( + central_meridian=CENTRAL_MERIDIANS[terrain], + false_easting=-x, + false_northing=-z, + scale_factor=0.9996, + ) + + if test_parameters(airbases, parameters): + sys.exit("Found errors in projection parameters. Quitting.") + + return parameters + + +@contextmanager +def mission_scripting(): + liberation_install.replace_mission_scripting_file() + try: + yield + finally: + liberation_install.restore_original_mission_scripting() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + + parser.add_argument("map", choices=list(ARG_TO_TERRAIN_MAP.keys())) + + return parser.parse_args() + + +def main() -> None: + if liberation_install.init(): + print("Set up Liberation first.") + return + + args = parse_args() + terrain = ARG_TO_TERRAIN_MAP[args.map] + mission = create_mission(terrain) + with mission_scripting(): + input( + f"Created {mission} and replaced MissionScript.lua. Open DCS and load the " + "mission. Once the mission starts running, close it and press enter." + ) + coords_path = Path(persistency.base_path()) / "coords.json" + parameters = compute_tmerc_parameters(coords_path, args.map) + out_file = THIS_DIR.parent.parent / "game/theater" / f"{args.map}.py" + out_file.write_text( + textwrap.dedent( + f"""\ + from game.theater.projections import TransverseMercator + + PARAMETERS = TransverseMercator( + central_meridian={parameters.central_meridian}, + false_easting={parameters.false_easting}, + false_northing={parameters.false_northing}, + scale_factor={parameters.scale_factor}, + ) + """ + ) + ) + + +if __name__ == "__main__": + main() diff --git a/resources/tools/mkrelease.py b/resources/tools/mkrelease.py index 5d70e86d..1704dda1 100644 --- a/resources/tools/mkrelease.py +++ b/resources/tools/mkrelease.py @@ -1,7 +1,9 @@ import os +from pathlib import Path import shutil -from zipfile import * +THIS_DIR = Path(__file__).resolve() +SRC_DIR = THIS_DIR.parents[1] IGNORED_PATHS = [ @@ -15,10 +17,8 @@ IGNORED_PATHS = [ "venv", ] -VERSION = input("version str:") - -def _zip_dir(archieve, path): +def _zip_dir(archive, path): for path, directories, files in os.walk(path): is_ignored = False for ignored_path in IGNORED_PATHS: @@ -32,29 +32,16 @@ def _zip_dir(archieve, path): for file in files: if file in IGNORED_PATHS: continue - archieve.write(os.path.join(path, file)) + archive.write(os.path.join(path, file)) -def _mk_archieve(): - path = os.path.join( - os.path.dirname(__file__), - os.pardir, - os.pardir, - "build", - "dcs_liberation_{}.zip".format(VERSION), - ) - if os.path.exists(path): - print("version already exists") - return - +def main(): try: shutil.rmtree("./dist") except FileNotFoundError: pass os.system("pyinstaller.exe --clean pyinstaller.spec") - # archieve = ZipFile(path, "w") - # archieve.writestr("dcs_liberation.bat", "cd dist\\dcs_liberation\r\nliberation_main \"%UserProfile%\\Saved Games\" \"{}\"".format(VERSION)) - # _zip_dir(archieve, "./dist/dcs_liberation") -_mk_archieve() +if __name__ == "__main__": + main() diff --git a/resources/ui/ground_assets/aa_blue_alive.svg b/resources/ui/ground_assets/aa_blue_alive.svg new file mode 100644 index 00000000..d6af6e07 --- /dev/null +++ b/resources/ui/ground_assets/aa_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/aa_blue_damaged.svg b/resources/ui/ground_assets/aa_blue_damaged.svg new file mode 100644 index 00000000..c098893b --- /dev/null +++ b/resources/ui/ground_assets/aa_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/aa_blue_destroyed.svg b/resources/ui/ground_assets/aa_blue_destroyed.svg new file mode 100644 index 00000000..ed5960aa --- /dev/null +++ b/resources/ui/ground_assets/aa_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/aa_red_alive.svg b/resources/ui/ground_assets/aa_red_alive.svg new file mode 100644 index 00000000..3375cf66 --- /dev/null +++ b/resources/ui/ground_assets/aa_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/aa_red_damaged.svg b/resources/ui/ground_assets/aa_red_damaged.svg new file mode 100644 index 00000000..7e56fd59 --- /dev/null +++ b/resources/ui/ground_assets/aa_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/aa_red_destroyed.svg b/resources/ui/ground_assets/aa_red_destroyed.svg new file mode 100644 index 00000000..b861fc4f --- /dev/null +++ b/resources/ui/ground_assets/aa_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/airfield_blue_alive.svg b/resources/ui/ground_assets/airfield_blue_alive.svg new file mode 100644 index 00000000..dcc9a38e --- /dev/null +++ b/resources/ui/ground_assets/airfield_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/airfield_blue_damaged.svg b/resources/ui/ground_assets/airfield_blue_damaged.svg new file mode 100644 index 00000000..107f6b62 --- /dev/null +++ b/resources/ui/ground_assets/airfield_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/airfield_blue_destroyed.svg b/resources/ui/ground_assets/airfield_blue_destroyed.svg new file mode 100644 index 00000000..7d49ff30 --- /dev/null +++ b/resources/ui/ground_assets/airfield_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/airfield_red_alive.svg b/resources/ui/ground_assets/airfield_red_alive.svg new file mode 100644 index 00000000..1b481dd0 --- /dev/null +++ b/resources/ui/ground_assets/airfield_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/airfield_red_damaged.svg b/resources/ui/ground_assets/airfield_red_damaged.svg new file mode 100644 index 00000000..39693c44 --- /dev/null +++ b/resources/ui/ground_assets/airfield_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/airfield_red_destroyed.svg b/resources/ui/ground_assets/airfield_red_destroyed.svg new file mode 100644 index 00000000..1a2303a1 --- /dev/null +++ b/resources/ui/ground_assets/airfield_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/allycamp_blue_alive.svg b/resources/ui/ground_assets/allycamp_blue_alive.svg new file mode 100644 index 00000000..fdcb4754 --- /dev/null +++ b/resources/ui/ground_assets/allycamp_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/allycamp_blue_damaged.svg b/resources/ui/ground_assets/allycamp_blue_damaged.svg new file mode 100644 index 00000000..e37e3eab --- /dev/null +++ b/resources/ui/ground_assets/allycamp_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/allycamp_blue_destroyed.svg b/resources/ui/ground_assets/allycamp_blue_destroyed.svg new file mode 100644 index 00000000..4f58f9c0 --- /dev/null +++ b/resources/ui/ground_assets/allycamp_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/allycamp_red_alive.svg b/resources/ui/ground_assets/allycamp_red_alive.svg new file mode 100644 index 00000000..4c3338fc --- /dev/null +++ b/resources/ui/ground_assets/allycamp_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/allycamp_red_damaged.svg b/resources/ui/ground_assets/allycamp_red_damaged.svg new file mode 100644 index 00000000..ce234ce9 --- /dev/null +++ b/resources/ui/ground_assets/allycamp_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/allycamp_red_destroyed.svg b/resources/ui/ground_assets/allycamp_red_destroyed.svg new file mode 100644 index 00000000..640ff25d --- /dev/null +++ b/resources/ui/ground_assets/allycamp_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ammo_blue_alive.svg b/resources/ui/ground_assets/ammo_blue_alive.svg new file mode 100644 index 00000000..e100dd64 --- /dev/null +++ b/resources/ui/ground_assets/ammo_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ammo_blue_damaged.svg b/resources/ui/ground_assets/ammo_blue_damaged.svg new file mode 100644 index 00000000..39f2f524 --- /dev/null +++ b/resources/ui/ground_assets/ammo_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ammo_blue_destroyed.svg b/resources/ui/ground_assets/ammo_blue_destroyed.svg new file mode 100644 index 00000000..56e7b193 --- /dev/null +++ b/resources/ui/ground_assets/ammo_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ammo_red_alive.svg b/resources/ui/ground_assets/ammo_red_alive.svg new file mode 100644 index 00000000..6672db0d --- /dev/null +++ b/resources/ui/ground_assets/ammo_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ammo_red_damaged.svg b/resources/ui/ground_assets/ammo_red_damaged.svg new file mode 100644 index 00000000..ec899cba --- /dev/null +++ b/resources/ui/ground_assets/ammo_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ammo_red_destroyed.svg b/resources/ui/ground_assets/ammo_red_destroyed.svg new file mode 100644 index 00000000..5a5f0e92 --- /dev/null +++ b/resources/ui/ground_assets/ammo_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/armor.png b/resources/ui/ground_assets/armor.png new file mode 100644 index 00000000..6034304c Binary files /dev/null and b/resources/ui/ground_assets/armor.png differ diff --git a/resources/ui/ground_assets/armor_blue.png b/resources/ui/ground_assets/armor_blue.png new file mode 100644 index 00000000..6034304c Binary files /dev/null and b/resources/ui/ground_assets/armor_blue.png differ diff --git a/resources/ui/ground_assets/armor_blue_alive.svg b/resources/ui/ground_assets/armor_blue_alive.svg new file mode 100644 index 00000000..e8127d8a --- /dev/null +++ b/resources/ui/ground_assets/armor_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/armor_blue_damaged.svg b/resources/ui/ground_assets/armor_blue_damaged.svg new file mode 100644 index 00000000..22b2b5df --- /dev/null +++ b/resources/ui/ground_assets/armor_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/armor_blue_destroyed.svg b/resources/ui/ground_assets/armor_blue_destroyed.svg new file mode 100644 index 00000000..a9390423 --- /dev/null +++ b/resources/ui/ground_assets/armor_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/armor_red_alive.svg b/resources/ui/ground_assets/armor_red_alive.svg new file mode 100644 index 00000000..60f41ca0 --- /dev/null +++ b/resources/ui/ground_assets/armor_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/armor_red_damaged.svg b/resources/ui/ground_assets/armor_red_damaged.svg new file mode 100644 index 00000000..a02c58ef --- /dev/null +++ b/resources/ui/ground_assets/armor_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/armor_red_destroyed.svg b/resources/ui/ground_assets/armor_red_destroyed.svg new file mode 100644 index 00000000..73272413 --- /dev/null +++ b/resources/ui/ground_assets/armor_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/coastal_blue_alive.svg b/resources/ui/ground_assets/coastal_blue_alive.svg new file mode 100644 index 00000000..c978973c --- /dev/null +++ b/resources/ui/ground_assets/coastal_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/coastal_blue_damaged.svg b/resources/ui/ground_assets/coastal_blue_damaged.svg new file mode 100644 index 00000000..275bfb74 --- /dev/null +++ b/resources/ui/ground_assets/coastal_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/coastal_blue_destroyed.svg b/resources/ui/ground_assets/coastal_blue_destroyed.svg new file mode 100644 index 00000000..a5b66ea1 --- /dev/null +++ b/resources/ui/ground_assets/coastal_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/coastal_red_alive.svg b/resources/ui/ground_assets/coastal_red_alive.svg new file mode 100644 index 00000000..80ffd3e8 --- /dev/null +++ b/resources/ui/ground_assets/coastal_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/coastal_red_damaged.svg b/resources/ui/ground_assets/coastal_red_damaged.svg new file mode 100644 index 00000000..72146d2f --- /dev/null +++ b/resources/ui/ground_assets/coastal_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/coastal_red_destroyed.svg b/resources/ui/ground_assets/coastal_red_destroyed.svg new file mode 100644 index 00000000..bf6f2cd5 --- /dev/null +++ b/resources/ui/ground_assets/coastal_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/comms_blue_alive.svg b/resources/ui/ground_assets/comms_blue_alive.svg new file mode 100644 index 00000000..8492da13 --- /dev/null +++ b/resources/ui/ground_assets/comms_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/comms_blue_damaged.svg b/resources/ui/ground_assets/comms_blue_damaged.svg new file mode 100644 index 00000000..c2a82bc8 --- /dev/null +++ b/resources/ui/ground_assets/comms_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/comms_blue_destroyed.svg b/resources/ui/ground_assets/comms_blue_destroyed.svg new file mode 100644 index 00000000..7180db20 --- /dev/null +++ b/resources/ui/ground_assets/comms_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/comms_red_alive.svg b/resources/ui/ground_assets/comms_red_alive.svg new file mode 100644 index 00000000..48dec8e2 --- /dev/null +++ b/resources/ui/ground_assets/comms_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/comms_red_damaged.svg b/resources/ui/ground_assets/comms_red_damaged.svg new file mode 100644 index 00000000..8040ea4a --- /dev/null +++ b/resources/ui/ground_assets/comms_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/comms_red_destroyed.svg b/resources/ui/ground_assets/comms_red_destroyed.svg new file mode 100644 index 00000000..8ed19b3f --- /dev/null +++ b/resources/ui/ground_assets/comms_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/cv_blue_alive.svg b/resources/ui/ground_assets/cv_blue_alive.svg new file mode 100644 index 00000000..4ae3a57c --- /dev/null +++ b/resources/ui/ground_assets/cv_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/cv_blue_damaged.svg b/resources/ui/ground_assets/cv_blue_damaged.svg new file mode 100644 index 00000000..33b0c0bb --- /dev/null +++ b/resources/ui/ground_assets/cv_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/cv_blue_destination.svg b/resources/ui/ground_assets/cv_blue_destination.svg new file mode 100644 index 00000000..9e4ae54f --- /dev/null +++ b/resources/ui/ground_assets/cv_blue_destination.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/cv_blue_destroyed.svg b/resources/ui/ground_assets/cv_blue_destroyed.svg new file mode 100644 index 00000000..55ef5cc2 --- /dev/null +++ b/resources/ui/ground_assets/cv_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/cv_red_alive.svg b/resources/ui/ground_assets/cv_red_alive.svg new file mode 100644 index 00000000..b71d1366 --- /dev/null +++ b/resources/ui/ground_assets/cv_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/cv_red_damaged.svg b/resources/ui/ground_assets/cv_red_damaged.svg new file mode 100644 index 00000000..22e50f5f --- /dev/null +++ b/resources/ui/ground_assets/cv_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/cv_red_destination.svg b/resources/ui/ground_assets/cv_red_destination.svg new file mode 100644 index 00000000..3e92f8e3 --- /dev/null +++ b/resources/ui/ground_assets/cv_red_destination.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/cv_red_destroyed.svg b/resources/ui/ground_assets/cv_red_destroyed.svg new file mode 100644 index 00000000..b8a8e243 --- /dev/null +++ b/resources/ui/ground_assets/cv_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/derrick_blue_alive.svg b/resources/ui/ground_assets/derrick_blue_alive.svg new file mode 100644 index 00000000..088202c3 --- /dev/null +++ b/resources/ui/ground_assets/derrick_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/derrick_blue_damaged.svg b/resources/ui/ground_assets/derrick_blue_damaged.svg new file mode 100644 index 00000000..da58acec --- /dev/null +++ b/resources/ui/ground_assets/derrick_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/derrick_blue_destroyed .svg b/resources/ui/ground_assets/derrick_blue_destroyed .svg new file mode 100644 index 00000000..139ea116 --- /dev/null +++ b/resources/ui/ground_assets/derrick_blue_destroyed .svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/derrick_red_alive.svg b/resources/ui/ground_assets/derrick_red_alive.svg new file mode 100644 index 00000000..7a103a23 --- /dev/null +++ b/resources/ui/ground_assets/derrick_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/derrick_red_damaged.svg b/resources/ui/ground_assets/derrick_red_damaged.svg new file mode 100644 index 00000000..f6859a06 --- /dev/null +++ b/resources/ui/ground_assets/derrick_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/derrick_red_destroyed.svg b/resources/ui/ground_assets/derrick_red_destroyed.svg new file mode 100644 index 00000000..2ba91be4 --- /dev/null +++ b/resources/ui/ground_assets/derrick_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ewr_blue_alive.svg b/resources/ui/ground_assets/ewr_blue_alive.svg new file mode 100644 index 00000000..1ffa8d5b --- /dev/null +++ b/resources/ui/ground_assets/ewr_blue_alive.svg @@ -0,0 +1 @@ +EWG \ No newline at end of file diff --git a/resources/ui/ground_assets/ewr_blue_damaged.svg b/resources/ui/ground_assets/ewr_blue_damaged.svg new file mode 100644 index 00000000..99792494 --- /dev/null +++ b/resources/ui/ground_assets/ewr_blue_damaged.svg @@ -0,0 +1 @@ +EWG \ No newline at end of file diff --git a/resources/ui/ground_assets/ewr_blue_destroyed.svg b/resources/ui/ground_assets/ewr_blue_destroyed.svg new file mode 100644 index 00000000..81b1872a --- /dev/null +++ b/resources/ui/ground_assets/ewr_blue_destroyed.svg @@ -0,0 +1 @@ +EWG \ No newline at end of file diff --git a/resources/ui/ground_assets/ewr_red_alive.svg b/resources/ui/ground_assets/ewr_red_alive.svg new file mode 100644 index 00000000..2447a2fe --- /dev/null +++ b/resources/ui/ground_assets/ewr_red_alive.svg @@ -0,0 +1 @@ +EWG \ No newline at end of file diff --git a/resources/ui/ground_assets/ewr_red_damaged.svg b/resources/ui/ground_assets/ewr_red_damaged.svg new file mode 100644 index 00000000..4872e6ca --- /dev/null +++ b/resources/ui/ground_assets/ewr_red_damaged.svg @@ -0,0 +1 @@ +EWG \ No newline at end of file diff --git a/resources/ui/ground_assets/ewr_red_destroyed.svg b/resources/ui/ground_assets/ewr_red_destroyed.svg new file mode 100644 index 00000000..b783ce70 --- /dev/null +++ b/resources/ui/ground_assets/ewr_red_destroyed.svg @@ -0,0 +1 @@ +EWG \ No newline at end of file diff --git a/resources/ui/ground_assets/factory_blue_alive.svg b/resources/ui/ground_assets/factory_blue_alive.svg new file mode 100644 index 00000000..b89968bd --- /dev/null +++ b/resources/ui/ground_assets/factory_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/factory_blue_damaged.svg b/resources/ui/ground_assets/factory_blue_damaged.svg new file mode 100644 index 00000000..d21c47aa --- /dev/null +++ b/resources/ui/ground_assets/factory_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/factory_blue_destroyed.svg b/resources/ui/ground_assets/factory_blue_destroyed.svg new file mode 100644 index 00000000..2933295b --- /dev/null +++ b/resources/ui/ground_assets/factory_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/factory_red_alive.svg b/resources/ui/ground_assets/factory_red_alive.svg new file mode 100644 index 00000000..d8aaf876 --- /dev/null +++ b/resources/ui/ground_assets/factory_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/factory_red_damaged.svg b/resources/ui/ground_assets/factory_red_damaged.svg new file mode 100644 index 00000000..d71c65cd --- /dev/null +++ b/resources/ui/ground_assets/factory_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/factory_red_destroyed.svg b/resources/ui/ground_assets/factory_red_destroyed.svg new file mode 100644 index 00000000..4354ee70 --- /dev/null +++ b/resources/ui/ground_assets/factory_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/farp_blue_alive.svg b/resources/ui/ground_assets/farp_blue_alive.svg new file mode 100644 index 00000000..1c76cbbe --- /dev/null +++ b/resources/ui/ground_assets/farp_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/farp_blue_damaged.svg b/resources/ui/ground_assets/farp_blue_damaged.svg new file mode 100644 index 00000000..2eaec315 --- /dev/null +++ b/resources/ui/ground_assets/farp_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/farp_blue_destroyed.svg b/resources/ui/ground_assets/farp_blue_destroyed.svg new file mode 100644 index 00000000..f6af1443 --- /dev/null +++ b/resources/ui/ground_assets/farp_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/farp_red_alive.svg b/resources/ui/ground_assets/farp_red_alive.svg new file mode 100644 index 00000000..bfa7662c --- /dev/null +++ b/resources/ui/ground_assets/farp_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/farp_red_damaged.svg b/resources/ui/ground_assets/farp_red_damaged.svg new file mode 100644 index 00000000..3da498bd --- /dev/null +++ b/resources/ui/ground_assets/farp_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/farp_red_destroyed.svg b/resources/ui/ground_assets/farp_red_destroyed.svg new file mode 100644 index 00000000..697ae951 --- /dev/null +++ b/resources/ui/ground_assets/farp_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_blue_alive.svg b/resources/ui/ground_assets/fob_blue_alive.svg new file mode 100644 index 00000000..e78bbd81 --- /dev/null +++ b/resources/ui/ground_assets/fob_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_blue_damaged.svg b/resources/ui/ground_assets/fob_blue_damaged.svg new file mode 100644 index 00000000..c93ec0e6 --- /dev/null +++ b/resources/ui/ground_assets/fob_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_blue_destroyed.svg b/resources/ui/ground_assets/fob_blue_destroyed.svg new file mode 100644 index 00000000..4cc0764f --- /dev/null +++ b/resources/ui/ground_assets/fob_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_red_alive.svg b/resources/ui/ground_assets/fob_red_alive.svg new file mode 100644 index 00000000..f6e1e238 --- /dev/null +++ b/resources/ui/ground_assets/fob_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_red_damaged.svg b/resources/ui/ground_assets/fob_red_damaged.svg new file mode 100644 index 00000000..72ff98f7 --- /dev/null +++ b/resources/ui/ground_assets/fob_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/fob_red_destroyed.svg b/resources/ui/ground_assets/fob_red_destroyed.svg new file mode 100644 index 00000000..0a00635a --- /dev/null +++ b/resources/ui/ground_assets/fob_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/fuel_blue_alive.svg b/resources/ui/ground_assets/fuel_blue_alive.svg new file mode 100644 index 00000000..ec3b1951 --- /dev/null +++ b/resources/ui/ground_assets/fuel_blue_alive.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/fuel_blue_damaged.svg b/resources/ui/ground_assets/fuel_blue_damaged.svg new file mode 100644 index 00000000..24c9fca4 --- /dev/null +++ b/resources/ui/ground_assets/fuel_blue_damaged.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/fuel_blue_destroyed.svg b/resources/ui/ground_assets/fuel_blue_destroyed.svg new file mode 100644 index 00000000..1c6f9d56 --- /dev/null +++ b/resources/ui/ground_assets/fuel_blue_destroyed.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/fuel_red_alive.svg b/resources/ui/ground_assets/fuel_red_alive.svg new file mode 100644 index 00000000..df4b610d --- /dev/null +++ b/resources/ui/ground_assets/fuel_red_alive.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/fuel_red_damaged.svg b/resources/ui/ground_assets/fuel_red_damaged.svg new file mode 100644 index 00000000..84f029a6 --- /dev/null +++ b/resources/ui/ground_assets/fuel_red_damaged.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/fuel_red_destroyed.svg b/resources/ui/ground_assets/fuel_red_destroyed.svg new file mode 100644 index 00000000..110ed58d --- /dev/null +++ b/resources/ui/ground_assets/fuel_red_destroyed.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/lha_blue_alive.svg b/resources/ui/ground_assets/lha_blue_alive.svg new file mode 100644 index 00000000..e01cc213 --- /dev/null +++ b/resources/ui/ground_assets/lha_blue_alive.svg @@ -0,0 +1 @@ +LHA \ No newline at end of file diff --git a/resources/ui/ground_assets/lha_blue_damaged.svg b/resources/ui/ground_assets/lha_blue_damaged.svg new file mode 100644 index 00000000..4aeffeeb --- /dev/null +++ b/resources/ui/ground_assets/lha_blue_damaged.svg @@ -0,0 +1 @@ +LHA \ No newline at end of file diff --git a/resources/ui/ground_assets/lha_blue_destination.svg b/resources/ui/ground_assets/lha_blue_destination.svg new file mode 100644 index 00000000..ff80f36a --- /dev/null +++ b/resources/ui/ground_assets/lha_blue_destination.svg @@ -0,0 +1 @@ +LHA \ No newline at end of file diff --git a/resources/ui/ground_assets/lha_blue_destroyed.svg b/resources/ui/ground_assets/lha_blue_destroyed.svg new file mode 100644 index 00000000..2d0092c3 --- /dev/null +++ b/resources/ui/ground_assets/lha_blue_destroyed.svg @@ -0,0 +1 @@ +LHA \ No newline at end of file diff --git a/resources/ui/ground_assets/lha_red_alive.svg b/resources/ui/ground_assets/lha_red_alive.svg new file mode 100644 index 00000000..f3960157 --- /dev/null +++ b/resources/ui/ground_assets/lha_red_alive.svg @@ -0,0 +1 @@ +LHA \ No newline at end of file diff --git a/resources/ui/ground_assets/lha_red_damaged.svg b/resources/ui/ground_assets/lha_red_damaged.svg new file mode 100644 index 00000000..4c3c6aa3 --- /dev/null +++ b/resources/ui/ground_assets/lha_red_damaged.svg @@ -0,0 +1 @@ +LHA \ No newline at end of file diff --git a/resources/ui/ground_assets/lha_red_destination.svg b/resources/ui/ground_assets/lha_red_destination.svg new file mode 100644 index 00000000..9ef1411b --- /dev/null +++ b/resources/ui/ground_assets/lha_red_destination.svg @@ -0,0 +1 @@ +LHA \ No newline at end of file diff --git a/resources/ui/ground_assets/lha_red_destroyed.svg b/resources/ui/ground_assets/lha_red_destroyed.svg new file mode 100644 index 00000000..87ff95ac --- /dev/null +++ b/resources/ui/ground_assets/lha_red_destroyed.svg @@ -0,0 +1 @@ +LHA \ No newline at end of file diff --git a/resources/ui/ground_assets/missile_blue_alive.svg b/resources/ui/ground_assets/missile_blue_alive.svg new file mode 100644 index 00000000..91bfa0b9 --- /dev/null +++ b/resources/ui/ground_assets/missile_blue_alive.svg @@ -0,0 +1 @@ +T \ No newline at end of file diff --git a/resources/ui/ground_assets/missile_blue_damaged.svg b/resources/ui/ground_assets/missile_blue_damaged.svg new file mode 100644 index 00000000..841b8ba9 --- /dev/null +++ b/resources/ui/ground_assets/missile_blue_damaged.svg @@ -0,0 +1 @@ +T \ No newline at end of file diff --git a/resources/ui/ground_assets/missile_blue_destroyed.svg b/resources/ui/ground_assets/missile_blue_destroyed.svg new file mode 100644 index 00000000..801cc220 --- /dev/null +++ b/resources/ui/ground_assets/missile_blue_destroyed.svg @@ -0,0 +1 @@ +T \ No newline at end of file diff --git a/resources/ui/ground_assets/missile_red_alive.svg b/resources/ui/ground_assets/missile_red_alive.svg new file mode 100644 index 00000000..faf9b1c9 --- /dev/null +++ b/resources/ui/ground_assets/missile_red_alive.svg @@ -0,0 +1 @@ +T \ No newline at end of file diff --git a/resources/ui/ground_assets/missile_red_damaged.svg b/resources/ui/ground_assets/missile_red_damaged.svg new file mode 100644 index 00000000..f4b69b86 --- /dev/null +++ b/resources/ui/ground_assets/missile_red_damaged.svg @@ -0,0 +1 @@ +T \ No newline at end of file diff --git a/resources/ui/ground_assets/missile_red_destroyed.svg b/resources/ui/ground_assets/missile_red_destroyed.svg new file mode 100644 index 00000000..24b2fef3 --- /dev/null +++ b/resources/ui/ground_assets/missile_red_destroyed.svg @@ -0,0 +1 @@ +T \ No newline at end of file diff --git a/resources/ui/ground_assets/oil_blue_alive.svg b/resources/ui/ground_assets/oil_blue_alive.svg new file mode 100644 index 00000000..088202c3 --- /dev/null +++ b/resources/ui/ground_assets/oil_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/oil_blue_damaged.svg b/resources/ui/ground_assets/oil_blue_damaged.svg new file mode 100644 index 00000000..da58acec --- /dev/null +++ b/resources/ui/ground_assets/oil_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/oil_blue_destroyed.svg b/resources/ui/ground_assets/oil_blue_destroyed.svg new file mode 100644 index 00000000..139ea116 --- /dev/null +++ b/resources/ui/ground_assets/oil_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/oil_red_alive.svg b/resources/ui/ground_assets/oil_red_alive.svg new file mode 100644 index 00000000..7a103a23 --- /dev/null +++ b/resources/ui/ground_assets/oil_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/oil_red_damaged.svg b/resources/ui/ground_assets/oil_red_damaged.svg new file mode 100644 index 00000000..f6859a06 --- /dev/null +++ b/resources/ui/ground_assets/oil_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/oil_red_destroyed.svg b/resources/ui/ground_assets/oil_red_destroyed.svg new file mode 100644 index 00000000..2ba91be4 --- /dev/null +++ b/resources/ui/ground_assets/oil_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/poewr_blue_damaged.svg b/resources/ui/ground_assets/poewr_blue_damaged.svg new file mode 100644 index 00000000..edc02ac8 --- /dev/null +++ b/resources/ui/ground_assets/poewr_blue_damaged.svg @@ -0,0 +1 @@ +GEN \ No newline at end of file diff --git a/resources/ui/ground_assets/power_blue_alive.svg b/resources/ui/ground_assets/power_blue_alive.svg new file mode 100644 index 00000000..5459d166 --- /dev/null +++ b/resources/ui/ground_assets/power_blue_alive.svg @@ -0,0 +1 @@ +GEN \ No newline at end of file diff --git a/resources/ui/ground_assets/power_blue_destroyed.svg b/resources/ui/ground_assets/power_blue_destroyed.svg new file mode 100644 index 00000000..1d648453 --- /dev/null +++ b/resources/ui/ground_assets/power_blue_destroyed.svg @@ -0,0 +1 @@ +GEN \ No newline at end of file diff --git a/resources/ui/ground_assets/power_red_alive.svg b/resources/ui/ground_assets/power_red_alive.svg new file mode 100644 index 00000000..443b5dd5 --- /dev/null +++ b/resources/ui/ground_assets/power_red_alive.svg @@ -0,0 +1 @@ +GEN \ No newline at end of file diff --git a/resources/ui/ground_assets/power_red_damaged.svg b/resources/ui/ground_assets/power_red_damaged.svg new file mode 100644 index 00000000..f628a2a0 --- /dev/null +++ b/resources/ui/ground_assets/power_red_damaged.svg @@ -0,0 +1 @@ +GEN \ No newline at end of file diff --git a/resources/ui/ground_assets/power_red_destroyed.svg b/resources/ui/ground_assets/power_red_destroyed.svg new file mode 100644 index 00000000..48b8f196 --- /dev/null +++ b/resources/ui/ground_assets/power_red_destroyed.svg @@ -0,0 +1 @@ +GEN \ No newline at end of file diff --git a/resources/ui/ground_assets/ship_blue_alive.svg b/resources/ui/ground_assets/ship_blue_alive.svg new file mode 100644 index 00000000..8ae1393f --- /dev/null +++ b/resources/ui/ground_assets/ship_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ship_blue_damaged.svg b/resources/ui/ground_assets/ship_blue_damaged.svg new file mode 100644 index 00000000..edcb3cd9 --- /dev/null +++ b/resources/ui/ground_assets/ship_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ship_blue_destroyed.svg b/resources/ui/ground_assets/ship_blue_destroyed.svg new file mode 100644 index 00000000..8ed1ee02 --- /dev/null +++ b/resources/ui/ground_assets/ship_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ship_red_alive.svg b/resources/ui/ground_assets/ship_red_alive.svg new file mode 100644 index 00000000..538cf721 --- /dev/null +++ b/resources/ui/ground_assets/ship_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ship_red_damaged.svg b/resources/ui/ground_assets/ship_red_damaged.svg new file mode 100644 index 00000000..f97a1109 --- /dev/null +++ b/resources/ui/ground_assets/ship_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ship_red_destroyed.svg b/resources/ui/ground_assets/ship_red_destroyed.svg new file mode 100644 index 00000000..176e9ae5 --- /dev/null +++ b/resources/ui/ground_assets/ship_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/village_blue_alive.svg b/resources/ui/ground_assets/village_blue_alive.svg new file mode 100644 index 00000000..c8bdb38f --- /dev/null +++ b/resources/ui/ground_assets/village_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/village_blue_damaged.svg b/resources/ui/ground_assets/village_blue_damaged.svg new file mode 100644 index 00000000..c54784b6 --- /dev/null +++ b/resources/ui/ground_assets/village_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/village_blue_destroyed.svg b/resources/ui/ground_assets/village_blue_destroyed.svg new file mode 100644 index 00000000..827ef39d --- /dev/null +++ b/resources/ui/ground_assets/village_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/village_red_alive.svg b/resources/ui/ground_assets/village_red_alive.svg new file mode 100644 index 00000000..d70c0dcb --- /dev/null +++ b/resources/ui/ground_assets/village_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/village_red_damaged.svg b/resources/ui/ground_assets/village_red_damaged.svg new file mode 100644 index 00000000..bb32e616 --- /dev/null +++ b/resources/ui/ground_assets/village_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/village_red_destroyed.svg b/resources/ui/ground_assets/village_red_destroyed.svg new file mode 100644 index 00000000..d8924600 --- /dev/null +++ b/resources/ui/ground_assets/village_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ware_blue_alive.svg b/resources/ui/ground_assets/ware_blue_alive.svg new file mode 100644 index 00000000..ec3b1951 --- /dev/null +++ b/resources/ui/ground_assets/ware_blue_alive.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/ware_blue_damaged.svg b/resources/ui/ground_assets/ware_blue_damaged.svg new file mode 100644 index 00000000..24c9fca4 --- /dev/null +++ b/resources/ui/ground_assets/ware_blue_damaged.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/ware_blue_destroyed.svg b/resources/ui/ground_assets/ware_blue_destroyed.svg new file mode 100644 index 00000000..1c6f9d56 --- /dev/null +++ b/resources/ui/ground_assets/ware_blue_destroyed.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/ware_red_alive.svg b/resources/ui/ground_assets/ware_red_alive.svg new file mode 100644 index 00000000..df4b610d --- /dev/null +++ b/resources/ui/ground_assets/ware_red_alive.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/ware_red_damaged.svg b/resources/ui/ground_assets/ware_red_damaged.svg new file mode 100644 index 00000000..84f029a6 --- /dev/null +++ b/resources/ui/ground_assets/ware_red_damaged.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/ware_red_destroyed.svg b/resources/ui/ground_assets/ware_red_destroyed.svg new file mode 100644 index 00000000..110ed58d --- /dev/null +++ b/resources/ui/ground_assets/ware_red_destroyed.svg @@ -0,0 +1 @@ +STOR \ No newline at end of file diff --git a/resources/ui/ground_assets/ww2bunker_blue_alive.svg b/resources/ui/ground_assets/ww2bunker_blue_alive.svg new file mode 100644 index 00000000..01eed590 --- /dev/null +++ b/resources/ui/ground_assets/ww2bunker_blue_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ww2bunker_blue_damaged.svg b/resources/ui/ground_assets/ww2bunker_blue_damaged.svg new file mode 100644 index 00000000..e9dacd53 --- /dev/null +++ b/resources/ui/ground_assets/ww2bunker_blue_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ww2bunker_blue_destroyed.svg b/resources/ui/ground_assets/ww2bunker_blue_destroyed.svg new file mode 100644 index 00000000..f9b1e2a0 --- /dev/null +++ b/resources/ui/ground_assets/ww2bunker_blue_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ww2bunker_red_alive.svg b/resources/ui/ground_assets/ww2bunker_red_alive.svg new file mode 100644 index 00000000..68ee8a5e --- /dev/null +++ b/resources/ui/ground_assets/ww2bunker_red_alive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ww2bunker_red_damaged.svg b/resources/ui/ground_assets/ww2bunker_red_damaged.svg new file mode 100644 index 00000000..bfb93b9a --- /dev/null +++ b/resources/ui/ground_assets/ww2bunker_red_damaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/ground_assets/ww2bunker_red_destroyed.svg b/resources/ui/ground_assets/ww2bunker_red_destroyed.svg new file mode 100644 index 00000000..4b150097 --- /dev/null +++ b/resources/ui/ground_assets/ww2bunker_red_destroyed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/ui/map/canvas.html b/resources/ui/map/canvas.html new file mode 100644 index 00000000..42830fe8 --- /dev/null +++ b/resources/ui/map/canvas.html @@ -0,0 +1,36 @@ + + + + DCS Liberation Map + + + + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js new file mode 100644 index 00000000..5a8dee3d --- /dev/null +++ b/resources/ui/map/map.js @@ -0,0 +1,980 @@ +const Colors = Object.freeze({ + Blue: "#0084ff", + Red: "#c85050", + Green: "#80BA80", + Highlight: "#ffff00", +}); + +const Categories = Object.freeze([ + "aa", + "allycamp", + "ammo", + "armor", + "coastal", + "comms", + "derrick", + "ewr", + "factory", + "farp", + "fuel", + "missile", + "oil", + "power", + "ship", + "village", + "ware", + "ww2bunker", +]); + +const UnitState = Object.freeze({ + Alive: "alive", + Damaged: "damaged", + Destroyed: "destroyed", +}); + +class CpIcons { + constructor() { + this.icons = {}; + for (const player of [true, false]) { + this.icons[player] = {}; + for (const state of Object.values(UnitState)) { + this.icons[player][state] = { + airfield: this.loadIcon("airfield", player, state), + cv: this.loadIcon("cv", player, state), + fob: this.loadIcon("fob", player, state), + lha: this.loadIcon("lha", player, state), + offmap: this.loadIcon("airfield", player, state), + }; + } + } + } + + icon(category, player, state) { + return this.icons[player][state][category]; + } + + loadIcon(category, player, state) { + const color = player ? "blue" : "red"; + return new L.Icon({ + iconUrl: `../ground_assets/${category}_${color}_${state}.svg`, + iconSize: [32, 32], + }); + } +} + +class TgoIcons { + constructor() { + this.icons = {}; + for (const category of Categories) { + this.icons[category] = {}; + for (const player of [true, false]) { + this.icons[category][player] = {}; + for (const state of Object.values(UnitState)) { + this.icons[category][player][state] = this.loadIcon( + category, + player, + state + ); + } + } + } + } + + icon(category, player, state) { + return this.icons[category][player][state]; + } + + loadIcon(category, player, state) { + const color = player ? "blue" : "red"; + return new L.Icon({ + iconUrl: `../ground_assets/${category}_${color}_${state}.svg`, + iconSize: [32, 32], + }); + } + + loadLegacyIcon(category, player) { + const playerSuffix = player ? "_blue" : ""; + return new L.Icon({ + iconUrl: `../ground_assets/${category}${playerSuffix}.png`, + }); + } +} + +const Icons = Object.freeze({ + ControlPoints: new CpIcons(), + Objectives: new TgoIcons(), +}); + +function metersToNauticalMiles(meters) { + return meters * 0.000539957; +} + +function formatLatLng(latLng) { + const lat = latLng.lat.toFixed(2); + const lng = latLng.lng.toFixed(2); + const ns = lat >= 0 ? "N" : "S"; + const ew = lng >= 0 ? "E" : "W"; + return `${lat}°${ns} ${lng}°${ew}`; +} + +const map = L.map("map", { + doubleClickZoom: false, + zoomControl: false, +}).setView([0, 0], 3); +L.control.scale({ maxWidth: 200 }).addTo(map); + +// https://esri.github.io/esri-leaflet/api-reference/layers/basemap-layer.html +const baseLayers = { + "Imagery Clarity": L.esri.basemapLayer("ImageryClarity", { maxZoom: 17 }), + "Imagery Firefly": L.esri.basemapLayer("ImageryFirefly", { maxZoom: 17 }), + Topographic: L.esri.basemapLayer("Topographic", { maxZoom: 16 }), +}; + +const defaultBaseMap = baseLayers["Imagery Clarity"]; +defaultBaseMap.addTo(map); + +// Enabled by default, so addTo(map). +const controlPointsLayer = L.layerGroup().addTo(map); +const airDefensesLayer = L.layerGroup().addTo(map); +const factoriesLayer = L.layerGroup().addTo(map); +const shipsLayer = L.layerGroup().addTo(map); +const groundObjectsLayer = L.layerGroup().addTo(map); +const supplyRoutesLayer = L.layerGroup().addTo(map); +const frontLinesLayer = L.layerGroup().addTo(map); +const redSamThreatLayer = L.layerGroup().addTo(map); +const blueFlightPlansLayer = L.layerGroup().addTo(map); + +// Added to map by the user via layer controls. +const blueSamThreatLayer = L.layerGroup(); +const blueSamDetectionLayer = L.layerGroup(); +const redSamDetectionLayer = L.layerGroup(); +const redFlightPlansLayer = L.layerGroup(); +const selectedFlightPlansLayer = L.layerGroup(); +const allFlightPlansLayer = L.layerGroup(); + +const blueFullThreatZones = L.layerGroup(); +const blueAircraftThreatZones = L.layerGroup(); +const blueAirDefenseThreatZones = L.layerGroup(); +const blueRadarSamThreatZones = L.layerGroup(); + +const redFullThreatZones = L.layerGroup(); +const redAircraftThreatZones = L.layerGroup(); +const redAirDefenseThreatZones = L.layerGroup(); +const redRadarSamThreatZones = L.layerGroup(); + +const blueNavmesh = L.layerGroup(); +const redNavmesh = L.layerGroup(); + +const inclusionZones = L.layerGroup(); +const exclusionZones = L.layerGroup(); +const seaZones = L.layerGroup(); +const unculledZones = L.layerGroup(); + +// 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 +// allow, but may need to start auto-collapsing it (or fix the plugin to add a +// scrollbar when non-collapsing) if it gets much larger. +L.control + .groupedLayers( + baseLayers, + { + "Points of Interest": { + "Control points": controlPointsLayer, + "Air defenses": airDefensesLayer, + Factories: factoriesLayer, + Ships: shipsLayer, + "Other ground objects": groundObjectsLayer, + "Supply routes": supplyRoutesLayer, + "Front lines": frontLinesLayer, + }, + "Enemy Air Defenses": { + "Enemy SAM threat range": redSamThreatLayer, + "Enemy SAM detection range": redSamDetectionLayer, + }, + "Allied Air Defenses": { + "Ally SAM threat range": blueSamThreatLayer, + "Ally SAM detection range": blueSamDetectionLayer, + }, + "Flight Plans": { + Hide: L.layerGroup(), + "Show selected blue": selectedFlightPlansLayer, + "Show all blue": blueFlightPlansLayer, + "Show all red": redFlightPlansLayer, + "Show all": allFlightPlansLayer, + }, + }, + { + collapsed: false, + exclusiveGroups: ["Flight Plans"], + groupCheckboxes: true, + } + ) + .addTo(map); + +// 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, + } + ) + .addTo(map); + +let game; +new QWebChannel(qt.webChannelTransport, function (channel) { + game = channel.objects.game; + drawInitialMap(); + game.cleared.connect(clearAllLayers); + game.mapCenterChanged.connect(recenterMap); + game.controlPointsChanged.connect(drawControlPoints); + game.groundObjectsChanged.connect(drawGroundObjects); + game.supplyRoutesChanged.connect(drawSupplyRoutes); + game.frontLinesChanged.connect(drawFrontLines); + game.flightsChanged.connect(drawFlightPlans); + game.threatZonesChanged.connect(drawThreatZones); + game.navmeshesChanged.connect(drawNavmeshes); + game.mapZonesChanged.connect(drawMapZones); + game.unculledZonesChanged.connect(drawUnculledZones); +}); + +function recenterMap(center) { + map.setView(center, 8, { animate: true, duration: 1 }); +} + +class ControlPoint { + constructor(cp) { + this.cp = cp; + // The behavior we want is for the CP to be draggable when it has no + // destination, but for the destination to be draggable when it does. The + // primary marker is always shown and draggable. When a destination exists, + // the primary marker marks the destination and the secondary marker marks + // the location. When no destination exists, the primary marker marks the + // location and the secondary marker is not shown. + this.primaryMarker = this.makePrimaryMarker(); + this.secondaryMarker = this.makeSecondaryMarker(); + this.path = this.makePath(); + this.attachTooltipsAndHandlers(); + this.cp.destinationChanged.connect(() => this.onDestinationChanged()); + } + + icon() { + return Icons.ControlPoints.icon( + this.cp.category, + this.cp.blue, + this.cp.status + ); + } + + hasDestination() { + return this.cp.destination.length > 0; + } + + hideDestination() { + this.secondaryMarker.removeFrom(controlPointsLayer); + this.path.removeFrom(controlPointsLayer); + } + + setDestination(destination) { + this.cp.setDestination([destination.lat, destination.lng]).then((err) => { + if (err) { + console.log(`Could not set control point destination: ${err}`); + this.locationMarker().bindPopup(err).openPopup(); + // Reset markers and paths on error. On success this happens when we get + // the destinationChanged signal from the backend. + this.onDestinationChanged(); + } + }); + } + + onDrag(destination) { + this.path.setLatLngs([this.cp.position, destination]); + this.path.addTo(controlPointsLayer); + const distance = metersToNauticalMiles( + destination.distanceTo(this.cp.position) + ); + this.primaryMarker.unbindTooltip(); + this.primaryMarker.bindTooltip( + `Move ${distance.toFixed(1)}nm to ${formatLatLng(destination)}`, + { + permanent: true, + } + ); + this.cp + .destinationInRange([destination.lat, destination.lng]) + .then((inRange) => { + this.path.setStyle({ + color: inRange ? Colors.Green : Colors.Red, + }); + }); + } + + detachTooltipsAndHandlers() { + this.primaryMarker.unbindTooltip(); + this.primaryMarker.off("click"); + this.primaryMarker.off("contextmenu"); + this.secondaryMarker.unbindTooltip(); + this.secondaryMarker.off("click"); + this.secondaryMarker.off("contextmenu"); + } + + locationMarker(dragging = false) { + return this.hasDestination() || dragging + ? this.secondaryMarker + : this.primaryMarker; + } + + destinationMarker() { + return this.hasDestination() ? this.primaryMarker : null; + } + + attachTooltipsAndHandlers(dragging = false) { + this.detachTooltipsAndHandlers(); + const locationMarker = this.locationMarker(dragging); + const destinationMarker = this.destinationMarker(); + locationMarker + .bindTooltip(`

${this.cp.name}

`) + .on("click", () => { + this.cp.showInfoDialog(); + }) + .on("contextmenu", () => { + this.cp.showPackageDialog(); + }); + if (destinationMarker != null) { + const origin = locationMarker.getLatLng(); + const destination = destinationMarker.getLatLng(); + const distance = metersToNauticalMiles( + destination.distanceTo(origin) + ).toFixed(1); + const dest = formatLatLng(destination); + destinationMarker.bindTooltip( + `${this.cp.name} moving ${distance}nm to ${dest} next turn` + ); + destinationMarker.on("contextmenu", () => this.cp.cancelTravel()); + destinationMarker.addTo(map); + } + } + + makePrimaryMarker() { + const location = this.hasDestination() + ? this.cp.destination + : this.cp.position; + // We might draw other markers on top of the CP. The tooltips from the other + // markers are helpful so we want to keep them, but make sure the CP is + // always the clickable thing. + return L.marker(location, { + icon: this.icon(), + zIndexOffset: 1000, + draggable: this.cp.mobile, + autoPan: true, + }) + .on("dragstart", () => { + this.secondaryMarker.addTo(controlPointsLayer); + this.attachTooltipsAndHandlers(true); + }) + .on("drag", (event) => { + const marker = event.target; + const newPosition = marker.getLatLng(); + this.onDrag(newPosition); + }) + .on("dragend", (event) => { + const marker = event.target; + const newPosition = marker.getLatLng(); + this.setDestination(newPosition); + }) + .addTo(map); + } + + makeSecondaryMarker() { + return L.marker(this.cp.position, { + icon: this.icon(), + zIndexOffset: 1000, + }); + } + + makePath() { + const destination = this.hasDestination() ? this.cp.destination : [0, 0]; + return L.polyline([this.cp.position, destination], { + color: Colors.Green, + weight: 1, + }); + } + + onDestinationChanged() { + if (this.hasDestination()) { + this.primaryMarker.setLatLng(this.cp.destination); + this.secondaryMarker.addTo(controlPointsLayer); + this.path.setLatLngs([this.cp.position, this.cp.destination]); + this.path.addTo(controlPointsLayer); + this.path.setStyle({ color: Colors.Green }); + } else { + this.hideDestination(); + this.primaryMarker.setLatLng(this.cp.position); + } + this.attachTooltipsAndHandlers(); + } + + drawDestination() { + this.secondaryMarker.addTo(controlPointsLayer); + this.path.addTo(controlPointsLayer); + } + + draw() { + this.primaryMarker.addTo(controlPointsLayer); + if (this.hasDestination()) { + this.drawDestination(); + } + } +} + +function drawControlPoints() { + controlPointsLayer.clearLayers(); + game.controlPoints.forEach((cp) => { + new ControlPoint(cp).draw(); + }); +} + +class TheaterGroundObject { + constructor(tgo) { + this.tgo = tgo; + } + + samIsThreat() { + for (const range of this.tgo.samThreatRanges) { + if (range > 0) { + return true; + } + } + + return false; + } + + icon() { + let state; + if (this.tgo.dead) { + state = UnitState.Destroyed; + } else if (this.tgo.category == "aa" && !this.samIsThreat()) { + state = UnitState.Damaged; + } else { + state = UnitState.Alive; + } + return Icons.Objectives.icon(this.tgo.category, this.tgo.blue, state); + } + + layer() { + switch (this.tgo.category) { + case "aa": + return airDefensesLayer; + case "factory": + return factoriesLayer; + case "ship": + return shipsLayer; + default: + return groundObjectsLayer; + } + } + + drawSamThreats() { + const detectionLayer = this.tgo.blue + ? blueSamDetectionLayer + : redSamDetectionLayer; + const threatLayer = this.tgo.blue ? blueSamThreatLayer : redSamThreatLayer; + const threatColor = this.tgo.blue ? Colors.Blue : Colors.Red; + const detectionColor = this.tgo.blue ? "#bb89ff" : "#eee17b"; + + this.tgo.samDetectionRanges.forEach((range) => { + L.circle(this.tgo.position, { + radius: range, + color: detectionColor, + fill: false, + weight: 1, + }).addTo(detectionLayer); + }); + + this.tgo.samThreatRanges.forEach((range) => { + L.circle(this.tgo.position, { + radius: range, + color: threatColor, + fill: false, + weight: 2, + }).addTo(threatLayer); + }); + } + + draw() { + if (!this.tgo.blue && this.tgo.dead) { + // Don't bother drawing dead opfor TGOs. Blue is worth showing because + // some of them can be repaired, but the player can't interact with dead + // red things so there's no point in showing them. + return; + } + + L.marker(this.tgo.position, { icon: this.icon() }) + .bindTooltip(`${this.tgo.name}
${this.tgo.units.join("
")}`) + .on("click", () => this.tgo.showInfoDialog()) + .on("contextmenu", () => this.tgo.showPackageDialog()) + .addTo(this.layer()); + this.drawSamThreats(); + } +} + +function drawGroundObjects() { + airDefensesLayer.clearLayers(); + factoriesLayer.clearLayers(); + shipsLayer.clearLayers(); + groundObjectsLayer.clearLayers(); + blueSamDetectionLayer.clearLayers(); + redSamDetectionLayer.clearLayers(); + blueSamThreatLayer.clearLayers(); + redSamThreatLayer.clearLayers(); + game.groundObjects.forEach((tgo) => { + new TheaterGroundObject(tgo).draw(); + }); +} + +function drawSupplyRoutes() { + supplyRoutesLayer.clearLayers(); + game.supplyRoutes.forEach((route) => { + let color; + if (route.frontActive) { + color = Colors.Red; + } else if (route.blue) { + color = "#2d3e50"; + } else { + color = "#8c1414"; + } + const line = L.polyline(route.points, { + color: color, + weight: route.isSea ? 4 : 6, + }).addTo(supplyRoutesLayer); + const activeTransports = route.activeTransports; + if (activeTransports.length > 0) { + line.bindTooltip(activeTransports.join("
")); + L.polyline(route.points, { + color: "#ffffff", + weight: 2, + }).addTo(supplyRoutesLayer); + } else { + line.bindTooltip("This supply route is inactive."); + } + }); +} + +function drawFrontLines() { + frontLinesLayer.clearLayers(); + game.frontLines.forEach((front) => { + L.polyline(front.extents, { weight: 8, color: "#fe7d0a" }) + .on("contextmenu", function () { + front.showPackageDialog(); + }) + .addTo(frontLinesLayer); + }); +} + +const SHOW_WAYPOINT_INFO_AT_ZOOM = 9; + +class Waypoint { + constructor(waypoint, flight) { + this.waypoint = waypoint; + this.flight = flight; + this.marker = this.makeMarker(); + this.waypoint.positionChanged.connect(() => this.relocate()); + this.waypoint.timingChanged.connect(() => this.updateDescription()); + } + + position() { + return this.waypoint.position; + } + + shouldMark() { + // We don't need a marker for the departure waypoint (and it's likely + // coincident with the landing waypoint, so hard to see). We do want to draw + // the path from it though. + // + // We also don't need the landing waypoint since we'll be drawing that path + // as well and it's clear what it is, and only obscured the CP icon. + // + // The divert waypoint also obscures the CP. We don't draw the path to it, + // but it can be seen in the flight settings page so it's not really a + // problem to exclude it. + // + // Bullseye ought to be (but currently isn't) drawn *once* rather than as a + // flight waypoint. + return !( + this.waypoint.isTakeoff || + this.waypoint.isLanding || + this.waypoint.isDivert || + this.waypoint.isBullseye + ); + } + + draggable() { + // Target *points* are the exact location of a unit, whereas the target area + // is only the center of the objective. Allow moving the latter since its + // exact location isn't very important. + // + // Landing, and divert should be changed in the flight settings UI, takeoff + // cannot be changed because that's where the plane is. + // + // Moving the bullseye reference only makes it wrong. + return !( + this.waypoint.isTargetPoint || + this.waypoint.isTakeoff || + this.waypoint.isLanding || + this.waypoint.isDivert || + this.waypoint.isBullseye + ); + } + + description(dragging) { + const timing = dragging + ? "Waiting to recompute TOT..." + : this.waypoint.timing; + return ( + `${this.waypoint.number} ${this.waypoint.name}
` + + `${this.waypoint.altitudeFt} ft ${this.waypoint.altitudeReference}
` + + `${timing}` + ); + } + + relocate() { + this.marker.setLatLng(this.waypoint.position); + } + + updateDescription(dragging) { + this.marker.setTooltipContent(this.description(dragging)); + } + + makeMarker() { + const zoom = map.getZoom(); + return L.marker(this.waypoint.position, { draggable: this.draggable() }) + .bindTooltip(this.description(), { + permanent: zoom >= SHOW_WAYPOINT_INFO_AT_ZOOM, + }) + .on("dragstart", (e) => { + this.updateDescription(true); + }) + .on("drag", (e) => { + const marker = e.target; + const destination = marker.getLatLng(); + this.flight.updatePath(this.waypoint.number, destination); + }) + .on("dragend", (e) => { + const marker = e.target; + const destination = marker.getLatLng(); + this.waypoint + .setPosition([destination.lat, destination.lng]) + .then((err) => { + if (err) { + console.log(err); + marker.bindPopup(err); + } + }); + }); + } + + includeInPath() { + return !this.waypoint.isDivert && !this.waypoint.isBullseye; + } +} + +class Flight { + constructor(flight) { + this.flight = flight; + this.flightPlan = this.flight.flightPlan.map((p) => new Waypoint(p, this)); + this.path = null; + this.commitBoundary = null; + this.flight.flightPlanChanged.connect(() => this.draw()); + this.flight.commitBoundaryChanged.connect(() => this.drawCommitBoundary()); + } + + shouldMark(waypoint) { + return this.flight.selected && waypoint.shouldMark(); + } + + flightPlanLayer() { + return this.flight.blue ? blueFlightPlansLayer : redFlightPlansLayer; + } + + updatePath(idx, position) { + const points = this.path.getLatLngs(); + points[idx] = position; + this.path.setLatLngs(points); + } + + drawPath(path) { + const color = this.flight.blue ? Colors.Blue : Colors.Red; + const layer = this.flightPlanLayer(); + if (this.flight.selected) { + this.path = L.polyline(path, { color: Colors.Highlight }) + .addTo(selectedFlightPlansLayer) + .addTo(layer) + .addTo(allFlightPlansLayer); + } else { + this.path = L.polyline(path, { color: color }) + .addTo(layer) + .addTo(allFlightPlansLayer); + } + } + + drawCommitBoundary() { + if (this.commitBoundary != null) { + this.commitBoundary + .removeFrom(selectedFlightPlansLayer) + .removeFrom(this.flightPlanLayer()) + .removeFrom(allFlightPlansLayer); + } + if (this.flight.selected) { + if (this.flight.commitBoundary) { + this.commitBoundary = L.polyline(this.flight.commitBoundary, { + color: Colors.Highlight, + weight: 1, + }) + .addTo(selectedFlightPlansLayer) + .addTo(this.flightPlanLayer()) + .addTo(allFlightPlansLayer); + } + } + } + + draw() { + const path = []; + this.flightPlan.forEach((waypoint) => { + if (waypoint.includeInPath()) { + path.push(waypoint.position()); + } + if (this.shouldMark(waypoint)) { + waypoint.marker + .addTo(selectedFlightPlansLayer) + .addTo(this.flightPlanLayer()) + .addTo(allFlightPlansLayer); + } + }); + + this.drawPath(path); + this.drawCommitBoundary(); + } +} + +function drawFlightPlans() { + blueFlightPlansLayer.clearLayers(); + redFlightPlansLayer.clearLayers(); + selectedFlightPlansLayer.clearLayers(); + allFlightPlansLayer.clearLayers(); + let selected = null; + game.flights.forEach((flight) => { + // Draw the selected waypoint last so it's on top. bringToFront only brings + // it to the front of the *extant* elements, so any flights drawn later will + // be drawn on top. We could fight with manual Z-indexes but leaflet does a + // lot of that automatically so it'd be error prone. + if (flight.selected) { + selected = flight; + } else { + new Flight(flight).draw(); + } + }); + + if (selected != null) { + new Flight(selected).draw(); + } +} + +function _drawThreatZones(zones, layer, player) { + const color = player ? Colors.Blue : Colors.Red; + for (const zone of zones) { + L.polyline(zone, { + color: color, + weight: 1, + fill: true, + fillOpacity: 0.4, + noClip: true, + }).addTo(layer); + } +} + +function drawThreatZones() { + blueFullThreatZones.clearLayers(); + blueAircraftThreatZones.clearLayers(); + blueAirDefenseThreatZones.clearLayers(); + blueRadarSamThreatZones.clearLayers(); + redFullThreatZones.clearLayers(); + redAircraftThreatZones.clearLayers(); + redAirDefenseThreatZones.clearLayers(); + redRadarSamThreatZones.clearLayers(); + + _drawThreatZones(game.threatZones.blue.full, blueFullThreatZones, true); + _drawThreatZones( + game.threatZones.blue.aircraft, + blueAircraftThreatZones, + true + ); + _drawThreatZones( + game.threatZones.blue.airDefenses, + blueAirDefenseThreatZones, + true + ); + _drawThreatZones( + game.threatZones.blue.radarSams, + blueRadarSamThreatZones, + true + ); + + _drawThreatZones(game.threatZones.red.full, redFullThreatZones, false); + _drawThreatZones( + game.threatZones.red.aircraft, + redAircraftThreatZones, + false + ); + _drawThreatZones( + game.threatZones.red.airDefenses, + redAirDefenseThreatZones, + false + ); + _drawThreatZones( + game.threatZones.red.radarSams, + redRadarSamThreatZones, + false + ); +} + +function drawNavmesh(zones, layer) { + for (const zone of zones) { + L.polyline(zone, { + color: "#000000", + weight: 1, + fill: false, + }).addTo(layer); + } +} + +function drawNavmeshes() { + blueNavmesh.clearLayers(); + redNavmesh.clearLayers(); + + drawNavmesh(game.navmeshes.blue, blueNavmesh); + drawNavmesh(game.navmeshes.red, redNavmesh); +} + +function drawMapZones() { + seaZones.clearLayers(); + inclusionZones.clearLayers(); + exclusionZones.clearLayers(); + + for (const zone of game.mapZones.seaZones) { + L.polygon(zone, { + color: "#344455", + fillColor: "#344455", + fillOpacity: 1, + }).addTo(seaZones); + } + + for (const zone of game.mapZones.inclusionZones) { + L.polygon(zone, { + color: "#969696", + fillColor: "#4b4b4b", + fillOpacity: 1, + }).addTo(inclusionZones); + } + + for (const zone of game.mapZones.exclusionZones) { + L.polygon(zone, { + color: "#969696", + fillColor: "#303030", + fillOpacity: 1, + }).addTo(exclusionZones); + } +} + +function drawUnculledZones() { + unculledZones.clearLayers(); + + for (const zone of game.unculledZones) { + L.circle(zone.position, { + radius: zone.radius, + color: "#b4ff8c", + stroke: false, + }).addTo(unculledZones); + } +} + +function drawInitialMap() { + recenterMap(game.mapCenter); + drawControlPoints(); + drawGroundObjects(); + drawSupplyRoutes(); + drawFrontLines(); + drawFlightPlans(); + drawThreatZones(); + drawNavmeshes(); + drawMapZones(); + drawUnculledZones(); +} + +function clearAllLayers() { + map.eachLayer(function (layer) { + if (layer.clearLayers !== undefined) { + layer.clearLayers(); + } + }); +} + +function setTooltipZoomThreshold(layerGroup, showAt) { + let showing = map.getZoom() >= showAt; + map.on("zoomend", function () { + const zoom = map.getZoom(); + if (zoom < showAt && showing) { + showing = false; + layerGroup.eachLayer(function (layer) { + if (layer.getTooltip()) { + const tooltip = layer.getTooltip(); + layer.unbindTooltip().bindTooltip(tooltip, { + permanent: false, + }); + } + }); + } else if (zoom >= showAt && !showing) { + showing = true; + layerGroup.eachLayer(function (layer) { + if (layer.getTooltip()) { + const tooltip = layer.getTooltip(); + layer.unbindTooltip().bindTooltip(tooltip, { + permanent: true, + }); + } + }); + } + }); +} + +setTooltipZoomThreshold(selectedFlightPlansLayer, SHOW_WAYPOINT_INFO_AT_ZOOM); diff --git a/resources/ui/templates/campaigntemplate_EN.j2 b/resources/ui/templates/campaigntemplate_EN.j2 index 2ec97f6e..c9ee31e2 100644 --- a/resources/ui/templates/campaigntemplate_EN.j2 +++ b/resources/ui/templates/campaigntemplate_EN.j2 @@ -1,8 +1,25 @@ -Author(s): {{ campaign.authors }} -

+

Author(s): {{ campaign.authors }}

-Default factions: - {{campaign.recommended_player_faction}} VS  {{campaign.recommended_enemy_faction}} -
+{% if not campaign.version %} +

This campaign was created for an unknown version +of DCS Liberation.

+

You can still attempt to play this campaign but there may be game breaking +bugs.

+{% elif campaign.is_out_of_date %} +

This campaign was created for an older version +of DCS Liberation.

+

You can still attempt to play this campaign but there may be game breaking +bugs.

+{% elif campaign.is_from_future %} +

This campaign was created for a newer version +of DCS Liberation.

+

You can still attempt to play this campaign but there may be game breaking +bugs.

+{% else %} +

This campaign is up to date.

+{% endif %} -{{ campaign.description|safe }} +

Default factions:

+

{{campaign.recommended_player_faction}} VS  {{campaign.recommended_enemy_faction}}

+ +{{ campaign.description|safe }} \ No newline at end of file diff --git a/resources/ui/templates/mission_start_EN.j2 b/resources/ui/templates/mission_start_EN.j2 index a162635b..661ebfdf 100644 --- a/resources/ui/templates/mission_start_EN.j2 +++ b/resources/ui/templates/mission_start_EN.j2 @@ -2,8 +2,8 @@

Some player flights may be delayed to start. For such flights, it will not be - possible to enter the cockpit for a delayed flight until its mission start - time, shown in the flight information window. + possible to enter the cockpit until its mission start time, shown in the flight + information window.

@@ -20,34 +20,30 @@

- For more information, see the mission planning documentation on - - the wiki. + For more information, see the + mission planning documentation on our wiki.

-

For Singleplayer:

+

Launching the mission:

- In DCS, open the Mission Editor and load the file: liberation_nextturn. + Launch the liberation_nextturn mission as you normally would for single- + or multi-player. Note that even for single-player, running the mission with the + dedicated server, even on the same machine, can significantly improve frame + rates, but may cause AI wingmen to behave strangely during taxi.

- Once the mission is loaded in the ME, use the "FLY" option in the "Flight" - menu to launch. + For advice on using a dedicated server to play DCS Liberation, see + the guide on our wiki.

-

For Multiplayer:

- -

- In DCS, open the Mission Editor, and load the file: liberation_nextturn -

- -

Select File/Save, exit the mission editor, and then select Multiplayer.

- -

Then host a server with the mission, and tell your friends to join!

- -(The step in the mission editor is important, and fix a game breaking bug.) -

Finishing

Once you have played the mission, click on the \"Accept Results\" button.

diff --git a/resources/ui/units/aircrafts/banners/JAS39Gripen_24.jpg b/resources/ui/units/aircrafts/banners/JAS39Gripen_24.jpg new file mode 100644 index 00000000..3d58c569 Binary files /dev/null and b/resources/ui/units/aircrafts/banners/JAS39Gripen_24.jpg differ diff --git a/resources/ui/units/aircrafts/banners/JAS39Gripen_AG_24.jpg b/resources/ui/units/aircrafts/banners/JAS39Gripen_AG_24.jpg new file mode 100644 index 00000000..403f2fe8 Binary files /dev/null and b/resources/ui/units/aircrafts/banners/JAS39Gripen_AG_24.jpg differ diff --git a/resources/ui/units/aircrafts/icons/JAS39Gripen_24.jpg b/resources/ui/units/aircrafts/icons/JAS39Gripen_24.jpg new file mode 100644 index 00000000..0a531578 Binary files /dev/null and b/resources/ui/units/aircrafts/icons/JAS39Gripen_24.jpg differ diff --git a/resources/ui/units/aircrafts/icons/JAS39Gripen_AG_24.jpg b/resources/ui/units/aircrafts/icons/JAS39Gripen_AG_24.jpg new file mode 100644 index 00000000..0a531578 Binary files /dev/null and b/resources/ui/units/aircrafts/icons/JAS39Gripen_AG_24.jpg differ diff --git a/resources/units/unit_info_text.json b/resources/units/unit_info_text.json index 93f302d5..433ae1cd 100644 --- a/resources/units/unit_info_text.json +++ b/resources/units/unit_info_text.json @@ -12,6 +12,7 @@ "A-10C": [{ "default": { "name": "A-10C Thunderbolt II (Suite 3)", + "text": "The A-10C Thunderbolt II, also known as the Warthog, is a 'flying gun'. The A-10C is a high-survivability and versatile aircraft, popular with pilots for the 'get home' effectiveness.The mission of the aircraft is ground attack against tanks, armored vehicles and installations, and close air support of ground forces. The Warthog is famous for its massive 30mm cannon, but it can also be armed with Maverick guided missiles and several types of bombs and rockets.", "country-of-origin": "USA", "manufacturer": "Fairchild Republic", "role": "Close Air Support/Attack", @@ -21,6 +22,7 @@ "A-10C_2": [{ "default": { "name": "A-10C Thunderbolt II (Suite 7)", + "text": "The A-10C Thunderbolt II, also known as the Warthog, is a 'flying gun'. The A-10C is a high-survivability and versatile aircraft, popular with pilots for the 'get home' effectiveness.The mission of the aircraft is ground attack against tanks, armored vehicles and installations, and close air support of ground forces. The Warthog is famous for its massive 30mm cannon, but it can also be armed with Maverick guided missiles and several types of bombs and rockets.", "country-of-origin": "USA", "manufacturer": "Fairchild Republic", "role": "Close Air Support/Attack", @@ -30,6 +32,7 @@ "A-20G": [{ "default": { "name": "A-20G Havoc", + "text": "The Douglas A-20 Havoc (company designation DB-7) is an American medium bomber, attack aircraft, night intruder, night fighter, and reconnaissance aircraft of World War II. Designed to meet an Army Air Corps requirement for a bomber, it was ordered by France for their air force before the USAAC decided it would also meet their requirements. French DB-7s were the first to see combat; after the fall of France the bomber, under the service name Boston continued with the Royal Air Force. From 1941, night fighter and intruder versions were given the service name Havoc. In 1942 USAAF A-20s saw combat in North Africa.", "country-of-origin": "USA", "manufacturer": "Douglas", "role": "Medium Bomber/Attack", @@ -42,6 +45,7 @@ "A-4E-C": [{ "default": { "name": "A-4E Skyhawk", + "text": "The Skyhawk is a relatively lightweight aircraft, with a maximum takeoff weight of 24,500 pounds (11,100 kg), and has a top speed of 670 miles per hour (1,080 km/h). The aircraft's five hardpoints support a variety of missiles, bombs, and other munitions. Skyhawks played key roles in the Vietnam War, the Yom Kippur War, and the Falklands War. Sixty years after the aircraft's first flight in 1954, some of the 2,960 produced (through February 1979)[1] remain in service with the Argentine Air Force and the Brazilian Naval Aviation.", "country-of-origin": "USA", "manufacturer": "Douglas", "role": "Carrier-based Attack/Light Fighter", @@ -51,6 +55,7 @@ "AH_1W": [{ "default": { "name": "AH-1W SuperCobra", + "text": "The AH-1 Cobra was developed in the mid-1960s as an interim gunship for the U.S. Army for use during the Vietnam War. The Cobra shared the proven transmission, rotor system, and the T53 turboshaft engine of the UH-1 'Huey'. By June 1967, the first AH-1G HueyCobras had been delivered. Bell built 1,116 AH-1Gs for the U.S. Army between 1967 and 1973, and the Cobras chalked up over a million operational hours in Vietnam.", "country-of-origin": "USA", "manufacturer": "Bell", "role": "Attack", @@ -63,6 +68,7 @@ "AH-64A": [{ "default": { "name": "AH-64A Apache", + "text": "The legendary 'Apache' is an US twin-turboshaft attack helicopter for a crew of two. It features a nose-mounted sensor suite for target acquisition and night vision systems. It is armed with a 30 mm (1.18 in) M230 chain gun carried between the main landing gear, under the aircraft's forward fuselage, and four hardpoints mounted on stub-wing pylons for carrying armament and stores, typically a mixture of AGM-114 Hellfire missiles and Hydra 70 rocket pods. The AH-64 has significant systems redundancy to improve combat survivability. American AH-64s have served in conflicts in Panama, the Persian Gulf, Kosovo, Afghanistan, and Iraq. Israel used the Apache in its military conflicts in Lebanon and the Gaza Strip. British and Dutch Apaches have seen deployments in wars in Afghanistan and Iraq.", "country-of-origin": "USA", "manufacturer": "Boeing", "role": "Attack", @@ -72,6 +78,7 @@ "AH-64D": [{ "default": { "name": "AH-64D Apache Longbow", + "text": "The legendary 'Apache' is an US twin-turboshaft attack helicopter for a crew of two. It features a nose-mounted sensor suite for target acquisition and night vision systems. It is armed with a 30 mm (1.18 in) M230 chain gun carried between the main landing gear, under the aircraft's forward fuselage, and four hardpoints mounted on stub-wing pylons for carrying armament and stores, typically a mixture of AGM-114 Hellfire missiles and Hydra 70 rocket pods. The AH-64 has significant systems redundancy to improve combat survivability. American AH-64s have served in conflicts in Panama, the Persian Gulf, Kosovo, Afghanistan, and Iraq. Israel used the Apache in its military conflicts in Lebanon and the Gaza Strip. British and Dutch Apaches have seen deployments in wars in Afghanistan and Iraq.", "country-of-origin": "USA", "manufacturer": "Boeing", "role": "Attack", @@ -101,6 +108,7 @@ "B-1B": [{ "default": { "name": "B-1B Lancer", + "text": "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 during Operation Desert Fox in 1998 and again during the NATO action in Kosovo the following year. The B-1B has supported U.S. and NATO military forces in Afghanistan and Iraq. The Air Force had 62 B-1Bs in service as of 2016. The Northrop Grumman B-21 Raider is to begin replacing the B-1B after 2025; all B-1s are planned to be retired by 2036.", "country-of-origin": "USA", "manufacturer": "Rockwell", "role": "Supersonic Strategic Bomber", @@ -110,6 +118,7 @@ "B-17G": [{ "default": { "name": "B-17G Flying Fortress", + "text": "The B-17 was primarily employed by the USAAF in the daylight strategic bombing campaign of World War II against German industrial, military and civilian targets. The United States Eighth Air Force, based at many airfields in central, eastern and southern England, and the Fifteenth Air Force, based in Italy, complemented the RAF Bomber Command's night-time area bombing in the Combined Bomber Offensive to help secure air superiority over the cities, factories and battlefields of Western Europe in preparation for the invasion of France in 1944.it was a relatively fast, high-flying, long-range bomber with heavy defensive armament at the expense of bombload. It developed a reputation for toughness based upon stories and photos of badly damaged B-17s safely returning to base. The B-17 dropped more bombs than any other U.S. aircraft in World War II. Of approximately 1.5 million tons of bombs dropped on Nazi Germany and its occupied territories by U.S. aircraft, over 640,000 tons were dropped from B-17s.", "country-of-origin": "USA", "manufacturer": "Boeing", "role": "Heavy Bomber", @@ -122,6 +131,7 @@ "B-52H": [{ "default": { "name": "B-52H Stratofortress", + "text": "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 2015, the last airplanes are expected to serve into the 2050s.", "country-of-origin": "USA", "manufacturer": "Boeing", "role": "Strategic Bomber", @@ -131,6 +141,7 @@ "Bf-109K-4": [{ "default": { "name": "Bf 109 K-4 Kurfürst", + "text": "The BF 109 series was, along with the Focke-Wulf Fw 190, the backbone of the Luftwaffe's fighter force. The Bf 109 first saw operational service in 1937 during the Spanish Civil War and was still in service at the dawn of the jet age at the end of World War II in 1945. The final production version of the Bf 109 was the K series or Kurfürst, introduced in late 1944, powered by the DB 605D engine with up to 2,000 PS (1,973 HP). Though externally akin to the late production Bf 109G series, a large number of internal changes and aerodynamic improvements were incorporated that improved its effectiveness and remedied flaws, keeping it competitive with the latest Allied and Soviet fighters. The Bf 109's outstanding rate of climb was superior to many Allied adversaries including the P-51D Mustang, Spitfire Mk. XIV and Hawker Tempest Mk. V.", "manufacturer": "Messerschmitt", "role": "Fighter", "year-of-variant-introduction": "1944" @@ -169,6 +180,7 @@ "F-4E": [{ "default": { "name": "F-4E Phantom II", + "text": "Proving highly adaptable, the F-4 entered service with the Navy in 1961 before it was adopted by the United States Marine Corps and the United States Air Force, and by the mid-1960s it had become a major part of their air arms. Phantom production ran from 1958 to 1981 with a total of 5,195 aircraft built, making it the most produced American supersonic military aircraft in history, and cementing its position as an iconic combat aircraft of the Cold War. The F-4 was used extensively during the Vietnam War. It served as the principal air superiority fighter for the U.S. Air Force, Navy, and Marine Corps and became important in the ground-attack and aerial reconnaissance roles late in the war.", "country-of-origin": "USA", "manufacturer": "McDonnell Douglas", "role": "Fighter-Bomber", @@ -230,6 +242,7 @@ "F-15E": [{ "default": { "name": "F-15E Strike Eagle", + "text": "The F-15 has often been labeled as the greatest U.S. fighter aircraft from the 1970s until the early 21st century. The F-15E is a multirole fighter and exceeds in CAS operations. It served worldwide without suffering any confirmed losses.", "country-of-origin": "USA", "manufacturer": "McDonnell Douglas", "role": "Multirole Strike Fighter", @@ -252,6 +265,7 @@ "F-22A":[{ "default": { "name": "F-22A Raptor", + "text": "The F-22A is an American single-seat, twin-engine, all-weather stealth tactical fighter aircraft developed exclusively for the United States Air Force (USAF). The result of the USAF's Advanced Tactical Fighter (ATF) program, the aircraft was designed primarily as an air superiority fighter, but also has ground attack, electronic warfare, and signal intelligence capabilities. Currently it is viewed as the most advanced fighter in the world.", "country-of-origin": "USA", "manufacturer": "Lockheed Martin", "role": "Stealth Air-Superiority Fighter", @@ -271,6 +285,7 @@ "F-111F": [{ "default": { "name": "F-111F Aardvark", + "text": "The General Dynamics F-111 Aardvark is a retired American supersonic, medium-range interdictor and tactical attack aircraft that also filled the roles of strategic nuclear bomber, aerial reconnaissance, and electronic-warfare aircraft in its various versions. The word 'aardvark' is Afrikaans for 'earth pig' and reflects the look of the long nose of the aircraft that might remind one of the nose of the aardvark.", "country-of-origin": "USA", "manufacturer": "General Dynamics", "role": "Fighter-Bomber", @@ -280,6 +295,7 @@ "F-117A": [{ "default": { "name": "F-117A Nighthawk", + "text": "The Lockheed F-117 Nighthawk is a semi-retired American single-seat, twin-engine stealth attack aircraft that was developed by Lockheed's secretive Skunk Works division and operated by the United States Air Force (USAF). It was the first operational aircraft to be designed around stealth technology. The F-117 was widely publicized for its role in the Persian Gulf War of 1991. Although it was commonly referred to as the 'Stealth Fighter', it was strictly a ground-attack aircraft. F-117s took part in the conflict in Yugoslavia, where one was shot down and another damaged by surface-to-air missiles (SAM) in 1999. The U.S. Air Force retired the F-117 in April 2008, primarily due to the fielding of the F-22 Raptor. Despite the type's retirement, a portion of the fleet has been kept in airworthy condition, and Nighthawks have been observed flying in 2020.", "country-of-origin": "USA", "manufacturer": "Lockheed", "role": "Stealth Attack", @@ -325,6 +341,7 @@ "Hercules": [{ "default": { "name": "C-130J-30 Super Hercules", + "text": "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.", "country-of-origin": "USA", "manufacturer": "Lockheed", "role": "Transport", @@ -344,12 +361,33 @@ "J-11A": [{ "default": { "name": "J-11A Flanker-L", + "text": "The Shenyang J-11 (NATO reporting name Flanker-L) is a twin-engine jet fighter whose airframe is based on the Soviet-designed Sukhoi Su-27. It is manufactured by the Shenyang Aircraft Corporation (SAC). The aircraft is operated by the People's Liberation Army Air Force (PLAAF) and the People's Liberation Army Naval Air Force (PLANAF).", "country-of-origin": "China", "manufacturer": "Shenyang", "role": "Air-Superiority Fighter", "year-of-variant-introduction": "1998" } }], + "JAS39Gripen": [{ + "default": { + "name": "JAS 39 Gripen", + "text": "The Saab JAS 39 Gripen is a light single-engine multirole fighter aircraft manufactured by the Swedish aerospace company Saab AB. The Gripen has a delta wing and canard configuration with relaxed stability design and fly-by-wire flight controls. Various versions have been built, grouped as A-, C- and E-series. This is the AA Version, since the Mod for this aircraft splitted it in an AA and AG Version.", + "country-of-origin": "Sweden", + "manufacturer": "Saab AB", + "role": "Fighter", + "year-of-variant-introduction": "2002" + } + }], + "JAS39Gripen_AG": [{ + "default": { + "name": "JAS 39 Gripen A/G", + "text": "The Saab JAS 39 Gripen is a light single-engine multirole fighter aircraft manufactured by the Swedish aerospace company Saab AB. The Gripen has a delta wing and canard configuration with relaxed stability design and fly-by-wire flight controls. Various versions have been built, grouped as A-, C- and E-series. This is the AG Version, since the Mod for this aircraft splitted it in an AA and AG Version.", + "country-of-origin": "Sweden", + "manufacturer": "Saab AB", + "role": "Attack", + "year-of-variant-introduction": "2002" + } + }], "JF-17": [{ "default": { "name": "JF-17 Thunder", @@ -367,6 +405,7 @@ "Ju-88A4": [{ "default": { "name": "Ju 88 A-4", + "text": "The Junkers Ju 88 is a German World War II Luftwaffe twin-engined multirole combat aircraft. Junkers Aircraft and Motor Works (JFM) designed the plane in the mid-1930s as a so-called Schnellbomber ('fast bomber') that would be too fast for fighters of its era to intercept. It suffered from technical problems during its development and early operational periods but became one of the most versatile combat aircraft of the war. Like a number of other Luftwaffe bombers, it served as a bomber, dive bomber, night fighter, torpedo bomber, reconnaissance aircraft, heavy fighter and at the end of the war, as a flying bomb. Despite a protracted development, it became one of the Luftwaffe's most important aircraft. The assembly line ran constantly from 1936 to 1945 and more than 15,000 Ju 88s were built in dozens of variants, more than any other twin-engine German aircraft of the period. Throughout production the basic structure of the aircraft remained unchanged.", "country-of-origin": "Germany", "manufacturer": "Junkers", "role": "Tactical/Torpedo Bomber", @@ -416,6 +455,7 @@ "MB-339PAN":[{ "default": { "name": "MB-339PAN", + "text": "The Aermacchi MB-339 is a military jet trainer and light attack aircraft designed and manufactured by Italian aviation company Aermacchi.", "country-of-origin": "Italy", "manufacturer": "Aermacchi", "role": "Aerobatic", @@ -425,6 +465,7 @@ "Mirage 2000-5": [{ "default": { "name": "Mirage 2000-5", + "text": "The Dassault Mirage 2000 is a French multirole, single-engined, fourth-generation jet fighter manufactured by Dassault Aviation. It was designed in the late 1970s as a lightweight fighter to replace the Mirage III for the French Air Force (Armée de l'air). The Mirage 2000 evolved into a multirole aircraft with several variants developed, with sales to a number of nations. It was later developed into the Mirage 2000N and 2000D strike variants, the improved Mirage 2000-5, and several export variants. Over 600 aircraft were built and it has been in service with 9 nations.", "country-of-origin": "France", "manufacturer": "Dassault", "role": "Multirole Fighter", @@ -434,6 +475,7 @@ "Mi-24V": [{ "default": { "name": "Mi-24V Hind-E", + "text": "The Mil Mi-24 (Russian: Миль Ми-24; NATO reporting name: Hind) is a large helicopter gunship, attack helicopter and low-capacity troop transport with room for eight passengers. It is produced by Mil Moscow Helicopter Plant and has been operated since 1972 by the Soviet Air Force and its successors, along with 48 other nations. Soviet pilots called the Mi-24 the 'flying tank' (Russian: летающий танк, romanized: letayushchiy tank), a term used historically with the famous World War II Soviet Il-2 Shturmovik armored ground attack aircraft. More common unofficial nicknames were 'Galina' (or 'Galya'), 'Crocodile' (Russian: Крокодил, romanized: Krokodil), due to the helicopter's camouflage scheme, and 'Drinking Glass' (Russian: Стакан, romanized: Stakan), because of the flat glass plates that surround earlier Mi-24 variants' cockpits. It served to a great success in the Afghanistan war, until the Taliban where equiped with Stinger Misseles from the CIA.", "country-of-origin": "USSR/Russia", "manufacturer": "Mil", "role": "Attack/Transport", @@ -443,6 +485,7 @@ "Mi-28N": [{ "default": { "name": "Mi-28N Havoc", + "text": "The Mil Mi-28 (NATO reporting name 'Havoc') is a Russian all-weather, day-night, military tandem, two-seat anti-armor attack helicopter. It is an attack helicopter with no intended secondary transport capability, better optimized than the Mil Mi-24 gunship for the role. It carries a single gun in an undernose barbette, plus external loads carried on pylons beneath stub wings.", "country-of-origin": "USSR/Russia", "manufacturer": "Mil", "role": "Attack", @@ -504,6 +547,7 @@ "MiG-23MLD": [{ "default": { "name": "MiG-23MLD Flogger-K", + "text": "The Mikoyan-Gurevich MiG-23 (Russian: Микоян и Гуревич МиГ-23; NATO reporting name: Flogger) is a variable-geometry fighter aircraft, designed by the Mikoyan-Gurevich design bureau in the Soviet Union. It is a third-generation jet fighter, the world's most-produced variable-geometry aircraft, along with similar Soviet aircraft such as the Su-17 'Fitter'. It was the first Soviet fighter to field a look-down/shoot-down radar, the RP-23 Sapfir, and one of the first to be armed with beyond-visual-range missiles. Production started in 1969 and reached large numbers with over 5,000 aircraft built, making it the most produced variable-sweep wing aircraft in history. Today the MiG-23 remains in limited service with some export customers.", "country-of-origin": "USSR/Russia", "manufacturer": "Mikoyan-Gurevich", "role": "Fighter", @@ -517,6 +561,7 @@ "MiG-25PD": [{ "default": { "name": "MiG-25PD Foxbat-E", + "text": "The Mikoyan-Gurevich MiG-25 (Russian: Микоян и Гуревич МиГ-25; NATO reporting name: Foxbat) is a supersonic interceptor and reconnaissance aircraft that was among the fastest military aircraft to enter service. It was designed by the Soviet Union's Mikoyan-Gurevich bureau and is one of the few combat aircraft built primarily using stainless steel. It was the last plane designed by Mikhail Gurevich before his retirement. The first prototype flew in 1964, and the aircraft entered service in 1970. The MiG-25 was theoretically capable of a maximum speed exceeding Mach 3 and a ceiling of 27 km (89,000 ft). Its high speed was problematic: although sufficient thrust was available to reach Mach 3.2, a limit of Mach 2.83 had to be imposed as the engines tended to overspeed and overheat at higher air speeds, possibly damaging them beyond repair. The MiG-25 features powerful radar and four air-to-air missiles. When first seen in reconnaissance photography, the large wings suggested an enormous and highly maneuverable fighter, at a time when U.S. design theories were also evolving towards higher maneuverability due to combat performance in the Vietnam War. The appearance of the MiG-25 sparked serious concern in the West and prompted dramatic increases in performance for the McDonnell Douglas F-15 Eagle, then under development in the late 1960s. The capabilities of the MiG-25 were better understood by the west in 1976 when Soviet pilot Viktor Belenko defected in a MiG-25 to the United States via Japan. It turned out that the aircraft's weight necessitated its large wings.", "country-of-origin": "USSR/Russia", "manufacturer": "Mikoyan-Gurevich", "role": "Interceptor", @@ -526,6 +571,7 @@ "MiG-25RBT": [{ "default": { "name": "MiG-25RBT Foxbat-B", + "text": "The Mikoyan-Gurevich MiG-25 (Russian: Микоян и Гуревич МиГ-25; NATO reporting name: Foxbat) is a supersonic interceptor and reconnaissance aircraft that was among the fastest military aircraft to enter service. It was designed by the Soviet Union's Mikoyan-Gurevich bureau and is one of the few combat aircraft built primarily using stainless steel. It was the last plane designed by Mikhail Gurevich before his retirement. The first prototype flew in 1964, and the aircraft entered service in 1970. The MiG-25 was theoretically capable of a maximum speed exceeding Mach 3 and a ceiling of 27 km (89,000 ft). Its high speed was problematic: although sufficient thrust was available to reach Mach 3.2, a limit of Mach 2.83 had to be imposed as the engines tended to overspeed and overheat at higher air speeds, possibly damaging them beyond repair. The MiG-25 features powerful radar and four air-to-air missiles. When first seen in reconnaissance photography, the large wings suggested an enormous and highly maneuverable fighter, at a time when U.S. design theories were also evolving towards higher maneuverability due to combat performance in the Vietnam War. The appearance of the MiG-25 sparked serious concern in the West and prompted dramatic increases in performance for the McDonnell Douglas F-15 Eagle, then under development in the late 1960s. The capabilities of the MiG-25 were better understood by the west in 1976 when Soviet pilot Viktor Belenko defected in a MiG-25 to the United States via Japan. It turned out that the aircraft's weight necessitated its large wings.", "country-of-origin": "USSR/Russia", "manufacturer": "Mikoyan-Gurevich", "role": "Strike Fighter", @@ -535,6 +581,7 @@ "MiG-27K": [{ "default": { "name": "MiG-27K Flogger-J2", + "text": "The Mikoyan MiG-27 (Russian: Микоян МиГ-27; NATO reporting name: Flogger-D/J) is a variable-sweep ground-attack aircraft, originally built by the Mikoyan-Gurevich design bureau in the Soviet Union and later licence-produced in India by Hindustan Aeronautics as the Bahadur ('Valiant'). It is based on the Mikoyan-Gurevich MiG-23 fighter aircraft, but optimised for air-to-ground attack. Unlike the MiG-23, the MiG-27 did not have widespread use outside Russia, as most countries opted for the Mikoyan-Gurevich MiG-23BN and Sukhoi Su-22 instead. It remains in service only with the Kazakh Air Forces in the ground attack role. All Russian, Indian and Ukrainian MiG-27s have been retired.", "country-of-origin": "USSR/Russia", "manufacturer": "Mikoyan", "role": "Attack", @@ -574,6 +621,7 @@ "MiG-31": [{ "default": { "name": "MiG-31 Foxhound", + "text": "The Mikoyan MiG-31 (Russian: Микоян МиГ-31; NATO reporting name: Foxhound) is a supersonic interceptor aircraft that was developed for use by the Soviet Air Forces. The aircraft was designed by the Mikoyan design bureau as a replacement for the earlier MiG-25 \"Foxbat\"; the MiG-31 is based on and shares design elements with the MiG-25. The MiG-31 is among the fastest combat jets in the world. It continues to be operated by the Russian Air Force and the Kazakhstan Air Force following the end of the Cold War and the collapse of the Soviet Union in 1991. The Russian Defence Ministry expects the MiG-31 to remain in service until 2030 or beyond and was confirmed in 2020 when an announcement was made to extend the service lifetime from 2,500 to 3,500 hours on the existing airframes.", "country-of-origin": "USSR/Russia", "manufacturer": "Mikoyan", "role": "Interceptor", @@ -583,6 +631,7 @@ "OH-58D": [{ "default": { "name": "OH-58D Kiowa Warrior", + "text": "The Bell OH-58 Kiowa is a family of single-engine, single-rotor, military helicopters used for observation, utility, and direct fire support. Bell Helicopter manufactured the OH-58 for the United States Army based on its Model 206A JetRanger helicopter. The OH-58 was in continuous U.S. Army service from 1969 to 2017, when it was replaced in these roles by the Boeing AH-64 Apache and Eurocopter UH-72 Lakota. The latest model, the OH-58D Kiowa Warrior, is primarily operated in an armed reconnaissance role in support of ground troops. The OH-58 has been exported to Austria, Canada, Croatia, the Dominican Republic, Taiwan, Saudi Arabia, and Greece. It has also been produced under license in Australia.", "country-of-origin": "USA", "manufacturer": "Bell", "role": "Light Attack/Forward Air Control", @@ -631,6 +680,7 @@ "P-51D": [{ "default": { "name": "P-51D-25-NA Mustang", + "text": "The North American Aviation P-51 Mustang is an American long-range, single-seat fighter and fighter-bomber used during World War II and the Korean War, among other conflicts. From late 1943, P-51Bs and P-51Cs (supplemented by P-51Ds from mid-1944) were used by the USAAF's Eighth Air Force to escort bombers in raids over Germany, while the RAF's Second Tactical Air Force and the USAAF's Ninth Air Force used the Merlin-powered Mustangs as fighter-bombers, roles in which the Mustang helped ensure Allied air superiority in 1944. The P-51 was also used by Allied air forces in the North African, Mediterranean, Italian, and Pacific theaters. During World War II, Mustang pilots claimed to have destroyed 4,950 enemy aircraft. At the start of the Korean War, the Mustang, by then redesignated F-51, was the main fighter of the United States until jet fighters, including North American's F-86, took over this role; the Mustang then became a specialized fighter-bomber. Despite the advent of jet fighters, the Mustang remained in service with some air forces until the early 1980s. After the Korean War, Mustangs became popular civilian warbirds and air racing aircraft.", "country-of-origin": "USA", "manufacturer": "North American", "role": "Fighter", @@ -643,6 +693,7 @@ "P-51D-30-NA": [{ "default": { "name": "P-51D-30-NA Mustang", + "text": "The North American Aviation P-51 Mustang is an American long-range, single-seat fighter and fighter-bomber used during World War II and the Korean War, among other conflicts. From late 1943, P-51Bs and P-51Cs (supplemented by P-51Ds from mid-1944) were used by the USAAF's Eighth Air Force to escort bombers in raids over Germany, while the RAF's Second Tactical Air Force and the USAAF's Ninth Air Force used the Merlin-powered Mustangs as fighter-bombers, roles in which the Mustang helped ensure Allied air superiority in 1944. The P-51 was also used by Allied air forces in the North African, Mediterranean, Italian, and Pacific theaters. During World War II, Mustang pilots claimed to have destroyed 4,950 enemy aircraft. At the start of the Korean War, the Mustang, by then redesignated F-51, was the main fighter of the United States until jet fighters, including North American's F-86, took over this role; the Mustang then became a specialized fighter-bomber. Despite the advent of jet fighters, the Mustang remained in service with some air forces until the early 1980s. After the Korean War, Mustangs became popular civilian warbirds and air racing aircraft.", "country-of-origin": "USA", "manufacturer": "North American", "role": "Fighter", @@ -655,6 +706,7 @@ "Rafale_A_S": [{ "default": { "name": "Rafale M (Air-to-Ground)", + "text": "The Dassault Rafale (French pronunciation: ​[ʁafal], literally meaning \"gust of wind\", and \"burst of fire\" in a more military sense) is a French twin-engine, canard delta wing, multirole fighter aircraft designed and built by Dassault Aviation. Equipped with a wide range of weapons, the Rafale is intended to perform air supremacy, interdiction, aerial reconnaissance, ground support, in-depth strike, anti-ship strike and nuclear deterrence missions. The Rafale is referred to as an \"omnirole\" aircraft by Dassault. The Rafale has been used in combat over Afghanistan, Libya, Mali, Iraq and Syria.", "country-of-origin": "France", "manufacturer": "Dassault", "role": "Multirole Fighter", @@ -664,6 +716,7 @@ "Rafale_B": [{ "default": { "name": "Rafale B", + "text": "The Dassault Rafale (French pronunciation: ​[ʁafal], literally meaning \"gust of wind\", and \"burst of fire\" in a more military sense) is a French twin-engine, canard delta wing, multirole fighter aircraft designed and built by Dassault Aviation. Equipped with a wide range of weapons, the Rafale is intended to perform air supremacy, interdiction, aerial reconnaissance, ground support, in-depth strike, anti-ship strike and nuclear deterrence missions. The Rafale is referred to as an \"omnirole\" aircraft by Dassault. The Rafale has been used in combat over Afghanistan, Libya, Mali, Iraq and Syria.", "country-of-origin": "France", "manufacturer": "Dassault", "role": "Carrier-based Multirole Fighter", @@ -673,6 +726,7 @@ "Rafale_M": [{ "default": { "name": "Rafale M (Air-to-Air)", + "text": "The Dassault Rafale (French pronunciation: ​[ʁafal], literally meaning \"gust of wind\", and \"burst of fire\" in a more military sense) is a French twin-engine, canard delta wing, multirole fighter aircraft designed and built by Dassault Aviation. Equipped with a wide range of weapons, the Rafale is intended to perform air supremacy, interdiction, aerial reconnaissance, ground support, in-depth strike, anti-ship strike and nuclear deterrence missions. The Rafale is referred to as an \"omnirole\" aircraft by Dassault. The Rafale has been used in combat over Afghanistan, Libya, Mali, Iraq and Syria.", "country-of-origin": "France", "manufacturer": "Dassault", "role": "Multirole Fighter", @@ -682,6 +736,7 @@ "S-3B": [{ "default": { "name": "S-3B Viking", + "text": "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 capabilities to a carrier battle group. A carrier-based, subsonic, all-weather, long-range, multi-mission aircraft, it carried automated weapon systems and was capable of extended missions with in-flight refueling. Because of its characteristic sound, it was nicknamed the \"War Hoover\" after the vacuum cleaner brand. The S-3 was phased out from front-line fleet service aboard aircraft carriers in January 2009, with its missions taken over by aircraft like the P-3C Orion, P-8 Poseidon, Sikorsky SH-60 Seahawk and Boeing F/A-18E/F Super Hornet", "country-of-origin": "USA", "manufacturer": "Lockheed", "role": "Carrier-based Attack", @@ -726,6 +781,7 @@ "SH-60B": [{ "default": { "name": "SH-60B Seahawk", + "text": "The Sikorsky SH-60/MH-60 Seahawk (or Sea Hawk) is a twin turboshaft engine, multi-mission United States Navy helicopter based on the United States Army UH-60 Black Hawk and a member of the Sikorsky S-70 family. The most significant modifications are the folding main rotor and a hinged tail to reduce its footprint aboard ships. The U.S. Navy uses the H-60 airframe under the model designations SH-60B, SH-60F, HH-60H, MH-60R, and MH-60S. Able to deploy aboard any air-capable frigate, destroyer, cruiser, fast combat support ship, amphibious assault ship, Littoral combat ship or aircraft carrier, the Seahawk can handle anti-submarine warfare (ASW), anti-surface warfare (ASUW), naval special warfare (NSW) insertion, search and rescue (SAR), combat search and rescue (CSAR), vertical replenishment (VERTREP), and medical evacuation (MEDEVAC).", "country-of-origin": "USA", "manufacturer": "Sikorsky", "role": "Transport/Anti-Ship", @@ -755,6 +811,7 @@ "Su-17M4": [{ "default": { "name": "Su-17M4 Fitter-K", + "text": "The Sukhoi Su-17 (izdeliye S-32) is a variable-sweep wing fighter-bomber developed for the Soviet military. Its NATO reporting name is \"Fitter\". Developed from the Sukhoi Su-7, the Su-17 was the first variable-sweep wing aircraft to enter Soviet service. Two subsequent Sukhoi aircraft, the Su-20 and Su-22, have usually been regarded as variants of the Su-17. The Su-17 has had a long career and has been operated by many other air forces of including the Russian Federation, other former Soviet republics, the former Warsaw Pact, countries in the Arab world, Angola and Peru.", "country-of-origin": "USSR/Russia", "manufacturer": "Sukhoi", "role": "Fighter-Bomber", @@ -776,6 +833,7 @@ "Su-24M": [{ "default": { "name": "Su-24M Fencer-D", + "text": "The Sukhoi Su-24 (NATO reporting name: Fencer) is a supersonic, all-weather attack aircraft developed in the Soviet Union. The aircraft has a variable-sweep wing, twin-engines and a side-by-side seating arrangement for its crew of two. It was the first of the USSR's aircraft to carry an integrated digital navigation/attack system. It remains in service with the Russian Air Force, Syrian Air Force, Ukrainian Air Force, Azerbaijan Air Force , Iraqi Air Force and various air forces to which it was exported.", "country-of-origin": "USSR/Russia", "manufacturer": "Sukhoi", "role": "Attack", @@ -819,6 +877,7 @@ "Su-30": [{ "default": { "name": "Su-30 Flanker-C", + "text": "The Sukhoi Su-30 (Russian: Сухой Су-30; NATO reporting name: Flanker-C/G/H) is a twin-engine, two-seat supermaneuverable fighter aircraft developed in the Soviet Union by Russia's Sukhoi Aviation Corporation. It is a multirole fighter for all-weather, air-to-air and air-to-surface deep interdiction missions. 630 numbers have been build.", "country-of-origin": "USSR/Russia", "manufacturer": "Sukhoi", "role": "Multirole Fighter", @@ -852,6 +911,7 @@ "Su-34": [{ "default": { "name": "Su-34 Fullback", + "text": "The Sukhoi Su-34 (Russian: Сухой Су-34; NATO reporting name: Fullback) is a Soviet-origin Russian twin-engine, twin-seat, all-weather supersonic medium-range fighter-bomber/strike aircraft. It first flew in 1990, intended for the Soviet Air Forces, and it entered service in 2014 with the Russian Air Force. Based on the Sukhoi Su-27 Flanker air superiority fighter, the Su-34 has an armored cockpit for side-by-side seating of its two-person crew. The Su-34 is designed primarily for tactical deployment against ground and naval targets (tactical bombing/attack/interdiction roles, including against small and mobile targets) on solo and group missions in daytime and at night, under favourable and adverse weather conditions and in a hostile environment with counter-fire and electronic Warfare (EW) counter-measures deployed, as well as for aerial reconnaissance. The Su-34 will eventually replace the Su-24 tactical strike fighter and the Tu-22M3 long-distance bomber.", "country-of-origin": "USSR/Russia", "manufacturer": "Sukhoi", "role": "Fighter-Bomber/Strike Fighter", @@ -861,6 +921,7 @@ "Su-57": [{ "default": { "name": "Su-57 Felon", + "text": "The Sukhoi Su-57 (Russian: Сухой Су-57; NATO reporting name: Felon) is a single-seat, twin-engine stealth multirole fighter developed by Sukhoi for the Russian Aerospace Forces. According to Sukhoi, the multirole fighter is designed to have supercruise, supermaneuverability, stealth, and integrated avionics to overcome the previous generations fighter aircraft as well as ground and naval defences. The Su-57 is intended to succeed the MiG-29 and Su-27 in the Russian Air Force and entered service in December 2020.", "country-of-origin": "Russia", "manufacturer": "Sukhoi", "role": "Stealth Air-Superiority Fighter", @@ -870,6 +931,7 @@ "Tornado GR4": [{ "default": { "name": "Tornado GR4", + "text": "The Panavia Tornado is a family of twin-engine, variable-sweep wing multirole combat aircraft, jointly developed and manufactured by Italy, the United Kingdom and West Germany. There are three primary Tornado variants: the Tornado IDS (interdictor/strike) fighter-bomber, the suppression of enemy air defences Tornado ECR (electronic combat/reconnaissance) and the Tornado ADV (air defence variant) interceptor aircraft.", "country-of-origin": "UK/Italy/West Germany", "manufacturer": "Panavia", "role": "Strike Fighter", @@ -879,6 +941,7 @@ "Tornado IDS": [{ "default": { "name": "Tornado IDS", + "text": "The Panavia Tornado is a family of twin-engine, variable-sweep wing multirole combat aircraft, jointly developed and manufactured by Italy, the United Kingdom and West Germany. There are three primary Tornado variants: the Tornado IDS (interdictor/strike) fighter-bomber, the suppression of enemy air defences Tornado ECR (electronic combat/reconnaissance) and the Tornado ADV (air defence variant) interceptor aircraft.", "country-of-origin": "UK/Italy/West Germany", "manufacturer": "Panavia", "role": "Strike Fighter", @@ -888,6 +951,7 @@ "Tu-22M3": [{ "default": { "name": "Tu-22M3 Backfire-C", + "text": "The Tupolev Tu-22M (Russian: Туполев Ту-22М; NATO reporting name: Backfire) is a supersonic, variable-sweep wing, long-range strategic and maritime strike bomber developed by the Tupolev Design Bureau in the 1960s. According to some sources, the bomber was believed to be designated Tu-26 at one time. During the Cold War, the Tu-22M was operated by the Soviet Air Forces (VVS) in a missile carrier strategic bombing role, and by the Soviet Naval Aviation (Aviacija Vojenno-Morskogo Flota, AVMF) in a long-range maritime anti-shipping role. Significant numbers remain in service with the Russian Air Force, and as of 2014 more than 100 Tu-22Ms are in use.", "country-of-origin": "USSR/Russia", "manufacturer": "Tupolev", "role": "Strategic/Maritime Strike Bomber", @@ -897,6 +961,7 @@ "Tu-95MS": [{ "default": { "name": "Tu-95MS Bear-H", + "text": "The Tupolev Tu-95 (Russian: Туполев Ту-95; NATO reporting name: \"Bear\") is a large, four-engine turboprop-powered strategic bomber and missile platform. First flown in 1952, the Tu-95 entered service with the Long-Range Aviation of the Soviet Air Forces in 1956 and is expected to serve the Russian Aerospace Forces until at least 2040. A development of the bomber for maritime patrol is designated Tu-142, while a passenger airliner derivative was called Tu-114. The aircraft has four Kuznetsov NK-12 engines with contra-rotating propellers. It is the only propeller-powered strategic bomber still in operational use today. The Tu-95 is one of the loudest military aircraft, particularly because the tips of the propeller blades move faster than the speed of sound", "country-of-origin": "USSR/Russia", "manufacturer": "Tupolev", "role": "Strategic Bomber", @@ -906,6 +971,7 @@ "Tu-142": [{ "default": { "name": "Tu-142 Bear-F", + "text": "The Tupolev Tu-142 (Russian: Туполев Ту-142; NATO reporting name: Bear F/J) is a Soviet/Russian maritime reconnaissance and anti-submarine warfare (ASW) aircraft derived from the Tu-95 turboprop strategic bomber. A specialised communications variant designated Tu-142MR was tasked with long-range communications duties with Soviet ballistic missile submarines. The Tu-142 was designed by the Tupolev design bureau, and manufactured by the Kuibyshev Aviation and Taganrog Machinery Plants from 1968 to 1994. Formerly operated by the Soviet Navy and Ukrainian Air Force, the Tu-142 currently serves with the Russian Navy.", "country-of-origin": "USSR/Russia", "manufacturer": "Tupolev", "role": "Maritime Patrol/Anti-Ship", @@ -915,6 +981,7 @@ "Tu-160": [{ "default": { "name": "Tu-160 Blackjack", + "text": "The Tupolev Tu-160 (Russian: Туполев Ту-160 Белый лебедь, romanized: Belyj Lebeď, lit. 'White Swan'; NATO reporting name: Blackjack) is a supersonic, variable-sweep wing heavy strategic bomber designed by the Tupolev Design Bureau in the Soviet Union in the 1970s. It is the largest and heaviest Mach 2+ supersonic military aircraft ever built and next to the experimental XB-70 Valkyrie in overall length. As of 2021, it is the largest and heaviest combat aircraft, the fastest bomber in use and the largest and heaviest variable-sweep wing airplane ever flown.", "country-of-origin": "USSR/Russia", "manufacturer": "Tupolev", "role": "Supersonic Strategic Bomber", @@ -937,6 +1004,7 @@ "AAA 8,8cm Flak 18": [{ "default": { "name": "8.8 cm Flak 18", + "text": "The 8.8 cm Flak 18/36/37/41 is a German 88 mm anti-aircraft and anti-tank artillery gun, developed in the 1930s. It was widely used by Germany throughout World War II and is one of the most recognized German weapons of that conflict. Development of the original model led to a wide variety of guns. Air defense units were usually deployed with either a Kommandogerät (\"command device\") fire control computer or a portable Würzburg radar, which were responsible for its high level of accuracy against aircraft. The versatile carriage allowed the 8.8 cm Flak to be fired in a limited anti-tank mode when still on its wheels; it could be completely emplaced in only two and a half minutes.", "country-of-origin": "Germany", "manufacturer": "Krupp/Rheinmetall", "role": "Anti-Aircraft Gun/Anti-Tank Gun", @@ -946,6 +1014,7 @@ "AAA 8,8cm Flak 36": [{ "default": { "name": "8.8 cm Flak 36", + "text": "The 8.8 cm Flak 18/36/37/41 is a German 88 mm anti-aircraft and anti-tank artillery gun, developed in the 1930s. It was widely used by Germany throughout World War II and is one of the most recognized German weapons of that conflict. Development of the original model led to a wide variety of guns. Air defense units were usually deployed with either a Kommandogerät (\"command device\") fire control computer or a portable Würzburg radar, which were responsible for its high level of accuracy against aircraft. The versatile carriage allowed the 8.8 cm Flak to be fired in a limited anti-tank mode when still on its wheels; it could be completely emplaced in only two and a half minutes.", "country-of-origin": "Germany", "manufacturer": "Krupp/Rheinmetall", "role": "Anti-Aircraft Gun/Anti-Tank Gun", @@ -955,6 +1024,7 @@ "AAA 8,8cm Flak 37": [{ "default": { "name": "8.8 cm Flak 37", + "text": "The 8.8 cm Flak 18/36/37/41 is a German 88 mm anti-aircraft and anti-tank artillery gun, developed in the 1930s. It was widely used by Germany throughout World War II and is one of the most recognized German weapons of that conflict. Development of the original model led to a wide variety of guns. Air defense units were usually deployed with either a Kommandogerät (\"command device\") fire control computer or a portable Würzburg radar, which were responsible for its high level of accuracy against aircraft. The versatile carriage allowed the 8.8 cm Flak to be fired in a limited anti-tank mode when still on its wheels; it could be completely emplaced in only two and a half minutes.", "country-of-origin": "Germany", "manufacturer": "Krupp/Rheinmetall", "role": "Anti-Aircraft Gun/Anti-Tank Gun", @@ -964,6 +1034,7 @@ "AAA 8,8cm Flak 41": [{ "default": { "name": "8.8 cm Flak 41", + "text": "The 8.8 cm Flak 18/36/37/41 is a German 88 mm anti-aircraft and anti-tank artillery gun, developed in the 1930s. It was widely used by Germany throughout World War II and is one of the most recognized German weapons of that conflict. Development of the original model led to a wide variety of guns. Air defense units were usually deployed with either a Kommandogerät (\"command device\") fire control computer or a portable Würzburg radar, which were responsible for its high level of accuracy against aircraft. The versatile carriage allowed the 8.8 cm Flak to be fired in a limited anti-tank mode when still on its wheels; it could be completely emplaced in only two and a half minutes.", "country-of-origin": "Germany", "manufacturer": "Krupp/Rheinmetall", "role": "Anti-Aircraft Gun/Anti-Tank Gun", @@ -973,6 +1044,7 @@ "AAA Bofors 40mm": [{ "default": { "name": "Bofors 40 mm Gun", + "text": "The Bofors 40 mm gun, often referred to simply as the Bofors gun, is an anti-aircraft autocannon designed in the 1930s by the Swedish arms manufacturer AB Bofors. It was one of the most popular medium-weight anti-aircraft systems during World War II, used by most of the western Allies as well as some captured systems being used by the Axis powers. A small number of these weapons remain in service to this day, and saw action as late as the Persian Gulf War.", "country-of-origin": "Sweden", "manufacturer": "Bofors", "role": "Anti-Aircraft Gun", @@ -986,6 +1058,7 @@ "AAA Flak 38": [{ "default": { "name": "2 cm Flak 38", + "text": "The Flak 30 (Flugzeugabwehrkanone 30) and improved Flak 38 were 20 mm anti-aircraft guns used by various German forces throughout World War II. It was not only the primary German light anti-aircraft gun, but by far the most numerously produced German artillery piece throughout the war. It was produced in a variety of models, notably the Flakvierling 38 which combined four Flak 38 autocannons onto a single carriage.", "country-of-origin": "Germany", "manufacturer": "Mauser", "role": "Anti-Aircraft Gun", @@ -995,6 +1068,7 @@ "AAA Flak-Vierling 38": [{ "default": { "name": "2 cm Flakvierling 38", + "text": "The Flak 30 (Flugzeugabwehrkanone 30) and improved Flak 38 were 20 mm anti-aircraft guns used by various German forces throughout World War II. It was not only the primary German light anti-aircraft gun, but by far the most numerously produced German artillery piece throughout the war. It was produced in a variety of models, notably the Flakvierling 38 which combined four Flak 38 autocannons onto a single carriage.", "country-of-origin": "Germany", "manufacturer": "Mauser", "role": "Anti-Aircraft Gun", @@ -1004,6 +1078,7 @@ "AAA M45 Quadmount": [{ "default": { "name": "M45 Quadmount", + "text": "The M45 Quadmount (nicknamed the \"meat chopper\" and \"Krautmower\" for its high rate of fire) was a weapon mounting consisting of four of the \"HB\", or \"heavy barrel\" .50 caliber M2 Browning machine guns mounted in pairs on each side of an open, electrically powered turret. It was developed by the W. L. Maxson Corporation to replace the earlier M33 twin mount (also from Maxson). Although designed as an anti-aircraft weapon, it was also used against ground targets. Introduced in 1943 during World War II, it remained in US service as late as the Vietnam War.", "country-of-origin": "USA", "manufacturer": "W. L. Maxson Corporation", "role": "Anti-Aircraft Gun", @@ -1013,6 +1088,7 @@ "AAA M1 37mm": [{ "default": { "name": "M1 37mm Gun", + "text": "The 37 mm gun M1 was an anti-aircraft autocannon developed in the United States. It was used by the US Army in World War II. In addition to the towed variant, the gun was mounted, with two M2 machine guns, on the M2/M3 half-track, resulting in the T28/T28E1/M15/M15A1 series of multiple gun motor carriages. In early World War II, each Army Anti-Aircraft Artillery (AAA) Auto-Weapons battalion was authorized a total of thirty-two 37 mm guns in its four firing batteries, plus other weapons.", "country-of-origin": "USA", "manufacturer": "Colt", "role": "Anti-Aircraft Gun", @@ -1022,6 +1098,7 @@ "AAA Vulcan M163": [{ "default": { "name": "M163 Vulcan Air Defense System", + "text": "The M163 had a fairly limited range from the start. Its 20x102mm round gave it a low effective range of only 1,200 meters, and its standard air-defense load of HEI-T rounds would self-destruct at approximately 1800 meters, a hard limit on range. Additionally, the radar was a range-only set incapable of finding targets. In US and Israeli service, the VADS has rarely been needed in its intended purpose of providing defense against aerial threats—consequently, the Vulcan gun system was in use throughout the late 1980s and early 1990s primarily as a ground support weapon.", "country-of-origin": "USA", "manufacturer": "General Electric", "role": "Self-Propelled Anti-Aircraft Gun", @@ -1031,6 +1108,7 @@ "AAA ZSU-57-2": [{ "default": { "name": "ZSU-57-2 'Sparka'", + "text": "The ZSU-57-2 Ob'yekt 500 is a Soviet self-propelled anti-aircraft gun (SPAAG), armed with two 57 mm autocannons. 'ZSU' stands for Zenitnaya Samokhodnaya Ustanovka (Russian: Зенитная Самоходная Установка), meaning \"anti-aircraft self-propelled mount\", '57' stands for the bore of the armament in millimetres and '2' stands for the number of gun barrels. It was the first Soviet mass-produced tracked SPAAG.", "country-of-origin": "USSR/Russia", "manufacturer": "Omsk Works", "role": "Self-Propelled Anti-Aircraft Gun", @@ -1040,6 +1118,7 @@ "AAA ZU-23 on Ural-375": [{ "default": { "name": "ZU-23 on Ural-375", + "text": "The ZSU-57-2 Ob'yekt 500 is a Soviet self-propelled anti-aircraft gun (SPAAG), armed with two 57 mm autocannons. 'ZSU' stands for Zenitnaya Samokhodnaya Ustanovka (Russian: Зенитная Самоходная Установка), meaning \"anti-aircraft self-propelled mount\", '57' stands for the bore of the armament in millimetres and '2' stands for the number of gun barrels. It was the first Soviet mass-produced tracked SPAAG.", "country-of-origin": "USSR/Russia", "manufacturer": "KBP/Ural", "role": "Self-Propelled Anti-Aircraft Gun", @@ -1049,6 +1128,7 @@ "AAA ZU-23 Insurgent on Ural-375": [{ "default": { "name": "ZU-23 on Ural-375", + "text": "The ZSU-57-2 Ob'yekt 500 is a Soviet self-propelled anti-aircraft gun (SPAAG), armed with two 57 mm autocannons. 'ZSU' stands for Zenitnaya Samokhodnaya Ustanovka (Russian: Зенитная Самоходная Установка), meaning \"anti-aircraft self-propelled mount\", '57' stands for the bore of the armament in millimetres and '2' stands for the number of gun barrels. It was the first Soviet mass-produced tracked SPAAG.", "country-of-origin": "USSR/Russia", "manufacturer": "KBP/Ural", "role": "Self-Propelled Anti-Aircraft Gun", @@ -1058,6 +1138,7 @@ "AA gun QF 3,7\"": [{ "default": { "name": "QF 3.7-inch AA Gun", + "text": "The QF 3.7-inch AA was Britain's primary heavy anti-aircraft gun during World War II. It was roughly the equivalent of the German 88 mm FlaK and American 90 mm, but with a slightly larger calibre of 3.7 inches, approximately 94 mm. Production began in 1937 and it was used throughout World War II in all theatres except the Eastern Front. It remained in use after the war until AA guns were replaced by guided missiles beginning in 1957.", "country-of-origin": "UK", "manufacturer": "Vickers", "role": "Anti-Aircraft Gun", @@ -1067,6 +1148,7 @@ "AC Sd.Kfz.234/2 Puma": [{ "default": { "name": "Sd.Kfz.234/2 Puma", + "text": "The Sd.Kfz. 234 (Sonderkraftfahrzeug 234, Special Purpose Vehicle 234), was a family of armoured cars designed and built in Germany during World War II. The vehicles were lightly armoured, armed with a 20, 50 or 75 mm main gun, and powered by a Tatra V12 diesel engine.", "country-of-origin": "Germany", "manufacturer": "Tatra/Büssing/Daimler-Benz/Schichau", "role": "Amphibious Armoured Personnel Carrier", @@ -1076,6 +1158,7 @@ "APC AAV-7": [{ "default": { "name": "AAVP-7A1 'Amtrac'", + "text": "The Assault Amphibious Vehicle (AAV)—official designation AAVP-7A1 (formerly known as Landing Vehicle, Tracked, Personnel-7 abbr. LVTP-7)—is a fully tracked amphibious landing vehicle manufactured by U.S. Combat Systems (previously by United Defense, a former division of FMC Corporation). The AAV-P7/A1 is the current amphibious troop transport of the United States Marine Corps. It is used by U.S. Marine Corps Assault Amphibian Battalions to land the surface assault elements of the landing force and their equipment in a single lift from assault shipping during amphibious operations to inland objectives and to conduct mechanized operations and related combat support in subsequent mechanized operations ashore. It is also operated by other forces.", "country-of-origin": "USA", "manufacturer": "United Defense", "role": "Amphibious Armoured Personnel Carrier", @@ -1085,6 +1168,7 @@ "APC BTR-80": [{ "default": { "name": "BTR-80", + "text": "The BTR-80 (Russian: бронетранспортёр, romanized: Bronyetransportyor, literally \"Armoured Transporter\") is an 8×8 wheeled amphibious armoured personnel carrier (APC) designed in the USSR. It was adopted in 1985 and replaced the previous vehicles, the BTR-60 and BTR-70, in the Soviet Army. It was first deployed during the Soviet–Afghan War.", "country-of-origin": "USSR/Russia", "manufacturer": "Arzamas", "role": "Amphibious Armoured Personnel Carrier", @@ -1094,6 +1178,7 @@ "APC BTR-82A": [{ "default": { "name": "BTR-82A", + "text": "The BTR-80 (Russian: бронетранспортёр, romanized: Bronyetransportyor, literally \"Armoured Transporter\") is an 8×8 wheeled amphibious armoured personnel carrier (APC) designed in the USSR. It was adopted in 1985 and replaced the previous vehicles, the BTR-60 and BTR-70, in the Soviet Army. It was first deployed during the Soviet–Afghan War. This is a modernized Version of the BTR 80.", "country-of-origin": "Russia", "manufacturer": "Arzamas", "role": "Amphibious Armoured Personnel Carrier", @@ -1103,6 +1188,7 @@ "APC Cobra": [{ "default": { "name": "Cobra", + "text": "The Cobra (Turkish: Kobra) is an armoured tactical vehicle developed by Turkish firm Otokar.", "country-of-origin": "Turkey", "manufacturer": "Otokar", "role": "Armoured Car", @@ -1112,6 +1198,7 @@ "APC M2A1": [{ "default": { "name": "M2A1 Half-Track", + "text": "The M2 half-track car is an armored half-track produced by the United States during World War II. ", "country-of-origin": "USA", "manufacturer": "White Motor Company", "role": "Armoured Personnel Carrier", @@ -1121,6 +1208,7 @@ "APC M113": [{ "default": { "name": "M113", + "text": "The M113 is a fully tracked armored personnel carrier (APC) that was developed and produced by the Food Machinery and Chemical Corporation (FMC). The M113 was sent to United States Army Europe to replace the mechanized infantry's M59 APCs from 1961. The M113 was first used in combat in April 1962 after the United States provided the South Vietnamese Army (ARVN) with heavy weaponry such as the M113, under the Military Assistance Command, Vietnam (MACV) program. Eventually, the M113 was the most widely used armored vehicle of the U.S. Army in the Vietnam War and was used to break through heavy thickets in the midst of the jungle to attack and overrun enemy positions. About 80.000 have been build.", "country-of-origin": "USA", "manufacturer": "Food Machinery Corp", "role": "Armoured Personnel Carrier", @@ -1130,6 +1218,7 @@ "APC M1043 HMMWV Armament": [{ "default": { "name": "M1043 HMMWV (M2 HMG)", + "text": "The High Mobility Multipurpose Wheeled Vehicle (HMMWV; colloquial: Humvee) is a family of light, four-wheel drive, military trucks and utility vehicles produced by AM General.", "country-of-origin": "USA", "manufacturer": "AM General", "role": "Armoured Car", @@ -1139,6 +1228,7 @@ "APC M1126 Stryker ICV": [{ "default": { "name": "M1126 Stryker ICV (M2 HMG)", + "text": "The ICV (Infantry Carrier Vehicle) Stryker is a family of eight-wheeled armored fighting vehicles derived from the Canadian LAV III. ", "country-of-origin": "USA", "manufacturer": "General Dynamics", "role": "Armoured Personnel Carrier", @@ -1148,6 +1238,7 @@ "APC MTLB": [{ "default": { "name": "MT-LB", + "text": "The MT-LB (Russian: Многоцелевой Тягач Легкий Бронированный, romanized: Mnogotselevoy tyagach legky bronirovanny, literally \"multi-purpose towing vehicle light armored\") is a Soviet multi-purpose fully amphibious auxiliary armored tracked vehicle, which was introduced in the 1950s. It is also produced in Poland, where (starting mid-1990s) its YaMZ engine was replaced by a Polish version.", "country-of-origin": "USSR/Russia", "manufacturer": "Kharkiv", "role": "Armoured Personnel Carrier", @@ -1157,6 +1248,7 @@ "APC Sd.Kfz.251": [{ "default": { "name": "Sd.Kfz.251 \"Hanomag\"", + "text": "The Sd.Kfz. 251 (Sonderkraftfahrzeug 251) half-track was a World War II German armored personnel carrier designed by the Hanomag company, based on its earlier, unarmored Sd.Kfz. 11 vehicle. The Sd.Kfz. 251 was designed to transport the Panzergrenadier (German mechanized infantry) into battle. Sd.Kfz. 251s were the most widely produced German half-tracks of the war, with at least 15,252 vehicles and variants produced by seven manufacturers.", "country-of-origin": "Germany", "manufacturer": "Hanomag", "role": "Armoured Personnel Carrier", @@ -1166,6 +1258,7 @@ "ARV BRDM-2": [{ "default": { "name": "BRDM-2", + "text": "The BRDM-2 (Boyevaya Razvedyvatelnaya Dozornaya Mashina, Боевая Разведывательная Дозорная Машина, literally \"Combat Reconnaissance/Patrol Vehicle\") is an amphibious armoured patrol car used by Russia and the former Soviet Union. It was also known under the designations BTR-40PB, BTR-40P-2 and GAZ 41-08. This vehicle, like many other Soviet designs, has been exported extensively and is in use in at least 38 countries.", "country-of-origin": "USSR/Russia", "manufacturer": "GAZ", "role": "Amphibious Armoured Car", @@ -1175,6 +1268,7 @@ "ARV BTR-RD": [{ "default": { "name": "BTR-D", + "text": "The BTR-D is a Soviet airborne multi-purpose tracked armoured personnel carrier which was introduced in 1974 and first seen by the West in 1979 during the Soviet–Afghan War. BTR-D stands for Bronetransportyor Desanta (БТР-Д, Бронетранспортер Десанта, literally \"armoured transporter of the Airborne\")", "country-of-origin": "USSR/Russia", "manufacturer": "Volgograd", "role": "Airborne Amphibious Armoured Personnel Carrier", @@ -1184,6 +1278,7 @@ "ATGM M1045 HMMWV TOW": [{ "default": { "name": "M1045 HMMWV (BGM-71 TOW)", + "text": "The High Mobility Multipurpose Wheeled Vehicle (HMMWV; colloquial: Humvee) is a family of light, four-wheel drive, military trucks and utility vehicles produced by AM General. This verison carries a TOW for ATGM.", "country-of-origin": "USA", "manufacturer": "AM General", "role": "Armoured Car", @@ -1193,6 +1288,7 @@ "ATGM M1134 Stryker": [{ "default": { "name": "M1134 Stryker ATGM (BGM-71 TOW)", + "text": "The ICV (Infantry Carrier Vehicle) Stryker is a family of eight-wheeled armored fighting vehicles derived from the Canadian LAV III.", "country-of-origin": "USA", "manufacturer": "General Dynamics", "role": "Armoured Car", @@ -1202,6 +1298,7 @@ "CT Centaur IV": [{ "default": { "name": "A27L Cruiser Tank MK VIII Centaur IV", + "text": "The Cromwell tank, officially Tank, Cruiser, Mk VIII, Cromwell (A27M), was one of the series of cruiser tanks fielded by Britain in the Second World War. Named after the English Civil War leader Oliver Cromwell, the Cromwell was the first tank put into service by the British to combine high speed from a powerful and reliable engine (the Rolls-Royce Meteor), and reasonable armour. ", "country-of-origin": "UK", "manufacturer": "Leyland", "role": "Cruiser Tank", @@ -1211,6 +1308,7 @@ "CT Cromwell IV": [{ "default": { "name": "A27M Cruiser Tank MK VIII Cromwell IV", + "text": "The Cromwell tank, officially Tank, Cruiser, Mk VIII, Cromwell (A27M), was one of the series of cruiser tanks fielded by Britain in the Second World War. Named after the English Civil War leader Oliver Cromwell, the Cromwell was the first tank put into service by the British to combine high speed from a powerful and reliable engine (the Rolls-Royce Meteor), and reasonable armour. ", "country-of-origin": "UK", "manufacturer": "Birmingham Railway Carriage and Wagon Company", "role": "Cruiser Tank", @@ -1220,6 +1318,7 @@ "Daimler Armoured Car": [{ "default": { "name": "Daimler Armoured Car Mk I", + "text": "The Daimler Armoured Car was a successful British armoured car design of the Second World War that continued in service into the 1950s. It was designed for armed reconnaissance and liaison purposes. During the postwar era, it doubled as an internal security vehicle in a number of countries. Former British Daimler armoured cars were exported to various Commonwealth of Nations member states throughout the 1950s and 1960s. In 2012, some were still being operated by the Qatari Army.", "country-of-origin": "UK", "manufacturer": "Daimler", "role": "Armoured Car", @@ -1229,6 +1328,7 @@ "HIT Churchill_VII": [{ "default": { "name": "A22 Infantry Tank MK IV Churchill VII", + "text": "The Tank, Infantry, Mk IV (A22) Churchill was a British heavy infantry tank used in the Second World War, best known for its heavy armour, large longitudinal chassis with all-around tracks with multiple bogies, its ability to climb steep slopes, and its use as the basis of many specialist vehicles. It was one of the heaviest Allied tanks of the war.", "country-of-origin": "UK", "manufacturer": "Vauxhall Motors", "role": "Infantry Tank", @@ -1238,6 +1338,7 @@ "HT Pz.Kpfw.VI Tiger I": [{ "default": { "name": "Panzerkampfwagen VI Tiger Ausf. E", + "text": "The Tiger I was a German heavy tank of World War II that operated beginning in 1942 in Africa and in the Soviet Union, usually in independent heavy tank battalions. It was designated Panzerkampfwagen VI Ausf H during development but was changed to Panzerkampfwagen VI Ausf E during production. The Tiger I gave the German Army its first armoured fighting vehicle that mounted the 8.8 cm KwK 36 gun (derived from the 8.8 cm Flak 36). 1,347 were built between August 1942 and August 1944. After August 1944, production of the Tiger I was phased out in favour of the Tiger II. 1347 have been build.", "country-of-origin": "Germany", "manufacturer": "Henschel", "role": "Heavy Tank", @@ -1247,6 +1348,7 @@ "HT Pz.Kpfw.VI Ausf. B Tiger II": [{ "default": { "name": "Panzerkampfwagen Tiger Ausf. B Tiger II", + "text": "The Tiger II was the successor to the Tiger I, combining the latter's thick armour with the armour sloping used on the Panther medium tank. The tank weighed almost 70 tonnes, and was protected by 100 to 185 mm (3.9 to 7.3 in) of armour to the front.It was armed with the long barrelled 8.8 cm KwK 43 L/71 anti-tank cannon. The chassis was also the basis for the Jagdtiger turretless Jagdpanzer anti-tank vehicle. The Tiger II was issued to heavy tank battalions of the Army and the Waffen-SS. It was first used in combat by 503rd Heavy Panzer Battalion during the Allied invasion of Normandy on 11 July 1944; on the Eastern Front, the first unit to be outfitted with the Tiger II was the 501st Heavy Panzer Battalion, which by 1 September 1944 listed 25 Tiger IIs operational.", "country-of-origin": "Germany", "manufacturer": "Henschel/Krupp", "role": "Heavy Tank", @@ -1265,6 +1367,7 @@ "IFV BMP-1": [{ "default": { "name": "BMP-1", + "text": "The BMD-1 is a Soviet airborne amphibious tracked infantry fighting vehicle, which was introduced in 1969 and first seen by the West in 1970. BMD stands for Boyevaya Mashina Desanta (Боевая Машина Десанта, which literally translates to \"Combat Vehicle of the Airborne\"). It can be dropped by parachute and although it resembles the BMP-1 it is in fact much smaller. The BMD-1 was used as an IFV by the Soviet Army's airborne divisions. ", "country-of-origin": "USSR/Russia", "manufacturer": "Kurganmashzavod", "role": "Amphibious Infantry Fighting Vehicle", @@ -1274,6 +1377,7 @@ "IFV BMP-2": [{ "default": { "name": "BMP-2", + "text": "The BMP-2 (Boyevaya Mashina Pekhoty, Russian: Боевая Машина Пехоты, literally \"infantry combat vehicle\") is a second-generation, amphibious infantry fighting vehicle introduced in the 1980s in the Soviet Union, following on from the BMP-1 of the 1960s.", "country-of-origin": "USSR/Russia", "manufacturer": "Kurganmashzavod", "role": "Amphibious Infantry Fighting Vehicle", @@ -1283,6 +1387,7 @@ "IFV BMP-3": [{ "default": { "name": "BMP-3", + "text": "The BMP-3 is a Soviet and Russian infantry fighting vehicle, successor to the BMP-1 and BMP-2. The abbreviation BMP stands for boevaya mashina pehoty (боевая машина пехоты, literally \"infantry combat vehicle\").", "country-of-origin": "USSR/Russia", "manufacturer": "Kurganmashzavod", "role": "Amphibious Infantry Fighting Vehicle", @@ -1292,6 +1397,7 @@ "IFV LAV-25": [{ "default": { "name": "LAV-25", + "text": "The LAV-25 (Light Armored Vehicle) is an eight-wheeled amphibious armored reconnaissance vehicle built by General Dynamics Land Systems and used by the United States Marine Corps and the United States Army.", "country-of-origin": "USA", "manufacturer": "General Dynamics", "role": "Amphibious Armoured Car", @@ -1301,6 +1407,7 @@ "IFV M2A2 Bradley": [{ "default": { "name": "M2A2 Bradley", + "text": "The M2 Bradley, or Bradley IFV, is an American infantry fighting vehicle that is a member of the Bradley Fighting Vehicle family. It is manufactured by BAE Systems Land & Armaments, which was formerly United Defense. The Bradley is designed for reconnaissance and to transport a squad of infantry, providing them protection from small arms fire, while also providing firepower to both suppress and eliminate most threats to friendly infantry. It is designed to be highly maneuverable and to be fast enough to keep up with heavy armor during an advance. The M2 holds a crew of three: a commander, a gunner and a driver, as well as six fully equipped soldiers. In the year 2000 the total cost of the program was $5,664,100,000 for 1602 units, giving an average unit cost of $3,166,000. If you want to know more than you ever wanted about the Bradley, watch the film 'The Pentagon Wars', its great!", "country-of-origin": "USA", "manufacturer": "United Defense", "role": "Infantry Fighting Vehicle", @@ -1310,6 +1417,7 @@ "IFV Marder": [{ "default": { "name": "Marder 1A3", + "text": "The Marder (German for \"marten\") is a German infantry fighting vehicle operated by the German Army as the main weapon of the Panzergrenadiere (mechanized infantry) from the 1970s through to the present day. Developed as part of the rebuilding of Germany's armoured fighting vehicle industry, the Marder has proven to be a successful and solid infantry fighting vehicle design. While it used to include a few unique features, such as a fully remote machine gun on the rear deck and gun ports on the sides for infantry to fire through, these features have been deleted or streamlined in later upgrade packages to bring it more in line with modern IFV design. Around 2,100 were taken into service by the German Army in the early 1970s, but the vehicle in its German variant was not sold to any foreign militaries. As the German Army began to retire older vehicles, the Chilean government agreed to acquire 200 Marders; the government of Greece has considered the purchase of 450 retired vehicles in the past. Argentina uses a simplified and locally produced variant, the VCTP, and has a number of vehicles based on that platform constructed by Henschel and built by TAMSE.", "country-of-origin": "Germany", "manufacturer": "Rheinmetall Landsysteme", "role": "Infantry Fighting Vehicle", @@ -1319,6 +1427,7 @@ "IFV MCV-80": [{ "default": { "name": "FV510 Warrior", + "text": "The Warrior tracked vehicle family is a series of British armoured vehicles, originally developed to replace the older FV430 series of armoured vehicles. The Warrior started life as the MCV-80, \"Mechanised Combat Vehicle for the 1980s\". One of the requirements of the new vehicle was a top speed able to keep up with the projected new MBT, the MBT-80 – later cancelled and replaced by what became the Challenger 1 – which the then-current FV432 armoured personnel carrier could not. ", "country-of-origin": "UK", "manufacturer": "GKN Sankey", "role": "Infantry Fighting Vehicle", @@ -1328,6 +1437,7 @@ "LAC M8 Greyhound": [{ "default": { "name": "M8 Greyhound Light Armored Car", + "text": "The M8 Light Armored Car is a 6×6 armored car produced by the Ford Motor Company during World War II. It was used from 1943 by United States and British forces in Europe and the Pacific until the end of the war. The vehicle was widely exported and as of 2006 still remained in service with some countries.", "country-of-origin": "USA", "manufacturer": "Ford Motor Company", "role": "Light Armoured Car", @@ -1337,6 +1447,7 @@ "LT Mk VII Tetrarch": [{ "default": { "name": "A17 Light Tank Mk VII Tetrarch", + "text": "The Light Tank Mk VII (A17), also known as the Tetrarch, was a British light tank produced by Vickers-Armstrongs in the late 1930s and used during the Second World War. The Tetrarch was the latest in the line of light tanks built by the company for the British Army. They where declered obsolete in 1946.", "country-of-origin": "UK", "manufacturer": "Vickers-Armstrongs", "role": "Airborne Light Tank", @@ -1346,6 +1457,7 @@ "M4 Tractor": [{ "default": { "name": "M4 High-Speed Tractor", + "text": "The M4 High-Speed Tractor was an artillery tractor used by the US Army from 1943.", "country-of-origin": "USA", "manufacturer": "Allis-Chalmers", "role": "Tracked Cargo Transporter", @@ -1364,6 +1476,7 @@ "MBT Challenger II": [{ "default": { "name": "FV4034 Challenger 2", + "text": "The FV4034 Challenger 2 is a third generation British main battle tank (MBT) in service with the armies of the United Kingdom and Oman. It has seen operational service in Bosnia and Herzegovina, Kosovo and Iraq. To date, the only time the tank has been destroyed during operations was by another Challenger 2 in a \"blue on blue\" (friendly fire) incident at Basra in 2003 when the destroyed tank had its hatch open at the time of the incident.", "country-of-origin": "UK", "manufacturer": "Vickers Defence Systems", "role": "Main Battle Tank", @@ -1373,6 +1486,7 @@ "M12 GMC": [{ "default": { "name": "M12 Gun Motor Carriage", + "text": "The 155 mm Gun Motor Carriage M12 was a U.S. self-propelled gun developed during the Second World War. It mounted a 155 mm gun derived from the French Canon de 155mm GPF field gun.", "country-of-origin": "USA", "manufacturer": "Pressed Steel Car Company", "role": "Self-Propelled Gun", @@ -1382,6 +1496,7 @@ "MBT Leclerc": [{ "default": { "name": "Leclerc Séries 2", + "text": "The Leclerc tank (French: char Leclerc) is a main battle tank (MBT) built by GIAT, now Nexter of France. It was named in honour of General Philippe Leclerc de Hauteclocque, who led the French element of the drive towards Paris while in command of the Free French 2nd Armoured Division (2e DB) in World War II. The designation AMX-56 – while very popular – is incorrect. During the Iron spear exercise, October 2019, Leclerc tanks crewed by the Lynx 6 Tactical Inter-Service Sub-Group (S-GTIA) participated in an inter-alliance exercise and surpassed the American M1A2 Abrams, German, Spanish and Norwegian Leopard 2s, Italian Ariete C1 and Polish P91.", "country-of-origin": "France", "manufacturer": "GIAT Industries", "role": "Main Battle Tank", @@ -1391,6 +1506,7 @@ "MBT Leopard 1A3": [{ "default": { "name": "Leopard 1A3", + "text": "The Leopard (or Leopard 1) is a main battle tank designed and produced by Porsche in West Germany that first entered service in 1965. Developed in an era when HEAT warheads were thought to make conventional heavy armour of limited value, the Leopard focused on firepower in the form of the German-built version of the British L7 105-mm gun, and improved cross-country performance that was unmatched by other designs of the era. The Leopard quickly became a standard of many European militaries, and eventually served as the main battle tank in over a dozen countries worldwide, with West Germany, Italy and the Netherlands being the largest operators until their retirement. Currently, the largest operators are Greece, with 520 vehicles, Turkey, with 397 vehicles and Brazil with 378 vehicles.", "country-of-origin": "Germany", "manufacturer": "Krauss-Maffei", "role": "Main Battle Tank", @@ -1400,6 +1516,7 @@ "MBT Leopard-2": [{ "default": { "name": "Leopard 2A6", + "text": "The Leopard 2 is a main battle tank developed by Krauss-Maffei in the 1970s for the West German Army. The tank first entered service in 1979 and succeeded the earlier Leopard 1 as the main battle tank of the German Army. It is armed with a 120 mm smoothbore cannon, and is powered by a V-12 twin-turbo diesel engine. Various versions have served in the armed forces of Germany and 12 other European countries, as well as several non-European nations, including Canada, Chile, Indonesia, Singapore, and Turkey. The Leopard 2 was used in Kosovo with the German Army, and has seen action in Afghanistan with the Dutch, Danish and Canadian contributions to the International Security Assistance Force, as well as seeing action in Syria with the Turkish Armed Forces.", "country-of-origin": "Germany", "manufacturer": "Krauss-Maffei", "role": "Main Battle Tank", @@ -1409,6 +1526,7 @@ "MBT M1A2 Abrams": [{ "default": { "name": "M1A2 Abrams", + "text": "The M1 Abrams is a third-generation American main battle tank designed by Chrysler Defense (now General Dynamics Land Systems)[9] and named for General Creighton Abrams. Conceived for modern armored ground warfare and now one of the heaviest tanks in service at nearly 68 short tons. The M1 Abrams entered service in 1980 and currently serves as the main battle tank of the United States Army and Marine Corps. The export version is used by the armies of Egypt, Kuwait, Saudi Arabia, Australia, and Iraq. The Abrams was first used in combat in the Persian Gulf War and has seen combat in both the War in Afghanistan and Iraq War under U.S. service, while Iraqi Abrams tanks have seen action in the war against ISIL and have seen use by Saudi Arabia during the Yemeni Civil War.", "country-of-origin": "USA", "manufacturer": "General Dynamics", "role": "Main Battle Tank", @@ -1418,6 +1536,7 @@ "MBT M60A3 Patton": [{ "default": { "name": "M60A3 \"Patton\"", + "text": "The M60 reached operational capability with fielding to US Army units in Europe beginning in December 1960. The first combat usage of the M60 was with Israel during the 1973 Yom Kippur War where it saw service under the \"Magach 6\" designation, performing well in combat against comparable tanks such as the T-62. In 1982 the Israelis once again used the M60 during the 1982 Lebanon War, equipped with upgrades such as explosive reactive armor to defend against guided missiles that proved very effective at destroying tanks. The M60 also saw use in 1983 with Operation Urgent Fury, supporting US Marines in an amphibious assault into Grenada. M60s delivered to Iran also served in the Iran–Iraq War. The United States' largest deployment of M60s was in the 1991 Gulf War, where the US Marines equipped with M60A1s effectively defeated Iraqi armored forces, including T-72 tanks. The United States readily retired the M60 from front-line combat after Operation Desert Storm, with the last tanks being retired from National Guard service in 1997.[16] M60-series vehicles continue in front-line service with a number of countries' militaries, though most of these have been highly modified and had their firepower, mobility and protection upgraded to increase their combat effectiveness on the modern battlefield.", "country-of-origin": "USA", "manufacturer": "General Dynamics", "role": "Main Battle Tank", @@ -1427,6 +1546,7 @@ "MBT Merkava Mk. 4": [{ "default": { "name": "Merkava Mk IV", + "text": "The Merkava is a main battle tank used by the Israel Defense Forces. The tank began development in 1970, and entered official service in 1979. Four main variants of the tank have been deployed. It was first used extensively in the 1982 Lebanon War. The name \"Merkava\" was derived from the IDF's initial development program name. Design criteria include rapid repair of battle damage, survivability, cost-effectiveness and off-road performance. ", "country-of-origin": "Israel", "manufacturer": "MANTAK", "role": "Main Battle Tank", @@ -1436,6 +1556,7 @@ "MBT T-55": [{ "default": { "name": "T-55A", + "text": "The T-54/55 series was the most-produced tank in history. Estimated production numbers for the series range from 86,000 to 100,000. They were replaced by the T-62, T-64, T-72, T-80 and T-90 tanks in the Soviet and Russian armies, but remain in use by up to 50 other armies worldwide, some having received sophisticated retrofitting. During the Cold War, Soviet tanks never directly faced their NATO adversaries in combat in Europe. However, the T-54/55's first appearance in the West around the period of the 1950s (then the beginning of the Cold War) spurred the United Kingdom to develop a new tank gun, the Royal Ordnance L7, and the United States to develop the M60 Patton.", "country-of-origin": "USSR/Russia", "manufacturer": "Kharkiv/UralVagonZavod", "role": "Main Battle Tank", @@ -1445,6 +1566,7 @@ "MBT T-72B": [{ "default": { "name": "T-72B with Kontakt-1 ERA", + "text": "The T-72 is a family of Soviet/Russian main battle tanks that first entered production in 1971. About 20,000 T-72 tanks have been built, and refurbishment has enabled many to remain in service for decades. The T-72A version introduced in 1979 is considered a second-generation main battle tank. It was widely exported and saw service in 40 countries and in numerous conflicts. The T-72B3 version introduced in 2010 is considered a third-generation main battle tank (MBT).", "country-of-origin": "USSR/Russia", "manufacturer": "UralVagonZavod", "role": "Main Battle Tank", @@ -1454,6 +1576,7 @@ "MBT T-72B3": [{ "default": { "name": "T-72B3 model 2011", + "text": "The T-72 is a family of Soviet/Russian main battle tanks that first entered production in 1971. About 20,000 T-72 tanks have been built, and refurbishment has enabled many to remain in service for decades. The T-72A version introduced in 1979 is considered a second-generation main battle tank. It was widely exported and saw service in 40 countries and in numerous conflicts. The T-72B3 version introduced in 2010 is considered a third-generation main battle tank (MBT).", "country-of-origin": "USSR/Russia", "manufacturer": "UralVagonZavod", "role": "Main Battle Tank", @@ -1463,6 +1586,7 @@ "MBT T-80U": [{ "default": { "name": "T-80U", + "text": "The T-80 is a main battle tank (MBT) designed and manufactured in the Soviet Union. When it entered service in 1976, it was the second MBT in the world to be equipped with a gas turbine engine after the Swedish Strv 103 and the first to use it as a primary propulsion engine. The T-80U was last produced in a factory in Omsk, Russia, while the T-80UD and further-developed T-84 continue to be produced in Ukraine. The T-80 and its variants are in service in Belarus, Cyprus, Egypt, Kazakhstan,[12] Pakistan, Russia, South Korea, and Ukraine.", "country-of-origin": "USSR/Russia", "manufacturer": "Omsk Transmash", "role": "Main Battle Tank", @@ -1472,6 +1596,7 @@ "MBT T-90": [{ "default": { "name": "T-90A", + "text": "The T-90 is a third-generation Russian main battle tank that entered service in 1993. The tank is a modern variation of the T-72B and incorporates many features found on the T-80U. Originally called the T-72BU, but later renamed to T-90, it is an advanced tank in service with Russian Ground Forces and the Naval Infantry. ", "country-of-origin": "Russia", "manufacturer": "UralVagonZavod", "role": "Main Battle Tank", @@ -1481,6 +1606,7 @@ "MLRS 9A52 Smerch": [{ "default": { "name": "BM-30 Smerch (9M55K Cluster Rockets)", + "text": "The BM-30 Smerch (Russian: Смерч, \"tornado\", \"whirlwind\"), 9K58 Smerch or 9A52-2 Smerch-M is a Soviet heavy multiple rocket launcher. The system is intended to defeat personnel, armored, and soft targets in concentration areas, artillery batteries, command posts and ammunition depots.", "country-of-origin": "USSR/Russia", "manufacturer": "Splav", "role": "Multiple-Launch Rocket System", @@ -1490,6 +1616,7 @@ "MLRS 9A52 Smerch HE": [{ "default": { "name": "BM-30 Smerch (9M55K5 HE Rockets)", + "text": "The BM-30 Smerch (Russian: Смерч, \"tornado\", \"whirlwind\"), 9K58 Smerch or 9A52-2 Smerch-M is a Soviet heavy multiple rocket launcher. The system is intended to defeat personnel, armored, and soft targets in concentration areas, artillery batteries, command posts and ammunition depots.", "country-of-origin": "USSR/Russia", "manufacturer": "Splav", "role": "Multiple-Launch Rocket System", @@ -1499,6 +1626,7 @@ "MLRS 9K57 Uragan BM-27": [{ "default": { "name": "BM-27 Uragan", + "text": "The BM-27 Uragan (Russian: Ураган, lit. 'Hurricane'; GRAU index 9P140) is a self-propelled multiple rocket launcher system designed in the Soviet Union.", "country-of-origin": "USSR/Russia", "manufacturer": "Splav", "role": "Multiple-Launch Rocket System", @@ -1508,6 +1636,7 @@ "MLRS BM-21 Grad": [{ "default": { "name": "BM-21 Grad", + "text": "The BM-21 \"Grad\" (Russian: БМ-21 \"Град\", lit. 'hail') is a Soviet truck-mounted 122 mm multiple rocket launcher.", "country-of-origin": "USSR/Russia", "manufacturer": "Splav", "role": "Multiple-Launch Rocket System", @@ -1517,6 +1646,7 @@ "MLRS FDDM": [{ "default": { "name": "M1043 HMMWV with Fire Direction Data Manager", + "text": "The High Mobility Multipurpose Wheeled Vehicle with a Fire Direction Data Manager.", "country-of-origin": "USA", "role": "Fire Control Vehicle", "year-of-variant-introduction": "1993" @@ -1525,6 +1655,7 @@ "MLRS M270": [{ "default": { "name": "M270 Multiple Launch Rocket System", + "text": "The M270 Multiple Launch Rocket System (M270 MLRS) is an armored, self-propelled, multiple rocket launcher (a type of rocket artillery). Since the first M270s were delivered to the U.S. Army in 1983, the MLRS has been adopted by several NATO countries. Some 1,300 M270 systems have been manufactured in the United States and in Europe, along with more than 700,000 rockets. The production of the M270 ended in 2003, when a last batch was delivered to the Egyptian Army.", "country-of-origin": "USA", "manufacturer": "Vought", "role": "Multiple-Launch Rocket System", @@ -1534,6 +1665,7 @@ "MT M4 Sherman": [{ "default": { "name": "M4A2(75) Sherman", + "text": "The M4 Sherman, officially Medium Tank, M4, was the most widely used medium tank by the United States and Western Allies in World War II. The M4 Sherman proved to be reliable, relatively cheap to produce, and available in great numbers. It was also the basis of several successful tank destroyers, such as the M10, 17pdr SP Achilles and M36B1. Tens of thousands were distributed through the Lend-Lease program to the British Commonwealth and Soviet Union.", "country-of-origin": "USA", "manufacturer": "Fisher", "role": "Medium Tank", @@ -1546,6 +1678,7 @@ "MT M4A4 Sherman Firefly": [{ "default": { "name": "M4A4 Sherman Firefly", + "text": "The Sherman Firefly was a tank used by the United Kingdom and some Commonwealth and Allied armoured formations in the Second World War. It was based on the US M4 Sherman, but fitted with the more powerful 3-inch (76.2 mm) calibre British 17-pounder anti-tank gun as its main weapon.", "country-of-origin": "USA/UK", "manufacturer": "Chrysler", "role": "Medium Tank", @@ -1558,6 +1691,7 @@ "MT Pz.Kpfw.IV Ausf.H": [{ "default": { "name": "Panzerkampfwagen IV Ausf. H", + "text": "The Panzerkampfwagen IV (Pz.Kpfw. IV), commonly known as the Panzer IV, was a German medium tank developed in the late 1930s and used extensively during the Second World War. Its ordnance inventory designation was Sd.Kfz. 161. The Panzer IV was the most numerous German tank and the second-most numerous German armored fighting vehicle of the Second World War, with some 8,500 built. ", "country-of-origin": "Germany", "manufacturer": "Krupp-Gruson/Vomag/Nibelungenwerke", "role": "Medium Tank", @@ -1567,6 +1701,7 @@ "MT Pz.Kpfw.V Panther Ausf.G": [{ "default": { "name": "Panzerkampfwagen V Panther Ausf. G", + "text": "The Panther is a German medium tank deployed during World War II on the Eastern and Western Fronts in Europe from mid-1943 to the war's end in 1945. The Panther was intended to counter the Soviet T-34 and to replace the Panzer III and Panzer IV. Nevertheless, it served alongside the Panzer IV and the heavier Tiger I until the end of the war. It is considered one of the best tanks of World War II for its excellent firepower and protection, although its reliability was less impressive.", "country-of-origin": "Germany", "manufacturer": "MAN/Daimler-Benz/MNH", "role": "Medium Tank", @@ -1576,6 +1711,7 @@ "SAM Avenger M1097": [{ "default": { "name": "M1097 Heavy HMMWV Avenger", + "text": "The Avenger Air Defense System, designated AN/TWQ-1 under the Joint Electronics Type Designation System, is an American self-propelled surface-to-air missile system which provides mobile, short-range air defense protection for ground units against cruise missiles, unmanned aerial vehicles, low-flying fixed-wing aircraft, and helicopters.", "country-of-origin": "USA", "manufacturer": "Boeing", "role": "Self-Propelled Surface-to-Air Missile Launcher", @@ -1585,6 +1721,7 @@ "SAM Chaparral M48": [{ "default": { "name": "M48 Chaparral", + "text": "The MIM-72A/M48 Chaparral is an American self-propelled surface-to-air missile system based on the AIM-9 Sidewinder air-to-air missile system. The launcher is based on the M113 family of vehicles. It entered service with the United States Army in 1969 and was phased out between 1990 and 1998.", "country-of-origin": "USA", "manufacturer": "Ford Motor Company", "role": "Self-Propelled Surface-to-Air Missile Launcher", @@ -1594,6 +1731,7 @@ "SAM Linebacker M6": [{ "default": { "name": "M6 Linebacker", + "text": "The M6 Linebacker short-range air defense system was developed by Boeing to meet the US Army requirements. The new vehicle was intended to counter the threat posed by low-flying aircraft, helicopters, cruise missiles and unmanned aerial vehicles to forward armored formations. Initial production Linebackers entered service with the US Army in 1997. A total of 99 Bradley Linebackers were ordered, however in 2006 these were phased-out of service. The M6 is an all-weather system, capable of operating in day/night and all weather conditions. It is based on the M2 Bradley IFV and is equipped with a quadruple launcher with FIM-92 Stinger short-range surface-to-air missiles instead of the TOW ATGW.", "country-of-origin": "USA", "manufacturer": "United Defense", "role": "Self-Propelled Anti-Aircraft System", @@ -1603,6 +1741,7 @@ "SAM Roland ADS": [{ "default": { "name": "Roland 2 (Marder Chassis)", + "text": "he Roland is a Franco-German mobile short-range surface-to-air missile (SAM) system. ", "country-of-origin": "France/Germany", "manufacturer": "Euromissile", "role": "Self-Propelled Surface-to-Air Missile Launcher", @@ -1612,6 +1751,7 @@ "SAM SA-13 Strela-10M3 9A35M3": [{ "default": { "name": "SA-13 Gopher (9K35 Strela-10M3)", + "text": "The 9K35 Strela-10 (Russian: 9К35 «Стрела-10»; English: arrow) is a highly mobile, short-range surface-to-air missile system. It is visually aimed, and utilizes optical/infrared-guidance. The system is primarily intended to engage low-altitude threats, such as helicopters. \"9K35\" is its GRAU designation; its NATO reporting name is SA-13 \"Gopher\".", "country-of-origin": "USSR/Russia", "manufacturer": "Ulyanovsk", "role": "Self-Propelled Surface-to-Air Missile Launcher", @@ -1621,6 +1761,7 @@ "SAM SA-19 Tunguska 2S6": [{ "default": { "name": "SA-19 Grison (2K22 Tunguska)", + "text": "The 2K22 Tunguska (Russian: 2К22 'Тунгуска') is a Russian tracked self-propelled anti-aircraft weapon armed with a surface-to-air gun and missile system. It is designed to provide day and night protection for infantry and tank regiments against low-flying aircraft, helicopters, and cruise missiles in all weather conditions. The NATO reporting name for the missile used by the weapon system is SA-19 \"Grison\".", "country-of-origin": "USSR/Russia", "manufacturer": "Ulyanovsk", "role": "Self-Propelled Anti-Aircraft System", @@ -1630,6 +1771,7 @@ "Sd.Kfz.184 Elefant": [{ "default": { "name": "Sd.Kfz.184 Elefant", + "text": "The Elefant (German for \"elephant\") was a heavy tank destroyer used by German Wehrmacht Panzerjäger during World War II. Ninety-one units were built in 1943 under the name Ferdinand, after its designer Ferdinand Porsche, using tank hulls produced for the Tiger I tank design abandoned in favour of a Henschel design.", "country-of-origin": "Germany", "manufacturer": "Porsche/Nibelungenwerk", "role": "Tank Destroyer", @@ -1639,6 +1781,7 @@ "SPAAA Gepard": [{ "default": { "name": "Flakpanzer Gepard", + "text": "The Flugabwehrkanonenpanzer Gepard (\"anti-aircraft cannon tank Cheetah\", better known as the Flakpanzer Gepard) is an all-weather-capable German self-propelled anti-aircraft gun (SPAAG).[1] It was developed in the 1960s and fielded in the 1970s, and has been upgraded several times with the latest electronics. It constituted a cornerstone of the air defence of the German Army (Bundeswehr) and a number of other NATO countries. In Germany, the Gepard was phased out in late 2010 and replaced by Wiesel 2 Ozelot Leichtes Flugabwehrsystem (LeFlaSys) with four FIM-92 Stinger or LFK NG missile launchers.", "country-of-origin": "West Germany", "manufacturer": "Krauss-Maffei", "role": "Self-Propelled Anti-Aircraft Gun", @@ -1648,6 +1791,7 @@ "SPAAA ZSU-23-4 Shilka": [{ "default": { "name": "ZSU-23-4 Shilka", + "text": "The ZSU-23-4 \"Shilka\" is a lightly armored Soviet self-propelled, radar guided anti-aircraft weapon system (SPAAG). About 6500 have been build.", "country-of-origin": "USSR/Russia", "manufacturer": "MMZ", "role": "Self-Propelled Anti-Aircraft Gun", @@ -1657,6 +1801,7 @@ "SPG M1128 Stryker MGS": [{ "default": { "name": "M1128 Stryker Mobile Gun System", + "text": "The M1128 Mobile Gun System is an eight-wheeled armored car of the Stryker armored fighting vehicle family, mounting a 105 mm tank gun, based on the Canadian LAV III light-armored vehicle manufactured by General Dynamics Land Systems. It is in service with the United States Army but will be retired by the end of 2022.", "country-of-origin": "USA", "manufacturer": "General Dynamics", "role": "Armoured Car", @@ -1666,6 +1811,7 @@ "SpGH Dana": [{ "default": { "name": "SpGH DANA", + "text": "The DANA (the name being derived from \"dělo automobilní nabíjené automaticky\" (gun on truck loaded automatically)) is a wheeled self-propelled artillery piece.", "country-of-origin": "Czechoslovakia", "manufacturer": "ZTS", "role": "Self-Propelled Gun", @@ -1675,6 +1821,7 @@ "SPH 2S9 Nona": [{ "default": { "name": "2S9 Nona-S", + "text": "The 2S9 NONA (Новейшее Орудие Наземной Артилерии - Newest Ordnance of Ground Artillery) is an extremely light-weight self-propelled and air-droppable 120 mm gun-mortar designed in the Soviet Union, which entered service in 1981. The 2S9 chassis is designated the S-120 and based on the aluminium hull of the BTR-D airborne multi-purpose tracked armoured personnel carrier. More generally, the 120 mm mortar is referred to as the Nona, with the 2S9 also known as the Nona-S. Although no figures have been released, it is estimated that well over 1,000 2S9 were built.", "country-of-origin": "USSR/Russia", "manufacturer": "Motovilikha", "role": "Self-Propelled Mortar", @@ -1684,6 +1831,7 @@ "SPH 2S3 Akatsia": [{ "default": { "name": "2S3 Akatsiya", + "text": "The SO-152 (Russian: СО-152) is a Soviet 152.4 mm self-propelled gun developed in 1968. It was a response to the American 155 mm M109. ", "country-of-origin": "USSR/Russia", "manufacturer": "Uraltransmash", "role": "Self-Propelled Gun", @@ -1693,6 +1841,7 @@ "SPH 2S1 Gvozdika": [{ "default": { "name": "2S1 Gvozdika", + "text": "The 2S1 Gvozdika (Russian: 2С1 «Гвоздика», \"Carnation\") is a Soviet self-propelled howitzer based on the MT-LBu multi-purpose chassis, mounting a 122 mm 2A18 howitzer. ", "country-of-origin": "USSR/Russia", "manufacturer": "Kharkiv", "role": "Self-Propelled Gun", @@ -1702,6 +1851,7 @@ "SPH 2S19 Msta": [{ "default": { "name": "2S19 Msta-S", + "text": "The 2S19 \"Msta-S\" (Russian: Мста, after the Msta River) is a 152.4 mm self-propelled howitzer designed by Soviet Union, which entered service in 1989 as the successor to the 2S3 Akatsiya. The vehicle is based on the T-80 tank hull, but is powered by the T-72's diesel engine.", "country-of-origin": "USSR/Russia", "manufacturer": "Uraltransmash", "role": "Self-Propelled Gun", @@ -1711,6 +1861,7 @@ "SPH M109 Paladin": [{ "default": { "name": "M109A6 Paladin", + "text": "The M109 is an American 155 mm turreted self-propelled howitzer, first introduced in the early 1960s to replace the M44.", "country-of-origin": "USA", "manufacturer": "United Defense", "role": "Self-Propelled Gun", @@ -1720,6 +1871,7 @@ "StuG III Ausf. G": [{ "default": { "name": "Sturmgeschütz III Ausf. G", + "text": "The Sturmgeschütz III (StuG III) assault gun was Germany's most-produced fully tracked armoured fighting vehicle during World War II, and second-most produced German armored combat vehicle of any type after the Sd.Kfz. 251 half-track. It was built on a slightly modified Panzer III chassis, replacing the turret with an armored, fixed superstructure mounting a more powerful gun. Initially intended as a mobile assault gun for direct-fire support for infantry, the StuG III was continually modified, and much like the later Jagdpanzer vehicles, was employed as a tank destroyer.", "country-of-origin": "Germany", "manufacturer": "Alkett/MIAG", "role": "Assault Gun/Tank Destroyer", @@ -1729,6 +1881,7 @@ "StuG IV": [{ "default": { "name": "Sturmgeschütz IV", + "text": "The Sturmgeschütz IV (StuG IV) (Sd.Kfz. 167) was a German assault gun variant of the Panzer IV used in the latter part of the Second World War. It was identical in role and concept to the highly successful StuG III assault gun variant of the Panzer III. Both StuG models were given an exclusively tank destroyer role in German formations and tactical planning in the last two years of the war, greatly augmenting the capability of the dwindling tank force available to the German army on the Eastern and Western fronts.", "country-of-origin": "Germany", "manufacturer": "Krupp", "role": "Assault Gun/Tank Destroyer", @@ -1738,6 +1891,7 @@ "Sturmpanzer IV Brummbär": [{ "default": { "name": "Sturmpanzer IV Brummbär", + "text": "The Sturmpanzer (also known as Sturmpanzer 43 or Sd.Kfz. 166) was a German armoured infantry support gun based on the Panzer IV chassis used in the Second World War. It was used at the Battles of Kursk, Anzio, Normandy, and was deployed in the Warsaw Uprising. It was known by the nickname Brummbär (German: \"Grouch\") by Allied intelligence, a name which was not used by the Germans. German soldiers nicknamed it the \"Stupa\", a contraction of the term Sturmpanzer. Just over 300 vehicles were built and they were assigned to four independent battalions.", "country-of-origin": "Germany", "manufacturer": "Vienna Arsenal", "role": "Self-Propelled Gun", @@ -1747,6 +1901,7 @@ "TD Jagdpanther G1": [{ "default": { "name": "Jagdpanther G1", + "text": "The Jagdpanther (German: \"hunting Panther\"), Sd.Kfz. 173, was a tank destroyer (Jagdpanzer, a self-propelled anti-tank gun) built by Germany during World War II. The Jagdpanther combined the 8.8 cm Pak 43 anti-tank gun, similar to the main gun of the Tiger II, and the armor and suspension of the Panther chassis.", "country-of-origin": "Germany", "manufacturer": "MIAG/MNH/MBA", "role": "Tank Destroyer", @@ -1756,6 +1911,7 @@ "TD Jagdpanzer IV": [{ "default": { "name": "Jagdpanzer IV", + "text": "The Jagdpanzer IV, Sd.Kfz. 162, was a German tank destroyer based on the Panzer IV chassis.", "country-of-origin": "Germany", "manufacturer": "Vomag", "role": "Tank Destroyer", @@ -1765,6 +1921,7 @@ "TD M10 GMC": [{ "default": { "name": "M10 3-inch Gun Motor Carriage", + "text": "The M10 tank destroyer was an American tank destroyer of World War II. The M10 was numerically the most important U.S. tank destroyer of World War II. It combined thin but sloped armor with the M4's reliable drivetrain and a reasonably potent anti-tank weapon mounted in an open-topped turret. Despite its obsolescence in the face of newer German tanks like the Panther and the introduction of more powerful and better-designed types as replacements, the M10 remained in service until the end of the war.", "country-of-origin": "USA", "manufacturer": "Fisher/Ford Motor Company", "role": "Tank Destroyer", @@ -1777,6 +1934,7 @@ "TPz Fuchs": [{ "default": { "name": "TPz Fuchs", + "text": "The TPz (Transportpanzer) Fuchs (\"fox\") is a German armoured personnel carrier originally developed by Daimler-Benz but manufactured and further developed by the now Rheinmetall MAN Military Vehicles (RMMV). Fuchs was the second wheeled armoured vehicle to enter service with the Bundeswehr (West German Military) and it can be used for tasks including troop transport, engineer transport, bomb disposal, Nuclear, Biological and Chemical reconnaissance and electronic warfare. RMMV and its predecessors manufactured 1,236 Fuchs 1, mostly for the German Army.", "country-of-origin": "Germany", "manufacturer": "Rheinstahl Wehrtechnik", "role": "Armoured Personnel Carrier", @@ -1786,6 +1944,7 @@ "ZTZ-96B": [{ "default": { "name": "Type 96B (ZTZ-96B)", + "text": "The Type 96 or ZTZ96 is a Chinese second generation main battle tank (MBT). The final evolution of the Type 88 design, the Type 96 entered service with the People's Liberation Army (PLA) in 1997. The later variants of the Type 96 are regarded as near-equivalents to China's third generation MBT.", "country-of-origin": "China", "manufacturer": "First Inner Mongolia Machinery Factory", "role": "Main Battle Tank", @@ -1795,6 +1954,7 @@ "ZBD-04A": [{ "default": { "name": "Type 04A (ZBD-04A)", + "text": "The ZBD-04 or Type 04 (industrial designation WZ502) is a Chinese infantry fighting vehicle. It bears some external resemblance to the BMP-3, particularly with regards to its turret and main armament; however, the chassis and internal subsystems possesses a different layout.", "country-of-origin": "China", "manufacturer": "Norinco", "role": "Main Battle Tank", diff --git a/tests/resources/invalid_faction_country.json b/tests/resources/invalid_faction_country.json index 245c66f1..50f3fe11 100644 --- a/tests/resources/invalid_faction_country.json +++ b/tests/resources/invalid_faction_country.json @@ -30,7 +30,7 @@ "IFV_M1126_Stryker_ICV", "IFV_M2A2_Bradley", "IFV_LAV_25", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV" ], "artillery_units": [ @@ -83,4 +83,4 @@ ], "has_jtac": true, "jtac_unit": "MQ_9_Reaper" -} \ No newline at end of file +} diff --git a/tests/resources/valid_faction.json b/tests/resources/valid_faction.json index c31b2c8a..1ee00e13 100644 --- a/tests/resources/valid_faction.json +++ b/tests/resources/valid_faction.json @@ -32,7 +32,7 @@ "IFV_M1126_Stryker_ICV", "IFV_M2A2_Bradley", "IFV_LAV_25", - "APC_HMMWV__Scout", + "Scout_HMMWV", "ATGM_HMMWV" ], "artillery_units": [ @@ -85,4 +85,4 @@ ], "has_jtac": true, "jtac_unit": "MQ_9_Reaper" -} \ No newline at end of file +} diff --git a/tests/test_factions.py b/tests/test_factions.py index 5b65abb5..232786d4 100644 --- a/tests/test_factions.py +++ b/tests/test_factions.py @@ -82,7 +82,7 @@ class TestFactionLoader(unittest.TestCase): self.assertIn(Armor.IFV_M1126_Stryker_ICV, faction.frontline_units) self.assertIn(Armor.IFV_M2A2_Bradley, faction.frontline_units) self.assertIn(Armor.IFV_LAV_25, faction.frontline_units) - self.assertIn(Armor.APC_HMMWV__Scout, faction.frontline_units) + self.assertIn(Armor.Scout_HMMWV, faction.frontline_units) self.assertIn(Armor.ATGM_HMMWV, faction.frontline_units) self.assertIn(Artillery.MLRS_M270_227mm, faction.artillery_units)