Compare commits

..

490 Commits

Author SHA1 Message Date
Dan Albert
7278878266 Remove incompatible campaigns.
(cherry picked from commit 2ea2ecec94)
2021-11-13 11:20:42 -08:00
Dan Albert
ec8391bbfb Add A-4E squadrons.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1716

(cherry picked from commit a3038e75cf)
2021-11-13 11:16:27 -08:00
Dan Albert
74504173c7 Add IRIAF F-4E squadron.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1721

(cherry picked from commit 5dcd4580c3)
2021-11-13 11:12:36 -08:00
MetalStormGhost
792c7c5091 Update Marianas campaigns.
More FARPs to both Orote Point and Andersen Northwest Field.

(cherry picked from commit 68b48ad610)
2021-11-13 11:10:26 -08:00
Dan Albert
b7e9a4a243 Drop a few unsupported campaigns.
Probably more coming but these are the ones that are confirmed not
happening before release.

(cherry picked from commit 94f65d8f70)
2021-11-12 14:03:21 -08:00
Dan Albert
bdbb338e83 Update Syria full campaign.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1728

(cherry picked from commit 46e5299c60)
2021-11-12 13:42:55 -08:00
Dan Albert
98fa70c73d Update Pacific Repartee.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1707

(cherry picked from commit d645b4fe73)
2021-11-06 19:13:40 -07:00
Dan Albert
7991e0157d Update Caucasus multi-part Georgia.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1706

(cherry picked from commit 68c794f608)
2021-11-06 19:13:39 -07:00
Dan Albert
21643c500f 5.0 changelog fixes.
(cherry picked from commit 475d18b701)
2021-11-06 19:07:18 -07:00
Dan Albert
b4ddfb9dfd Prevent assigning fixed wing squadrons to FARPs.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1671

(cherry picked from commit e9634b7066)
2021-11-06 18:45:05 -07:00
Dan Albert
d1e50a5bbe Fix fixed wing squadrons retreating to FARPs.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1615

(cherry picked from commit 35900c2350)
2021-11-06 16:54:59 -07:00
Dan Albert
a188f7b7e5 Add missing NavalControlPoint case for BAI.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1702

(cherry picked from commit 7a18d160c8)
2021-11-06 16:36:44 -07:00
Dan Albert
4f76b73de2 Split runway attack list from strike list.
Not all strike aircraft are capable of runway attack, so copy the strike
list into the runway attack list and remove the incompatible aircraft.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1703

(cherry picked from commit a33104d7c4)
2021-11-06 16:26:36 -07:00
Dan Albert
a4b09bc973 Update Skynet to 2.4.0.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1713

(cherry picked from commit 7f57180da4)
2021-11-06 16:22:11 -07:00
Dan Albert
a792c73cae Restore missing income multiplier labels.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1712

(cherry picked from commit a23e7fe83d)
2021-11-06 16:20:05 -07:00
Dan Albert
113380661c Updates for Syria full.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1675

(cherry picked from commit c854508381)
2021-11-06 16:16:19 -07:00
Dan Albert
3ab9b25b08 Updates for Northern Russia.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1676

(cherry picked from commit d1cf8915e3)
2021-11-06 16:16:19 -07:00
Dan Albert
29d4ca38f9 Check in Pacific Repartee campaign.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1701

(cherry picked from commit d9b5b87f2b)
2021-11-06 16:16:18 -07:00
Dan Albert
6a3ff8d6ac Tell Qt that we actually want text to fit.
Why isn't this the default?

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1670

(cherry picked from commit 5923ba21de)
2021-11-03 19:36:12 -07:00
Dan Albert
c1f194b3d5 Add E-2D variant of the E-2C.
In game this is an E-2D, but the ID of the aircraft in the game data is
E-2C. Presumably it was repainted at some point in a DCS update.

This adds a variant but doesn't delete the old one to avoid breaking
campaigns and factions. I moved blufor modern to the E-2D but the rest
of the factions are too old so we'll let them pretend.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1696

(cherry picked from commit 33f00fb811)
2021-10-30 15:29:41 -07:00
Dan Albert
a6809e0103 Document Su-33 carrier takeoff fix.
https://github.com/dcs-liberation/dcs_liberation/issues/1352
(cherry picked from commit ae99558f40)
2021-10-30 15:17:18 -07:00
Dan Albert
a559aa8646 Fix (presumable) accidental edit of A-4 pylons.
I'm not sure if this was a mistaken edit that the author made on check-
in or if we have a broken script that's generating these. For now I've
manually fixed it.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1574

(cherry picked from commit 70dbe7c9ca)
2021-10-30 15:06:26 -07:00
ghost
c766322960 Update Marianas campaigns to use the Tu-142.
(cherry picked from commit 2d0b5023c9)
2021-10-30 15:06:18 -07:00
ghost
c7581568c2 Enable anti-ship missions for the Tu-142.
This is the only mission type that the Tu-142 is capable of.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1683

(cherry picked from commit 92fdd0b80d)
2021-10-30 15:06:08 -07:00
Starfire13
f0827a429e Campaigns updated to 9.1
Peace Spring, Vectron's Claw, and Vegas Nerve have been updated to 9.1. Squadrons have also been completely overhauled to work much better, so there should no longer be a whole bunch of squadrons on OPFOR that are never used.

(cherry picked from commit 2d93ac58fc)
2021-10-30 15:06:00 -07:00
Starfire13
c2ee44d8bb H-6J Loadout Update
Added loadouts for DEAD and OCA/Aircraft as those are default mission types for the H-6J.

(cherry picked from commit 79924a59bc)
2021-10-30 15:05:57 -07:00
MetalStormGhost
af362be3a2 Forcibly allow GPS for more non-US factions.
Enable unrestricted_satnav with non-US factions operating either the
F-16CM or the F/A-18C to allow the use of GPS in missions.

(cherry picked from commit 545f974552)
2021-10-30 15:05:50 -07:00
Dan Albert
80c8563d67 Use stored alignment for the F-14A.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1689

(cherry picked from commit 2699a38f7b)
2021-10-30 15:05:28 -07:00
MetalStormGhost
f44654f66e Use Chinese navy group generators for China.
Replaced Chinese factions' Type54GroupGenerator naval group generators with
ChineseNavyGroupGenerator to make Type 52B and Type 52C destroyers also
spawn besides frigates.

(cherry picked from commit 13d52803d6)
2021-10-30 15:04:05 -07:00
MetalStormGhost
ca7f61c938 Added H-6J support for China 2010 and Iraq 1991.
Includes H-6J loadouts by @Starfire13

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1661

(cherry picked from commit 49033f67f3)
2021-10-30 15:03:22 -07:00
MetalStormGhost
10ccada17a Marianas campaigns 2.7.7.14727
Updated Marianas Mount Barrigada and Landing at Agat campaigns to DCS World 2.7.7.14727 open beta.

(cherry picked from commit 410077467b)
2021-10-30 15:03:18 -07:00
Dan Albert
46bf952562 Add Forrestal support, use in US 1975.
https://github.com/dcs-liberation/dcs_liberation/issues/1657
2021-10-22 10:11:15 -07:00
Dan Albert
822d737f65 Adapt to pydcs update.
https://github.com/dcs-liberation/dcs_liberation/issues/1657
2021-10-22 00:13:33 -07:00
Dan Albert
d656ec3220 Update pydcs.
https://github.com/dcs-liberation/dcs_liberation/issues/1657
2021-10-22 00:10:20 -07:00
Dan Albert
a2140b915f Flip main/detail text boldness.
Emphasize the main item text and leave the fine print as fine print.
2021-10-21 22:27:35 -07:00
Dan Albert
6dae5b98d5 Fix carrier option not taking effect this turn.
This really shouldn't need to happen but I don't feel like rewriting the
culling code right now. There's no reason for these to be persisted to
the Game at all, we should be generating these once they're needed.
2021-10-21 20:05:07 -07:00
Dan Albert
626740366b Port the mission generator settings to auto.
Done now. Not porting the cheat menu because it contains non-settings
elements as well.
2021-10-21 19:54:58 -07:00
Dan Albert
a618f00662 Port the campaign management page to auto.
Also fixes the oversight in the previous commit where float options were
not saved when changed.
2021-10-21 19:16:47 -07:00
Dan Albert
7bec4c62f7 Generate settings pages automatically.
This adds metadata to settings fields that can be used to automatically
generate the settings window. For now I have replaced the Difficulty
page. Will follow up to replace the others.
2021-10-21 18:30:56 -07:00
Dan Albert
39fae9effc Update MissionScripting.original.lua.
We don't really even need this. Some cleanup of the replacer could just
keep the original contents in memory, but this will do for now.
2021-10-21 15:50:52 -07:00
Dan Albert
3c145cf2ff Updates for Fuzzle's campaigns and squadrons.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1664
2021-10-21 15:23:49 -07:00
Dan Albert
ad6f3ef8cc Update for Northern Russia campaign.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1663
2021-10-21 15:22:18 -07:00
MetalStormGhost
79839f83a0 Change AI Su-33 carrier flights to "Takeoff from runway".
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1352
2021-10-20 19:11:22 +00:00
Johan Aberg
17011820de Fix crash if log window is open when entering mission.
Use signal to append text to LogWindow to avoid crash from crossing thread boundaries.
Change modal flag to enable interacting with LogWindow while the mission is running

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1493
2021-10-19 17:04:16 -07:00
Dan Albert
551ea728fc Update Skynet to 2.3.0.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1658
2021-10-18 17:02:14 -07:00
Dan Albert
d4f77f6588 Fix changelog sorting. 2021-10-18 17:01:32 -07:00
Dan Albert
8fe7551176 Update pydcs with radio preset fix.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1596
2021-10-18 16:57:43 -07:00
Dan Albert
e47276ce2e Fix config file name. 2021-10-17 19:50:15 -07:00
Dan Albert
fd1463eb4c Disable the blank issue template.
Why do we even have that lever?
2021-10-17 19:38:45 -07:00
Dan Albert
d5eaa4d091 Add a preferred_start_date for campaigns.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1079
2021-10-17 17:37:23 -07:00
Dan Albert
b174e668f4 Fix day being off-by-one on turn zero.
Doesn't have any real effect since no mission can be generated for that
turn, but shows up wrong in the top bar.
2021-10-17 17:33:29 -07:00
Dan Albert
fd49d213c2 Load custom campaigns from the user directory.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1311
2021-10-17 16:48:19 -07:00
Dan Albert
bbeb80fc48 Give aircraft purchase the majority of the window.
This makes long squadron names more likely to fit. I also added a
horizontal scroll bar for the cases when this still isn't enough space
and made the vertical scroll bar only appear when necessary. Typically
aircraft purchase menus are neither wide enough for long enough to need
either scroll bar.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1592
2021-10-17 13:28:02 -07:00
Dan Albert
702e29b54b Fix case of unused aircraft not spawning.
This function was exiting too early causing unused aircraft to stop
being spawned at *any* airfield as soon as the first full airfield was
found.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1566
2021-10-17 13:03:15 -07:00
Dan Albert
0fd911feb1 Fix some CAS planning quirks.
CAS would never be planned for turn 0 because there were no enemy ground
units, so a breakthrough attack was planned and CAS was considered
unnecessary. Fix that by rejecting all frontline stance tasks when there
are no *friendly* ground units to command.

Another issue is that CAS missions were being planned even when there
were no enemy front line forces. Skip planning in that case except on
turn 0 since we expect there to be enemy ground units on turn 1.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1629
2021-10-17 12:31:12 -07:00
Dan Albert
39234adff7 Update Allied Sword campaign miz.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1655.
2021-10-17 11:27:07 -07:00
Dan Albert
e45505b406 Note PR #1600 in the changelog. 2021-10-17 11:23:24 -07:00
Dan Albert
b1eb876572 Note PR #1646 in the changelog. 2021-10-17 11:21:45 -07:00
Dan Albert
7e66aa16f7 Fix submerged ammo depot in Black Sea.
Not sure if this was originally an accidental extra ammo depot or if it
was intended to belong to Sochi, but I've moved it to Sochi to increase
the difficulty at the first front.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1644
2021-10-17 11:16:40 -07:00
Dan Albert
35b30a01ed Fix flight resizing when switching aircraft.
No idea why we were only resetting this if the max was >= 2. Instead,
always reset the flight to the default size when switching aircraft. The
default for anything capable of a two-ship is two, but limit based on
the airframe's group size limit and the number of aircraft available.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1593
2021-10-14 21:03:55 -07:00
Dan Albert
76b3ff5f6e Override squadron base preferences when named.
This allows campaign designers to forcibly place specific squadrons at
bases that they would not normally be assigned to, such as assigning
CV-only squadrons to the shore. The airframe itself must be compatible
with the location type, so A-10s still may not be assigned to carriers.

This change only applies to squadrons that are named explicitly. There's
no need for squadrons defined by type because a squadron will always be
generated to fit the need if no pre-defined squadron is found.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1621
2021-10-14 20:49:18 -07:00
Starfire13
5e5f249bd2 Starfire's Campaigns 9.0 Update (#1654) 2021-10-13 22:51:13 -07:00
Dan Albert
ce57acb9d6 Update Fuzzle's campaigns and supporting content.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1651
2021-10-13 17:45:49 -07:00
MetalStormGhost
2a79e4a4e5 Force carrier planes to start at +1s (#1600)
Forces carrier planes with original start_time of zero seconds to have a start time of 1 second. This will prevent them from spawning on the 'sixpack' and functions as a workaround for a DCS problem (deadlock / traffic jam).

Fixes #1309.
2021-10-13 17:10:17 -07:00
ghost
7f948465a4 Added a missing call to commit_cargo_ship_losses when committing mission results, fixing a problem which caused lost ground units on sinked cargo ships to not register. 2021-10-13 17:04:49 -07:00
ghost
a24ab63fc7 Changed "range" to "max_range" on several aircraft .yaml files since that is the key which is actually evaluated in the code. 2021-10-13 17:03:59 -07:00
MetalStormGhost
643718be23 Two Marianas Guam campaigns (#1652)
This PR contains two new campaigns for the Marianas terrain, depicting capture of the island of Guam. The first has BLUFOR start after landing at Agat and requires a carrier/LHA capable BLUFOR faction, because there are no land based airbases captured initially. The second has about half of the island already in control, enabling BLUFOR land based aircraft to operate from Antonio B Won Pat (and can also be used by WWII factions). Both campaigns support inversion.

Credit to Dank Williams, the campaigns were heavily inspired by his Marianas insurgency campaign and contain some ground units placed by him, used with permission of course.

They are by no means historical and contain a relatively large number of FOBs compared to only having two airfields. The routes zig-zag a bit since space is limited on the island and I had to place the FOBs far enough from each other to clear the capture zone radius.

The two other airfields on Guam, Orote and Andersen Northwest Field, have FARPs, enabling helicopter operations as well as rearming and refueling for player fixed wing flights (but no mission starts or AI takeoffs).

Included are also two WWII factions which don't require the WWII asset pack. The "Japanese" faction consists of German/Russian equipment with stock Japanese liveries.
2021-10-13 17:01:54 -07:00
C. Perreau
d4d6ee3d26 Merge pull request #1645 from MetalStormGhost/mi24_hind_campaign_squadrons
Changed the Mi-24 squadrons of 2 campaigns
2021-10-12 22:29:57 +02:00
Khopa
4399e10cef SA-5 support 🚀 2021-10-12 22:26:59 +02:00
ghost
519f0542dd Changed the Mi-24 squadrons of 2 campaigns from:
- Mi-24P Hind-F
        - Mi-24P Hind-E
to:
        - Mi-24P Hind-F
        - Mi-24V Hind-E
fixing a problem which caused Mi-24V no not show up in the campaign.
2021-10-08 20:42:28 +03:00
Khopa
9456fd77d1 Updated Mozdok to Maykop campaign to v9 2021-09-21 22:27:50 +02:00
Khopa
37874d82f2 Pydcs : Switched to the pydcs version from the official repo 2021-09-21 21:24:44 +02:00
Khopa
9f2a9bf458 pydcs version : Use temporary version with mosquito flyable fix 2021-09-19 23:30:57 +02:00
Khopa
43a8897c28 Added MosquitoFBMkVI support (use campaign operation dynamo) 2021-09-19 02:05:08 +02:00
Khopa
6980f96697 JAS-39 mod : removed reference to a weapon class that was removed in last update 2021-09-18 18:51:54 +02:00
Khopa
4ffb294d65 Update pydcs version 2021-09-18 18:01:17 +02:00
C. Perreau
ae46631b2b Merge pull request #1622 from teamMOYA/banners_icons
Banners and icons
2021-09-18 17:49:07 +02:00
teamMOYA
c42bdd256f fix wrong extension 2021-09-18 15:32:42 +02:00
teamMOYA
8990e0c1ff leopard-2A4 banner fix 2021-09-18 15:27:09 +02:00
teamMOYA
f26452c07d vehicle banners 2021-09-18 15:10:44 +02:00
teamMOYA
89789f16d2 f-86 icon fix 2021-09-18 15:09:05 +02:00
teamMOYA
3bb4c9c29a aircrafts banners 2021-09-16 22:26:17 +02:00
teamMOYA
3b8e392395 aircrafts icons 2021-09-16 22:24:47 +02:00
Magnus Wolffelt
3e6d63e8f7 Fix random heading function, randomize wind better (#1619)
* Fix random heading function, randomize wind better

* Use 359 as max default for random heading, for uniform distribution
2021-09-16 12:29:00 +02:00
Khopa
90ca619839 Load red static group to define helipads for farp 2021-09-14 22:22:46 +02:00
Starfire13
8c2aa78b9f Add missing data to Frenchpack technicals. 2021-09-10 15:34:57 -07:00
Starfire13
e544b2d1ba Mirage Squadron Mission Types Fix (#1607)
* Update ADA_EscadronDeChasse_1-30_Alsace.yaml

* Update ADA_EscadronDeChasse_2-5_IleDeFrance.yaml

* Update ADA_EscadronDeChasse_1-12_Cambresis.yaml
2021-09-09 09:52:26 -04:00
C. Perreau
82f5287282 Merge pull request #1183 from dcs-liberation/helipads
[WIP] Add possibility to add helipads to FOB control points
2021-09-08 22:54:05 +02:00
C. Perreau
589a353f02 Merge pull request #1603 from teamMOYA/bugfix/helicopter_carrier-Type_071
fix for Type_071 helicopter carrier
2021-09-08 22:47:37 +02:00
Khopa
e84e36fd22 Merge remote-tracking branch 'khopa/develop' into helipads
# Conflicts:
#	changelog.md
2021-09-08 21:56:45 +02:00
dependabot[bot]
a4b03c5cfe Bump pillow from 8.2.0 to 8.3.2
Bumps [pillow](https://github.com/python-pillow/Pillow) from 8.2.0 to 8.3.2.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/master/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/8.2.0...8.3.2)

---
updated-dependencies:
- dependency-name: pillow
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-07 20:00:45 -07:00
teamMOYA
6aee4c2ec4 fix for Type_071 helicopter carrier
fixed bad name for Type_071 helicopter carrier
2021-09-07 11:59:36 +02:00
Starfire13
65abef7979 Update squadrondefgenerator.py 2021-09-07 02:37:25 -07:00
Dan Albert
45b52f4dea Remove auto-loss on front line for skipped turns. 2021-09-05 21:19:36 -07:00
Dan Albert
18336f58d3 Minor cleanup of notification system. 2021-09-05 21:15:32 -07:00
Dan Albert
12ad4fbf63 Note the FC3 laser codes in the changelog. 2021-09-05 02:13:13 -07:00
MetalStormGhost
b1fee9fe56 Add an option to use FC3-compatible laser codes.
FC3 aircraft don't have laser codes like all the other aircraft do, they just use 1113.
2021-09-05 02:10:24 -07:00
Dan Albert
2a6f250706 Fix transcription error in Abu Dhabi. 2021-09-04 14:20:29 -07:00
Dan Albert
ab2bb6814e Clean up aircraft allocation and procurement.
This also does improve the over-purchase problems, though I can't spot
the behavior change that's causing that. Presumably the old
implementation had a bug I can't spot and in rewriting it I solved the
problem...

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1582
2021-09-03 17:08:32 -07:00
Dan Albert
94fb0d8c66 Don't create squadrons for removed bases. 2021-09-03 15:25:34 -07:00
Dan Albert
a192e4c872 Allow showing the enemy aircraft inventory. 2021-09-03 13:56:03 -07:00
Dan Albert
99acd52e89 Increase some range estimates.
These are still fairly pessimistic because the AI loves afterburner, but
less so.
2021-09-03 13:33:15 -07:00
Dan Albert
a1ee9d7476 Add more aircraft range estimates. 2021-09-03 13:23:23 -07:00
Dan Albert
757363e372 Fix JSON -> YAML translation error in Black Sea. 2021-09-03 13:14:38 -07:00
Dan Albert
7d0b3a096d Update Abu Dhabi. 2021-09-03 13:14:16 -07:00
Starfire13
24a0211d8c Update naming.py 2021-09-02 04:06:44 -07:00
Dan Albert
2c8f960696 Prevent creating empty ferry packages.
An empty squadron or a fully-assigned squadron won't have anything to
assign to the ferry mission.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1588
2021-09-01 19:22:37 -07:00
Dan Albert
16d397db1c Fix unit info menus for aircraft.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1586
2021-09-01 19:22:37 -07:00
Dan Albert
9c3171f1ce Sort the animal names list.
Make it easier to figure out what's already there.
2021-09-01 18:01:53 -07:00
Dan Albert
8a60fa5c83 Fix errors when changing task or aircraft type.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1587
2021-09-01 17:14:51 -07:00
Magnus Wolffelt
15ce48e712 Arm the ferry viper :) (#1584) 2021-09-01 21:56:11 +02:00
Dan Albert
c252fd6a77 Add a ferry loadout for the viper. 2021-08-31 23:13:02 -07:00
Dan Albert
90a8bb63dc Fix AI landing behavior.
The landing waypoints need the airdrome_id field set to actually
associate with the airfield. Without this ferry flights will take off
and immediately land at their departure airfield.
2021-08-31 23:06:20 -07:00
Dan Albert
1a4be911c0 Implement ferry flights for squadron transfers.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1145
2021-08-31 23:03:27 -07:00
Dan Albert
f9f0b429b6 Set the flight airfields based on the Squadron. 2021-08-31 22:14:00 -07:00
Dan Albert
a404792bd2 Reset the max flight size when changing squadrons. 2021-08-31 22:10:23 -07:00
Dan Albert
e0047b1bbc Move the start type requirement into ControlPoint. 2021-08-31 22:09:39 -07:00
Dan Albert
18eb661e84 Merge branch 'squadron-relocation' into develop 2021-08-29 16:22:02 -07:00
Dan Albert
c2e5cba061 Implement manual squadron transfers.
Lightly tested but seems to work fine.

https://github.com/dcs-liberation/dcs_liberation/issues/1145
2021-08-29 16:21:54 -07:00
Dan Albert
cd15de6d42 Add an uncaught exception handler. 2021-08-29 16:21:54 -07:00
Mustang-25
60a5ee42fe Adds Most Default USN F-14A & B Squadrons 2021-08-29 15:26:44 -07:00
Mustang-25
46e2d8c1f9 Adds All Default USAF F-15E Squadrons 2021-08-29 15:26:44 -07:00
Mustang-25
824745c11d Adds All Default A-10C I & II Squadrons 2021-08-29 15:26:44 -07:00
Mustang-25
33709b0558 Adds All Default USAF F-15C Squadrons 2021-08-29 15:26:44 -07:00
Mustang-25
aac91c15d9 Adds AEW&C Squadrons 2021-08-29 15:26:44 -07:00
Mustang-25
67405e4af5 Adds Tanker Squadrons 2021-08-29 15:26:44 -07:00
Mustang-25
41aa743947 Adds all default USAF Viper Squadrons 2021-08-29 15:26:44 -07:00
Mustang-25
7aca108ef5 Adds Russian VVS Mig-29S Squadrons 2021-08-29 15:26:44 -07:00
Dan Albert
380d6bf47a Fix weird wrong default campaign field bug.
I tried fixing this using setField after registering it, but it does
nothing. I suspect this is because the page hasn't been registered with
the wizard yet so it's setting the field for the wrong wizard.
2021-08-29 02:17:10 -07:00
Dan Albert
8fea8e7b47 Move squadron end-turn behavior into the squadron. 2021-08-28 18:03:33 -07:00
Dan Albert
469b1e5efe Reimplement aircraft retreats for captured bases. 2021-08-28 18:02:11 -07:00
Dan Albert
5fae178081 Reduce squadron location bookkeeping. 2021-08-28 17:59:56 -07:00
Dan Albert
4715773bba Store the owning coalition in ControlPoint.
This is needed fairly often, and we have a lot of Game being passed
around to ControlPoint methods specifically to support this. Just store
the owning Coalition directly in the ControlPoint to clean up. I haven't
cleaned up *every* API here, but did that aircraft allocations as an
example.
2021-08-28 16:40:55 -07:00
Dan Albert
74577752e0 Update pydcs for the Viper CBU-105. 2021-08-26 23:59:29 -07:00
Magnus Wolffelt
056e6b28da Simplify and rename TACAN registry reserve function (#1559)
* Simplify and rename TACAN registry reserve function

* Remove unused tacan error
2021-08-18 14:46:55 +02:00
Nils Heiden
0cb10e4224 Make the Mi-24 LHA capable. 2021-08-17 17:00:16 -07:00
Magnus Wolffelt
34ff5fbc6a Allow operation.py to ignore TACAN rules 2021-08-18 00:06:34 +02:00
Magnus Wolffelt
f63a35b1fa Use TACAN channels more selectively, use pytest (#1554)
* Use TACAN channels more selectively

* Increase tacan range to 126

* Use pytest and add workflow

* Skip faction tests due to outdated test data

* Run mypy on tests directory also

* Use iterators for bands AND usages, add tests
2021-08-17 23:14:54 +02:00
Khopa
57e78d5c55 Added squadrons for Syria & Israel 2021-08-16 19:47:40 +02:00
Khopa
2ee604d2a4 Fixed : Missing icons for E-2C Hawkeye 2021-08-16 19:47:38 +02:00
Khopa
a7c3a0f7fd Added squadrons for Syria & Israel 2021-08-16 19:47:00 +02:00
Khopa
5445c41f81 Fixed : Missing icons for E-2C Hawkeye 2021-08-16 19:45:43 +02:00
Khopa
e1e1e471a1 Added comment on total_aircraft_parking 2021-08-16 19:37:21 +02:00
Khopa
2e3b43b28b Allow both FARP SINGLE_HELIPAD, and Invisible FARP in campaigns files. 2021-08-16 13:27:41 +02:00
Khopa
fe118d81db Fixed error introduced during last merge. (Missing import) 2021-08-16 13:04:58 +02:00
Khopa
d3b2a751e2 Golan heights campaign migrated to v 9.0 2021-08-16 12:58:26 +02:00
Khopa
b856a84adc Ran black to fix lint issue. 2021-08-16 12:23:51 +02:00
C. Perreau
707d13a65c Merge branch 'develop' into helipads 2021-08-16 12:20:43 +02:00
Magnus Wolffelt
7417429fdb Merge pull request #1552 from dcs-liberation/better-tarcap-racetracks-2
Better TARCAP racetracks
2021-08-16 11:52:50 +02:00
Magnus Wolffelt
8f5b6f58d1 Add rmul to distance and speeds, so that reversed operands work 2021-08-16 10:38:26 +02:00
Magnus Wolffelt
08365bcbda Simplify and enhance tarcap flight planning 2021-08-16 10:37:44 +02:00
Dan Albert
4423288a53 Assign aircraft to squadrons rather than bases.
This is needed to support the upcoming squadron transfers, since
squadrons need to bring their aircraft with them.

https://github.com/dcs-liberation/dcs_liberation/issues/1145
2021-08-15 17:42:56 -07:00
Dan Albert
99274133ff Add range estimates for the F-15s. 2021-08-14 21:51:25 -07:00
Dan Albert
55c6728c42 Update Black Sea to campaign version 9.0. 2021-08-14 21:51:01 -07:00
Dan Albert
357487b767 Remove random purchase pessimization.
Aircraft variety is now handled by explicit squadron selection, so no
longer needed.
2021-08-14 21:46:27 -07:00
Dan Albert
9768fb3493 Add base selection UI to startup.
https://github.com/dcs-liberation/dcs_liberation/issues/1145
2021-08-14 21:46:27 -07:00
Dan Albert
90ad1f4a61 Change squadrons to operate out of a single base.
https://github.com/dcs-liberation/dcs_liberation/issues/1145

Currently this is fixed at the start of the campaign. The squadron
locations are defined by the campaign file.

Follow up work:

* Track aircraft ownership per-squadron rather than per-airbase.
* UI for relocating squadrons.
* Ferry missions for squadrons that are relocating.
* Auto-relocation (probably only for retreat handling).

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1138
2021-08-14 21:46:27 -07:00
Dan Albert
51e056a765 Convert Black Sea to yaml. 2021-08-14 20:15:05 -07:00
Dan Albert
8e1b33bc51 Add range info for some Russian aircraft. 2021-08-14 20:12:33 -07:00
Dan Albert
d2e22ef8bf More campaign loader cleanup. 2021-08-14 13:39:27 -07:00
Magnus Wolffelt
103675e5bb Make some paths cross-platform compatible (#1543)
* Make some paths cross-platform compatible

* Fix lint error for Path
2021-08-14 21:45:23 +02:00
RndName
b5b0d82a1a Open all files with utf-8 encoding
- will not be used for binary read/writes (rb,wb)!
- prevents a bug where units with special characters in the unit name can not be tracked anymore as there will be a name mismatch due to wrong encoding
2021-08-14 13:10:43 +02:00
Magnus Wolffelt
c80d0e5378 Raise helo AGL to 60 m for ingress, egress, cap, escort (#1542) 2021-08-14 00:54:48 +02:00
RndName
adeebbc422 Enable sell button according to available aircraft
also added a tooltip which gives the user the hint that maybe the aircraft is assigned to mission and therefore the button is disabled
2021-08-12 22:06:48 +02:00
RndName
ee8e8d4a9a Fix selling of units not visible
allow pending_deliveries to become negative again but still delete the delivery when the amount of a specific unit_type comes to exactly 0 to prevent emtpy group sizes
2021-08-12 19:42:40 +02:00
RndName
bc5ffdec8e EWR heading towards conflict
implements #1530
2021-08-11 20:58:54 +02:00
Dan Albert
88d52003b3 Allow using yaml for campaign definitions.
JSON continues to be supported as well. No additional work needed here,
just needed to allow both.
2021-08-10 18:21:52 -07:00
Dan Albert
6c7b62b8b1 Move MizCampaignLoader out of conflicttheater.py.
Also removes the useless `size` and `importance` fields from
`ControlPoint`.
2021-08-10 18:21:26 -07:00
Magnus Wolffelt
37491ceffb Stop using Su-34 for CAP missions, update changelog 2021-08-10 13:38:35 +02:00
Magnus Wolffelt
b3dedbdf75 Update changelog with sam site heading fix 2021-08-10 13:32:58 +02:00
Magnus Wolffelt
42d09292b7 Update changelog with Marianas fix for 4.1.1 2021-08-10 13:32:12 +02:00
Dan Albert
c80293d9e0 Add a bug template for mod support requests. 2021-08-08 12:58:04 -07:00
RndName
b31c09c4ff Fix AAA Flak generator using wrong index
- Fixes #1519 as the Opel Blitz unit generator was using the index without incrementing it
2021-08-08 13:16:39 +02:00
Dan Albert
91daabc9d2 Fixup auto-assignable tasks when limits change.
The air wing config was fixing the main `mission_types` field, but the
`auto_assignable_mission_types` property had already been set. Update
that field whenever the `mission_types` are changed.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1515
2021-08-07 14:04:32 -07:00
Kangwook Lee
def5454e5f Add battle damage assessment option 2021-08-07 13:11:10 -07:00
Kangwook Lee
74e6226d13 Fix typo 2021-08-07 13:03:13 -07:00
Kangwook Lee
72d83e2fe4 Reorganize setting fields to match UI 2021-08-07 13:00:22 -07:00
Dan Albert
85ccbf34c7 Add fuel esimtations for the Viper. 2021-08-06 19:24:07 -07:00
Khopa
5e715daded Changed helipad generation structure as to not impact "Game" object, as suggested by Dan. 2021-08-07 01:05:57 +02:00
Khopa
5412487178 Handle another error case in generate_at_cp_helipad + align helos heading with their slot 2021-08-07 00:38:12 +02:00
Khopa
c4937e95e9 Refactored the helipad generation code in a dedicated method "_generate_at_cp_helipad" + better error handling + removed useless temp variables 2021-08-07 00:16:07 +02:00
Khopa
4f53e2beea Fixed another minor issue with the PR 2021-08-07 00:12:55 +02:00
Khopa
9ea1edf9db Applied minor requested changes 2021-08-06 23:55:58 +02:00
Khopa
ce1c416b20 Revert version bump for campaign that do not use helipads 2021-08-06 23:28:36 +02:00
Magnus Wolffelt
fdbc3c55c7 Add weather changes to changelog (#1513)
Co-authored-by: Magnus Wolffelt <magnus.wolffelt@avanza.se>
2021-08-06 18:17:46 +02:00
Magnus Wolffelt
71559154a8 Cherry-pick cap/helo changelog from 4.x (#1512)
* Changelog updates for 4.x.

Regarding patrol speeds and helo fix.

* Update changelog for 4.x

BAI missions are actually planned at low altitude. The problem remaining is that they have join/hold/split waypoints, which makes the flight times _incredibly_ long for these slow movers.
2021-08-06 18:01:24 +02:00
Dan Albert
07f8a203ea Remove abandoned campaigns. 2021-08-05 19:32:52 -07:00
RndName
b67fd16081 tweak the airlift procurement
- only buy airlift capable aircraft if there is one friendly cp without a factory which can only be reached via airlift
- prevent that an airlift procurement gets fulfilled at a different cp than the requesting one. this ensures that the cp also has a factory to produce ground units which can then be transported
- fixes an infinite buy loop if the fulfilling cp has no factory and the requesting cp has no space for airlift
- have always 2 reserve transport planes at the biggest CP
2021-08-05 22:09:53 +02:00
Magnus Wolffelt
9792c17c69 Make arid theaters more likely to have clouds, and tweak others sliiightly (#1501) 2021-08-05 13:54:42 +02:00
Magnus Wolffelt
8488a5ec1a Use AGL altitude for helo hold (#1499) 2021-08-05 13:52:33 +02:00
Dan Albert
ff571db494 Update Syria Full and Humble Helper campaigns.
https://github.com/dcs-liberation/dcs_liberation/issues/1494
https://github.com/dcs-liberation/dcs_liberation/issues/1497
2021-08-04 23:33:22 -07:00
Kangwook Lee
aaa932f725 Update freq list for ARC-210 2021-08-04 20:40:47 -07:00
Kangwook Lee
c58ecd96f0 Make Radio dataclass store list of ranges 2021-08-04 20:40:47 -07:00
Kangwook Lee
8608b73009 Wrap lines for NotesPage 2021-08-03 17:53:01 -07:00
Magnus Wolffelt
30801dff9f Use more sensible patrol speeds for CAP, and fix is_helo (#1492)
* Use more sensible patrol speeds for CAP, and fix is_helo
2021-08-03 12:22:55 +02:00
Khopa
912311ad55 changelog update 2021-08-03 01:32:10 +02:00
Khopa
14615f9976 Bump campaign miz file version for helipad support 2021-08-03 01:10:00 +02:00
Khopa
9121cf7ecb Fixed icons not appearing in UI for Mi-24V, Tu-95 and Tu-142 2021-08-03 01:05:27 +02:00
Khopa
a831800a05 Fixed mypy errors 😒 2021-08-03 01:00:56 +02:00
Khopa
399c739fd7 Fixed mypy errors 2021-08-03 00:51:47 +02:00
Khopa
63f687a20e Fixed mypy errors in gen 2021-08-03 00:42:49 +02:00
Khopa
fbd0198771 Fixed mypy errors 2021-08-03 00:37:26 +02:00
Khopa
00e85280fd Golan heights campaign updated with helipads. 2021-08-03 00:29:06 +02:00
Khopa
5b37698d36 Fixed mypy errors 2021-08-03 00:28:22 +02:00
Khopa
483640b0c6 Fixed errors after merge on helipad feature. 2021-08-03 00:15:14 +02:00
Khopa
1e96aad484 Fixed icons not appearing in UI for Mi-24V, Tu-95 and Tu-142 2021-08-02 23:51:56 +02:00
Khopa
b88e0e8a52 Merge branch 'develop' into helipads
# Conflicts:
#	resources/squadrons/A20/no_107_squadron_raf.yaml
#	resources/squadrons/SpitfireLFMkIX/no_145_squadron_raf.yaml
#	resources/squadrons/SpitfireLFMkIX/no_16_squadron_raf.yaml
2021-08-02 22:05:36 +02:00
Khopa
6028009aac Added RAF clipped wing spitfire squadron 2021-08-02 22:00:17 +02:00
Khopa
9aa9b72557 Fixed issue with previously added squadrons 2021-08-02 21:57:50 +02:00
Khopa
8c023c5727 Added Ju-88A4 squadron 2021-08-02 21:57:27 +02:00
Khopa
d9edaede89 Added FW-190D9 squadron 2021-08-02 21:57:10 +02:00
Khopa
0220b37c2d Added FW-190A8 squadron 2021-08-02 21:57:00 +02:00
Khopa
f2d2d1cc8d Added Bf-109K4 squadron 2021-08-02 21:56:47 +02:00
Khopa
437eeef9a0 Fixed icon not showing for Ju 88 A-4 2021-08-02 21:15:22 +02:00
Khopa
8e4f291389 Added Spitfire LF Mk IX's squadrons for RAF 2021-08-02 21:08:07 +02:00
Khopa
8f27222b07 Added A-20G squadron for RAF 2021-08-02 21:08:05 +02:00
Khopa
9e05991908 Fixed issue with livery in custom M2000-C squadron 2021-08-02 21:08:05 +02:00
Khopa
1c76bf93a2 Added Spitfire LF Mk IX's squadrons for RAF 2021-08-02 20:00:14 +02:00
Khopa
d5cedee6c5 Added A-20G squadron for RAF 2021-08-02 19:59:13 +02:00
Khopa
eb1b7176a6 Fixed issue with livery in custom M2000-C squadron 2021-08-02 19:46:07 +02:00
Khopa
71143536bf Merge branch 'develop' into helipads
# Conflicts:
#	game/game.py
#	game/operation/operation.py
#	game/theater/conflicttheater.py
#	game/theater/controlpoint.py
#	gen/groundobjectsgen.py
#	resources/campaigns/golan_heights_lite.miz
2021-08-02 19:34:05 +02:00
Khopa
f2608cecd5 Campaign Update 8.0 : operation_dynamo.miz, updated mission targets ids 2021-08-02 19:24:34 +02:00
Khopa
c95d5464d8 Campaign Update 8.0 : golan_heights_lite.miz, updated mission targets ids 2021-08-02 19:21:09 +02:00
Khopa
0ac7466a81 Campaign Update 8.0 : caen_to_evreux.miz, updated mission targets ids 2021-08-02 19:15:31 +02:00
Kangwook Lee
77e62d5a54 Fix text foreground color for dark kneeboard 2021-08-01 23:37:10 -07:00
Dan Albert
bef015eb57 Changelog updates for 4.x. 2021-08-01 17:27:01 -07:00
Kangwook Lee
d99d95217f Remove console window 2021-08-01 15:42:14 -07:00
Kangwook Lee
5cbf8db272 Add QLogsWindow 2021-08-01 15:42:14 -07:00
Kangwook Lee
6ee0c7600b Add HookableInMemoryHandler logging handler 2021-08-01 15:42:14 -07:00
Magnus Wolffelt
6621421a6f Tweak max-speed-based patrol altitudes 2021-08-01 15:21:32 -07:00
Dan Albert
edf95ea9fb Dedup purchase requests.
Since the theater commander runs once per campaign action, missions that
do not have aircraft available may be checked more than once a turn.
Without deduping requests this can lead to cases where the AI buys
dozens of tankers on turn 0.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1470
2021-08-01 15:17:43 -07:00
Magnus Wolffelt
a3e3e9046f Estimate preferred patrol altitude based on max speed 2021-08-01 12:15:15 -07:00
RndName
04cdb6fbfc fix for wrong patrol speed 2021-08-01 12:05:28 -07:00
bgreman
8c7e56a2bd Update skynet plugin (#1478) 2021-08-01 11:36:37 -04:00
Dan Albert
3e08574fbe Reduce the size of kneeboard non-table text.
The bullseye and weather data were looking a bit more like headers than
content.
2021-07-31 16:54:00 -07:00
Dan Albert
73ba7933da Assign laser codes to players with TGPs.
This doesn't configure the bombs in the mission or anything yet; it only
pre-assigns a laser code for the player to use if they choose to.
2021-07-31 16:49:58 -07:00
bgreman
fc45c3b98c Fixes an unlikely bug with JTAC laser code allocation (#1477)
* Fixes an unlikely bug with JTAC laser code allocation, allows for future allocation of codes to a/c with TGPs

* Fixing typing issues

* Changelog
2021-07-31 18:44:16 -04:00
Mustang-25
0d6f420f97 Rebalanced Aircraft Planning Hierarchies
CAP List:
[+] Mig-21 #1372
[+] Su-34
[moved up] F-15C above the F-14 (probably contentious to some but IMO the AI never capitalizes on the AIM-54 range and the Eagle AI seems to do better in general)
[moved up] JF-17
[moved up] Gripen
[moved down] Su-33
[moved down] Su-27
[moved down] MiG-31
[moved down] MiG-25
[moved down] MiG-29G
[moved down] MiG-29A

* Downgraded MiGs and Sukhois that do not have Fox-3s due to this disadvantage. From personal experience, the 31s and 25s also won't use the longer range of their Fox-1s to warrant for a higher spot on the list.

CAS/BAI List:
[+] Su-33 #1367
[-] Su-24MR (dedicated recce platform, no AG munitions)
[moved up] Su-34
[moved down] Mig-19P
[moved down] UH-1

Strike List:
[-] Su-24MR (dedicated recce platform, no AG munitions)
[moved up] JF-17
[moved up] Harrier

Runway Attack List:
[+] Mirage 2000C
2021-07-31 14:57:49 -07:00
Dan Albert
bef85963a6 Update USN 2005 faction.
https://github.com/dcs-liberation/dcs_liberation/issues/1427
2021-07-31 14:47:13 -07:00
Dan Albert
3be57efa97 Campaign updates from Starfire. 2021-07-31 14:40:38 -07:00
Dan Albert
981d8510c2 Ack new campaign version for unaffected maps. 2021-07-31 14:37:49 -07:00
Dan Albert
5d8f655243 Work around pydcs bug.
https://github.com/pydcs/dcs/issues/175 causes setting the AI comm
frequency to raise an exception for aircraft without preset channel
support.
2021-07-31 14:23:46 -07:00
Dan Albert
0cb41469ab Update pydcs to latest master.
https://github.com/dcs-liberation/dcs_liberation/issues/1448
2021-07-31 13:54:17 -07:00
Dan Albert
971d7e730a Add ALIC codes for the tin shield and NASAMS.
https://github.com/dcs-liberation/dcs_liberation/issues/1448
2021-07-31 13:53:04 -07:00
Dan Albert
06f8b9b817 Update the F-16 DEAD loadout to use JSOWs.
https://github.com/dcs-liberation/dcs_liberation/issues/1448
2021-07-31 13:48:02 -07:00
Dan Albert
db51384b63 Add auto-generation warning to files. 2021-07-31 13:40:04 -07:00
RndName
ac088ea692 improved the validation for planned transfers
- instead of only checking if the transfer destination was captured it now checks if there is a valid route between origin and destination. This also ensures that there will be a check if the current position or next_stop was captured and therefore the transfer should be disbanded.
- disband uncompletable transfer before planning or performing (also when user cheated a base capture)
2021-07-31 13:27:48 -07:00
Dan Albert
2a5793e8ce Fix display of AEW&C channel on the kneeboard. 2021-07-31 13:04:42 -07:00
Dan Albert
6c60ff88a3 Fix intra_flight_channel filter syntax. 2021-07-31 13:00:14 -07:00
Dan Albert
8d68c10905 Set up JTAC channel assignments. 2021-07-31 13:00:14 -07:00
bgreman
119d4b9514 Vendor ruler (#1476)
* Fixes ruler module integrity issues by bringing module into source

* Changing ruler stylesheet to vaguely match DCS theme in Liberation

* Changelog
2021-07-31 15:43:48 -04:00
Kangwook Lee (이강욱)
0370aa8df5 Add AFAC task to JTAC unit.
This causes the JTAC unit that's used for autolase to also work as
a FAC over the radio.
2021-07-31 12:37:18 -07:00
Kangwook Lee (이강욱)
6034c899d3 Add flight intra radio channel to mission briefing (#1475) 2021-07-31 12:34:49 -07:00
bgreman
d2fe11ba6f Updates Gripen support, fixes missing DEAD legacy loadouts. (#1469) 2021-07-31 12:07:57 -04:00
bgreman
58c96e1329 Adds more details to frontline movement logging (#1465)
* adds more detailed logging for frontline movement

* Fixing attribute name

* Fixing if, adding else
2021-07-31 12:05:22 -04:00
Magnus Wolffelt
4c51b4b822 Seasonal weather types per theater.
Adjusts the weather conditions per theater and  per season.
2021-07-31 03:57:23 -07:00
C. Perreau
f5dea4935c Merge pull request #1467 from Mustang-25/develop
Increment Campaigns to v8.0
2021-07-31 00:13:45 +02:00
Mustang-25
0117ab8aa4 Increment to Campaign v8.0 2021-07-28 10:37:14 -07:00
Mustang-25
a5ade0c41a Increment to Campaign v8.0 2021-07-28 10:36:43 -07:00
Mustang-25
4df12ae675 Increment to Campaign v8.0 2021-07-28 10:36:17 -07:00
Mustang-25
274a41f052 Increment to Campaign v8.0 2021-07-28 10:35:12 -07:00
Mustang-25
3670c8f879 Increment to Campaign v8.0 2021-07-28 10:34:28 -07:00
Mustang-25
e88bb442f3 Increment to Campaign v8.0 2021-07-28 10:32:47 -07:00
Mustang-25
a0d1bf4b5c Merge branch 'dcs-liberation:develop' into develop 2021-07-28 10:31:31 -07:00
Khopa
32f05dccd9 Added Tin Shield EWR support 2021-07-28 00:15:00 +02:00
Khopa
4aac2d2b7b Added NASAMS support 2021-07-27 23:43:00 +02:00
Mustang-25
e5a40bfb69 Merge branch 'dcs-liberation:develop' into develop 2021-07-27 14:07:41 -07:00
C. Perreau
741ae36d4c Merge pull request #1459 from RndName/fix-empty-transfer
fix generation of empty transfer during cp capture
2021-07-27 22:54:51 +02:00
RndName
67fa4a8910 fix generation of empty transfer during cp capture
when a cp capture happens and the next cp has pending unit deliveries then they will be redeployed to the newly captured cp. The redeploy was drecreasing the num of pending unit deliveries for the old cp but was not removing them completly from the dict when all were removed
2021-07-25 15:24:16 +02:00
Dan Albert
80bf3c97b2 Remove the SA-10 from Syria 2011.
They didn't get this until a few years later. This was a stand-in for
the SA-5 that DCS doesn't have, but the SA-10 is so much more capable
that it's not a good replacement.
2021-07-24 15:10:22 -07:00
Dan Albert
9f23cb35a9 Update pydcs to latest master. 2021-07-24 15:09:35 -07:00
RndName
458de17b8f adopt sam heading to new heading class 2021-07-23 15:57:03 -07:00
RndName
dd50ee92a9 calculate heading to center of conflict for sams 2021-07-23 15:57:03 -07:00
bgreman
1094085872 Fixes #1449 and updates another area where the Heading class can apply (#1451) 2021-07-22 15:30:46 -04:00
Dan Albert
edbd3de4a4 Bump campaign version to 8.0 for latest DCS.
Building IDs changed again. Ack the change in my two campaigns which
don't use these target types.
2021-07-21 17:10:29 -07:00
bgreman
91d430085e Addresses #478, adding a heading class to represent headings and angles (#1387)
* Addresses #478, adding a heading class to represent headings and angles
Removed some unused code

* Fixing bad merge

* Formatting

* Fixing type issues and other merge resolution misses
2021-07-21 10:29:37 -04:00
Dan Albert
fab550157a Add a per-aircraft weapon linter.
Run with `main.py lint-weapons $AIRCRAFT` to show all the weapons the
aircraft can carry that do not have data.
2021-07-19 20:07:58 -07:00
Dan Albert
5e2ed04d72 Add weapon data for the CBU-87 and CBU-97. 2021-07-19 16:53:49 -07:00
Dan Albert
e87aa83666 Add CLI generator options for date restrictions. 2021-07-19 16:27:20 -07:00
Dan Albert
c9b6b5d4a8 Correct changelog. 2021-07-18 19:38:55 -07:00
Dan Albert
ce01ad2083 Default to aircraft at only appropriate bases. 2021-07-18 17:12:34 -07:00
Dan Albert
0eb8ec70d9 Make opfor airwing configurable. 2021-07-18 16:09:20 -07:00
Dan Albert
270f87f193 Add per-aircraft tabs to air wing configuration. 2021-07-18 15:49:58 -07:00
Dan Albert
c2951e5e41 Increase minimum hold distance.
The previous values were far too optimistic for a non-AB climb to hold
altitude, especially for the AI.
2021-07-18 14:52:12 -07:00
Dan Albert
e22e8669e1 Add fallback locations for join zones.
It's rare with the current 5NM buffer around the origin, but if we use
the hold distance as the buffer like we maybe should it's possible for
the preferred join locations to fall entirely within the home zone. In
that case, fall back to a location within the max-turn-zone that's
outside the home zone and is nearest the IP.
2021-07-18 14:52:12 -07:00
Dan Albert
2580fe6b79 Update the changelog. 2021-07-17 19:53:16 -07:00
Dan Albert
c11c6f40d5 Add minimum fuel per waypoint on the kneeboard. 2021-07-17 19:51:55 -07:00
Dan Albert
3c90a92641 Add fuel consumption data for the Hornet.
Will be used to calculate bingo and min remaining fuel for the
kneeboard.
2021-07-17 18:23:20 -07:00
Dan Albert
4c0a97e62f Log a warning for unknown max ranges. 2021-07-17 17:27:40 -07:00
Dan Albert
0a57bb5029 Increase airfield distance in Battle of Abu Dhabi.
Removes some of the low capacity airfields from the campaign now that
missions can plan longer ranges if needed.

This removes Khasab, Bandar Lengeh, and Qeshm from the blue side, so
blue no longer has any airfields on the peninsula.

The CVN has moved quite a ways west to make it a good platform for
attacking the area around Dubai, and to prevent it from being the
primary mission source (with a 90 aircraft limit, a *lot* of missions
can get planned there before other airbases will be used).

The LHA moves to near where the CVN was, making it a good platform for
early game missions. Once the LHA's 20 aircraft limit is exhausted, Kish
and Bandar Abbas will be the primary airfields early game. Bandar Abbas
is still close enough to source Hornet and Viper missions to most of the
area around Dubai. It's unable to reach Lar with those aircraft, but
Kish and the CVN can (as can captured airfields).
2021-07-17 16:34:13 -07:00
Dan Albert
c65ac5a7cf Move mission range data into the aircraft type.
The doctrine/task limits were capturing a reasonable average for the
era, but it did a bad job for cases like the Harrier vs the Hornet,
which perform similar missions but have drastically different max
ranges. It also forced us into limiting CAS missions (even those flown
by long range aircraft like the A-10) to 50nm since helicopters could
commonly be fragged to them.

This should allow us to design campaigns without needing airfields to be
a max of ~50-100nm apart.
2021-07-17 16:34:13 -07:00
Dan Albert
04a8040292 Prevent carriers from claiming most TGOs.
The naval CP generators will only spawn ships, so if any of the other
TGO types were closest to the CV or LHA they just would not be
generated.
2021-07-17 16:34:13 -07:00
Dan Albert
adab00bc0e Update changelog. 2021-07-17 14:31:14 -07:00
Dan Albert
9bb8e00c3d Allow configuration of the air wing at game start.
After completing the new game wizard but before initializing turn 0,
open a dialog to allow the player to customize their air wing. With this
they can remove squadrons from the game, rename them, add players, or
change allowed mission types. *Adding* squadrons is not currently
supported, nor is changing the squadron's livery (the data in pydcs is
an arbitrary class hierarchy that can't be safely indexed by country).

This only applies to the blue air wing for now.

Future improvements:

* Add squadron button.
* Collapse disable squadrons to declutter?
* Tabs on the side like the settings dialog to group by aircraft type.
* Top tab bar to switch between red and blue air wings.
2021-07-17 14:29:04 -07:00
Dan Albert
f2dc95b86d Fix typo. 2021-07-16 22:43:59 -07:00
Dan Albert
28f98aed88 Migrate pressure to a typed unit. 2021-07-16 22:38:41 -07:00
Dan Albert
d11174da21 Stop cluttering the kneeboard with empty notes. 2021-07-16 22:25:40 -07:00
Dan Albert
8e977f994f Remove LGBs from degraded loadouts without TGPs.
This only takes effect for default loadouts. Custom loadouts set from
the UI will allow LGBs. In the default case there will not be buddy-lase
coordination so we should take iron bombs instead.

Also adds single/double Mk 83 and Mk 82 weapon data to accomodate this.
2021-07-16 18:34:41 -07:00
Dan Albert
11c2d4ab25 Add JDAMs and their fallbacks.
Hornet should be compatible with 1990 campaigns now. Air-to-ground
weapon restrictions are less interesting for AI aircraft so I haven't
covered *all* the variants here (the >2 variants of each carried by the
B1 and such).
2021-07-16 16:23:22 -07:00
Dan Albert
b733e6855b Add SLAM/SLAM-ER weapon data. 2021-07-16 15:48:12 -07:00
Dan Albert
aa3d644f97 Prevent empty cheek stations for the Hornet.
This is a bit of a hack that makes the TGPs fall back to AIM-120s. It
works okay because this only applies to a few cases:

The A-10 gets an empty pylon. That's fine. Maybe later we can add
multiple fallback paths and depth-first-search through them so that that
pylon could carry bombs instead.

The Viper has no replacemnt for that station. The jammer goes on the
other fuselage station, the HTS isn't a replacement, and we don't have
LANTIRN for the Viper. No weapons can be fit to those stations.

What this helps is the Hornet, where any Gulf War scenario ends up with
an empty cheek station because we don't have the NITE HAWK to fall back
to. In this case we can instead fall back through the air-to-air
missiles to fill the station.
2021-07-16 15:27:32 -07:00
Dan Albert
bb46d00f22 Add weapon data for R-77, R-27, and R-24. 2021-07-16 15:22:11 -07:00
Dan Albert
771c74ee75 Fill out weapon data for AIM-9s. 2021-07-16 15:12:49 -07:00
Magnus Wolffelt
04a346678c Add situational temperature and pressure variation.
Now varies by:

* Season
* Theater
* Weather
* Time of day
2021-07-16 14:08:14 -07:00
Dan Albert
e5c0fc92ec Don't reload weapon data if already loaded. 2021-07-16 01:06:31 -07:00
Dan Albert
1b640f40dc Fix map issues when debugging tools are disabled. 2021-07-16 00:27:11 -07:00
Mustang-25
ee77516716 Replace TGP with SPJ for JF-17 CAP/SEAD.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1422.
2021-07-16 00:17:20 -07:00
Dan Albert
82cca0a602 [3/3] Rework hold points. 2021-07-15 23:21:56 -07:00
Dan Albert
d444d716f5 [2/3] Improve join point placement. 2021-07-15 23:18:10 -07:00
Dan Albert
e03d710d53 [1/3] Rework IP placement.
Test cases:

1. Target is not threatened.

   The IP should be placed on a direct heading from the origin to the
   target at the max ingress distance, or very near the origin airfield
   if the airfield is closer to the target than the IP distance.

2. Unthreatened home zone, max IP between origin and target, safe
   locations available for IP.

   The IP should be placed in LAR at the closest point to home.

3. Unthreatened home zone, origin within LAR, safe locations available
   for IP.

   The IP should be placed near the origin airfield to prevent
   backtracking more than needed.

4. Unthreatened home zone, origin entirely nearer the target than LAR,
   safe locations available for IP.

   The IP should be placed in LAR as close as possible to the origin.

5. Threatened home zone, safe locations available for IP.

   The IP should be placed in LAR as close as possible to the origin.

6. No safe IP.

   The IP should be placed in LAR at the point nearest the threat
   boundary.
2021-07-15 23:18:10 -07:00
Mustang-25
b46d44c3a5 Change CAP and SEAD Loadouts
Replaced the TGP with SPJ pod for these roles.
2021-07-15 16:58:14 -07:00
Mustang-25
7a459fd5b8 Merge branch 'dcs-liberation:develop' into develop 2021-07-15 16:55:03 -07:00
Magnus Wolffelt
2b696144e3 Add QNH and temperature to the kneeboard. 2021-07-15 15:34:58 -07:00
RndName
62036a273e allow user to set empty dcs install dir
This will allow expert users to disable the automatic MissionScripting.lua replacement. There are many warnings and errors which have to be ignored to achieve this because DCS Liberation will not work with unmodified MissionScripting.lua
2021-07-15 13:46:59 -07:00
Magnus Wolffelt
d25befabdd Randomize mission temperature and pressure. 2021-07-15 13:34:09 -07:00
Dan Albert
56b17dfbcf Correct behavior for multi-task HTN methods.
Add tasks to the left of the deque, not the right.

Not symptomatic yet since we don't actually have any multi-task methods
currently.
2021-07-14 18:34:33 -07:00
Dan Albert
72c181a399 Fix budget mismatch in the UI.
Much of the UI was using the old budget which wasn't removed from Game
like it should have been when Coaltion was introduced. The UI displayed
(and in some cases pulled from) the starting budget rather than the real
budget.
2021-07-14 17:33:01 -07:00
Dan Albert
b1b60f4286 Add JSOW A and C weapon data and fallbacks.
Both types use JSOW -> Walleye -> Mk 84. The JSOW A should maybe fall
back to some CBU instead, but I think the standoff capability is more
important to preserve than the warhead type.
2021-07-14 01:27:00 -07:00
Dan Albert
7648716199 Make weapon groups explicit and moddable.
The only parts of the old weapon data that worked well (didn't commonly
result in empty pylons) did this implicitly, so make the grouping
explicit.

This also moves the data out of Python and into the resources, which
both makes the data moddable and isolates us from a huge amount of
effort and a save compat break whenever ED changes weapon names.

I didn't auto migrate the old data since the old groups were not
explict and there's no way to infer the grouping. Besides, since most of
the weapons were *not* grouped, the old data did more harm than good in
my experience. I've handled the AIM-120 and AIM-7 for now, but will get
at least all the fox 3 missiles before we ship.
2021-07-14 01:04:03 -07:00
Dan Albert
9177588220 Don't target ammo depots at inactive front lines. 2021-07-13 20:49:31 -07:00
Dan Albert
9bbcee645e Cleanup and document some of Doctrine. 2021-07-13 20:18:30 -07:00
Dan Albert
a7d49b986d Exclude both path endpoints for joins/splits.
Without this the points would often be placed exactly on top of the
origin airfield, when in that case we actually should use the fallback
behavior.
2021-07-13 20:11:09 -07:00
Dan Albert
7c3e08050f Perturb fallback join/split points.
Otherwise they're placed exactly on top of each other, which makes the
map harder to read.
2021-07-13 20:10:29 -07:00
Dan Albert
076df7cf66 Fix placement of fallback split points.
Using the target placed the split in the threatened area.
2021-07-13 20:07:42 -07:00
Dan Albert
415b8c6317 Allow the split point to differ from the join.
This places the split point based on the best path from the IP to home,
rather than the best path from home to the target. The outcome is that
the planner might choose an alternate route out of a threatened area
based on the safest escape from the IP, which is where the aircraft
should be when it releases its weapons, rather than at the target.
That's of course not always perfect since the IP distance is not based
on the carried weapon, but it's a better choice when it matters more
(when carrying standoff weapons attacking a more dangerous target).
2021-07-13 19:59:22 -07:00
Dan Albert
c1d3c93dbb Speed up turn processing.
Run the expensive precondition check (package fulfillment) last.
2021-07-13 18:56:06 -07:00
Dan Albert
8e59c99666 Improve hold point placement for nearby joins. 2021-07-13 18:43:02 -07:00
Dan Albert
dfcd372d2d Remove egress points from most flight plans.
These don't have any function. Remove them and remove the angled attack
heading from the IP.
2021-07-13 18:36:38 -07:00
Dan Albert
f7bbe0fa94 Improve flight plan layout for untheatened IPs.
Try all the nav points between the origin and the target rather than
just the first non-threatened point. This prevents us from using the
fallback behavior for any target that's sufficiently far from the
package airfield.
2021-07-13 18:22:42 -07:00
Dan Albert
a1910f49a8 Color navmesh zones based on threat. 2021-07-13 17:49:10 -07:00
Dan Albert
5f8be5fa91 Fix type checker issue. 2021-07-13 17:08:37 -07:00
Dan Albert
587034ad03 Prioritize ammo depots when appropriate.
The AI will now prioritize targeting ammo depots if the current
deployable enemy forces outnumber the friendly cap by 50% or more.
2021-07-13 17:06:25 -07:00
Dan Albert
9568bc7ea6 Fix inversion of AI management for opfor. 2021-07-13 17:04:49 -07:00
Dan Albert
ccf6b6ef5f Check for available aircraft as task precondition.
This makes it so that the mission planning effects are applied only if
the package can be fulfilled. For example, breakthrough will be used
only if all the BAI missions were fulfilled, not if they will *attempt*
to be fulfilled.
2021-07-13 16:26:15 -07:00
Dan Albert
24f6aff8c8 Reduce mission planning dependence on Game. 2021-07-13 15:20:42 -07:00
Dan Albert
17c19d453b Factor out Coalition from Game. 2021-07-13 14:29:40 -07:00
Dan Albert
4534758c21 Account for planned missions for breakthrough.
Consider BAI missions planned this turn when determining if a control
point is still garrisioned for preventing breakthrough.

This isn't very accurate yet since the HTN isn't checking for aircraft
fulfillment yet, so it might *plan* a mission to kill the garrison, but
there's no way to know if it will be fulfilled.
2021-07-13 13:50:50 -07:00
Dan Albert
c180eb466d Use aggressive stance for similar troop counts.
Bumps the breakthrough requirement to 2x, elimination to 1.5x, and uses
agressive for 0.8-1.5x.
2021-07-12 21:28:00 -07:00
Dan Albert
0a416ab758 Let the TheaterCommander manage front line stance.
This improves the AI behavior by choosing the stances non-randomly:

* Breakthrough will be used if the base is expected to be capturable and
  the coalition outnumbers the enemy by 20%.
* Elimination will be used if the coalition has at least as many units
  as the enemy.
* Defensive will be used if the coalition has at least half as many
  units as the enemy.
* Retreat will be used if the coalition is significantly outnumbers.

This also exposes the option to the player.
2021-07-12 21:12:02 -07:00
Dan Albert
575aca5886 Fix targeting dead BAI targets. 2021-07-12 20:44:19 -07:00
Dan Albert
c0cc5657a7 Attack detecting radars with low priority.
IADS that are in detection range (but not attack range) of missions will
be targeted at very low priority. These will typically only be planned
when no other targets are in range.
2021-07-12 17:33:45 -07:00
Dan Albert
78514b6c2e Only auto-target strike against buildings.
The players can still manually assign strike missions on other target
types since that's sometimes better for player waypoint generation (one
waypoint per unit is nice for SAMs), but it's bad for the AI so by
default we should exclude non-buildings.

This also prevents double targeting of groups, since they might have
been identified by other missions as well.

We already did some of this, but since we were excluding specific TGO
types rather than only allowing building TGOs we were often missing
things (missile sites, coastal defenses, and EWRs, it seems).
2021-07-12 17:04:17 -07:00
Dan Albert
7e4390d743 Improve prioritization of garrison targeting.
Garrison groups should be preferred with the following priority:

1. Groups blocking base capture
2. Groups at bases connected to an active front line
3. Rear guard units

Previously they were being prioritized based on the distance to the
closest friendy control point, which is similar to this but an
aggressively placed carrier could throw it off.
2021-07-12 16:59:49 -07:00
Dan Albert
cd558daf5a Add decorator for tracking save compat.
Used to decorate functions or methods that have save compat code for a
given major version.

```
@has_save_compat_for(5)
def foo() -> None:
    ...
```

This function will raise an error at startup if it is decorated as
having save compat for a version other than the current major version of
the game. A new major version is the definition of a save compat break,
so keeping around the old compat code serves no purpose other than
hiding initialization bugs. The compat code and the decorator should be
removed in the branch raising the error.
2021-07-12 13:44:49 -07:00
Dan Albert
dda5955121 Improve DEAD mission prioritization.
This alters the DEAD task planning to be the *least* preferred task, but
prevents other tasks from being planned unless they are excepted to be
clear of air defenses first. Even so, missions are a guaranteed success
so those other missions will still get SEAD escorts if there's potential
for a SAM in the area.

This means that air defenses that are not protecting a more useful
target (like a convoy, armor column, building, etc) will no longer be
considered by the mission planner. This isn't *quite* right since we
currently only check the target area for air defenses rather than the
entire flight plan, so there's a chance that we ignore IADS that have
threatened ingress points (though that's mostly solved by the flight
plan layout).

This also is still slightly limited because it's not checking for
aircraft availability at this stage yet, so we may aggressively plan
missions that we should be skipping unless we can guarantee that the
DEAD mission was planned. However, that's not new behavior.
2021-07-12 13:02:23 -07:00
Dan Albert
783ac18222 Replace existing campaign planner with an HTN.
An HTN (https://en.wikipedia.org/wiki/Hierarchical_task_network) is
similar to a decision tree, but it is able to reset to an earlier stage
if a subtask fails and tasks are able to account for the changes in
world state caused by earlier tasks.

Currently this just uses exactly the same strategy as before so we can
prove the system, but it should make it simpler to improve on task
planning.
2021-07-12 13:02:23 -07:00
Dan Albert
81c8052449 Note tracking bug for shapely type annotations. 2021-07-11 14:41:40 -07:00
Dan Albert
6ce02282e7 Correct int/float confusion in Point APIs.
The heading and distance calculations always return floats.
2021-07-11 14:33:46 -07:00
Dan Albert
a19a0b6789 Use Pillow types from typeshed. 2021-07-11 13:53:58 -07:00
Dan Albert
9de08dc83f Update to latest pydcs.
This includes the basics that we need to get type checking for pydcs
calls.

Type checking has been disabled in a few monkey-patching cases. Patches
ought to be sent upstream (or in the case of dead unit tracking,
replaced with a better model).
2021-07-11 13:37:17 -07:00
Dan Albert
96c7b87ac7 More adaptation for pydcs updates.
This is as much as we can do until pydcs actually adds the py.typed
file. Once that's added there are a few ugly monkey patching corners
that will just need `# type: ignore` for now, but we can't pre-add those
since we have mypy warning us about superfluous ignore comments.
2021-07-09 16:35:03 -07:00
Brock Greman
469dd49def Fixing broken group generation. 2021-07-09 12:35:32 -07:00
Dan Albert
53f6a0b32b Fix some typing in preparation for pydcs types.
Not complete, but progress.
2021-07-08 23:23:05 -07:00
Dan Albert
fb9a0fe833 Flesh out typing information, enforce. 2021-07-07 17:41:29 -07:00
Dan Albert
69c3d41a8a Disallow partially specified generics. 2021-07-07 16:01:20 -07:00
Dan Albert
fc32b98341 Type check the contents of untyped functions.
By default mypy doesn't type check the code within an untyped function.
This enables that and fixes typing errors to accomodate it.

This did uncover a very old bug:
https://github.com/dcs-liberation/dcs_liberation/issues/1417
2021-07-07 15:47:19 -07:00
Dan Albert
299ed88f09 Fix unreachable code issues, enable checking.
The loadout case actually could (and previously did) hide bugs from the
type checker, since mypy was smart enough to see that we were removing
None from the input it assumed that the member was non-optional, but
later modifications could cause null values, and since those came from
the UI mypy couldn't reason about this. This meant that mypy assumed the
type could not be optional and wouldn't check that case.
2021-07-07 15:17:05 -07:00
Dan Albert
29753a6aa9 Add (mostly disabled) mypy configs.
We're missing a lot of checking right now. Most of it requires
additional cleanup. For now I've enabled what I could and will follow up
to clean up and enable more checking.
2021-07-07 15:17:05 -07:00
Dan Albert
7983cd8d62 Add documentation for turn processing. 2021-07-07 14:44:38 -07:00
RndName
05fab1f79d correct display of turn statistics 2021-07-07 14:12:20 -07:00
RndName
7229b886e0 replan opfor mission on sell or buy of tgos 2021-07-07 14:12:20 -07:00
Dan Albert
8b70d2674f Note fix for empty convoy groups. 2021-07-05 15:54:23 -07:00
RndName
8ba27cdaea remove completely destroyed units from the convoy 2021-07-05 15:51:34 -07:00
Khopa
1c2411a0fc Added a basic campaign on Mariana Islands theater to try it out. 2021-07-04 19:35:36 +02:00
Khopa
ec88d07ef1 Corrected some bugs preventing marianas campaigns from running 2021-07-04 19:34:58 +02:00
bgreman
aa328d3ef7 Adds Marianas Islands support (#1406)
* Implements #1399

* Reverting accidental change in generate_landmap.py

* Changelog update

* Import beacon data for Marianas.

Co-authored-by: Dan Albert <dan@gingerhq.net>
2021-07-03 14:51:26 -04:00
Dan Albert
727facfb90 Fixup None loadouts for aircraft with no loadouts.
Aircraft that have no loadouts at all (such as the IL-78M) will have no
loadouts and thus no values in the dropdown menu. If the player toggles
the custom layout box we reset the flight's loadout to the selected
loadout, and with no loadouts in the combo box that is None, and
`Flight.loadout` isn't supposed to be optional.

Check for that case in the loadout selector and replace with an empty
loadout if that happens.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1402
2021-07-02 17:33:24 -07:00
Dan Albert
4add853473 Fix the legacy tanker.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1379
2021-07-02 17:18:21 -07:00
RndName
b2db27f9aa remove prices from sam generators
The prices are only estimations due to randomization. the real price will be only known when the generator was used and the final units are known
2021-07-02 16:46:16 -07:00
RndName
96be6c0efe correct prices for ewr and sams
prices will now be calculated for the whole group by the generator by
looking up the price using the  GroundUnitType wrapper

fixes #1163
2021-07-02 16:46:16 -07:00
Dan Albert
3f42f1281d Note the silkworm fix in the changelog. 2021-07-02 16:28:06 -07:00
Mustang-25
bab8384803 Corrected Silkworm launcher name 2021-07-02 16:26:47 -07:00
Mustang-25
ceb77c990b Corrected Silkworm launcher name 2021-07-02 11:45:01 -07:00
Florian
3f65928e9d Remove the randomness from SAM group size. 2021-07-02 01:38:27 -07:00
Dan Albert
4e6659e7e8 4.0.1 -> 4.1.0
This includes new features now.
2021-07-02 01:27:51 -07:00
Chris Seagraves
9e22d4b5df Note TGO tooltip improvement in the changelog. 2021-07-02 01:26:55 -07:00
RndName
357361de3d fixed lua data generation 2021-07-02 01:25:03 -07:00
RndName
de443fa3f0 reworked the skynet group name generation
- added information about the role of the aa site
- moved handling of ground name from tgo to the sam generator to make the tgo cleaner
- adjusted the skynet-config lua to the changes
2021-07-02 01:25:03 -07:00
Dan Albert
20839853b7 Minor formatting fix for the changelog. 2021-07-01 20:07:28 -07:00
Chris Seagraves
bc2539b566 Fix for crash when clear weather.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1394
2021-07-01 20:07:01 -07:00
Dan Albert
c89416702d Revert "Revert "Add Cloud Base Altitude to Weather Display (#1371)""
This reverts commit b2dd8c68e1.
2021-07-01 20:07:01 -07:00
Dan Albert
b2dd8c68e1 Revert "Add Cloud Base Altitude to Weather Display (#1371)"
Reverting until
https://github.com/dcs-liberation/dcs_liberation/issues/1394 is
resovled.

This reverts commit f80696b724.
2021-07-01 20:00:08 -07:00
Dan Albert
2ef2eafdd3 Remove debug cruft.
We don't need to print the description of every unit on startup.
2021-07-01 19:37:08 -07:00
Schneefl0cke
568655d503 Add incomes for WW2 villages and camps. 2021-07-01 17:04:03 -07:00
bgreman
9bd6f9ef47 Addresses #478 to clean up the angle summing functionality. (#1386) 2021-06-30 23:58:20 -04:00
bgreman
c8e5cefd36 Increasing time JTAC radio messages stay on the UI. (#1369)
- Target lost or killed: 10s -> 20s
- New target : 10s -> 30s
- Request JTAC Status: 25s -> 60s
2021-06-30 23:55:37 -04:00
bgreman
7ba4077f9f Fixes #240 by making statistics windows axis labels integers (#1370) 2021-06-30 23:50:02 -04:00
Mustang-25
151f8bf329 Update TGP Restriction Dates
TGP dates to more accurately reflect IRL IOC dates.
2021-06-30 19:17:14 -07:00
Chris Seagraves
e94d48c265 Notes to kneeboard (#1375)
Adds global-level kneeboard notes.  Explicit save compatability with 4.0.0
2021-06-30 18:07:53 -04:00
Fryderyk Wysocki
2a5c523afd Update poland_2010.json (#1380)
* Update poland_2010.json

* Adding MiG-29G to PL faction

Poland has bought some MiG-29Gs from unified Germany in the early '90s
2021-06-30 15:30:38 -04:00
Chris Seagraves
f80696b724 Add Cloud Base Altitude to Weather Display (#1371)
Adds tooltip with cloud base altitude to weather panel
2021-06-30 15:22:14 -04:00
Chris Seagraves
5f5b5f69e3 asset reference links 😎 (#1363)
Adds urls to unit info pages that don't have data.
2021-06-30 15:04:06 -04:00
Chris Seagraves
d99f8fef09 Update main.py (#1382) 2021-06-29 18:19:37 -04:00
Khopa
97e59db5e6 Helipads : Support for warm takeoff, use InvisibleFarp rather than Single Helipad. 2021-06-27 19:57:04 +02:00
Khopa
1c813c0e0e Merge remote-tracking branch 'khopa/develop' into helipads 2021-06-27 18:35:24 +02:00
Simon Clark
e39f17b3de Fix begin campaign button on reload. 2021-06-26 22:43:22 +01:00
Dan Albert
0b90b53e09 Add changelog section for 4.0.1. 2021-06-26 14:40:00 -07:00
Dan Albert
847d729ba4 Release 4.0.0. (#1365) 2021-06-26 12:46:26 -07:00
Dan Albert
aa86a6e53b Add the most important feature to the changelog. 2021-06-26 12:33:56 -07:00
Brock Greman
34470336e4 Clarify the impact of non-cold flight starts. 2021-06-26 12:29:17 -07:00
Mustang-25
5a2a89f19e Update Op Mole Cricket 2010 Campaign.
Moved SAM generator at Rosh Pina so it does not spawn units on the runway.
2021-06-26 12:17:27 -07:00
Dan Albert
7eb4df770e Revert accidental change to default pilot limit. 2021-06-26 12:06:18 -07:00
Simon Clark
550bb5fd33 Bump campaign versions. 2021-06-26 19:30:03 +01:00
Chris Seagraves
ffcae66f59 Include control point name in ground object info.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/498
2021-06-26 11:24:12 -07:00
Dan Albert
d2df795ba7 Ack campaign version update.
No scenery targets in this campaign so no work needed.

https://github.com/dcs-liberation/dcs_liberation/issues/1359
2021-06-26 11:19:02 -07:00
Dan Albert
b930e13964 Remove dead campaign.
https://github.com/dcs-liberation/dcs_liberation/issues/1359
2021-06-26 11:18:46 -07:00
Khopa
1b9da9cdd8 Fixed mypy error after merge 2021-06-26 20:00:10 +02:00
Dan Albert
e6bf318cdf Fix save path for new games. 2021-06-26 10:59:58 -07:00
Dan Albert
4cfed08247 Disband unfilled incompletable transfers.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1317
2021-06-26 10:54:09 -07:00
Khopa
4460b526cb Golan heights : re-add helipads for testing in helipad branch after merge of develop feature. 2021-06-26 19:27:33 +02:00
Khopa
01e6a87968 Mod support : Updated frenchpach to version 4.6 (Added new units VBCI and AMX-13 support) + some frenchpack units yaml tweaks 2021-06-26 19:22:13 +02:00
Khopa
7667a4f8c0 Merge branch 'develop' into helipads
# Conflicts:
#	game/game.py
#	resources/campaigns/golan_heights_lite.miz
2021-06-26 18:00:36 +02:00
Khopa
6fbfb83e6c Fixed duplicates in france 2005 faction 2021-06-26 15:26:33 +02:00
Khopa
123d3e182a Fixed yaml issue causing an issue with Leclerc MBT 2021-06-26 15:16:46 +02:00
Khopa
fd8d16035c Updated campaign : Operation Dynamo for The Channel map 2021-06-26 14:44:16 +02:00
Khopa
1ff45b55d6 Updated campaign : Russia Small, renamed it to "From Mozdok to Maykop" 2021-06-26 13:48:31 +02:00
Khopa
0ce02d7766 Updated campaign : Battle for Golan Heights 2021-06-26 13:27:01 +02:00
Dan Albert
959a13a514 Fix save path cleanup. 2021-06-25 23:21:31 -07:00
Chris Seagraves
b601d713d2 Use directory of current save for open/save-as. 2021-06-25 23:01:49 -07:00
Dan Albert
dc96d8699a Add "Nevada Limited Air" campaign.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1358
2021-06-25 21:34:28 -07:00
Dan Albert
f38cdd8432 Add "Scenic Route" campaign.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1334
2021-06-25 21:28:27 -07:00
Dan Albert
91655a3d5a Fix lint. 2021-06-25 19:33:55 -07:00
Dan Albert
7774a9b2ab Move the default save game directory.
The top level DCS directory gets messy fast if we fill it with save
games.
2021-06-25 17:48:09 -07:00
Dan Albert
80cf8f484d Fix targeting of carrier groups with TGOs.
The assumption that the first group is the carrier group is wrong.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1346
2021-06-25 16:46:49 -07:00
Dan Albert
cb7c075a61 State carrier requirement for Blackball.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1355
2021-06-25 16:35:15 -07:00
Dan Albert
4d0fb67c53 Fix crash when buying or selling TGO units.
Updating the game destroys this window so we cannot continue with the
calls. It worked in my initial testing, so presumably it's partly
dependent on when the finalizers run.

Since the windows will be destroyed there's nothing for us to actually
update, so just remove that signal and the explicit close calls.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1344
2021-06-25 16:30:18 -07:00
Dan Albert
380d1d4f18 Ack campaign version bump.
Campaigns don't use scenery targets.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1341
2021-06-24 18:17:25 -07:00
Dan Albert
71832859a5 Update pydcs to use latest master.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/993
2021-06-24 18:14:11 -07:00
docofmur
a31432ad9e Fixes #1337 by making ground location search look in both directions (#1338) 2021-06-24 13:24:13 -04:00
bgreman
26743154d8 Implements #1331 by changing the Pass Turn button text on Turn 0. (#1333) 2021-06-24 10:59:12 -04:00
bgreman
a50a6fa917 Adds a ruler to the map (#1332)
* Adds a ruler to the map

* Updating changelog

* Updating changelog
2021-06-24 02:58:39 -04:00
bgreman
b43e5bac0b Fix #1329 player loses frontline progress when skipping turn 0 (#1330) 2021-06-24 02:04:27 -04:00
Dan Albert
ddaef1fb64 Retry reading state.json on failure.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1313
2021-06-23 20:18:06 -07:00
Dan Albert
6f264ff5de Signal game update when buying/selling TGO units.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1312
2021-06-23 20:08:05 -07:00
Dan Albert
a06fc6d80f Fix another unit type mismatch. 2021-06-23 20:01:38 -07:00
Dan Albert
3ddfc47d3a Add a feature flag for pilot limits.
This doesn't currently interact very well with the auto purchase since
the procurer might by aircraft that don't have pilots available. That
should be fixed, but for the short term we should just default to not
enabling this new feature.
2021-06-23 18:47:34 -07:00
docofmur
905bd05ba8 Campaign version update (#1326)
Caucasus Multi part campaign version update. No map strike objects so just the version change
2021-06-23 20:00:39 -04:00
Dan Albert
aa19787654 Document high level concepts of unit transfers. 2021-06-23 16:50:54 -07:00
bgreman
3274f3ec35 Fix empty convoys (#1327)
* Hopefully getting rid of empty convoys for good

* changing Dict to dict for type checks
2021-06-23 19:48:16 -04:00
bgreman
c3b8c48ca2 Fixes #1310 (#1325)
* Fixes #1310 by only refunding GUs if no faction CP has an attached factory.  Previously it would refund all units at the CP, including aircraft.

Also changes the CP CAPTURE cheat to work at any CP regardless of adjacency to frontline or BLUEFOR/OPFOR state.

* Fixing typing issues, changint all Dict[] types to dict[]

* Updating changelog
2021-06-23 17:09:17 -04:00
Dan Albert
d365094616 Update From Caen to Evreux.
Add support for inversion and ack the version change (Normandy is
unaffected by ID updates).

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1232
2021-06-23 13:57:16 -07:00
Dan Albert
7c76684076 Ack version update for PG campaigns.
PG is unaffected by building ID changes.
2021-06-23 13:49:27 -07:00
Dan Albert
0ef27b038a Update Vectron's Claw and Peace Spring.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1323
2021-06-23 12:58:56 -07:00
Dan Albert
610a27c0e4 Copy initialization fix to AircraftType. 2021-06-23 12:50:07 -07:00
RndName
752c91a721 set window title empty on new game
also fixed small exception when aborting the open file dialog which lead to " as filename

fixes #1305
2021-06-23 12:24:36 -07:00
Dan Albert
d3d655da07 Fixed missed initialization of unit data on load.
We'd only load unit data if a name lookup was done and missed it on a
type lookup. Ideally we wouldn't need to do a type lookup here until the
ground unit templates are reworked we still do.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1299
2021-06-22 23:41:05 -07:00
Dan Albert
db36cf248e Return pilots when canceling flight creation. 2021-06-22 23:36:39 -07:00
Dan Albert
153d8e106e Add the Around the Mountain campaign.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1280
2021-06-22 23:28:54 -07:00
Dan Albert
df8829b477 Add Operation Blackball campaign.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1320
2021-06-22 23:22:42 -07:00
Dan Albert
569bc297a8 Update Syria Full campaign.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1319
2021-06-22 23:20:03 -07:00
Dan Albert
099cbbdb64 Update Northern Russia campaign.
I bumped the submitted 6.1 to 7.0 (which didn't exist when the files
were uploaded) because this campaign uses no scenery targets.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1318
2021-06-22 23:16:12 -07:00
Dan Albert
ca7469b92e Update Allied Sword.
Only change from the uploaded files is that I increased the campaign
version to 7.0 since this doesn't use any scenery targets so has no work
to do for that.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1249
2021-06-22 23:13:16 -07:00
Dan Albert
6db4145927 Correct mistakenly updated campaign. 2021-06-22 23:08:12 -07:00
Dan Albert
ca93f2baff Bump campaign version to account for DCS changes.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1308
2021-06-22 23:04:16 -07:00
Dan Albert
84a0a3caeb Fix unit type mismatch.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1314
2021-06-22 22:54:40 -07:00
Dan Albert
7b327693e2 Update Operation Peace Spring.
https://github.com/dcs-liberation/dcs_liberation/issues/1303
2021-06-22 15:17:39 -07:00
docofmur
dba70dc6d5 Faction Audit.
Transports and mod aircraft added where needed cleaned up various
duplicates in factions.
2021-06-22 15:01:50 -07:00
Mike Jones
bd1618e41d Use pydcs has_tacan attribute to check if tankers support TACAN. 2021-06-22 14:35:28 -07:00
Mike Jones
08b7aff0d8 Add gunfighter flag to aircraft data files. 2021-06-22 14:35:28 -07:00
Mike Jones
a75688f89c Add patrol configuration to unit data files.
This allows altitude/speed of AEW&C and tankers to be configured.
2021-06-22 14:35:28 -07:00
Mike Jones
30763b5401 Fix unit type comparisons.
When comparing UnitType against a pydcs type, use .dcs_unit_type.
2021-06-22 14:35:28 -07:00
Chris Seagraves
814519248c Fix bug with file name in title with invalid save games. 2021-06-22 14:19:53 -07:00
Simon Clark
8c71be5257 Campaign clarity. 2021-06-22 17:21:21 +01:00
Simon Clark
91763b233e Add clarity for mod selection page. 2021-06-21 20:04:58 +01:00
Simon Clark
ab51f5e69a Merge branch 'develop' of https://github.com/dcs-liberation/dcs_liberation into develop 2021-06-21 19:46:10 +01:00
Simon Clark
d278d58f6c Add Operation Atilla campaign.
It's a Cyprus invasion campaign - what's not to like!
2021-06-21 19:46:05 +01:00
Dan Albert
47e038c9fa Fix command line campaign generator. 2021-06-20 23:46:06 -07:00
Dan Albert
e96210f48c Don't order transports for incapable factions.
If these orders can't be fulfilled for the faction it will prevent the
faction from ordering any non-reserve aircraft since transports are
given priority after reserve missions, and they'll never be fulfillable.
As such, no non-reserve aircraft will ever be purchased for factions
without transport aircraft.

Factions without transport aircraft are screwed in other ways, but this
will fix their air planning for campaigns that aren't dependent on
airlift.
2021-06-20 23:44:00 -07:00
Simon Clark
aa3811ad02 Updated factions to reflect mod select changes. 2021-06-21 01:32:43 +01:00
Simon Clark
963ab38b2e Refactor the mod select changes, re-add accidentally deleted factions. 2021-06-21 01:16:48 +01:00
Simon Clark
11069cc219 Make mod selection nicer and deprecate MB-339.
Mod selection is now done via checkbox in the new game wizard.

The MB-339 is being turned into a paid module, and the free mod no longer works, so it's been removed.
2021-06-21 01:16:41 +01:00
Dan Albert
d074500109 Revert "Don't propose missions the air wing can't plan."
This is redundant because plan_mission already checks this.

This reverts commit 3338df9836.
2021-06-20 15:57:54 -07:00
Dan Albert
63af28b016 Develop is now 5.x. 2021-06-20 15:48:54 -07:00
Khopa
82bb2fcf6a Helipad : fixed typing errors after merge 2021-06-20 18:25:19 +02:00
Khopa
e56e765450 Helipad : fixed errors after merge of develop features. 2021-06-20 18:20:40 +02:00
Khopa
c70169b4a0 Merge branch 'develop' into helipads
# Conflicts:
#	game/game.py
#	game/operation/operation.py
#	game/theater/controlpoint.py
#	gen/aircraft.py
#	resources/campaigns/golan_heights_lite.miz
2021-06-20 18:07:50 +02:00
Khopa
adad88681e Generate helipads as neutral objects, so they do not interfer with base capture trigger 2021-06-10 23:18:41 +02:00
Khopa
1b9ac088e4 Refactor & fix code after comments 2021-06-10 13:19:22 +02:00
Khopa
e00951e5b9 Merge remote-tracking branch 'khopa/develop' into helipads 2021-06-10 13:01:24 +02:00
Khopa
b7a0feba5b Added more helipads on Golan Heights 2021-06-08 13:45:21 +02:00
Khopa
51fa0a0891 Added function to get cp number of fuel depots 2021-06-08 13:18:27 +02:00
Khopa
f4c54bb9e6 Autogenerate ammo & fuel for helipads so player controlled helicopter can use ground crew menu to refuel and rearm at FARPs. 2021-06-08 13:17:34 +02:00
Khopa
e00ca5d096 Merge branch 'develop' into helipads 2021-06-08 13:09:11 +02:00
Khopa
07b93167f0 Improved implementation. 2021-06-06 17:49:56 +02:00
Khopa
29c0a8d054 Fixed ground start for helos in FOB 2021-06-06 17:28:23 +02:00
Khopa
73b1be36a2 Merge branch 'develop' into helipads
# Conflicts:
#	game/theater/conflicttheater.py
#	gen/flights/flightplan.py
2021-06-06 15:46:30 +02:00
Khopa
4eb78810c6 Merge branch 'develop' into helipads
# Conflicts:
#	resources/campaigns/golan_heights_lite.miz
2021-05-30 17:29:55 +02:00
Khopa
f31861441b Merge branch 'develop' into helipads
# Conflicts:
#	game/data/weapons.py
#	game/db.py
#	game/theater/conflicttheater.py
#	resources/factions/france_1995.json
#	resources/factions/insurgents.json
#	resources/factions/iraq_1991.json
#	resources/factions/syria_1967_with_ww2_weapons.json
#	resources/factions/syria_2011.json
2021-05-21 13:58:22 +02:00
Khopa
cc93c686d9 pydcs update for DCS 2.7.1 2021-05-21 01:17:56 +02:00
Khopa
d5990e60c9 Helicopter flights can be planned from FOBs 2021-05-21 00:34:51 +02:00
646 changed files with 17418 additions and 7717 deletions

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

12
.github/ISSUE_TEMPLATE/mod_support.md vendored Normal file
View File

@@ -0,0 +1,12 @@
---
name: Mod support request
about: Request Liberation support for new mods, or updates to existing mods
title: Add/update <mod name>
labels: mod support
assignees: ''
---
* Mod name:
* Mod URL:
* Update or new mod?

View File

@@ -36,6 +36,11 @@ jobs:
run: |
./venv/scripts/activate
mypy gen
- name: mypy tests
run: |
./venv/scripts/activate
mypy tests
- name: update build number
run: |

33
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Test
on: [push, pull_request]
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
with:
submodules: true
- name: Set up Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install environment
run: |
python -m venv ./venv
- name: Install dependencies
run: |
./venv/scripts/activate
python -m pip install -r requirements.txt
# For some reason the shiboken2.abi3.dll is not found properly, so I copy it instead
Copy-Item .\venv\Lib\site-packages\shiboken2\shiboken2.abi3.dll .\venv\Lib\site-packages\PySide2\ -Force
- name: run tests
run: |
./venv/scripts/activate
pytest tests

2
.gitignore vendored
View File

@@ -18,7 +18,7 @@ env/
/liberation_preferences.json
/state.json
logs/
/logs/
qt_ui/logs/liberation.log

View File

@@ -1,16 +1,50 @@
# 4.1.2
# 5.0.0
Saves from 4.1.1 are compatible with 4.1.2.
Saves from 4.x are not compatible with 5.0.
## Features/Improvements
* **[Campaign]** Weather! Theaters now experience weather that is more realistic for the region and its current season. For example, Persian Gulf will have very hot, sunny summers and Marianas will experience lots of rain during fall. These changes affect pressure, temperature, clouds and precipitation. Additionally, temperature will drop during the night, by an amount that is somewhat realistic for the region.
* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated.
* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts.
* **[Campaign]** FOBs control point can have FARP/helipad slot and host helicopters. To enable this feature on a FOB, add "Invisible FARP" statics objects near the FOB location in the campaign definition file.
* **[Campaign]** Squadrons now have a home base and will not operate out of other bases. See https://github.com/dcs-liberation/dcs_liberation/issues/1145 for status.
* **[Campaign]** Aircraft now belong to squadrons rather than bases to support squadron location transfers.
* **[Campaign]** Skipped turns are no longer counted as defeats on front lines.
* **[Campaign AI]** Overhauled campaign AI target prioritization.
* **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI.
* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points.
* **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken.
* **[Campaign AI]** Transport aircraft will now be bought only if necessary at control points which can produce ground units and are capable to operate transport aircraft.
* **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron.
* **[Engine]** Support for DCS 2.7.7.14727 and newer, including support for F-16 CBU-105s, SA-5s, and the Forrestal.
* **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet and Viper).
* **[Kneeboard]** QNH (pressure MSL) and temperature have been added to the kneeboard.
* **[Mission Generation]** EWRs are now also headed towards the center of the conflict
* **[Mission Generation]** FACs can now use FC3 compatible laser codes. Note that this setting is global, not per FAC.
* **[Modding]** Can now install custom campaigns to <DCS saved games>/Liberation/Campaigns instead of the Liberation install directory.
* **[Modding]** Campaigns can now define a default start date.
* **[Modding]** Campaigns now specify the squadrons that are present in the campaign, their roles, and their starting bases. Players can customize this at game start but the campaign will choose the defaults.
* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable, relocate, or rename squadrons.
* **[Plugins]** Updated SkynetIADS to 2.4.0 (adds SA-5 support).
* **[UI]** Sell Button for aircraft will be disabled if there are no units available to be sold or all are already assigned to a mission
* **[UI]** Enemy aircraft inventory now viewable in the air wing menu.
## Fixes
* **[UI]** Selling of Units is now visible again in the UI dialog and shows the correct amount of sold units
* **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning.
* **[Campaign]** Units aboard suck cargo ships will now have their losses tracked properly.
* **[Mission Generation]** Mission results and other files will now be opened with enforced utf-8 encoding to prevent an issue where destroyed ground units were untracked because of special characters in their names.
* **[Mission Generation]** Fixed generation of landing waypoints so that the AI obeys them.
* **[Mission Generation]** AI carrier aircraft with a start time of T+0 will now start at T+1s to avoid traffic jams.
* **[Mission Generation]** Fixed cases of unused aircraft not being spawned at airfields as soon as any airport filled up.
* **[Mission Generation]** Fixed cases with multiple client flights of the same airframe all received the same preset channels.
* **[Mission Generation]** F-14A is now generated with stored alignment.
* **[Mission Generation]** Su-33s set to cold or warm start on the Kuznetsov will always be generated as runway starts to avoid the AI getting stuck.
* **[Mission Generation]** Fixed AI not receiving anti-ship tasks against carriers and LHAs.
* **[Mods]** Fixed broken A-4 support causing no weapons to be available.
* **[UI]** Selling of Units is now visible again in the UI dialog and shows the correct amount of sold units
* **[UI]** Fixed bug where an incompatible campaign could be generated if no action is taken on the campaign selection screen.
# 4.1.1
@@ -35,8 +69,8 @@ Saves from 4.0.0 are compatible with 4.1.0.
* **[Flight Planning]** CAP patrol altitudes are now set per-aircraft. By default the altitude will be set based on the aircraft's maximum speed.
* **[Flight Planning]** CAP patrol speeds are now set per-aircraft to be more suitable/sensible. By default the speed will be set based on the aircraft's maximum speed.
* **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR
* **[Mods]** Support for version v1.5.0-Beta of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts.
* **[Mission Generation]** SAM sites are now headed towards the center of the conflict
* **[Mods]** Support for latest version of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts.
* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI.
* **[Plugins]** Updated SkynetIADS to 2.2.0 (adds NASAMS support).
* **[UI]** Added ability to take notes and have those notes appear as a kneeboard page.
@@ -62,7 +96,7 @@ Saves from 4.0.0 are compatible with 4.1.0.
* **[Mission Generation]** Planned transfers which will be impossible after a base capture will no longer prevent the mission result submit.
* **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed.
* **[Mission Generation]** Fix for AAA Flak generator using Opel Blitz preventing the mission from being generated because duplicate unit names were used.
* **[Campaign AI]** Transport aircraft will now be bought only if necessary at control points which can produce ground units and are capable to operate transport aircraft.
* **[Mission Generation]** Fixed a potential bug with laser code generation where it would generate invalid codes.
* **[UI]** Statistics window tick marks are now always integers.
* **[UI]** Statistics window now shows the correct info for the turn
* **[UI]** Toggling custom loadout for an aircraft with no preset loadouts no longer breaks the flight.

View File

@@ -0,0 +1,80 @@
# Measuring estimated fuel consumption
To estimate fuel consumption numbers for an aircraft, create a mission with a
typical heavy load for the aircraft. For example, to measure for the F/A-18C, a
loadout with two bags, two GBU-31s, two sidewinders, an AMRAAM, and an ATFLIR.
Do **not** drop bags or weapons during the test flight.
Start the aircraft on the ground at a large airport (for example, Akrotiri) at a
parking space at the opposite end of the takeoff runway so you can estimate long
taxi fuel consumption.
When you enter the jet, note the amount of fuel below, then taxi to the far end
of the runway. Hold short and note the remaining fuel below.
Follow a typical takeoff pattern for the aircraft. For the F/A-18C, this might
be AB takeoff, reduce to MIL at 350KIAS, and maintian 350KIAS/0.85 mach until
cruise altitude (angles 25).
Once you reach angels 25, pause the game. Note your remaining fuel below and
measure the distance traveled from takeoff. Mark your location on the map.
Level out and increase to cruise speed if needed. Liberation assumes 0.85 mach
for supersonic aircraft, for subsonic aircraft it depends so pick something
reasonable and note your descision in a comment in the file when done. Maintain
speed, heading, and altitude for a long distance (the longer the distance, the
more accurate the result, but be careful to leave enough fuel for the final
section). Once complete, note the distance traveled and the remaining fuel.
Finally, increase speed as you would for an attack. At least MIL power,
potentially use AB sparingly, etc. The goal is to measure fuel consumption per
mile traveled during an attack run.
```
start:
taxi end:
to 25k distance:
at 25k fuel:
cruise (.85 mach) distance:
cruise (.85 mach) end fuel:
combat distance:
combat end fuel:
```
Finally, fill out the data in the aircraft data. Below is an example for the
F/A-18C:
```
start: 15290
taxi end: 15120
climb distance: 40NM
at 25k fuel: 13350
cruise (.85 mach) distance: 100NM
cruise (.85 mach) end fuel: 11140
combat distance: 100NM
combat end fuel: 8390
taxi = start - taxi end = 15290 - 15120 = 170
climb fuel = taxi end - at 25k fuel = 15120 - 13350 = 1770
climb ppm = climb fuel / climb distance = 1770 / 40 = 44.25
cruise fuel = at 25k fuel - cruise end fuel = 13350 - 11140 = 2210
cruise ppm = cruise fuel / cruise distance = 2210 / 100 = 22.1
combat fuel = cruise end fuel - combat end fuel = 11140 - 8390 = 2750
combat ppm = combat fuel / combat distance = 2750 / 100 = 27.5
```
```yaml
fuel:
# Parking A1 to RWY 32 at Akrotiri.
taxi: 170
# AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft.
climb_ppm: 44.25
# 0.85 mach for 100NM.
cruise_ppm: 22.1
# ~0.9 mach for 100NM. Occasional AB use.
combat_ppm: 27.5
min_safe: 2000
```
The last entry (`min_safe`) is the minimum amount of fuel that the aircraft
should land with.

View File

@@ -0,0 +1,2 @@
from .campaign import Campaign
from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig

View File

@@ -0,0 +1,183 @@
from __future__ import annotations
import datetime
import json
import logging
from collections import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple, Dict, Any
from packaging.version import Version
import yaml
from game.profiling import logged_duration
from game.theater import (
ConflictTheater,
CaucasusTheater,
NevadaTheater,
PersianGulfTheater,
NormandyTheater,
TheChannelTheater,
SyriaTheater,
MarianaIslandsTheater,
)
from game.version import CAMPAIGN_FORMAT_VERSION
from .campaignairwingconfig import CampaignAirWingConfig
from .mizcampaignloader import MizCampaignLoader
from .. import persistency
PERF_FRIENDLY = 0
PERF_MEDIUM = 1
PERF_HARD = 2
PERF_NASA = 3
@dataclass(frozen=True)
class Campaign:
name: str
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
recommended_start_date: Optional[datetime.date]
performance: int
data: Dict[str, Any]
path: Path
@classmethod
def from_file(cls, path: Path) -> Campaign:
with path.open() as campaign_file:
if path.suffix == ".yaml":
data = yaml.safe_load(campaign_file)
else:
data = json.load(campaign_file)
sanitized_theater = data["theater"].replace(" ", "")
version_field = data.get("version", "0")
try:
version = Version(version_field)
except TypeError:
logging.warning(
f"Non-string campaign version in {path}. Parse may be incorrect."
)
version = Version(str(version_field))
start_date_raw = data.get("recommended_start_date")
# YAML automatically parses dates, but while we still support JSON campaigns we
# need to be able to handle parsing dates from strings ourselves as well.
start_date: Optional[datetime.date]
if isinstance(start_date_raw, str):
start_date = datetime.date.fromisoformat(start_date_raw)
elif isinstance(start_date_raw, datetime.date):
start_date = start_date_raw
elif start_date_raw is None:
start_date = None
else:
raise RuntimeError(
f"Invalid value for recommended_start_date in {path}: {start_date_raw}"
)
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"),
start_date,
data.get("performance", 0),
data,
path,
)
def load_theater(self) -> ConflictTheater:
theaters = {
"Caucasus": CaucasusTheater,
"Nevada": NevadaTheater,
"Persian Gulf": PersianGulfTheater,
"Normandy": NormandyTheater,
"The Channel": TheChannelTheater,
"Syria": SyriaTheater,
"MarianaIslands": MarianaIslandsTheater,
}
theater = theaters[self.data["theater"]]
t = theater()
try:
miz = self.data["miz"]
except KeyError as ex:
raise RuntimeError(
"Old format (non-miz) campaigns are no longer supported."
) from ex
with logged_duration("Importing miz data"):
MizCampaignLoader(self.path.parent / miz, t).populate_theater()
return t
def load_air_wing_config(self, theater: ConflictTheater) -> CampaignAirWingConfig:
try:
squadron_data = self.data["squadrons"]
except KeyError:
logging.warning(f"Campaign {self.name} does not define any squadrons")
return CampaignAirWingConfig({})
return CampaignAirWingConfig.from_campaign_data(squadron_data, theater)
@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 self.version == (0, 0):
return False
if self.is_out_of_date:
return False
if self.is_from_future:
return False
return True
@staticmethod
def iter_campaigns_in_dir(path: Path) -> Iterator[Path]:
yield from path.glob("*.yaml")
yield from path.glob("*.json")
@classmethod
def iter_campaign_defs(cls) -> Iterator[Path]:
yield from cls.iter_campaigns_in_dir(
Path(persistency.base_path()) / "Liberation/Campaigns"
)
yield from cls.iter_campaigns_in_dir(Path("resources/campaigns"))
@classmethod
def load_each(cls) -> Iterator[Campaign]:
for path in cls.iter_campaign_defs():
try:
logging.debug(f"Loading campaign from {path}...")
campaign = Campaign.from_file(path)
yield campaign
except RuntimeError:
logging.exception(f"Unable to load campaign from {path}")

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING, Union
from gen.flights.flight import FlightType
from game.theater.controlpoint import ControlPoint
if TYPE_CHECKING:
from game.theater import ConflictTheater
@dataclass(frozen=True)
class SquadronConfig:
primary: FlightType
secondary: list[FlightType]
aircraft: list[str]
@property
def auto_assignable(self) -> set[FlightType]:
return set(self.secondary) | {self.primary}
@classmethod
def from_data(cls, data: dict[str, Any]) -> SquadronConfig:
secondary_raw = data.get("secondary")
if secondary_raw is None:
secondary = []
elif isinstance(secondary_raw, str):
secondary = cls.expand_secondary_alias(secondary_raw)
else:
secondary = [FlightType(s) for s in secondary_raw]
return SquadronConfig(
FlightType(data["primary"]), secondary, data.get("aircraft", [])
)
@staticmethod
def expand_secondary_alias(alias: str) -> list[FlightType]:
if alias == "any":
return list(FlightType)
elif alias == "air-to-air":
return [t for t in FlightType if t.is_air_to_air]
elif alias == "air-to-ground":
return [t for t in FlightType if t.is_air_to_ground]
raise KeyError(f"Unknown secondary mission type: {alias}")
@dataclass(frozen=True)
class CampaignAirWingConfig:
by_location: dict[ControlPoint, list[SquadronConfig]]
@classmethod
def from_campaign_data(
cls, data: dict[Union[str, int], Any], theater: ConflictTheater
) -> CampaignAirWingConfig:
by_location: dict[ControlPoint, list[SquadronConfig]] = defaultdict(list)
for base_id, squadron_configs in data.items():
if isinstance(base_id, int):
base = theater.find_control_point_by_id(base_id)
else:
base = theater.control_point_named(base_id)
for squadron_data in squadron_configs:
by_location[base].append(SquadronConfig.from_data(squadron_data))
return CampaignAirWingConfig(by_location)

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import logging
from typing import Optional, TYPE_CHECKING
from game.squadrons import Squadron
from game.squadrons.squadrondef import SquadronDef
from game.squadrons.squadrondefloader import SquadronDefLoader
from gen.flights.flight import FlightType
from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig
from .squadrondefgenerator import SquadronDefGenerator
from ..dcs.aircrafttype import AircraftType
from ..theater import ControlPoint
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
class DefaultSquadronAssigner:
def __init__(
self, config: CampaignAirWingConfig, game: Game, coalition: Coalition
) -> None:
self.config = config
self.game = game
self.coalition = coalition
self.air_wing = coalition.air_wing
self.squadron_defs = SquadronDefLoader(game, coalition).load()
self.squadron_def_generator = SquadronDefGenerator(self.coalition)
def claim_squadron_def(self, squadron: SquadronDef) -> None:
try:
self.squadron_defs[squadron.aircraft].remove(squadron)
except ValueError:
pass
def assign(self) -> None:
for control_point in self.game.theater.control_points_for(
self.coalition.player
):
for squadron_config in self.config.by_location[control_point]:
squadron_def = self.find_squadron_for(squadron_config, control_point)
if squadron_def is None:
logging.info(
f"{self.coalition.faction.name} has no aircraft compatible "
f"with {squadron_config.primary} at {control_point}"
)
continue
self.claim_squadron_def(squadron_def)
squadron = Squadron.create_from(
squadron_def, control_point, self.coalition, self.game
)
squadron.set_auto_assignable_mission_types(
squadron_config.auto_assignable
)
self.air_wing.add_squadron(squadron)
def find_squadron_for(
self, config: SquadronConfig, control_point: ControlPoint
) -> Optional[SquadronDef]:
for preferred_aircraft in config.aircraft:
squadron_def = self.find_preferred_squadron(
preferred_aircraft, config.primary, control_point
)
if squadron_def is not None:
return squadron_def
# If we didn't find any of the preferred types we should use any squadron
# compatible with the primary task.
squadron_def = self.find_squadron_for_task(config.primary, control_point)
if squadron_def is not None:
return squadron_def
# If we can't find any squadron matching the requirement, we should
# create one.
return self.squadron_def_generator.generate_for_task(
config.primary, control_point
)
def find_preferred_squadron(
self, preferred_aircraft: str, task: FlightType, control_point: ControlPoint
) -> Optional[SquadronDef]:
# Attempt to find a squadron with the name in the request.
squadron_def = self.find_squadron_by_name(
preferred_aircraft, task, control_point
)
if squadron_def is not None:
return squadron_def
# If the name didn't match a squadron available to this coalition, try to find
# an aircraft with the matching name that meets the requirements.
try:
aircraft = AircraftType.named(preferred_aircraft)
except KeyError:
# No aircraft with this name.
return None
if aircraft not in self.coalition.faction.aircrafts:
return None
squadron_def = self.find_squadron_for_airframe(aircraft, task, control_point)
if squadron_def is not None:
return squadron_def
# No premade squadron available for this aircraft that meets the requirements,
# so generate one if possible.
return self.squadron_def_generator.generate_for_aircraft(aircraft)
@staticmethod
def squadron_compatible_with(
squadron: SquadronDef,
task: FlightType,
control_point: ControlPoint,
ignore_base_preference: bool = False,
) -> bool:
if ignore_base_preference:
return control_point.can_operate(squadron.aircraft)
return squadron.operates_from(control_point) and task in squadron.mission_types
def find_squadron_for_airframe(
self, aircraft: AircraftType, task: FlightType, control_point: ControlPoint
) -> Optional[SquadronDef]:
for squadron in self.squadron_defs[aircraft]:
if self.squadron_compatible_with(squadron, task, control_point):
return squadron
return None
def find_squadron_by_name(
self, name: str, task: FlightType, control_point: ControlPoint
) -> Optional[SquadronDef]:
for squadrons in self.squadron_defs.values():
for squadron in squadrons:
if squadron.name == name and self.squadron_compatible_with(
squadron, task, control_point, ignore_base_preference=True
):
return squadron
return None
def find_squadron_for_task(
self, task: FlightType, control_point: ControlPoint
) -> Optional[SquadronDef]:
for squadrons in self.squadron_defs.values():
for squadron in squadrons:
if self.squadron_compatible_with(squadron, task, control_point):
return squadron
return None

View File

@@ -0,0 +1,473 @@
from __future__ import annotations
import itertools
from functools import cached_property
from pathlib import Path
from typing import Iterator, List, Dict, Tuple, TYPE_CHECKING
from dcs import Mission
from dcs.countries import CombinedJointTaskForcesBlue, CombinedJointTaskForcesRed
from dcs.country import Country
from dcs.planes import F_15C
from dcs.ships import Stennis, LHA_Tarawa, HandyWind, USS_Arleigh_Burke_IIa
from dcs.statics import Fortification, Warehouse
from dcs.terrain import Airport
from dcs.unitgroup import PlaneGroup, ShipGroup, VehicleGroup, StaticGroup
from dcs.vehicles import Armor, Unarmed, MissilesSS, AirDefence
from game.point_with_heading import PointWithHeading
from game.positioned import Positioned
from game.profiling import logged_duration
from game.scenery_group import SceneryGroup
from game.utils import Distance, meters, Heading
from game.theater.controlpoint import (
Airfield,
Carrier,
ControlPoint,
Fob,
Lha,
OffMapSpawn,
)
if TYPE_CHECKING:
from game.theater.conflicttheater import ConflictTheater
class MizCampaignLoader:
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
RED_COUNTRY = CombinedJointTaskForcesRed()
OFF_MAP_UNIT_TYPE = F_15C.id
CV_UNIT_TYPE = Stennis.id
LHA_UNIT_TYPE = LHA_Tarawa.id
FRONT_LINE_UNIT_TYPE = Armor.M_113.id
SHIPPING_LANE_UNIT_TYPE = HandyWind.id
FOB_UNIT_TYPE = Unarmed.SKP_11.id
FARP_HELIPADS_TYPE = ["Invisible FARP", "SINGLE_HELIPAD"]
OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id
SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id
MISSILE_SITE_UNIT_TYPE = MissilesSS.Scud_B.id
COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.Hy_launcher.id
# 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.Patriot_ln.id,
AirDefence.S_300PS_5P85C_ln.id,
AirDefence.S_300PS_5P85D_ln.id,
}
MEDIUM_RANGE_SAM_UNIT_TYPES = {
AirDefence.Hawk_ln.id,
AirDefence.S_75M_Volhov.id,
AirDefence._5p73_s_125_ln.id,
}
SHORT_RANGE_SAM_UNIT_TYPES = {
AirDefence.M1097_Avenger.id,
AirDefence.Rapier_fsa_launcher.id,
AirDefence._2S6_Tunguska.id,
AirDefence.Strela_1_9P31.id,
}
AAA_UNIT_TYPES = {
AirDefence.Flak18.id,
AirDefence.Vulcan.id,
AirDefence.ZSU_23_4_Shilka.id,
}
EWR_UNIT_TYPE = AirDefence._1L13_EWR.id
ARMOR_GROUP_UNIT_TYPE = Armor.M_1_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()
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
# both countries are initialized so we don't have to deal with None.
if self.mission.country(self.BLUE_COUNTRY.name) is None:
self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY)
if self.mission.country(self.RED_COUNTRY.name) is None:
self.mission.coalition["red"].add_country(self.RED_COUNTRY)
@staticmethod
def control_point_from_airport(airport: Airport) -> ControlPoint:
cp = Airfield(airport, starts_blue=airport.is_blue())
# Use the unlimited aircraft option to determine if an airfield should
# be owned by the player when the campaign is "inverted".
cp.captured_invert = airport.unlimited_aircrafts
return cp
def country(self, blue: bool) -> Country:
country = self.mission.country(
self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name
)
# Should be guaranteed because we initialized them.
assert country
return country
@property
def blue(self) -> Country:
return self.country(blue=True)
@property
def red(self) -> Country:
return self.country(blue=False)
def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]:
for group in self.country(blue).plane_group:
if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
yield group
def carriers(self, blue: bool) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.CV_UNIT_TYPE:
yield group
def lhas(self, blue: bool) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.LHA_UNIT_TYPE:
yield group
def fobs(self, blue: bool) -> Iterator[VehicleGroup]:
for group in self.country(blue).vehicle_group:
if group.units[0].type == self.FOB_UNIT_TYPE:
yield group
@property
def ships(self) -> Iterator[ShipGroup]:
for group in self.red.ship_group:
if group.units[0].type == self.SHIP_UNIT_TYPE:
yield group
@property
def offshore_strike_targets(self) -> Iterator[StaticGroup]:
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.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.red.vehicle_group:
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
yield group
@property
def long_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES:
yield group
@property
def medium_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES:
yield group
@property
def short_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
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
def helipads(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.FARP_HELIPADS_TYPE:
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 = {}
for airport in self.mission.terrain.airport_list():
if airport.is_blue() or airport.is_red():
control_point = self.control_point_from_airport(airport)
control_points[control_point.id] = control_point
for blue in (False, True):
for group in self.off_map_spawns(blue):
control_point = OffMapSpawn(
next(self.control_point_id),
str(group.name),
group.position,
starts_blue=blue,
)
control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point
for ship in self.carriers(blue):
control_point = Carrier(
ship.name,
ship.position,
next(self.control_point_id),
starts_blue=blue,
)
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for ship in self.lhas(blue):
control_point = Lha(
ship.name,
ship.position,
next(self.control_point_id),
starts_blue=blue,
)
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for fob in self.fobs(blue):
control_point = Fob(
str(fob.name),
fob.position,
next(self.control_point_id),
starts_blue=blue,
)
control_point.captured_invert = fob.late_activation
control_points[control_point.id] = control_point
return control_points
@property
def front_line_path_groups(self) -> Iterator[VehicleGroup]:
for group in self.country(blue=True).vehicle_group:
if group.units[0].type == self.FRONT_LINE_UNIT_TYPE:
yield group
@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. 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_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, near: Positioned, allow_naval: bool = False
) -> Tuple[ControlPoint, Distance]:
closest = self.theater.closest_control_point(near.position, allow_naval)
distance = meters(closest.position.distance_to_point(near.position))
return closest, distance
def add_preset_locations(self) -> None:
for static in self.offshore_strike_targets:
closest, distance = self.objective_info(static)
closest.preset_locations.offshore_strike_locations.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for ship in self.ships:
closest, distance = self.objective_info(ship, allow_naval=True)
closest.preset_locations.ships.append(
PointWithHeading.from_point(
ship.position, Heading.from_degrees(ship.units[0].heading)
)
)
for group in self.missile_sites:
closest, distance = self.objective_info(group)
closest.preset_locations.missile_sites.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.coastal_defenses:
closest, distance = self.objective_info(group)
closest.preset_locations.coastal_defenses.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.long_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.long_range_sams.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.medium_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.medium_range_sams.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.short_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.short_range_sams.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.aaa:
closest, distance = self.objective_info(group)
closest.preset_locations.aaa.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.ewrs:
closest, distance = self.objective_info(group)
closest.preset_locations.ewrs.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for group in self.armor_groups:
closest, distance = self.objective_info(group)
closest.preset_locations.armor_groups.append(
PointWithHeading.from_point(
group.position, Heading.from_degrees(group.units[0].heading)
)
)
for static in self.helipads:
closest, distance = self.objective_info(static)
closest.helipads.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for static in self.factories:
closest, distance = self.objective_info(static)
closest.preset_locations.factories.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for static in self.ammunition_depots:
closest, distance = self.objective_info(static)
closest.preset_locations.ammunition_depots.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for static in self.strike_targets:
closest, distance = self.objective_info(static)
closest.preset_locations.strike_locations.append(
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for scenery_group in self.scenery:
closest, distance = self.objective_info(scenery_group)
closest.preset_locations.scenery.append(scenery_group)
def populate_theater(self) -> None:
for control_point in self.control_points.values():
self.theater.add_controlpoint(control_point)
self.add_preset_locations()
self.add_supply_routes()
self.add_shipping_lanes()

View File

@@ -0,0 +1,325 @@
from __future__ import annotations
import itertools
import random
from typing import TYPE_CHECKING, Optional
from game.dcs.aircrafttype import AircraftType
from game.squadrons.operatingbases import OperatingBases
from game.squadrons.squadrondef import SquadronDef
from game.theater import ControlPoint
from gen.flights.ai_flight_planner_db import aircraft_for_task, tasks_for_aircraft
from gen.flights.flight import FlightType
if TYPE_CHECKING:
from game.coalition import Coalition
class SquadronDefGenerator:
def __init__(self, coalition: Coalition) -> None:
self.coalition = coalition
self.count = itertools.count(1)
self.used_nicknames: set[str] = set()
def generate_for_task(
self, task: FlightType, control_point: ControlPoint
) -> Optional[SquadronDef]:
aircraft_choice: Optional[AircraftType] = None
for aircraft in aircraft_for_task(task):
if aircraft not in self.coalition.faction.aircrafts:
continue
if not control_point.can_operate(aircraft):
continue
aircraft_choice = aircraft
# 50/50 chance to keep looking for an aircraft that isn't as far up the
# priority list to maintain some unit variety.
if random.choice([True, False]):
break
if aircraft_choice is None:
return None
return self.generate_for_aircraft(aircraft_choice)
def generate_for_aircraft(self, aircraft: AircraftType) -> SquadronDef:
return SquadronDef(
name=f"Squadron {next(self.count):03}",
nickname=self.random_nickname(),
country=self.coalition.country_name,
role="Flying Squadron",
aircraft=aircraft,
livery=None,
mission_types=tuple(tasks_for_aircraft(aircraft)),
operating_bases=OperatingBases.default_for_aircraft(aircraft),
pilot_pool=[],
)
@staticmethod
def _make_random_nickname() -> str:
from gen.naming import ANIMALS
animal = random.choice(ANIMALS)
adjective = random.choice(
(
None,
"Aggressive",
"Alpha",
"Ancient",
"Angelic",
"Angry",
"Apoplectic",
"Aquamarine",
"Astral",
"Avenging",
"Azure",
"Badass",
"Barbaric",
"Battle",
"Battling",
"Bellicose",
"Belligerent",
"Big",
"Bionic",
"Black",
"Bladed",
"Blazoned",
"Blood",
"Bloody",
"Blue",
"Bold",
"Boxing",
"Brash",
"Brass",
"Brave",
"Brazen",
"Brown",
"Brutal",
"Brzone",
"Burning",
"Buzzing",
"Celestial",
"Clever",
"Cloud",
"Cobalt",
"Copper",
"Coral",
"Crazy",
"Crimson",
"Crouching",
"Cursed",
"Cyan",
"Danger",
"Dangerous",
"Dapper",
"Daring",
"Dark",
"Dawn",
"Day",
"Deadly",
"Death",
"Defiant",
"Demon",
"Desert",
"Devil",
"Devil's",
"Diabolical",
"Diamond",
"Dire",
"Dirty",
"Doom",
"Doomed",
"Double",
"Drunken",
"Dusk",
"Dusty",
"Eager",
"Ebony",
"Electric",
"Emerald",
"Eternal",
"Evil",
"Faithful",
"Famous",
"Fanged",
"Fearless",
"Feisty",
"Ferocious",
"Fierce",
"Fiery",
"Fighting",
"Fire",
"First",
"Flame",
"Flaming",
"Flying",
"Forest",
"Frenzied",
"Frosty",
"Frozen",
"Furious",
"Gallant",
"Ghost",
"Giant",
"Gigantic",
"Glaring",
"Global",
"Gold",
"Golden",
"Green",
"Grey",
"Grim",
"Grizzly",
"Growling",
"Grumpy",
"Hammer",
"Hard",
"Hardy",
"Heavy",
"Hell",
"Hell's",
"Hidden",
"Homicidal",
"Hostile",
"Howling",
"Hyper",
"Ice",
"Icy",
"Immortal",
"Indignant",
"Infamous",
"Invincible",
"Iron",
"Jolly",
"Laser",
"Lava",
"Lavender",
"Lethal",
"Light",
"Lightning",
"Livid",
"Lucky",
"Mad",
"Magenta",
"Magma",
"Maroon",
"Menacing",
"Merciless",
"Metal",
"Midnight",
"Mighty",
"Mithril",
"Mocking",
"Moon",
"Mountain",
"Muddy",
"Nasty",
"Naughty",
"Night",
"Nova",
"Nutty",
"Obsidian",
"Ocean",
"Oddball",
"Old",
"Omega",
"Onyx",
"Orange",
"Perky",
"Pink",
"Power",
"Prickly",
"Proud",
"Puckered",
"Pugnacious",
"Puking",
"Purple",
"Ragged",
"Raging",
"Rainbow",
"Rampant",
"Razor",
"Ready",
"Reaper",
"Reckless",
"Red",
"Roaring",
"Rocky",
"Rolling",
"Royal",
"Rusty",
"Sable",
"Salty",
"Sand",
"Sarcastic",
"Saucy",
"Scarlet",
"Scarred",
"Scary",
"Screaming",
"Scythed",
"Shadow",
"Shiny",
"Shocking",
"Silver",
"Sky",
"Smoke",
"Smokin'",
"Snapping",
"Snappy",
"Snarling",
"Snow",
"Soaring",
"Space",
"Spiky",
"Spiny",
"Star",
"Steady",
"Steel",
"Stone",
"Storm",
"Striking",
"Strong",
"Stubborn",
"Sun",
"Super",
"Terrible",
"Thorny",
"Thunder",
"Top",
"Tough",
"Toxic",
"Tricky",
"Turquoise",
"Typhoon",
"Ultimate",
"Ultra",
"Ultramarine",
"Vengeful",
"Venom",
"Vermillion",
"Vicious",
"Victorious",
"Vigilant",
"Violent",
"Violet",
"War",
"Water",
"Whistling",
"White",
"Wicked",
"Wild",
"Wizard",
"Wrathful",
"Yellow",
"Young",
)
)
if adjective is None:
return animal.title()
return f"{adjective} {animal}".title()
def random_nickname(self) -> str:
while True:
nickname = self._make_random_nickname()
if nickname not in self.used_nicknames:
self.used_nicknames.add(nickname)
return nickname

235
game/coalition.py Normal file
View File

@@ -0,0 +1,235 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional
from dcs import Point
from faker import Faker
from game.campaignloader import CampaignAirWingConfig
from game.campaignloader.defaultsquadronassigner import DefaultSquadronAssigner
from game.commander import TheaterCommander
from game.commander.missionscheduler import MissionScheduler
from game.income import Income
from game.navmesh import NavMesh
from game.orderedset import OrderedSet
from game.profiling import logged_duration, MultiEventTracer
from game.squadrons import AirWing
from game.threatzones import ThreatZones
from game.transfers import PendingTransfers
if TYPE_CHECKING:
from game import Game
from game.data.doctrine import Doctrine
from game.factions.faction import Faction
from game.procurement import AircraftProcurementRequest, ProcurementAi
from game.theater.bullseye import Bullseye
from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
from gen.ato import AirTaskingOrder
class Coalition:
def __init__(
self, game: Game, faction: Faction, budget: float, player: bool
) -> None:
self.game = game
self.player = player
self.faction = faction
self.budget = budget
self.ato = AirTaskingOrder()
self.transit_network = TransitNetwork()
self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet()
self.bullseye = Bullseye(Point(0, 0))
self.faker = Faker(self.faction.locales)
self.air_wing = AirWing(player)
self.transfers = PendingTransfers(game, player)
# Late initialized because the two coalitions in the game are mutually
# dependent, so must be both constructed before this property can be set.
self._opponent: Optional[Coalition] = None
# Volatile properties that are not persisted to the save file since they can be
# recomputed on load. Keeping this data out of the save file makes save compat
# breaks less frequent. Each of these properties has a non-underscore-prefixed
# @property that should be used for non-Optional access.
#
# All of these are late-initialized (whether via on_load or called later), but
# will be non-None after the game has finished loading.
self._threat_zone: Optional[ThreatZones] = None
self._navmesh: Optional[NavMesh] = None
self.on_load()
@property
def doctrine(self) -> Doctrine:
return self.faction.doctrine
@property
def coalition_id(self) -> int:
if self.player:
return 2
return 1
@property
def country_name(self) -> str:
return self.faction.country
@property
def opponent(self) -> Coalition:
assert self._opponent is not None
return self._opponent
@property
def threat_zone(self) -> ThreatZones:
assert self._threat_zone is not None
return self._threat_zone
@property
def nav_mesh(self) -> NavMesh:
assert self._navmesh is not None
return self._navmesh
def __getstate__(self) -> dict[str, Any]:
state = self.__dict__.copy()
# Avoid persisting any volatile types that can be deterministically
# recomputed on load for the sake of save compatibility.
del state["_threat_zone"]
del state["_navmesh"]
del state["faker"]
return state
def __setstate__(self, state: dict[str, Any]) -> None:
self.__dict__.update(state)
# Regenerate any state that was not persisted.
self.on_load()
def on_load(self) -> None:
self.faker = Faker(self.faction.locales)
def set_opponent(self, opponent: Coalition) -> None:
if self._opponent is not None:
raise RuntimeError("Double-initialization of Coalition.opponent")
self._opponent = opponent
def configure_default_air_wing(
self, air_wing_config: CampaignAirWingConfig
) -> None:
DefaultSquadronAssigner(air_wing_config, self.game, self).assign()
def adjust_budget(self, amount: float) -> None:
self.budget += amount
def compute_threat_zones(self) -> None:
self._threat_zone = ThreatZones.for_faction(self.game, self.player)
def compute_nav_meshes(self) -> None:
self._navmesh = NavMesh.from_threat_zones(
self.opponent.threat_zone, self.game.theater
)
def update_transit_network(self) -> None:
self.transit_network = TransitNetworkBuilder(
self.game.theater, self.player
).build()
def set_bullseye(self, bullseye: Bullseye) -> None:
self.bullseye = bullseye
def end_turn(self) -> None:
"""Processes coalition-specific turn finalization.
For more information on turn finalization in general, see the documentation for
`Game.finish_turn`.
"""
self.air_wing.end_turn()
self.budget += Income(self.game, self.player).total
# Need to recompute before transfers and deliveries to account for captures.
# This happens in in initialize_turn as well, because cheating doesn't advance a
# turn but can capture bases so we need to recompute there as well.
self.update_transit_network()
# Must happen *before* unit deliveries are handled, or else new units will spawn
# one hop ahead. ControlPoint.process_turn handles unit deliveries. The
# coalition-specific turn-end happens before the theater-wide turn-end, so this
# is handled correctly.
self.transfers.perform_transfers()
def preinit_turn_0(self) -> None:
"""Runs final Coalition initialization.
Final initialization occurs before Game.initialize_turn runs for turn 0.
"""
self.air_wing.populate_for_turn_0()
def initialize_turn(self) -> None:
"""Processes coalition-specific turn initialization.
For more information on turn initialization in general, see the documentation
for `Game.initialize_turn`.
"""
# Needs to happen *before* planning transfers so we don't cancel them.
self.ato.clear()
self.air_wing.reset()
self.refund_outstanding_orders()
self.procurement_requests.clear()
with logged_duration("Transit network identification"):
self.update_transit_network()
with logged_duration("Procurement of airlift assets"):
self.transfers.order_airlift_assets()
with logged_duration("Transport planning"):
self.transfers.plan_transports()
self.plan_missions()
self.plan_procurement()
def refund_outstanding_orders(self) -> None:
# TODO: Split orders between air and ground units.
# This isn't quite right. If the player has ground purchases automated we should
# be refunding the ground units, and if they have air automated but not ground
# we should be refunding air units.
if self.player and not self.game.settings.automate_aircraft_reinforcements:
return
for cp in self.game.theater.control_points_for(self.player):
cp.ground_unit_orders.refund_all(self)
for squadron in self.air_wing.iter_squadrons():
squadron.refund_orders()
def plan_missions(self) -> None:
color = "Blue" if self.player else "Red"
with MultiEventTracer() as tracer:
with tracer.trace(f"{color} mission planning"):
with tracer.trace(f"{color} mission identification"):
TheaterCommander(self.game, self.player).plan_missions(tracer)
with tracer.trace(f"{color} mission scheduling"):
MissionScheduler(
self, self.game.settings.desired_player_mission_duration
).schedule_missions()
def plan_procurement(self) -> None:
# The first turn needs to buy a *lot* of aircraft to fill CAPs, so it gets much
# more of the budget that turn. Otherwise budget (after repairs) is split evenly
# between air and ground. For the default starting budget of 2000 this gives 600
# to ground forces and 1400 to aircraft. After that the budget will be spent
# proportionally based on how much is already invested.
if self.player:
manage_runways = self.game.settings.automate_runway_repair
manage_front_line = self.game.settings.automate_front_line_reinforcements
manage_aircraft = self.game.settings.automate_aircraft_reinforcements
else:
manage_runways = True
manage_front_line = True
manage_aircraft = True
self.budget = ProcurementAi(
self.game,
self.player,
self.faction,
manage_runways,
manage_front_line,
manage_aircraft,
).spend_budget(self.budget)
def add_procurement_request(self, request: AircraftProcurementRequest) -> None:
self.procurement_requests.add(request)

View File

@@ -0,0 +1 @@
from .theatercommander import TheaterCommander

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from collections import Iterator
from dataclasses import dataclass
from game.theater import ControlPoint
from game.theater.theatergroundobject import VehicleGroupGroundObject
from game.utils import meters
@dataclass
class Garrisons:
blocking_capture: list[VehicleGroupGroundObject]
defending_front_line: list[VehicleGroupGroundObject]
@property
def in_priority_order(self) -> Iterator[VehicleGroupGroundObject]:
yield from self.blocking_capture
yield from self.defending_front_line
def eliminate(self, garrison: VehicleGroupGroundObject) -> None:
if garrison in self.blocking_capture:
self.blocking_capture.remove(garrison)
if garrison in self.defending_front_line:
self.defending_front_line.remove(garrison)
def __contains__(self, item: VehicleGroupGroundObject) -> bool:
return item in self.in_priority_order
@classmethod
def for_control_point(cls, control_point: ControlPoint) -> Garrisons:
"""Categorize garrison groups based on target priority.
Any garrisons blocking base capture are the highest priority.
"""
blocking = []
defending = []
garrisons = [
tgo
for tgo in control_point.ground_objects
if isinstance(tgo, VehicleGroupGroundObject) and not tgo.is_dead
]
for garrison in garrisons:
if (
meters(garrison.distance_to(control_point))
< ControlPoint.CAPTURE_DISTANCE
):
blocking.append(garrison)
else:
defending.append(garrison)
return Garrisons(blocking, defending)

View File

@@ -0,0 +1,58 @@
from dataclasses import field, dataclass
from enum import Enum, auto
from typing import Optional
from game.theater import MissionTarget
from gen.flights.flight import FlightType
class EscortType(Enum):
AirToAir = auto()
Sead = auto()
@dataclass(frozen=True)
class ProposedFlight:
"""A flight outline proposed by the mission planner.
Proposed flights haven't been assigned specific aircraft yet. They have only
a task, a required number of aircraft, and a maximum distance allowed
between the objective and the departure airfield.
"""
#: The flight's role.
task: FlightType
#: The number of aircraft required.
num_aircraft: int
#: The type of threat this flight defends against if it is an escort. Escort
#: flights will be pruned if the rest of the package is not threatened by
#: the threat they defend against. If this flight is not an escort, this
#: field is None.
escort_type: Optional[EscortType] = field(default=None)
def __str__(self) -> str:
return f"{self.task} {self.num_aircraft} ship"
@dataclass(frozen=True)
class ProposedMission:
"""A mission outline proposed by the mission planner.
Proposed missions haven't been assigned aircraft yet. They have only an
objective location and a list of proposed flights that are required for the
mission.
"""
#: The mission objective.
location: MissionTarget
#: The proposed flights that are required for the mission.
flights: list[ProposedFlight]
asap: bool = field(default=False)
def __str__(self) -> str:
flights = ", ".join([str(f) for f in self.flights])
return f"{self.location.name}: {flights}"

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
import logging
import random
from collections import defaultdict
from datetime import timedelta
from typing import Iterator, Dict, TYPE_CHECKING
from game.theater import MissionTarget
from gen.flights.flight import FlightType
from gen.flights.traveltime import TotEstimator
if TYPE_CHECKING:
from game.coalition import Coalition
class MissionScheduler:
def __init__(self, coalition: Coalition, desired_mission_length: timedelta) -> None:
self.coalition = coalition
self.desired_mission_length = desired_mission_length
def schedule_missions(self) -> None:
"""Identifies and plans mission for the turn."""
def start_time_generator(
count: int, earliest: int, latest: int, margin: int
) -> Iterator[timedelta]:
interval = (latest - earliest) // count
for time in range(earliest, latest, interval):
error = random.randint(-margin, margin)
yield timedelta(seconds=max(0, time + error))
dca_types = {
FlightType.BARCAP,
FlightType.TARCAP,
}
previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta)
non_dca_packages = [
p for p in self.coalition.ato.packages if p.primary_task not in dca_types
]
start_time = start_time_generator(
count=len(non_dca_packages),
earliest=5 * 60,
latest=int(self.desired_mission_length.total_seconds()),
margin=5 * 60,
)
for package in self.coalition.ato.packages:
tot = TotEstimator(package).earliest_tot()
if package.primary_task in dca_types:
previous_end_time = previous_cap_end_time[package.target]
if tot > previous_end_time:
# Can't get there exactly on time, so get there ASAP. This
# will typically only happen for the first CAP at each
# target.
package.time_over_target = tot
else:
package.time_over_target = previous_end_time
departure_time = package.mission_departure_time
# Should be impossible for CAPs
if departure_time is None:
logging.error(f"Could not determine mission end time for {package}")
continue
previous_cap_end_time[package.target] = departure_time
elif package.auto_asap:
package.set_tot_asap()
else:
# But other packages should be spread out a bit. Note that take
# times are delayed, but all aircraft will become active at
# mission start. This makes it more worthwhile to attack enemy
# airfields to hit grounded aircraft, since they're more likely
# to be present. Runway and air started aircraft will be
# delayed until their takeoff time by AirConflictGenerator.
package.time_over_target = next(start_time) + tot

View File

@@ -0,0 +1,246 @@
from __future__ import annotations
import math
import operator
from collections import Iterator, Iterable
from typing import TypeVar, TYPE_CHECKING
from game.theater import (
ControlPoint,
OffMapSpawn,
MissionTarget,
Fob,
FrontLine,
Airfield,
)
from game.theater.theatergroundobject import (
BuildingGroundObject,
IadsGroundObject,
NavalGroundObject,
)
from game.utils import meters, nautical_miles
from gen.flights.closestairfields import ObjectiveDistanceCache, ClosestAirfields
if TYPE_CHECKING:
from game import Game
from game.transfers import CargoShip, Convoy
MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget)
class ObjectiveFinder:
"""Identifies potential objectives for the mission planner."""
# TODO: Merge into doctrine.
AIRFIELD_THREAT_RANGE = nautical_miles(150)
SAM_THREAT_RANGE = nautical_miles(100)
def __init__(self, game: Game, is_player: bool) -> None:
self.game = game
self.is_player = is_player
def enemy_air_defenses(self) -> Iterator[IadsGroundObject]:
"""Iterates over all enemy SAM sites."""
for cp in self.enemy_control_points():
for ground_object in cp.ground_objects:
if ground_object.is_dead:
continue
if isinstance(ground_object, IadsGroundObject):
yield ground_object
def enemy_ships(self) -> Iterator[NavalGroundObject]:
for cp in self.enemy_control_points():
for ground_object in cp.ground_objects:
if not isinstance(ground_object, NavalGroundObject):
continue
if ground_object.is_dead:
continue
yield ground_object
def threatening_ships(self) -> Iterator[NavalGroundObject]:
"""Iterates over enemy ships near friendly control points.
Groups are sorted by their closest proximity to any friendly control
point (airfield or fleet).
"""
return self._targets_by_range(self.enemy_ships())
def _targets_by_range(
self, targets: Iterable[MissionTargetType]
) -> Iterator[MissionTargetType]:
target_ranges: list[tuple[MissionTargetType, float]] = []
for target in targets:
ranges: list[float] = []
for cp in self.friendly_control_points():
ranges.append(target.distance_to(cp))
target_ranges.append((target, min(ranges)))
target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
for target, _range in target_ranges:
yield target
def strike_targets(self) -> Iterator[BuildingGroundObject]:
"""Iterates over enemy strike targets.
Targets are sorted by their closest proximity to any friendly control
point (airfield or fleet).
"""
targets: list[tuple[BuildingGroundObject, float]] = []
# Building objectives are made of several individual TGOs (one per
# building).
found_targets: set[str] = set()
for enemy_cp in self.enemy_control_points():
for ground_object in enemy_cp.ground_objects:
# TODO: Reuse ground_object.mission_types.
# The mission types for ground objects are currently not
# accurate because we include things like strike and BAI for all
# targets since they have different planning behavior (waypoint
# generation is better for players with strike when the targets
# are stationary, AI behavior against weaker air defenses is
# better with BAI), so that's not a useful filter. Once we have
# better control over planning profiles and target dependent
# loadouts we can clean this up.
if not isinstance(ground_object, BuildingGroundObject):
# Other group types (like ships, SAMs, garrisons, etc) have better
# suited mission types like anti-ship, DEAD, and BAI.
continue
if isinstance(enemy_cp, Fob) and ground_object.is_control_point:
# This is the FOB structure itself. Can't be repaired or
# targeted by the player, so shouldn't be targetable by the
# AI.
continue
if ground_object.is_dead:
continue
if ground_object.name in found_targets:
continue
ranges: list[float] = []
for friendly_cp in self.friendly_control_points():
ranges.append(ground_object.distance_to(friendly_cp))
targets.append((ground_object, min(ranges)))
found_targets.add(ground_object.name)
targets = sorted(targets, key=operator.itemgetter(1))
for target, _range in targets:
yield target
def front_lines(self) -> Iterator[FrontLine]:
"""Iterates over all active front lines in the theater."""
yield from self.game.theater.conflicts()
def vulnerable_control_points(self) -> Iterator[ControlPoint]:
"""Iterates over friendly CPs that are vulnerable to enemy CPs.
Vulnerability is defined as any enemy CP within threat range of of the
CP.
"""
for cp in self.friendly_control_points():
if isinstance(cp, OffMapSpawn):
# Off-map spawn locations don't need protection.
continue
airfields_in_proximity = self.closest_airfields_to(cp)
airfields_in_threat_range = (
airfields_in_proximity.operational_airfields_within(
self.AIRFIELD_THREAT_RANGE
)
)
for airfield in airfields_in_threat_range:
if not airfield.is_friendly(self.is_player):
yield cp
break
def oca_targets(self, min_aircraft: int) -> Iterator[ControlPoint]:
airfields = []
for control_point in self.enemy_control_points():
if not isinstance(control_point, Airfield):
continue
if control_point.allocated_aircraft().total_present >= min_aircraft:
airfields.append(control_point)
return self._targets_by_range(airfields)
def convoys(self) -> Iterator[Convoy]:
for front_line in self.front_lines():
yield from self.game.coalition_for(
self.is_player
).transfers.convoys.travelling_to(
front_line.control_point_hostile_to(self.is_player)
)
def cargo_ships(self) -> Iterator[CargoShip]:
for front_line in self.front_lines():
yield from self.game.coalition_for(
self.is_player
).transfers.cargo_ships.travelling_to(
front_line.control_point_hostile_to(self.is_player)
)
def friendly_control_points(self) -> Iterator[ControlPoint]:
"""Iterates over all friendly control points."""
return (
c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player)
)
def farthest_friendly_control_point(self) -> ControlPoint:
"""Finds the friendly control point that is farthest from any threats."""
threat_zones = self.game.threat_zone_for(not self.is_player)
farthest = None
max_distance = meters(0)
for cp in self.friendly_control_points():
if isinstance(cp, OffMapSpawn):
continue
distance = threat_zones.distance_to_threat(cp.position)
if distance > max_distance:
farthest = cp
max_distance = distance
if farthest is None:
raise RuntimeError("Found no friendly control points. You probably lost.")
return farthest
def closest_friendly_control_point(self) -> ControlPoint:
"""Finds the friendly control point that is closest to any threats."""
threat_zones = self.game.threat_zone_for(not self.is_player)
closest = None
min_distance = meters(math.inf)
for cp in self.friendly_control_points():
if isinstance(cp, OffMapSpawn):
continue
distance = threat_zones.distance_to_threat(cp.position)
if distance < min_distance:
closest = cp
min_distance = distance
if closest is None:
raise RuntimeError("Found no friendly control points. You probably lost.")
return closest
def enemy_control_points(self) -> Iterator[ControlPoint]:
"""Iterates over all enemy control points."""
return (
c
for c in self.game.theater.controlpoints
if not c.is_friendly(self.is_player)
)
def prioritized_unisolated_points(self) -> list[ControlPoint]:
prioritized = []
capturable_later = []
for cp in self.game.theater.control_points_for(not self.is_player):
if cp.is_isolated:
continue
if cp.has_active_frontline:
prioritized.append(cp)
else:
capturable_later.append(cp)
prioritized.extend(self._targets_by_range(capturable_later))
return prioritized
@staticmethod
def closest_airfields_to(location: MissionTarget) -> ClosestAirfields:
"""Returns the closest airfields to the given location."""
return ObjectiveDistanceCache.get_closest_airfields(location)

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
from game.utils import nautical_miles
from gen.ato import Package
from game.theater import MissionTarget, OffMapSpawn, ControlPoint
from gen.flights.flight import Flight
if TYPE_CHECKING:
from game.dcs.aircrafttype import AircraftType
from game.squadrons.airwing import AirWing
from gen.flights.closestairfields import ClosestAirfields
from .missionproposals import ProposedFlight
class PackageBuilder:
"""Builds a Package for the flights it receives."""
def __init__(
self,
location: MissionTarget,
closest_airfields: ClosestAirfields,
air_wing: AirWing,
is_player: bool,
package_country: str,
start_type: str,
asap: bool,
) -> None:
self.closest_airfields = closest_airfields
self.is_player = is_player
self.package_country = package_country
self.package = Package(location, auto_asap=asap)
self.air_wing = air_wing
self.start_type = start_type
def plan_flight(self, plan: ProposedFlight) -> bool:
"""Allocates aircraft for the given flight and adds them to the package.
If no suitable aircraft are available, False is returned. If the failed
flight was critical and the rest of the mission will be scrubbed, the
caller should return any previously planned flights to the inventory
using release_planned_aircraft.
"""
squadron = self.air_wing.best_squadron_for(
self.package.target, plan.task, plan.num_aircraft, this_turn=True
)
if squadron is None:
return False
start_type = squadron.location.required_aircraft_start_type
if start_type is None:
start_type = self.start_type
flight = Flight(
self.package,
self.package_country,
squadron,
plan.num_aircraft,
plan.task,
start_type,
divert=self.find_divert_field(squadron.aircraft, squadron.location),
)
self.package.add_flight(flight)
return True
def find_divert_field(
self, aircraft: AircraftType, arrival: ControlPoint
) -> Optional[ControlPoint]:
divert_limit = nautical_miles(150)
for airfield in self.closest_airfields.operational_airfields_within(
divert_limit
):
if airfield.captured != self.is_player:
continue
if airfield == arrival:
continue
if not airfield.can_operate(aircraft):
continue
if isinstance(airfield, OffMapSpawn):
continue
return airfield
return None
def build(self) -> Package:
"""Returns the built package."""
return self.package
def release_planned_aircraft(self) -> None:
"""Returns any planned flights to the inventory."""
flights = list(self.package.flights)
for flight in flights:
flight.return_pilots_and_aircraft()
self.package.remove_flight(flight)

View File

@@ -0,0 +1,221 @@
from __future__ import annotations
import logging
from collections import defaultdict
from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional
from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType
from game.commander.packagebuilder import PackageBuilder
from game.data.doctrine import Doctrine
from game.procurement import AircraftProcurementRequest
from game.profiling import MultiEventTracer
from game.settings import Settings
from game.squadrons import AirWing
from game.theater import ConflictTheater
from game.threatzones import ThreatZones
from gen.ato import AirTaskingOrder, Package
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
from gen.flights.flightplan import FlightPlanBuilder
if TYPE_CHECKING:
from game.coalition import Coalition
class PackageFulfiller:
"""Responsible for package aircraft allocation and flight plan layout."""
def __init__(
self, coalition: Coalition, theater: ConflictTheater, settings: Settings
) -> None:
self.coalition = coalition
self.theater = theater
self.player_missions_asap = settings.auto_ato_player_missions_asap
self.default_start_type = settings.default_start_type
@property
def is_player(self) -> bool:
return self.coalition.player
@property
def ato(self) -> AirTaskingOrder:
return self.coalition.ato
@property
def air_wing(self) -> AirWing:
return self.coalition.air_wing
@property
def doctrine(self) -> Doctrine:
return self.coalition.doctrine
@property
def threat_zones(self) -> ThreatZones:
return self.coalition.opponent.threat_zone
def add_procurement_request(self, request: AircraftProcurementRequest) -> None:
self.coalition.add_procurement_request(request)
def air_wing_can_plan(self, mission_type: FlightType) -> bool:
"""Returns True if it is possible for the air wing to plan this mission type.
Not all mission types can be fulfilled by all air wings. Many factions do not
have AEW&C aircraft, so they will never be able to plan those missions. It's
also possible for the player to exclude mission types from their squadron
designs.
"""
return self.air_wing.can_auto_plan(mission_type)
def plan_flight(
self,
mission: ProposedMission,
flight: ProposedFlight,
builder: PackageBuilder,
missing_types: Set[FlightType],
purchase_multiplier: int,
) -> None:
if not builder.plan_flight(flight):
missing_types.add(flight.task)
purchase_order = AircraftProcurementRequest(
near=mission.location,
task_capability=flight.task,
number=flight.num_aircraft * purchase_multiplier,
)
# Reserves are planned for critical missions, so prioritize those orders
# over aircraft needed for non-critical missions.
self.add_procurement_request(purchase_order)
def scrub_mission_missing_aircraft(
self,
mission: ProposedMission,
builder: PackageBuilder,
missing_types: Set[FlightType],
not_attempted: Iterable[ProposedFlight],
purchase_multiplier: int,
) -> None:
# Try to plan the rest of the mission just so we can count the missing
# types to buy.
for flight in not_attempted:
self.plan_flight(
mission, flight, builder, missing_types, purchase_multiplier
)
missing_types_str = ", ".join(sorted([t.name for t in missing_types]))
builder.release_planned_aircraft()
color = "Blue" if self.is_player else "Red"
logging.debug(
f"{color}: not enough aircraft in range for {mission.location.name} "
f"capable of: {missing_types_str}"
)
def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]:
threats = defaultdict(bool)
for flight in builder.package.flights:
if self.threat_zones.waypoints_threatened_by_aircraft(
flight.flight_plan.escorted_waypoints()
):
threats[EscortType.AirToAir] = True
if self.threat_zones.waypoints_threatened_by_radar_sam(
list(flight.flight_plan.escorted_waypoints())
):
threats[EscortType.Sead] = True
return threats
def plan_mission(
self,
mission: ProposedMission,
purchase_multiplier: int,
tracer: MultiEventTracer,
) -> Optional[Package]:
"""Allocates aircraft for a proposed mission and adds it to the ATO."""
builder = PackageBuilder(
mission.location,
ObjectiveDistanceCache.get_closest_airfields(mission.location),
self.air_wing,
self.is_player,
self.coalition.country_name,
self.default_start_type,
mission.asap,
)
# Attempt to plan all the main elements of the mission first. Escorts
# will be planned separately so we can prune escorts for packages that
# are not expected to encounter that type of threat.
missing_types: Set[FlightType] = set()
escorts = []
for proposed_flight in mission.flights:
if not self.air_wing_can_plan(proposed_flight.task):
# This air wing can never plan this mission type because they do not
# have compatible aircraft or squadrons. Skip fulfillment so that we
# don't place the purchase request.
continue
if proposed_flight.escort_type is not None:
# Escorts are planned after the primary elements of the package.
# If the package does not need escorts they may be pruned.
escorts.append(proposed_flight)
continue
with tracer.trace("Flight planning"):
self.plan_flight(
mission,
proposed_flight,
builder,
missing_types,
purchase_multiplier,
)
if missing_types:
self.scrub_mission_missing_aircraft(
mission, builder, missing_types, escorts, purchase_multiplier
)
return None
if not builder.package.flights:
# The non-escort part of this mission is unplannable by this faction. Scrub
# the mission and do not attempt planning escorts because there's no reason
# to buy them because this mission will never be planned.
return None
# Create flight plans for the main flights of the package so we can
# determine threats. This is done *after* creating all of the flights
# rather than as each flight is added because the flight plan for
# flights that will rendezvous with their package will be affected by
# the other flights in the package. Escorts will not be able to
# contribute to this.
flight_plan_builder = FlightPlanBuilder(
builder.package, self.coalition, self.theater
)
for flight in builder.package.flights:
with tracer.trace("Flight plan population"):
flight_plan_builder.populate_flight_plan(flight)
needed_escorts = self.check_needed_escorts(builder)
for escort in escorts:
# This list was generated from the not None set, so this should be
# impossible.
assert escort.escort_type is not None
if needed_escorts[escort.escort_type]:
with tracer.trace("Flight planning"):
self.plan_flight(
mission, escort, builder, missing_types, purchase_multiplier
)
# Check again for unavailable aircraft. If the escort was required and
# none were found, scrub the mission.
if missing_types:
self.scrub_mission_missing_aircraft(
mission, builder, missing_types, escorts, purchase_multiplier
)
return None
package = builder.build()
# Add flight plans for escorts.
for flight in package.flights:
if not flight.flight_plan.waypoints:
with tracer.trace("Flight plan population"):
flight_plan_builder.populate_flight_plan(flight)
if package.has_players and self.player_missions_asap:
package.auto_asap = True
package.set_tot_asap()
return package

View File

@@ -0,0 +1,11 @@
from collections import Iterator
from game.commander.tasks.primitive.aewc import PlanAewc
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class PlanAewcSupport(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for target in state.aewc_targets:
yield [PlanAewc(target)]

View File

@@ -0,0 +1,15 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.primitive.oca import PlanOcaStrike
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
@dataclass(frozen=True)
class AttackAirInfrastructure(CompoundTask[TheaterState]):
aircraft_cold_start: bool
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for garrison in state.oca_targets:
yield [PlanOcaStrike(garrison, self.aircraft_cold_start)]

View File

@@ -0,0 +1,15 @@
from collections import Iterator
from game.commander.tasks.primitive.strike import PlanStrike
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class AttackBuildings(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for building in state.strike_targets:
# Ammo depots are targeted based on the needs of the front line by
# ReduceEnemyFrontLineCapacity. No reason to target them before that front
# line is active.
if not building.is_ammo_depot:
yield [PlanStrike(building)]

View File

@@ -0,0 +1,12 @@
from collections import Iterator
from game.commander.tasks.primitive.bai import PlanBai
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class AttackGarrisons(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for garrisons in state.enemy_garrisons.values():
for garrison in garrisons.in_priority_order:
yield [PlanBai(garrison)]

View File

@@ -0,0 +1,51 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.compound.destroyenemygroundunits import (
DestroyEnemyGroundUnits,
)
from game.commander.tasks.compound.reduceenemyfrontlinecapacity import (
ReduceEnemyFrontLineCapacity,
)
from game.commander.tasks.primitive.breakthroughattack import BreakthroughAttack
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
from game.theater import FrontLine, ControlPoint
@dataclass(frozen=True)
class CaptureBase(CompoundTask[TheaterState]):
front_line: FrontLine
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
yield [BreakthroughAttack(self.front_line, state.context.coalition.player)]
yield [DestroyEnemyGroundUnits(self.front_line)]
if self.worth_destroying_ammo_depots(state):
yield [ReduceEnemyFrontLineCapacity(self.enemy_cp(state))]
def enemy_cp(self, state: TheaterState) -> ControlPoint:
return self.front_line.control_point_hostile_to(state.context.coalition.player)
def units_deployable(self, state: TheaterState, player: bool) -> int:
cp = self.front_line.control_point_friendly_to(player)
ammo_depots = list(state.ammo_dumps_at(cp))
return cp.deployable_front_line_units_with(len(ammo_depots))
def unit_cap(self, state: TheaterState, player: bool) -> int:
cp = self.front_line.control_point_friendly_to(player)
ammo_depots = list(state.ammo_dumps_at(cp))
return cp.front_line_capacity_with(len(ammo_depots))
def enemy_has_ammo_dumps(self, state: TheaterState) -> bool:
return bool(state.ammo_dumps_at(self.enemy_cp(state)))
def worth_destroying_ammo_depots(self, state: TheaterState) -> bool:
if not self.enemy_has_ammo_dumps(state):
return False
friendly_cap = self.unit_cap(state, state.context.coalition.player)
enemy_deployable = self.units_deployable(state, state.context.coalition.player)
# If the enemy can currently deploy 50% more units than we possibly could, it's
# worth killing an ammo depot.
return enemy_deployable / friendly_cap > 1.5

View File

@@ -0,0 +1,13 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.compound.capturebase import CaptureBase
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
@dataclass(frozen=True)
class CaptureBases(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for front in state.active_front_lines:
yield [CaptureBase(front)]

View File

@@ -0,0 +1,19 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.primitive.cas import PlanCas
from game.commander.tasks.primitive.defensivestance import DefensiveStance
from game.commander.tasks.primitive.retreatstance import RetreatStance
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
from game.theater import FrontLine
@dataclass(frozen=True)
class DefendBase(CompoundTask[TheaterState]):
front_line: FrontLine
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
yield [DefensiveStance(self.front_line, state.context.coalition.player)]
yield [RetreatStance(self.front_line, state.context.coalition.player)]
yield [PlanCas(self.front_line)]

View File

@@ -0,0 +1,13 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.compound.defendbase import DefendBase
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
@dataclass(frozen=True)
class DefendBases(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for front in state.active_front_lines:
yield [DefendBase(front)]

View File

@@ -0,0 +1,24 @@
from collections import Iterator
from typing import Union
from game.commander.tasks.primitive.antiship import PlanAntiShip
from game.commander.tasks.primitive.dead import PlanDead
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
class DegradeIads(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for air_defense in state.threatening_air_defenses:
yield [self.plan_against(air_defense)]
for detector in state.detecting_air_defenses:
yield [self.plan_against(detector)]
@staticmethod
def plan_against(
target: Union[IadsGroundObject, NavalGroundObject]
) -> Union[PlanDead, PlanAntiShip]:
if isinstance(target, IadsGroundObject):
return PlanDead(target)
return PlanAntiShip(target)

View File

@@ -0,0 +1,19 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.primitive.aggressiveattack import AggressiveAttack
from game.commander.tasks.primitive.cas import PlanCas
from game.commander.tasks.primitive.eliminationattack import EliminationAttack
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
from game.theater import FrontLine
@dataclass(frozen=True)
class DestroyEnemyGroundUnits(CompoundTask[TheaterState]):
front_line: FrontLine
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
yield [EliminationAttack(self.front_line, state.context.coalition.player)]
yield [AggressiveAttack(self.front_line, state.context.coalition.player)]
yield [PlanCas(self.front_line)]

View File

@@ -0,0 +1,11 @@
from collections import Iterator
from game.commander.tasks.primitive.cas import PlanCas
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class FrontLineDefense(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for front_line in state.vulnerable_front_lines:
yield [PlanCas(front_line)]

View File

@@ -0,0 +1,27 @@
from collections import Iterator
from game.commander.tasks.primitive.antishipping import PlanAntiShipping
from game.commander.tasks.primitive.convoyinterdiction import PlanConvoyInterdiction
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class InterdictReinforcements(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
# These will only rarely get planned. When a convoy is travelling multiple legs,
# they're targetable after the first leg. The reason for this is that
# procurement happens *after* mission planning so that the missions that could
# not be filled will guide the procurement process. Procurement is the stage
# that convoys are created (because they're created to move ground units that
# were just purchased), so we haven't created any yet. Any incomplete transfers
# from the previous turn (multi-leg journeys) will still be present though so
# they can be targeted.
#
# Even after this is fixed, the player's convoys that were created through the
# UI will never be targeted on the first turn of their journey because the AI
# stops planning after the start of the turn. We could potentially fix this by
# moving opfor mission planning until the takeoff button is pushed.
for convoy in state.enemy_convoys:
yield [PlanConvoyInterdiction(convoy)]
for ship in state.enemy_shipping:
yield [PlanAntiShipping(ship)]

View File

@@ -0,0 +1,34 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.compound.attackairinfrastructure import (
AttackAirInfrastructure,
)
from game.commander.tasks.compound.attackbuildings import AttackBuildings
from game.commander.tasks.compound.attackgarrisons import AttackGarrisons
from game.commander.tasks.compound.capturebases import CaptureBases
from game.commander.tasks.compound.defendbases import DefendBases
from game.commander.tasks.compound.degradeiads import DegradeIads
from game.commander.tasks.compound.interdictreinforcements import (
InterdictReinforcements,
)
from game.commander.tasks.compound.protectairspace import ProtectAirSpace
from game.commander.tasks.compound.theatersupport import TheaterSupport
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
@dataclass(frozen=True)
class PlanNextAction(CompoundTask[TheaterState]):
aircraft_cold_start: bool
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
yield [TheaterSupport()]
yield [ProtectAirSpace()]
yield [CaptureBases()]
yield [DefendBases()]
yield [InterdictReinforcements()]
yield [AttackGarrisons()]
yield [AttackAirInfrastructure(self.aircraft_cold_start)]
yield [AttackBuildings()]
yield [DegradeIads()]

View File

@@ -0,0 +1,12 @@
from collections import Iterator
from game.commander.tasks.primitive.barcap import PlanBarcap
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class ProtectAirSpace(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for cp, needed in state.barcaps_needed.items():
if needed > 0:
yield [PlanBarcap(cp, needed)]

View File

@@ -0,0 +1,16 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.primitive.strike import PlanStrike
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
from game.theater import ControlPoint
@dataclass(frozen=True)
class ReduceEnemyFrontLineCapacity(CompoundTask[TheaterState]):
control_point: ControlPoint
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for ammo_dump in state.ammo_dumps_at(self.control_point):
yield [PlanStrike(ammo_dump)]

View File

@@ -0,0 +1,11 @@
from collections import Iterator
from game.commander.tasks.primitive.refueling import PlanRefueling
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
class PlanRefuelingSupport(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
for target in state.refueling_targets:
yield [PlanRefueling(target)]

View File

@@ -0,0 +1,14 @@
from collections import Iterator
from dataclasses import dataclass
from game.commander.tasks.compound.aewcsupport import PlanAewcSupport
from game.commander.tasks.compound.refuelingsupport import PlanRefuelingSupport
from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method
@dataclass(frozen=True)
class TheaterSupport(CompoundTask[TheaterState]):
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
yield [PlanAewcSupport()]
yield [PlanRefuelingSupport()]

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
import math
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
from game.commander.theaterstate import TheaterState
from game.theater import FrontLine
from gen.ground_forces.combat_stance import CombatStance
if TYPE_CHECKING:
from game.coalition import Coalition
class FrontLineStanceTask(TheaterCommanderTask, ABC):
def __init__(self, front_line: FrontLine, player: bool) -> None:
self.front_line = front_line
self.friendly_cp = self.front_line.control_point_friendly_to(player)
self.enemy_cp = self.front_line.control_point_hostile_to(player)
@property
@abstractmethod
def stance(self) -> CombatStance:
...
@staticmethod
def management_allowed(state: TheaterState) -> bool:
return (
not state.context.coalition.player
or state.context.settings.automate_front_line_stance
)
def better_stance_already_set(self, state: TheaterState) -> bool:
current_stance = state.front_line_stances[self.front_line]
if current_stance is None:
return False
preference = (
CombatStance.RETREAT,
CombatStance.DEFENSIVE,
CombatStance.AMBUSH,
CombatStance.AGGRESSIVE,
CombatStance.ELIMINATION,
CombatStance.BREAKTHROUGH,
)
current_rating = preference.index(current_stance)
new_rating = preference.index(self.stance)
return current_rating >= new_rating
@property
@abstractmethod
def have_sufficient_front_line_advantage(self) -> bool:
...
@property
def ground_force_balance(self) -> float:
# TODO: Planned CAS missions should reduce the expected opposing force size.
friendly_forces = self.friendly_cp.deployable_front_line_units
enemy_forces = self.enemy_cp.deployable_front_line_units
if enemy_forces == 0:
return math.inf
return friendly_forces / enemy_forces
def preconditions_met(self, state: TheaterState) -> bool:
if not self.management_allowed(state):
return False
if self.better_stance_already_set(state):
return False
if self.friendly_cp.deployable_front_line_units == 0:
return False
return self.have_sufficient_front_line_advantage
def apply_effects(self, state: TheaterState) -> None:
state.front_line_stances[self.front_line] = self.stance
def execute(self, coalition: Coalition) -> None:
self.friendly_cp.stances[self.enemy_cp.id] = self.stance

View File

@@ -0,0 +1,174 @@
from __future__ import annotations
import itertools
import operator
from abc import abstractmethod
from dataclasses import dataclass, field
from enum import unique, IntEnum, auto
from typing import TYPE_CHECKING, Optional, Generic, TypeVar, Iterator, Union
from game.commander.missionproposals import ProposedFlight, EscortType, ProposedMission
from game.commander.packagefulfiller import PackageFulfiller
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
from game.commander.theaterstate import TheaterState
from game.settings import AutoAtoBehavior
from game.theater import MissionTarget
from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
from game.utils import Distance, meters
from gen.ato import Package
from gen.flights.flight import FlightType
if TYPE_CHECKING:
from game.coalition import Coalition
MissionTargetT = TypeVar("MissionTargetT", bound=MissionTarget)
@unique
class RangeType(IntEnum):
Detection = auto()
Threat = auto()
# TODO: Refactor so that we don't need to call up to the mission planner.
# Bypass type checker due to https://github.com/python/mypy/issues/5374
@dataclass # type: ignore
class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
target: MissionTargetT
flights: list[ProposedFlight] = field(init=False)
package: Optional[Package] = field(init=False, default=None)
def __post_init__(self) -> None:
self.flights = []
self.package = Package(self.target)
def preconditions_met(self, state: TheaterState) -> bool:
if (
state.context.coalition.player
and state.context.settings.auto_ato_behavior is AutoAtoBehavior.Disabled
):
return False
return self.fulfill_mission(state)
def execute(self, coalition: Coalition) -> None:
if self.package is None:
raise RuntimeError("Attempted to execute failed package planning task")
coalition.ato.add_package(self.package)
@abstractmethod
def propose_flights(self) -> None:
...
def propose_flight(
self,
task: FlightType,
num_aircraft: int,
escort_type: Optional[EscortType] = None,
) -> None:
self.flights.append(ProposedFlight(task, num_aircraft, escort_type))
@property
def asap(self) -> bool:
return False
@property
def purchase_multiplier(self) -> int:
"""The multiplier for aircraft quantity when missions could not be fulfilled.
For missions that do not schedule in rounds like BARCAPs do, this should be one
to ensure that the we only purchase enough aircraft to plan the mission once.
For missions that repeat within the same turn, however, we may need to buy for
the same mission more than once. If three rounds of BARCAP still need to be
fulfilled, this would return 3, and we'd triplicate the purchase order.
There is a small misbehavior here that's not symptomatic for our current mission
planning: multi-round, multi-flight packages will only purchase multiple sets of
aircraft for whatever is unavailable for the *first* failed package. For
example, if we extend this to CAS and have no CAS aircraft but enough TARCAP
aircraft for one round, we'll order CAS for every round but will not order any
TARCAP aircraft, since we can't know that TARCAP aircraft are needed until we
attempt to plan the second mission *without returning the first round aircraft*.
"""
return 1
def fulfill_mission(self, state: TheaterState) -> bool:
self.propose_flights()
fulfiller = PackageFulfiller(
state.context.coalition,
state.context.theater,
state.context.settings,
)
self.package = fulfiller.plan_mission(
ProposedMission(self.target, self.flights),
self.purchase_multiplier,
state.context.tracer,
)
return self.package is not None
def propose_common_escorts(self) -> None:
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
def iter_iads_ranges(
self, state: TheaterState, range_type: RangeType
) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
target_ranges: list[
tuple[Union[IadsGroundObject, NavalGroundObject], Distance]
] = []
all_iads: Iterator[
Union[IadsGroundObject, NavalGroundObject]
] = itertools.chain(state.enemy_air_defenses, state.enemy_ships)
for target in all_iads:
distance = meters(target.distance_to(self.target))
if range_type is RangeType.Detection:
target_range = target.max_detection_range()
elif range_type is RangeType.Threat:
target_range = target.max_threat_range()
else:
raise ValueError(f"Unknown RangeType: {range_type}")
if not target_range:
continue
# IADS out of range of our target area will have a positive
# distance_to_threat and should be pruned. The rest have a decreasing
# distance_to_threat as overlap increases. The most negative distance has
# the greatest coverage of the target and should be treated as the highest
# priority threat.
distance_to_threat = distance - target_range
if distance_to_threat > meters(0):
continue
target_ranges.append((target, distance_to_threat))
# TODO: Prioritize IADS by vulnerability?
target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
for target, _range in target_ranges:
yield target
def iter_detecting_iads(
self, state: TheaterState
) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
return self.iter_iads_ranges(state, RangeType.Detection)
def iter_iads_threats(
self, state: TheaterState
) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
return self.iter_iads_ranges(state, RangeType.Threat)
def target_area_preconditions_met(
self, state: TheaterState, ignore_iads: bool = False
) -> bool:
"""Checks if the target area has been cleared of threats."""
threatened = False
# Non-blocking, but analyzed so we can pick detectors worth eliminating.
for detector in self.iter_detecting_iads(state):
if detector not in state.detecting_air_defenses:
state.detecting_air_defenses.append(detector)
if not ignore_iads:
for iads_threat in self.iter_iads_threats(state):
threatened = True
if iads_threat not in state.threatening_air_defenses:
state.threatening_air_defenses.append(iads_threat)
return not threatened

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater import MissionTarget
from gen.flights.flight import FlightType
@dataclass
class PlanAewc(PackagePlanningTask[MissionTarget]):
def preconditions_met(self, state: TheaterState) -> bool:
if not super().preconditions_met(state):
return False
return self.target in state.aewc_targets
def apply_effects(self, state: TheaterState) -> None:
state.aewc_targets.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.AEWC, 1)
@property
def asap(self) -> bool:
# Supports all the early CAP flights, so should be in the air ASAP.
return True

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from gen.ground_forces.combat_stance import CombatStance
class AggressiveAttack(FrontLineStanceTask):
@property
def stance(self) -> CombatStance:
return CombatStance.AGGRESSIVE
@property
def have_sufficient_front_line_advantage(self) -> bool:
return self.ground_force_balance >= 0.8

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.missionproposals import EscortType
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater.theatergroundobject import NavalGroundObject
from gen.flights.flight import FlightType
@dataclass
class PlanAntiShip(PackagePlanningTask[NavalGroundObject]):
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.threatening_air_defenses:
return False
if not self.target_area_preconditions_met(state, ignore_iads=True):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.eliminate_ship(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.ANTISHIP, 2)
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.transfers import CargoShip
from gen.flights.flight import FlightType
@dataclass
class PlanAntiShipping(PackagePlanningTask[CargoShip]):
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.enemy_shipping:
return False
if not self.target_area_preconditions_met(state):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.enemy_shipping.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.ANTISHIP, 2)
self.propose_common_escorts()

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater.theatergroundobject import VehicleGroupGroundObject
from gen.flights.flight import FlightType
@dataclass
class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]):
def preconditions_met(self, state: TheaterState) -> bool:
if not state.has_garrison(self.target):
return False
if not self.target_area_preconditions_met(state):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.eliminate_garrison(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.BAI, 2)
self.propose_common_escorts()

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater import ControlPoint
from gen.flights.flight import FlightType
@dataclass
class PlanBarcap(PackagePlanningTask[ControlPoint]):
max_orders: int
def preconditions_met(self, state: TheaterState) -> bool:
if not state.barcaps_needed[self.target]:
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.barcaps_needed[self.target] -= 1
def propose_flights(self) -> None:
self.propose_flight(FlightType.BARCAP, 2)
@property
def purchase_multiplier(self) -> int:
return self.max_orders

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from game.commander.theaterstate import TheaterState
from gen.ground_forces.combat_stance import CombatStance
class BreakthroughAttack(FrontLineStanceTask):
@property
def stance(self) -> CombatStance:
return CombatStance.BREAKTHROUGH
@property
def have_sufficient_front_line_advantage(self) -> bool:
return self.ground_force_balance >= 2.0
def opposing_garrisons_eliminated(self, state: TheaterState) -> bool:
garrisons = state.enemy_garrisons[self.enemy_cp]
return not bool(garrisons.blocking_capture)
def preconditions_met(self, state: TheaterState) -> bool:
if not super().preconditions_met(state):
return False
return self.opposing_garrisons_eliminated(state)
def apply_effects(self, state: TheaterState) -> None:
super().apply_effects(state)
state.active_front_lines.remove(self.front_line)

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater import FrontLine
from gen.flights.flight import FlightType
@dataclass
class PlanCas(PackagePlanningTask[FrontLine]):
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.vulnerable_front_lines:
return False
# Do not bother planning CAS when there are no enemy ground units at the front.
# An exception is made for turn zero since that's not being truly planned, but
# just to determine what missions should be planned on turn 1 (when there *will*
# be ground units) and what aircraft should be ordered.
enemy_cp = self.target.control_point_friendly_to(
player=not state.context.coalition.player
)
if enemy_cp.deployable_front_line_units == 0 and state.context.turn > 0:
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.vulnerable_front_lines.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.CAS, 2)
self.propose_flight(FlightType.TARCAP, 2)

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.transfers import Convoy
from gen.flights.flight import FlightType
@dataclass
class PlanConvoyInterdiction(PackagePlanningTask[Convoy]):
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.enemy_convoys:
return False
if not self.target_area_preconditions_met(state):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.enemy_convoys.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.BAI, 2)
self.propose_common_escorts()

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.missionproposals import EscortType
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater.theatergroundobject import IadsGroundObject
from gen.flights.flight import FlightType
@dataclass
class PlanDead(PackagePlanningTask[IadsGroundObject]):
def preconditions_met(self, state: TheaterState) -> bool:
if (
self.target not in state.threatening_air_defenses
and self.target not in state.detecting_air_defenses
):
return False
if not self.target_area_preconditions_met(state, ignore_iads=True):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.eliminate_air_defense(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.DEAD, 2)
# Only include SEAD against SAMs that still have emitters. No need to
# suppress an EWR, and SEAD isn't useful against a SAM that no longer has a
# working track radar.
#
# For SAMs without track radars and EWRs, we still want a SEAD escort if
# needed.
#
# Note that there is a quirk here: we should potentially be included a SEAD
# escort *and* SEAD when the target is a radar SAM but the flight path is
# also threatened by SAMs. We don't want to include a SEAD escort if the
# package is *only* threatened by the target though. Could be improved, but
# needs a decent refactor to the escort planning to do so.
if self.target.has_live_radar_sam:
self.propose_flight(FlightType.SEAD, 2)
else:
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from gen.ground_forces.combat_stance import CombatStance
class DefensiveStance(FrontLineStanceTask):
@property
def stance(self) -> CombatStance:
return CombatStance.DEFENSIVE
@property
def have_sufficient_front_line_advantage(self) -> bool:
return self.ground_force_balance >= 0.5

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from gen.ground_forces.combat_stance import CombatStance
class EliminationAttack(FrontLineStanceTask):
@property
def stance(self) -> CombatStance:
return CombatStance.ELIMINATION
@property
def have_sufficient_front_line_advantage(self) -> bool:
return self.ground_force_balance >= 1.5

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater import ControlPoint
from gen.flights.flight import FlightType
@dataclass
class PlanOcaStrike(PackagePlanningTask[ControlPoint]):
aircraft_cold_start: bool
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.oca_targets:
return False
if not self.target_area_preconditions_met(state):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.oca_targets.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.OCA_RUNWAY, 2)
if self.aircraft_cold_start:
self.propose_flight(FlightType.OCA_AIRCRAFT, 2)
self.propose_common_escorts()

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater import MissionTarget
from gen.flights.flight import FlightType
@dataclass
class PlanRefueling(PackagePlanningTask[MissionTarget]):
def preconditions_met(self, state: TheaterState) -> bool:
if not super().preconditions_met(state):
return False
return self.target in state.refueling_targets
def apply_effects(self, state: TheaterState) -> None:
state.refueling_targets.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.REFUELING, 1)

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
from gen.ground_forces.combat_stance import CombatStance
class RetreatStance(FrontLineStanceTask):
@property
def stance(self) -> CombatStance:
return CombatStance.RETREAT
@property
def have_sufficient_front_line_advantage(self) -> bool:
return True

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.theater.theatergroundobject import TheaterGroundObject
from gen.flights.flight import FlightType
@dataclass
class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]):
def preconditions_met(self, state: TheaterState) -> bool:
if self.target not in state.strike_targets:
return False
if not self.target_area_preconditions_met(state):
return False
return super().preconditions_met(state)
def apply_effects(self, state: TheaterState) -> None:
state.strike_targets.remove(self.target)
def propose_flights(self) -> None:
self.propose_flight(FlightType.STRIKE, 2)
self.propose_common_escorts()

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from abc import abstractmethod
from typing import TYPE_CHECKING
from game.commander.theaterstate import TheaterState
from game.htn import PrimitiveTask
if TYPE_CHECKING:
from game.coalition import Coalition
class TheaterCommanderTask(PrimitiveTask[TheaterState]):
@abstractmethod
def execute(self, coalition: Coalition) -> None:
...

View File

@@ -0,0 +1,88 @@
"""The Theater Commander is the highest level campaign AI.
Target selection is performed with a hierarchical-task-network (HTN, linked below).
These work by giving the planner an initial "task" which decomposes into other tasks
until a concrete set of actions is formed. For example, the "capture base" task may
decompose in the following manner:
* Defend
* Reinforce front line
* Set front line stance to defend
* Destroy enemy front line units
* Set front line stance to elimination
* Plan CAS at front line
* Prepare
* Destroy enemy IADS
* Plan DEAD against SAM Armadillo
* ...
* Destroy enemy front line units
* Set front line stance to elimination
* Plan CAS at front line
* Inhibit
* Destroy enemy unit production infrastructure
* Destroy factory at Palmyra
* ...
* Destroy enemy front line units
* Set front line stance to elimination
* Plan CAS at front line
* Attack
* Set front line stance to breakthrough
* Destroy enemy front line units
* Set front line stance to elimination
* Plan CAS at front line
This is not a reflection of the actual task composition but illustrates the capability
of the system. Each task has preconditions which are checked before the task is
decomposed. If preconditions are not met the task is ignored and the next is considered.
For example the task to destroy the factory at Palmyra might be excluded until the air
defenses protecting it are eliminated; or defensive air operations might be excluded if
the enemy does not have sufficient air forces, or if the protected target has sufficient
SAM coverage.
Each action updates the world state, which causes each action to account for the result
of the tasks executed before it. Above, the preconditions for attacking the factory at
Palmyra may not have been met due to the IADS coverage, leading the planning to decide
on an attack against the IADS in the area instead. When planning the next task in the
same turn, the world state will have been updated to account for the (hopefully)
destroyed SAM sites, allowing the planner to choose the mission to attack the factory.
Preconditions can be aware of previous actions as well. A precondition for "Plan CAS at
front line" can be "No CAS missions planned at front line" to avoid over-planning CAS
even though it is a primitive task used by many other tasks.
https://en.wikipedia.org/wiki/Hierarchical_task_network
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from game.commander.tasks.compound.nextaction import PlanNextAction
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
from game.commander.theaterstate import TheaterState
from game.htn import Planner
from game.profiling import MultiEventTracer
if TYPE_CHECKING:
from game import Game
class TheaterCommander(Planner[TheaterState, TheaterCommanderTask]):
def __init__(self, game: Game, player: bool) -> None:
super().__init__(
PlanNextAction(
aircraft_cold_start=game.settings.default_start_type == "Cold"
)
)
self.game = game
self.player = player
def plan_missions(self, tracer: MultiEventTracer) -> None:
state = TheaterState.from_game(self.game, self.player, tracer)
while True:
result = self.plan(state)
if result is None:
# Planned all viable tasks this turn.
return
for task in result.tasks:
task.execute(self.game.coalition_for(self.player))
state = result.end_state

View File

@@ -0,0 +1,176 @@
from __future__ import annotations
import dataclasses
import itertools
import math
from collections import Iterator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Union, Optional
from game.commander.garrisons import Garrisons
from game.commander.objectivefinder import ObjectiveFinder
from game.htn import WorldState
from game.profiling import MultiEventTracer
from game.settings import Settings
from game.squadrons import AirWing
from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater
from game.theater.theatergroundobject import (
TheaterGroundObject,
NavalGroundObject,
IadsGroundObject,
VehicleGroupGroundObject,
BuildingGroundObject,
)
from game.threatzones import ThreatZones
from gen.ground_forces.combat_stance import CombatStance
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
from game.transfers import Convoy, CargoShip
@dataclass(frozen=True)
class PersistentContext:
coalition: Coalition
theater: ConflictTheater
turn: int
settings: Settings
tracer: MultiEventTracer
@dataclass
class TheaterState(WorldState["TheaterState"]):
context: PersistentContext
barcaps_needed: dict[ControlPoint, int]
active_front_lines: list[FrontLine]
front_line_stances: dict[FrontLine, Optional[CombatStance]]
vulnerable_front_lines: list[FrontLine]
aewc_targets: list[MissionTarget]
refueling_targets: list[MissionTarget]
enemy_air_defenses: list[IadsGroundObject]
threatening_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]]
detecting_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]]
enemy_convoys: list[Convoy]
enemy_shipping: list[CargoShip]
enemy_ships: list[NavalGroundObject]
enemy_garrisons: dict[ControlPoint, Garrisons]
oca_targets: list[ControlPoint]
strike_targets: list[TheaterGroundObject[Any]]
enemy_barcaps: list[ControlPoint]
threat_zones: ThreatZones
def _rebuild_threat_zones(self) -> None:
"""Recreates the theater's threat zones based on the current planned state."""
self.threat_zones = ThreatZones.for_threats(
self.context.coalition.opponent.doctrine,
barcap_locations=self.enemy_barcaps,
air_defenses=itertools.chain(self.enemy_air_defenses, self.enemy_ships),
)
def eliminate_air_defense(self, target: IadsGroundObject) -> None:
if target in self.threatening_air_defenses:
self.threatening_air_defenses.remove(target)
if target in self.detecting_air_defenses:
self.detecting_air_defenses.remove(target)
self.enemy_air_defenses.remove(target)
self._rebuild_threat_zones()
def eliminate_ship(self, target: NavalGroundObject) -> None:
if target in self.threatening_air_defenses:
self.threatening_air_defenses.remove(target)
if target in self.detecting_air_defenses:
self.detecting_air_defenses.remove(target)
self.enemy_ships.remove(target)
self._rebuild_threat_zones()
def has_garrison(self, target: VehicleGroupGroundObject) -> bool:
return target in self.enemy_garrisons[target.control_point]
def eliminate_garrison(self, target: VehicleGroupGroundObject) -> None:
self.enemy_garrisons[target.control_point].eliminate(target)
def ammo_dumps_at(
self, control_point: ControlPoint
) -> Iterator[BuildingGroundObject]:
for target in self.strike_targets:
if target.control_point != control_point:
continue
if target.is_ammo_depot:
assert isinstance(target, BuildingGroundObject)
yield target
def clone(self) -> TheaterState:
# Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly
# expensive.
return TheaterState(
context=self.context,
barcaps_needed=dict(self.barcaps_needed),
active_front_lines=list(self.active_front_lines),
front_line_stances=dict(self.front_line_stances),
vulnerable_front_lines=list(self.vulnerable_front_lines),
aewc_targets=list(self.aewc_targets),
refueling_targets=list(self.refueling_targets),
enemy_air_defenses=list(self.enemy_air_defenses),
enemy_convoys=list(self.enemy_convoys),
enemy_shipping=list(self.enemy_shipping),
enemy_ships=list(self.enemy_ships),
enemy_garrisons={
cp: dataclasses.replace(g) for cp, g in self.enemy_garrisons.items()
},
oca_targets=list(self.oca_targets),
strike_targets=list(self.strike_targets),
enemy_barcaps=list(self.enemy_barcaps),
threat_zones=self.threat_zones,
# Persistent properties are not copied. These are a way for failed subtasks
# to communicate requirements to other tasks. For example, the task to
# attack enemy garrisons might fail because the target area has IADS
# protection. In that case, the preconditions of PlanBai would fail, but
# would add the IADS that prevented it from being planned to the list of
# IADS threats so that DegradeIads will consider it a threat later.
threatening_air_defenses=self.threatening_air_defenses,
detecting_air_defenses=self.detecting_air_defenses,
)
@classmethod
def from_game(
cls, game: Game, player: bool, tracer: MultiEventTracer
) -> TheaterState:
coalition = game.coalition_for(player)
finder = ObjectiveFinder(game, player)
ordered_capturable_points = finder.prioritized_unisolated_points()
context = PersistentContext(
coalition, game.theater, game.turn, game.settings, tracer
)
# Plan enough rounds of CAP that the target has coverage over the expected
# mission duration.
mission_duration = game.settings.desired_player_mission_duration.total_seconds()
barcap_duration = coalition.doctrine.cap_duration.total_seconds()
barcap_rounds = math.ceil(mission_duration / barcap_duration)
return TheaterState(
context=context,
barcaps_needed={
cp: barcap_rounds for cp in finder.vulnerable_control_points()
},
active_front_lines=list(finder.front_lines()),
front_line_stances={f: None for f in finder.front_lines()},
vulnerable_front_lines=list(finder.front_lines()),
aewc_targets=[finder.farthest_friendly_control_point()],
refueling_targets=[finder.closest_friendly_control_point()],
enemy_air_defenses=list(finder.enemy_air_defenses()),
threatening_air_defenses=[],
detecting_air_defenses=[],
enemy_convoys=list(finder.convoys()),
enemy_shipping=list(finder.cargo_ships()),
enemy_ships=list(finder.enemy_ships()),
enemy_garrisons={
cp: Garrisons.for_control_point(cp) for cp in ordered_capturable_points
},
oca_targets=list(finder.oca_targets(min_aircraft=20)),
strike_targets=list(finder.strike_targets()),
enemy_barcaps=list(game.theater.control_points_for(not player)),
threat_zones=game.threat_zone_for(not player),
)

View File

@@ -1,9 +1,8 @@
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
from game.utils import Distance, feet, nautical_miles
@dataclass
@@ -26,13 +25,26 @@ class Doctrine:
antiship: bool
rendezvous_altitude: Distance
#: The minimum distance between the departure airfield and the hold point.
hold_distance: Distance
#: The minimum distance between the hold point and the join point.
push_distance: Distance
#: The distance between the join point and the ingress point. Only used for the
#: fallback flight plan layout (when the departure airfield is near a threat zone).
join_distance: Distance
split_distance: Distance
ingress_egress_distance: Distance
#: The maximum distance between the ingress point (beginning of the attack) and
#: target.
max_ingress_distance: Distance
#: The minimum distance between the ingress point (beginning of the attack) and
#: target.
min_ingress_distance: Distance
ingress_altitude: Distance
egress_altitude: Distance
min_patrol_altitude: Distance
max_patrol_altitude: Distance
@@ -73,13 +85,12 @@ MODERN_DOCTRINE = Doctrine(
strike=True,
antiship=True,
rendezvous_altitude=feet(25000),
hold_distance=nautical_miles(15),
hold_distance=nautical_miles(25),
push_distance=nautical_miles(20),
join_distance=nautical_miles(20),
split_distance=nautical_miles(20),
ingress_egress_distance=nautical_miles(45),
max_ingress_distance=nautical_miles(45),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(20000),
egress_altitude=feet(20000),
min_patrol_altitude=feet(15000),
max_patrol_altitude=feet(33000),
pattern_altitude=feet(5000),
@@ -111,13 +122,12 @@ COLDWAR_DOCTRINE = Doctrine(
strike=True,
antiship=True,
rendezvous_altitude=feet(22000),
hold_distance=nautical_miles(10),
hold_distance=nautical_miles(15),
push_distance=nautical_miles(10),
join_distance=nautical_miles(10),
split_distance=nautical_miles(10),
ingress_egress_distance=nautical_miles(30),
max_ingress_distance=nautical_miles(30),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(18000),
egress_altitude=feet(18000),
min_patrol_altitude=feet(10000),
max_patrol_altitude=feet(24000),
pattern_altitude=feet(5000),
@@ -148,14 +158,13 @@ WWII_DOCTRINE = Doctrine(
sead=False,
strike=True,
antiship=True,
hold_distance=nautical_miles(5),
hold_distance=nautical_miles(10),
push_distance=nautical_miles(5),
join_distance=nautical_miles(5),
split_distance=nautical_miles(5),
rendezvous_altitude=feet(10000),
ingress_egress_distance=nautical_miles(7),
max_ingress_distance=nautical_miles(7),
min_ingress_distance=nautical_miles(5),
ingress_altitude=feet(8000),
egress_altitude=feet(8000),
min_patrol_altitude=feet(4000),
max_patrol_altitude=feet(15000),
pattern_altitude=feet(5000),

View File

@@ -1,4 +1,5 @@
from dcs.ships import (
Forrestal,
PIOTR,
MOSCOW,
VINSON,
@@ -85,24 +86,25 @@ UNITS_WITH_RADAR = {
AirDefence.FuMG_401,
AirDefence.FuSe_65,
# Ships
VINSON,
PERRY,
TICONDEROG,
ALBATROS,
KUZNECOW,
MOLNIYA,
MOSCOW,
NEUSTRASH,
PIOTR,
REZKY,
CV_1143_5,
Stennis,
CVN_71,
CVN_72,
CVN_73,
USS_Arleigh_Burke_IIa,
CV_1143_5,
Forrestal,
KUZNECOW,
LHA_Tarawa,
MOLNIYA,
MOSCOW,
NEUSTRASH,
PERRY,
PIOTR,
REZKY,
Stennis,
TICONDEROG,
Type_052B,
Type_054A,
Type_052C,
Type_054A,
USS_Arleigh_Burke_IIa,
VINSON,
}

File diff suppressed because it is too large Load Diff

View File

@@ -318,6 +318,8 @@ REWARDS = {
"comms": 10,
"oil": 10,
"derrick": 8,
"village": 0.25,
"allycamp": 0.5,
}
"""

View File

@@ -36,12 +36,13 @@ from game.utils import (
feet,
kph,
knots,
nautical_miles,
)
if TYPE_CHECKING:
from gen.aircraft import FlightData
from gen import AirSupport, RadioFrequency, RadioRegistry
from gen.radios import Radio
from gen.airsupport import AirSupport
from gen.radios import Radio, RadioFrequency, RadioRegistry
@dataclass(frozen=True)
@@ -112,6 +113,35 @@ class PatrolConfig:
)
@dataclass(frozen=True)
class FuelConsumption:
#: The estimated taxi fuel requirement, in pounds.
taxi: int
#: The estimated fuel consumption for a takeoff climb, in pounds per nautical mile.
climb: float
#: The estimated fuel consumption for cruising, in pounds per nautical mile.
cruise: float
#: The estimated fuel consumption for combat speeds, in pounds per nautical mile.
combat: float
#: The minimum amount of fuel that the aircraft should land with, in pounds. This is
#: a reserve amount for landing delays or emergencies.
min_safe: int
@classmethod
def from_data(cls, data: dict[str, Any]) -> FuelConsumption:
return FuelConsumption(
int(data["taxi"]),
float(data["climb_ppm"]),
float(data["cruise_ppm"]),
float(data["combat_ppm"]),
int(data["min_safe"]),
)
# TODO: Split into PlaneType and HelicopterType?
@dataclass(frozen=True)
class AircraftType(UnitType[Type[FlyingType]]):
@@ -119,13 +149,20 @@ class AircraftType(UnitType[Type[FlyingType]]):
lha_capable: bool
always_keeps_gun: bool
# If true, the aircraft does not use the guns as the last resort weapons, but as a main weapon.
# It'll RTB when it doesn't have gun ammo left.
# If true, the aircraft does not use the guns as the last resort weapons, but as a
# main weapon. It'll RTB when it doesn't have gun ammo left.
gunfighter: bool
max_group_size: int
patrol_altitude: Optional[Distance]
patrol_speed: Optional[Speed]
#: The maximum range between the origin airfield and the target for which the auto-
#: planner will consider this aircraft usable for a mission.
max_mission_range: Distance
fuel_consumption: Optional[FuelConsumption]
intra_flight_radio: Optional[Radio]
channel_allocator: Optional[RadioChannelAllocator]
channel_namer: Type[ChannelNamer]
@@ -147,6 +184,10 @@ class AircraftType(UnitType[Type[FlyingType]]):
def flyable(self) -> bool:
return self.dcs_unit_type.flyable
@property
def helicopter(self) -> bool:
return self.dcs_unit_type.helicopter
@cached_property
def max_speed(self) -> Speed:
return kph(self.dcs_unit_type.max_speed)
@@ -299,6 +340,25 @@ class AircraftType(UnitType[Type[FlyingType]]):
radio_config = RadioConfig.from_data(data.get("radios", {}))
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
try:
mission_range = nautical_miles(int(data["max_range"]))
except (KeyError, ValueError):
mission_range = (
nautical_miles(50) if aircraft.helicopter else nautical_miles(150)
)
logging.warning(
f"{aircraft.id} does not specify a max_range. Defaulting to "
f"{mission_range.nautical_miles}NM"
)
fuel_data = data.get("fuel")
if fuel_data is not None:
fuel_consumption: Optional[FuelConsumption] = FuelConsumption.from_data(
fuel_data
)
else:
fuel_consumption = None
try:
introduction = data["introduced"]
if introduction is None:
@@ -326,6 +386,8 @@ class AircraftType(UnitType[Type[FlyingType]]):
max_group_size=data.get("max_group_size", aircraft.group_size_max),
patrol_altitude=patrol_config.altitude,
patrol_speed=patrol_config.speed,
max_mission_range=mission_range,
fuel_consumption=fuel_consumption,
intra_flight_radio=radio_config.intra_flight,
channel_allocator=radio_config.channel_allocator,
channel_namer=radio_config.channel_namer,

View File

@@ -18,7 +18,6 @@ from typing import (
Union,
)
from game import db
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.theater import Airfield, ControlPoint
@@ -136,10 +135,8 @@ class Debriefing:
self.game = game
self.unit_map = unit_map
self.player_country = game.player_country
self.enemy_country = game.enemy_country
self.player_country_id = db.country_id_from_name(game.player_country)
self.enemy_country_id = db.country_id_from_name(game.enemy_country)
self.player_country = game.blue.country_name
self.enemy_country = game.red.country_name
self.air_losses = self.dead_aircraft()
self.ground_losses = self.dead_ground_units()

View File

@@ -7,13 +7,11 @@ from dcs.mapping import Point
from dcs.task import Task
from game import persistency
from game.debriefing import AirLosses, Debriefing
from game.infos.information import Information
from game.debriefing import Debriefing
from game.operation.operation import Operation
from game.theater import ControlPoint
from gen import AirTaskingOrder
from gen.ato import AirTaskingOrder
from gen.ground_forces.combat_stance import CombatStance
from ..dcs.groundunittype import GroundUnitType
from ..unitmap import UnitMap
if TYPE_CHECKING:
@@ -53,7 +51,7 @@ class Event:
@property
def is_player_attacking(self) -> bool:
return self.attacker_name == self.game.player_faction.name
return self.attacker_name == self.game.blue.faction.name
@property
def tasks(self) -> List[Type[Task]]:
@@ -67,59 +65,6 @@ class Event:
)
return unit_map
@staticmethod
def _transfer_aircraft(
ato: AirTaskingOrder, losses: AirLosses, for_player: bool
) -> None:
for package in ato.packages:
for flight in package.flights:
# No need to transfer to the same location.
if flight.departure == flight.arrival:
continue
# Don't transfer to bases that were captured. Note that if the
# airfield was back-filling transfers it may overflow. We could
# attempt to be smarter in the future by performing transfers in
# order up a graph to prevent transfers to full airports and
# send overflow off-map, but overflow is fine for now.
if flight.arrival.captured != for_player:
logging.info(
f"Not transferring {flight} because {flight.arrival} "
"was captured"
)
continue
transfer_count = losses.surviving_flight_members(flight)
if transfer_count < 0:
logging.error(
f"{flight} had {flight.count} aircraft but "
f"{transfer_count} losses were recorded."
)
continue
aircraft = flight.unit_type
available = flight.departure.base.total_units_of_type(aircraft)
if available < transfer_count:
logging.error(
f"Found killed {aircraft} from {flight.departure} but "
f"that airbase has only {available} available."
)
continue
flight.departure.base.aircraft[aircraft] -= transfer_count
if aircraft not in flight.arrival.base.aircraft:
# TODO: Should use defaultdict.
flight.arrival.base.aircraft[aircraft] = 0
flight.arrival.base.aircraft[aircraft] += transfer_count
def complete_aircraft_transfers(self, debriefing: Debriefing) -> None:
self._transfer_aircraft(
self.game.blue_ato, debriefing.air_losses, for_player=True
)
self._transfer_aircraft(
self.game.red_ato, debriefing.air_losses, for_player=False
)
def commit_air_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.air_losses.losses:
if loss.pilot is not None and (
@@ -127,18 +72,18 @@ class Event:
or not self.game.settings.invulnerable_player_pilots
):
loss.pilot.kill()
squadron = loss.flight.squadron
aircraft = loss.flight.unit_type
cp = loss.flight.departure
available = cp.base.total_units_of_type(aircraft)
available = squadron.owned_aircraft
if available <= 0:
logging.error(
f"Found killed {aircraft} from {cp} but that airbase has "
f"Found killed {aircraft} from {squadron} but that airbase has "
"none available."
)
continue
logging.info(f"{aircraft} destroyed from {cp}")
cp.base.aircraft[aircraft] -= 1
logging.info(f"{aircraft} destroyed from {squadron}")
squadron.owned_aircraft -= 1
@staticmethod
def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
@@ -154,8 +99,8 @@ class Event:
pilot.record.missions_flown += 1
def commit_pilot_experience(self) -> None:
self._commit_pilot_experience(self.game.blue_ato)
self._commit_pilot_experience(self.game.red_ato)
self._commit_pilot_experience(self.game.blue.ato)
self._commit_pilot_experience(self.game.red.ato)
@staticmethod
def commit_front_line_losses(debriefing: Debriefing) -> None:
@@ -227,13 +172,10 @@ class Event:
def commit_building_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.building_losses:
loss.ground_object.kill()
self.game.informations.append(
Information(
"Building destroyed",
f"{loss.ground_object.dcs_identifier} has been destroyed at "
f"location {loss.ground_object.obj_name}",
self.game.turn,
)
self.game.message(
"Building destroyed",
f"{loss.ground_object.dcs_identifier} has been destroyed at "
f"location {loss.ground_object.obj_name}",
)
@staticmethod
@@ -245,19 +187,16 @@ class Event:
for captured in debriefing.base_captures:
try:
if captured.captured_by_player:
info = Information(
self.game.message(
f"{captured.control_point} captured!",
f"We took control of {captured.control_point}.",
self.game.turn,
)
else:
info = Information(
self.game.message(
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)
@@ -271,12 +210,12 @@ class Event:
self.commit_pilot_experience()
self.commit_front_line_losses(debriefing)
self.commit_convoy_losses(debriefing)
self.commit_cargo_ship_losses(debriefing)
self.commit_airlift_losses(debriefing)
self.commit_ground_object_losses(debriefing)
self.commit_building_losses(debriefing)
self.commit_damaged_runways(debriefing)
self.commit_captures(debriefing)
self.complete_aircraft_transfers(debriefing)
# Destroyed units carcass
# -------------------------
@@ -385,34 +324,28 @@ class Event:
# Handle the case where there are no casualties at all on either side but both sides still have units
if delta == 0.0:
print(status_msg)
info = Information(
self.game.message(
"Frontline Report",
f"Our ground forces from {cp.name} reached a stalemate with enemy forces from {enemy_cp.name}.",
self.game.turn,
)
self.game.informations.append(info)
else:
if player_won:
print(status_msg)
cp.base.affect_strength(delta)
enemy_cp.base.affect_strength(-delta)
info = Information(
self.game.message(
"Frontline Report",
f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}",
self.game.turn,
f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}",
)
self.game.informations.append(info)
else:
print(status_msg)
enemy_cp.base.affect_strength(delta)
cp.base.affect_strength(-delta)
info = Information(
self.game.message(
"Frontline Report",
f"Our ground forces from {cp.name} are losing ground against the enemy forces from "
f"{enemy_cp.name}. {status_msg}",
self.game.turn,
)
self.game.informations.append(info)
def redeploy_units(self, cp: ControlPoint) -> None:
""" "
@@ -458,22 +391,15 @@ class Event:
source.base.commit_losses(moved_units)
# Also transfer pending deliveries.
for unit_type, count in source.pending_unit_deliveries.units.items():
if not isinstance(unit_type, GroundUnitType):
continue
if count <= 0:
# Don't transfer *sales*...
continue
for unit_type, count in source.ground_unit_orders.units.items():
move_count = int(count * move_factor)
source.pending_unit_deliveries.sell({unit_type: move_count})
destination.pending_unit_deliveries.order({unit_type: move_count})
source.ground_unit_orders.sell({unit_type: move_count})
destination.ground_unit_orders.order({unit_type: move_count})
total_units_redeployed += move_count
if total_units_redeployed > 0:
text = (
self.game.message(
"Units redeployed",
f"{total_units_redeployed} units have been redeployed from "
f"{source.name} to {destination.name}"
f"{source.name} to {destination.name}",
)
info = Information("Units redeployed", text, self.game.turn)
self.game.informations.append(info)
logging.info(text)

View File

@@ -0,0 +1,3 @@
from .holdzonegeometry import HoldZoneGeometry
from .ipzonegeometry import IpZoneGeometry
from .joinzonegeometry import JoinZoneGeometry

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import shapely.ops
from dcs import Point
from shapely.geometry import Point as ShapelyPoint, Polygon, MultiPolygon
from game.theater import ConflictTheater
from game.utils import nautical_miles
if TYPE_CHECKING:
from game.coalition import Coalition
class HoldZoneGeometry:
"""Defines the zones used for finding optimal hold point placement.
The zones themselves are stored in the class rather than just the resulting hold
point so that the zones can be drawn in the map for debugging purposes.
"""
def __init__(
self,
target: Point,
home: Point,
ip: Point,
join: Point,
coalition: Coalition,
theater: ConflictTheater,
) -> None:
# Hold points are placed one of two ways. Either approach guarantees:
#
# * Safe hold point.
# * Minimum distance to the join point.
# * Not closer to the target than the join point.
#
# 1. As near the join point as possible with a specific distance from the
# departure airfield. This prevents loitering directly above the airfield but
# also keeps the hold point close to the departure airfield.
#
# 2. Alternatively, if the entire home zone is excluded by the above criteria,
# as neat the departure airfield as possible within a minimum distance from
# the join point, with a restricted turn angle at the join point. This
# handles the case where we need to backtrack from the departure airfield and
# the join point to place the hold point, but the turn angle limit restricts
# the maximum distance of the backtrack while maintaining the direction of
# the flight plan.
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
self.join = ShapelyPoint(join.x, join.y)
self.join_bubble = self.join.buffer(coalition.doctrine.push_distance.meters)
join_to_target_distance = join.distance_to_point(target)
self.target_bubble = ShapelyPoint(target.x, target.y).buffer(
join_to_target_distance
)
self.home_bubble = self.home.buffer(coalition.doctrine.hold_distance.meters)
excluded_zones = shapely.ops.unary_union(
[self.join_bubble, self.target_bubble, self.threat_zone]
)
if not isinstance(excluded_zones, MultiPolygon):
excluded_zones = MultiPolygon([excluded_zones])
self.excluded_zones = excluded_zones
join_heading = ip.heading_between_point(join)
# Arbitrarily large since this is later constrained by the map boundary, and
# we'll be picking a location close to the IP anyway. Just used to avoid real
# distance calculations to project to the map edge.
large_distance = nautical_miles(400).meters
turn_limit = 40
join_limit_ccw = join.point_from_heading(
join_heading - turn_limit, large_distance
)
join_limit_cw = join.point_from_heading(
join_heading + turn_limit, large_distance
)
join_direction_limit_wedge = Polygon(
[
(join.x, join.y),
(join_limit_ccw.x, join_limit_ccw.y),
(join_limit_cw.x, join_limit_cw.y),
]
)
permissible_zones = (
coalition.nav_mesh.map_bounds(theater)
.intersection(join_direction_limit_wedge)
.difference(self.excluded_zones)
.difference(self.home_bubble)
)
if not isinstance(permissible_zones, MultiPolygon):
permissible_zones = MultiPolygon([permissible_zones])
self.permissible_zones = permissible_zones
self.preferred_lines = self.home_bubble.boundary.difference(self.excluded_zones)
def find_best_hold_point(self) -> Point:
if self.preferred_lines.is_empty:
hold, _ = shapely.ops.nearest_points(self.permissible_zones, self.home)
else:
hold, _ = shapely.ops.nearest_points(self.preferred_lines, self.join)
return Point(hold.x, hold.y)

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import shapely.ops
from dcs import Point
from shapely.geometry import Point as ShapelyPoint, MultiPolygon
from game.utils import nautical_miles, meters
if TYPE_CHECKING:
from game.coalition import Coalition
class IpZoneGeometry:
"""Defines the zones used for finding optimal IP placement.
The zones themselves are stored in the class rather than just the resulting IP so
that the zones can be drawn in the map for debugging purposes.
"""
def __init__(
self,
target: Point,
home: Point,
coalition: Coalition,
) -> None:
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
max_ip_distance = coalition.doctrine.max_ingress_distance
min_ip_distance = coalition.doctrine.min_ingress_distance
# The minimum distance between the home location and the IP.
min_distance_from_home = nautical_miles(5)
# The distance that is expected to be needed between the beginning of the attack
# and weapon release. This buffers the threat zone to give a 5nm window between
# the edge of the "safe" zone and the actual threat so that "safe" IPs are less
# likely to end up with the attacker entering a threatened area.
attack_distance_buffer = nautical_miles(5)
home_threatened = coalition.opponent.threat_zone.threatened(home)
shapely_target = ShapelyPoint(target.x, target.y)
home_to_target_distance = meters(home.distance_to_point(target))
self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference(
self.home.buffer(min_distance_from_home.meters)
)
# If the home zone is not threatened and home is within LAR, constrain the max
# range to the home-to-target distance to prevent excessive backtracking.
#
# If the home zone *is* threatened, we need to back out of the zone to
# rendezvous anyway.
if not home_threatened and (
min_ip_distance < home_to_target_distance < max_ip_distance
):
max_ip_distance = home_to_target_distance
max_ip_bubble = shapely_target.buffer(max_ip_distance.meters)
min_ip_bubble = shapely_target.buffer(min_ip_distance.meters)
self.ip_bubble = max_ip_bubble.difference(min_ip_bubble)
# The intersection of the home bubble and IP bubble will be all the points that
# are within the valid IP range that are not farther from home than the target
# is. However, if the origin airfield is threatened but there are safe
# placements for the IP, we should not constrain to the home zone. In this case
# we'll either end up with a safe zone outside the home zone and pick the
# closest point in to to home (minimizing backtracking), or we'll have no safe
# IP anywhere within range of the target, and we'll later pick the IP nearest
# the edge of the threat zone.
if home_threatened:
self.permissible_zone = self.ip_bubble
else:
self.permissible_zone = self.ip_bubble.intersection(self.home_bubble)
if self.permissible_zone.is_empty:
# If home is closer to the target than the min range, there will not be an
# IP solution that's close enough to home, in which case we need to ignore
# the home bubble.
self.permissible_zone = self.ip_bubble
safe_zones = self.permissible_zone.difference(
self.threat_zone.buffer(attack_distance_buffer.meters)
)
if not isinstance(safe_zones, MultiPolygon):
safe_zones = MultiPolygon([safe_zones])
self.safe_zones = safe_zones
def _unsafe_ip(self) -> ShapelyPoint:
unthreatened_home_zone = self.home_bubble.difference(self.threat_zone)
if unthreatened_home_zone.is_empty:
# Nowhere in our home zone is safe. The package will need to exit the
# threatened area to hold and rendezvous. Pick the IP closest to the
# edge of the threat zone.
return shapely.ops.nearest_points(
self.permissible_zone, self.threat_zone.boundary
)[0]
# No safe point in the IP zone, but the home zone is safe. Pick the max-
# distance IP that's closest to the untreatened home zone.
return shapely.ops.nearest_points(
self.permissible_zone, unthreatened_home_zone
)[0]
def _safe_ip(self) -> ShapelyPoint:
# We have a zone of possible IPs that are safe, close enough, and in range. Pick
# the IP in the zone that's closest to the target.
return shapely.ops.nearest_points(self.safe_zones, self.home)[0]
def find_best_ip(self) -> Point:
if self.safe_zones.is_empty:
ip = self._unsafe_ip()
else:
ip = self._safe_ip()
return Point(ip.x, ip.y)

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import shapely.ops
from dcs import Point
from shapely.geometry import (
Point as ShapelyPoint,
Polygon,
MultiPolygon,
MultiLineString,
)
from game.utils import nautical_miles
if TYPE_CHECKING:
from game.coalition import Coalition
class JoinZoneGeometry:
"""Defines the zones used for finding optimal join point placement.
The zones themselves are stored in the class rather than just the resulting join
point so that the zones can be drawn in the map for debugging purposes.
"""
def __init__(
self, target: Point, home: Point, ip: Point, coalition: Coalition
) -> None:
# Normal join placement is based on the path from home to the IP. If no path is
# found it means that the target is on a direct path. In that case we instead
# want to enforce that the join point is:
#
# * Not closer to the target than the IP.
# * Not too close to the home airfield.
# * Not threatened.
# * A minimum distance from the IP.
# * Not too sharp a turn at the ingress point.
self.ip = ShapelyPoint(ip.x, ip.y)
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
self.ip_bubble = self.ip.buffer(coalition.doctrine.join_distance.meters)
ip_distance = ip.distance_to_point(target)
self.target_bubble = ShapelyPoint(target.x, target.y).buffer(ip_distance)
# The minimum distance between the home location and the IP.
min_distance_from_home = nautical_miles(5)
self.home_bubble = self.home.buffer(min_distance_from_home.meters)
excluded_zones = shapely.ops.unary_union(
[self.ip_bubble, self.target_bubble, self.threat_zone]
)
if not isinstance(excluded_zones, MultiPolygon):
excluded_zones = MultiPolygon([excluded_zones])
self.excluded_zones = excluded_zones
ip_heading = target.heading_between_point(ip)
# Arbitrarily large since this is later constrained by the map boundary, and
# we'll be picking a location close to the IP anyway. Just used to avoid real
# distance calculations to project to the map edge.
large_distance = nautical_miles(400).meters
turn_limit = 40
ip_limit_ccw = ip.point_from_heading(ip_heading - turn_limit, large_distance)
ip_limit_cw = ip.point_from_heading(ip_heading + turn_limit, large_distance)
ip_direction_limit_wedge = Polygon(
[
(ip.x, ip.y),
(ip_limit_ccw.x, ip_limit_ccw.y),
(ip_limit_cw.x, ip_limit_cw.y),
]
)
permissible_zones = ip_direction_limit_wedge.difference(
self.excluded_zones
).difference(self.home_bubble)
if permissible_zones.is_empty:
permissible_zones = MultiPolygon([])
if not isinstance(permissible_zones, MultiPolygon):
permissible_zones = MultiPolygon([permissible_zones])
self.permissible_zones = permissible_zones
preferred_lines = ip_direction_limit_wedge.intersection(
self.excluded_zones.boundary
).difference(self.home_bubble)
if preferred_lines.is_empty:
preferred_lines = MultiLineString([])
if not isinstance(preferred_lines, MultiLineString):
preferred_lines = MultiLineString([preferred_lines])
self.preferred_lines = preferred_lines
def find_best_join_point(self) -> Point:
if self.preferred_lines.is_empty:
join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip)
else:
join, _ = shapely.ops.nearest_points(self.preferred_lines, self.home)
return Point(join.x, join.y)

View File

@@ -1,48 +1,49 @@
from __future__ import annotations
import itertools
import logging
import math
import random
import sys
from collections import Iterator
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Any, List, Type, Union, cast
from typing import Any, List, Type, Union, cast, TYPE_CHECKING
from dcs.action import Coalition
from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors
from dcs.country import Country
from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence
from faker import Faker
from game.inventory import GlobalAircraftInventory
from game.models.game_stats import GameStats
from game.plugins import LuaPluginManager
from gen import naming
from gen.ato import AirTaskingOrder
from gen.conflictgen import Conflict
from gen.flights.ai_flight_planner import CoalitionMissionPlanner
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
from gen.ground_forces.ai_ground_planner import GroundPlanner
from . import persistency
from .campaignloader import CampaignAirWingConfig
from .coalition import Coalition
from .debriefing import Debriefing
from .event.event import Event
from .event.frontlineattack import FrontlineAttackEvent
from .factions.faction import Faction
from .income import Income
from .infos.information import Information
from .navmesh import NavMesh
from .procurement import AircraftProcurementRequest, ProcurementAi
from .profiling import logged_duration
from .settings import Settings, AutoAtoBehavior
from .squadrons import AirWing
from .settings import Settings
from .theater import ConflictTheater, ControlPoint
from .theater.bullseye import Bullseye
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
from .threatzones import ThreatZones
from .transfers import PendingTransfers
from .unitmap import UnitMap
from .weather import Conditions, TimeOfDay
if TYPE_CHECKING:
from .squadrons import AirWing
COMMISION_UNIT_VARIETY = 4
COMMISION_LIMITS_SCALE = 1.5
COMMISION_LIMITS_FACTORS = {
@@ -89,6 +90,7 @@ class Game:
player_faction: Faction,
enemy_faction: Faction,
theater: ConflictTheater,
air_wing_config: CampaignAirWingConfig,
start_date: datetime,
settings: Settings,
player_budget: float,
@@ -97,134 +99,87 @@ class Game:
self.settings = settings
self.events: List[Event] = []
self.theater = theater
self.player_faction = player_faction
self.player_country = player_faction.country
self.enemy_faction = enemy_faction
self.enemy_country = enemy_faction.country
# pass_turn() will be called when initialization is complete which will
# increment this to turn 0 before it reaches the player.
self.turn = -1
self.turn = 0
# NB: This is the *start* date. It is never updated.
self.date = date(start_date.year, start_date.month, start_date.day)
self.game_stats = GameStats()
self.notes = ""
self.ground_planners: dict[int, GroundPlanner] = {}
self.informations = []
self.informations.append(Information("Game Start", "-" * 40, 0))
self.informations: list[Information] = []
self.message("Game Start", "-" * 40)
# Culling Zones are for areas around points of interest that contain things we may not wish to cull.
self.__culling_zones: List[Point] = []
self.__destroyed_units: list[dict[str, Union[float, str]]] = []
self.savepath = ""
self.budget = player_budget
self.enemy_budget = enemy_budget
self.current_unit_id = 0
self.current_group_id = 0
self.name_generator = naming.namegen
self.conditions = self.generate_conditions()
self.blue_transit_network = TransitNetwork()
self.red_transit_network = TransitNetwork()
self.sanitize_sides(player_faction, enemy_faction)
self.blue = Coalition(self, player_faction, player_budget, player=True)
self.red = Coalition(self, enemy_faction, enemy_budget, player=False)
self.blue.set_opponent(self.red)
self.red.set_opponent(self.blue)
self.blue_procurement_requests: List[AircraftProcurementRequest] = []
self.red_procurement_requests: List[AircraftProcurementRequest] = []
for control_point in self.theater.controlpoints:
control_point.finish_init(self)
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.blue_faker = Faker(self.player_faction.locales)
self.red_faker = Faker(self.enemy_faction.locales)
self.blue_air_wing = AirWing(self, player=True)
self.red_air_wing = AirWing(self, player=False)
self.blue.configure_default_air_wing(air_wing_config)
self.red.configure_default_air_wing(air_wing_config)
self.on_load(game_still_initializing=True)
def __getstate__(self) -> dict[str, Any]:
state = self.__dict__.copy()
# Avoid persisting any volatile types that can be deterministically
# recomputed on load for the sake of save compatibility.
del state["blue_threat_zone"]
del state["red_threat_zone"]
del state["blue_navmesh"]
del state["red_navmesh"]
del state["blue_faker"]
del state["red_faker"]
return state
def __setstate__(self, state: dict[str, Any]) -> None:
self.__dict__.update(state)
# Regenerate any state that was not persisted.
self.on_load()
def ato_for(self, player: bool) -> AirTaskingOrder:
if player:
return self.blue_ato
return self.red_ato
@property
def coalitions(self) -> Iterator[Coalition]:
yield self.blue
yield self.red
def procurement_requests_for(
self, player: bool
) -> List[AircraftProcurementRequest]:
if player:
return self.blue_procurement_requests
return self.red_procurement_requests
def ato_for(self, player: bool) -> AirTaskingOrder:
return self.coalition_for(player).ato
def transit_network_for(self, player: bool) -> TransitNetwork:
if player:
return self.blue_transit_network
return self.red_transit_network
return self.coalition_for(player).transit_network
def generate_conditions(self) -> Conditions:
return Conditions.generate(
self.theater, self.current_day, self.current_turn_time_of_day, self.settings
)
def sanitize_sides(self) -> None:
@staticmethod
def sanitize_sides(player_faction: Faction, enemy_faction: Faction) -> None:
"""
Make sure the opposing factions are using different countries
:return:
"""
if self.player_country == self.enemy_country:
if self.player_country == "USA":
self.enemy_country = "USAF Aggressors"
elif self.player_country == "Russia":
self.enemy_country = "USSR"
if player_faction.country == enemy_faction.country:
if player_faction.country == "USA":
enemy_faction.country = "USAF Aggressors"
elif player_faction.country == "Russia":
enemy_faction.country = "USSR"
else:
self.enemy_country = "Russia"
enemy_faction.country = "Russia"
def faction_for(self, player: bool) -> Faction:
if player:
return self.player_faction
return self.enemy_faction
return self.coalition_for(player).faction
def faker_for(self, player: bool) -> Faker:
if player:
return self.blue_faker
return self.red_faker
return self.coalition_for(player).faker
def air_wing_for(self, player: bool) -> AirWing:
if player:
return self.blue_air_wing
return self.red_air_wing
return self.coalition_for(player).air_wing
def country_for(self, player: bool) -> str:
if player:
return self.player_country
return self.enemy_country
return self.coalition_for(player).country_name
def bullseye_for(self, player: bool) -> Bullseye:
if player:
return self.blue_bullseye
return self.red_bullseye
return self.coalition_for(player).bullseye
def _generate_player_event(
self, event_class: Type[Event], player_cp: ControlPoint, enemy_cp: ControlPoint
@@ -235,11 +190,22 @@ class Game:
player_cp,
enemy_cp,
enemy_cp.position,
self.player_faction.name,
self.enemy_faction.name,
self.blue.faction.name,
self.red.faction.name,
)
)
@property
def neutral_country(self) -> Type[Country]:
"""Return the best fitting country that can be used as neutral faction in the generated mission"""
countries_in_use = [self.red.country_name, self.blue.country_name]
if UnitedNationsPeacekeepers not in countries_in_use:
return UnitedNationsPeacekeepers
elif Switzerland.name not in countries_in_use:
return Switzerland
else:
return USAFAggressors
def _generate_events(self) -> None:
for front_line in self.theater.conflicts():
self._generate_player_event(
@@ -248,20 +214,13 @@ class Game:
front_line.red_cp,
)
def adjust_budget(self, amount: float, player: bool) -> None:
def coalition_for(self, player: bool) -> Coalition:
if player:
self.budget += amount
else:
self.enemy_budget += amount
return self.blue
return self.red
def process_player_income(self) -> None:
self.budget += Income(self, player=True).total
def process_enemy_income(self) -> None:
# TODO: Clean up save compat.
if not hasattr(self, "enemy_budget"):
self.enemy_budget = 0
self.enemy_budget += Income(self, player=False).total
def adjust_budget(self, amount: float, player: bool) -> None:
self.coalition_for(player).adjust_budget(amount)
@staticmethod
def initiate_event(event: Event) -> UnitMap:
@@ -292,12 +251,6 @@ class Game:
self.compute_conflicts_position()
if not game_still_initializing:
self.compute_threat_zones()
self.blue_faker = Faker(self.faction_for(player=True).locales)
self.red_faker = Faker(self.faction_for(player=False).locales)
def reset_ato(self) -> None:
self.blue_ato.clear()
self.red_ato.clear()
def finish_turn(self, skipped: bool = False) -> None:
"""Finalizes the current turn and advances to the next turn.
@@ -327,44 +280,29 @@ class Game:
Args:
skipped: True if the turn was skipped.
"""
self.informations.append(
Information("End of turn #" + str(self.turn), "-" * 40, 0)
)
self.message("End of turn #" + str(self.turn), "-" * 40)
self.turn += 1
# Need to recompute before transfers and deliveries to account for captures.
# This happens in in initialize_turn as well, because cheating doesn't advance a
# turn but can capture bases so we need to recompute there as well.
self.compute_transit_networks()
# The coalition-specific turn finalization *must* happen before unit deliveries,
# since the coalition-specific finalization handles transit network updates and
# transfer processing. If in the other order, units may be delivered to captured
# bases, and freshly delivered units will spawn one leg through their journey.
self.blue.end_turn()
self.red.end_turn()
# Must happen *before* unit deliveries are handled, or else new units will spawn
# one hop ahead. ControlPoint.process_turn handles unit deliveries.
self.transfers.perform_transfers()
# Needs to happen *before* planning transfers so we don't cancel them.
self.reset_ato()
for control_point in self.theater.controlpoints:
control_point.process_turn(self)
self.blue_air_wing.replenish()
self.red_air_wing.replenish()
if not skipped:
for cp in self.theater.player_points():
cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY)
elif self.turn > 1:
for cp in self.theater.player_points():
if not cp.is_carrier and not cp.is_lha:
cp.base.affect_strength(-PLAYER_BASE_STRENGTH_RECOVERY)
self.conditions = self.generate_conditions()
self.process_enemy_income()
self.process_player_income()
def begin_turn_0(self) -> None:
"""Initialization for the first turn of the game."""
self.turn = 0
self.blue.preinit_turn_0()
self.red.preinit_turn_0()
self.initialize_turn()
def pass_turn(self, no_action: bool = False) -> None:
@@ -401,8 +339,8 @@ class Game:
def set_bullseye(self) -> None:
player_cp, enemy_cp = self.theater.closest_opposing_control_points()
self.blue_bullseye = Bullseye(enemy_cp.position)
self.red_bullseye = Bullseye(player_cp.position)
self.blue.bullseye = Bullseye(enemy_cp.position)
self.red.bullseye = Bullseye(player_cp.position)
def initialize_turn(self, for_red: bool = True, for_blue: bool = True) -> None:
"""Performs turn initialization for the specified players.
@@ -450,98 +388,28 @@ class Game:
if turn_state in (TurnState.LOSS, TurnState.WIN):
return self.process_win_loss(turn_state)
# Plan flights & combat for next turn
with logged_duration("Computing conflict positions"):
self.compute_conflicts_position()
with logged_duration("Threat zone computation"):
self.compute_threat_zones()
# Plan Coalition specific turn
if for_red:
self.initialize_turn_for(player=False)
if for_blue:
self.initialize_turn_for(player=True)
self.blue.initialize_turn()
if for_red:
self.red.initialize_turn()
# Plan GroundWar
self.ground_planners = {}
for cp in self.theater.controlpoints:
if cp.has_frontline:
gplanner = GroundPlanner(cp, self)
gplanner.plan_groundwar()
self.ground_planners[cp.id] = gplanner
def initialize_turn_for(self, player: bool) -> None:
"""Processes coalition-specific turn initialization.
For more information on turn initialization in general, see the documentation
for `Game.initialize_turn`.
Args:
player: True if the player coalition is being initialized. False for opfor
initialization.
"""
self.ato_for(player).clear()
self.air_wing_for(player).reset()
self.aircraft_inventory.reset()
for cp in self.theater.controlpoints:
self.aircraft_inventory.set_from_control_point(cp)
# Refund all pending deliveries for opfor and if player
# has automate_aircraft_reinforcements
if (not player and not cp.captured) or (
player
and cp.captured
and self.settings.automate_aircraft_reinforcements
):
cp.pending_unit_deliveries.refund_all(self)
# Plan flights & combat for next turn
with logged_duration("Computing conflict positions"):
self.compute_conflicts_position()
with logged_duration("Threat zone computation"):
self.compute_threat_zones()
with logged_duration("Transit network identification"):
self.compute_transit_networks()
self.ground_planners = {}
self.procurement_requests_for(player).clear()
with logged_duration("Procurement of airlift assets"):
self.transfers.order_airlift_assets()
with logged_duration("Transport planning"):
self.transfers.plan_transports()
if not player or (
player and self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled
):
color = "Blue" if player else "Red"
with logged_duration(f"{color} mission planning"):
mission_planner = CoalitionMissionPlanner(self, player)
mission_planner.plan_missions()
self.plan_procurement_for(player)
def plan_procurement_for(self, for_player: bool) -> None:
# The first turn needs to buy a *lot* of aircraft to fill CAPs, so it
# gets much more of the budget that turn. Otherwise budget (after
# repairs) is split evenly between air and ground. For the default
# starting budget of 2000 this gives 600 to ground forces and 1400 to
# aircraft. After that the budget will be spend proportionally based on how much is already invested
if for_player:
self.budget = ProcurementAi(
self,
for_player=True,
faction=self.player_faction,
manage_runways=self.settings.automate_runway_repair,
manage_front_line=self.settings.automate_front_line_reinforcements,
manage_aircraft=self.settings.automate_aircraft_reinforcements,
).spend_budget(self.budget)
else:
self.enemy_budget = ProcurementAi(
self,
for_player=False,
faction=self.enemy_faction,
manage_runways=True,
manage_front_line=True,
manage_aircraft=True,
).spend_budget(self.enemy_budget)
def message(self, text: str) -> None:
self.informations.append(Information(text, turn=self.turn))
def message(self, title: str, text: str = "") -> None:
self.informations.append(Information(title, text, turn=self.turn))
@property
def current_turn_time_of_day(self) -> TimeOfDay:
@@ -565,32 +433,20 @@ class Game:
self.current_group_id += 1
return self.current_group_id
def compute_transit_networks(self) -> None:
self.blue_transit_network = self.compute_transit_network_for(player=True)
self.red_transit_network = self.compute_transit_network_for(player=False)
def compute_transit_network_for(self, player: bool) -> TransitNetwork:
return TransitNetworkBuilder(self.theater, player).build()
def compute_threat_zones(self) -> None:
self.blue_threat_zone = ThreatZones.for_faction(self, player=True)
self.red_threat_zone = ThreatZones.for_faction(self, player=False)
self.blue_navmesh = NavMesh.from_threat_zones(
self.red_threat_zone, self.theater
)
self.red_navmesh = NavMesh.from_threat_zones(
self.blue_threat_zone, self.theater
)
self.blue.compute_threat_zones()
self.red.compute_threat_zones()
self.blue.compute_nav_meshes()
self.red.compute_nav_meshes()
def threat_zone_for(self, player: bool) -> ThreatZones:
if player:
return self.blue_threat_zone
return self.red_threat_zone
return self.coalition_for(player).threat_zone
def navmesh_for(self, player: bool) -> NavMesh:
if player:
return self.blue_navmesh
return self.red_navmesh
return self.coalition_for(player).nav_mesh
def compute_conflicts_position(self) -> None:
"""
@@ -633,7 +489,7 @@ class Game:
if cpoint is not None:
zones.append(cpoint)
packages = itertools.chain(self.blue_ato.packages, self.red_ato.packages)
packages = itertools.chain(self.blue.ato.packages, self.red.ato.packages)
for package in packages:
if package.primary_task is FlightType.BARCAP:
# BARCAPs will be planned at most locations on smaller theaters,
@@ -679,25 +535,6 @@ class Game:
"""
return self.__culling_zones
# 1 = red, 2 = blue
def get_player_coalition_id(self) -> int:
return 2
def get_enemy_coalition_id(self) -> int:
return 1
def get_player_coalition(self) -> Coalition:
return Coalition.Blue
def get_enemy_coalition(self) -> Coalition:
return Coalition.Red
def get_player_color(self) -> str:
return "blue"
def get_enemy_color(self) -> str:
return "red"
def process_win_loss(self, turn_state: TurnState) -> None:
if turn_state is TurnState.WIN:
self.message(

View File

@@ -2,12 +2,11 @@ from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Optional, TYPE_CHECKING, Any
from typing import Optional, TYPE_CHECKING
from game.theater import ControlPoint
from .coalition import Coalition
from .dcs.groundunittype import GroundUnitType
from .dcs.unittype import UnitType
from .theater.transitnetwork import (
NoPathError,
TransitNetwork,
@@ -18,111 +17,93 @@ if TYPE_CHECKING:
from .game import Game
@dataclass(frozen=True)
class GroundUnitSource:
control_point: ControlPoint
class PendingUnitDeliveries:
class GroundUnitOrders:
def __init__(self, destination: ControlPoint) -> None:
self.destination = destination
# Maps unit type to order quantity.
self.units: dict[UnitType[Any], int] = defaultdict(int)
self.units: dict[GroundUnitType, int] = defaultdict(int)
def __str__(self) -> str:
return f"Pending delivery to {self.destination}"
return f"Pending ground unit delivery to {self.destination}"
def order(self, units: dict[UnitType[Any], int]) -> None:
def order(self, units: dict[GroundUnitType, int]) -> None:
for k, v in units.items():
self.units[k] += v
def sell(self, units: dict[UnitType[Any], int]) -> None:
def sell(self, units: dict[GroundUnitType, int]) -> None:
for k, v in units.items():
self.units[k] -= v
if self.units[k] == 0:
del self.units[k]
def refund_all(self, game: Game) -> None:
self.refund(game, self.units)
def refund_all(self, coalition: Coalition) -> None:
self._refund(coalition, self.units)
self.units = defaultdict(int)
def refund_ground_units(self, game: Game) -> None:
ground_units: dict[UnitType[Any], int] = {
u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType)
}
self.refund(game, ground_units)
for gu in ground_units.keys():
del self.units[gu]
def refund(self, game: Game, units: dict[UnitType[Any], int]) -> None:
def _refund(self, coalition: Coalition, units: dict[GroundUnitType, int]) -> None:
for unit_type, count in units.items():
logging.info(f"Refunding {count} {unit_type} at {self.destination.name}")
game.adjust_budget(
unit_type.price * count, player=self.destination.captured
)
coalition.adjust_budget(unit_type.price * count)
def pending_orders(self, unit_type: UnitType[Any]) -> int:
def pending_orders(self, unit_type: GroundUnitType) -> int:
pending_units = self.units.get(unit_type)
if pending_units is None:
pending_units = 0
return pending_units
def available_next_turn(self, unit_type: UnitType[Any]) -> 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:
coalition = game.coalition_for(self.destination.captured)
ground_unit_source = self.find_ground_unit_source(game)
if ground_unit_source is None:
game.message(
f"{self.destination.name} lost its source for ground unit "
"reinforcements. Refunding purchase price."
)
self.refund_ground_units(game)
self.refund_all(coalition)
bought_units: dict[UnitType[Any], int] = {}
bought_units: dict[GroundUnitType, int] = {}
units_needing_transfer: dict[GroundUnitType, int] = {}
sold_units: dict[UnitType[Any], int] = {}
for unit_type, count in self.units.items():
coalition = "Ally" if self.destination.captured else "Enemy"
d: dict[Any, int]
if (
isinstance(unit_type, GroundUnitType)
and self.destination != ground_unit_source
):
allegiance = "Ally" if self.destination.captured else "Enemy"
d: dict[GroundUnitType, int]
if self.destination != ground_unit_source:
source = ground_unit_source
d = units_needing_transfer
else:
source = self.destination
d = bought_units
if count >= 0:
if count < 0:
logging.error(
f"Attempted sale of {unit_type} at {self.destination} but ground "
"units cannot be sold"
)
elif count > 0:
d[unit_type] = count
game.message(
f"{coalition} reinforcements: {unit_type} x {count} at {source}"
f"{allegiance} reinforcements: {unit_type} x {count} at {source}"
)
else:
sold_units[unit_type] = -count
game.message(f"{coalition} sold: {unit_type} x {-count} at {source}")
self.units = defaultdict(int)
self.destination.base.commission_units(bought_units)
self.destination.base.commit_losses(sold_units)
if units_needing_transfer:
if ground_unit_source is None:
raise RuntimeError(
f"ground unit source could not be found for {self.destination} but still tried to "
f"transfer units to there"
f"Ground unit source could not be found for {self.destination} but "
"still tried to transfer units to there"
)
ground_unit_source.base.commission_units(units_needing_transfer)
self.create_transfer(game, ground_unit_source, units_needing_transfer)
self.create_transfer(coalition, ground_unit_source, units_needing_transfer)
def create_transfer(
self, game: Game, source: ControlPoint, units: dict[GroundUnitType, int]
self,
coalition: Coalition,
source: ControlPoint,
units: dict[GroundUnitType, int],
) -> None:
game.transfers.new_transfer(TransferOrder(source, self.destination, units))
coalition.transfers.new_transfer(TransferOrder(source, self.destination, units))
def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]:
# This is running *after* the turn counter has been incremented, so this is the

127
game/htn.py Normal file
View File

@@ -0,0 +1,127 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections import Iterator, deque, Sequence
from dataclasses import dataclass
from typing import Any, Generic, Optional, TypeVar
WorldStateT = TypeVar("WorldStateT", bound="WorldState[Any]")
class WorldState(ABC, Generic[WorldStateT]):
@abstractmethod
def clone(self) -> WorldStateT:
...
class Task(Generic[WorldStateT]):
pass
Method = Sequence[Task[WorldStateT]]
class PrimitiveTask(Task[WorldStateT], Generic[WorldStateT], ABC):
@abstractmethod
def preconditions_met(self, state: WorldStateT) -> bool:
...
@abstractmethod
def apply_effects(self, state: WorldStateT) -> None:
...
class CompoundTask(Task[WorldStateT], Generic[WorldStateT], ABC):
@abstractmethod
def each_valid_method(self, state: WorldStateT) -> Iterator[Method[WorldStateT]]:
...
PrimitiveTaskT = TypeVar("PrimitiveTaskT", bound=PrimitiveTask[Any])
@dataclass
class PlanningState(Generic[WorldStateT, PrimitiveTaskT]):
state: WorldStateT
tasks_to_process: deque[Task[WorldStateT]]
plan: list[PrimitiveTaskT]
methods: Optional[Iterator[Method[WorldStateT]]]
@dataclass(frozen=True)
class PlanningResult(Generic[WorldStateT, PrimitiveTaskT]):
tasks: list[PrimitiveTaskT]
end_state: WorldStateT
class PlanningHistory(Generic[WorldStateT, PrimitiveTaskT]):
def __init__(self) -> None:
self.states: list[PlanningState[WorldStateT, PrimitiveTaskT]] = []
def push(self, planning_state: PlanningState[WorldStateT, PrimitiveTaskT]) -> None:
self.states.append(planning_state)
def pop(self) -> PlanningState[WorldStateT, PrimitiveTaskT]:
return self.states.pop()
class Planner(Generic[WorldStateT, PrimitiveTaskT]):
def __init__(self, main_task: Task[WorldStateT]) -> None:
self.main_task = main_task
def plan(
self, initial_state: WorldStateT
) -> Optional[PlanningResult[WorldStateT, PrimitiveTaskT]]:
planning_state: PlanningState[WorldStateT, PrimitiveTaskT] = PlanningState(
initial_state, deque([self.main_task]), [], None
)
history: PlanningHistory[WorldStateT, PrimitiveTaskT] = PlanningHistory()
while planning_state.tasks_to_process:
task = planning_state.tasks_to_process.popleft()
if isinstance(task, PrimitiveTask):
if task.preconditions_met(planning_state.state):
task.apply_effects(planning_state.state)
# Ignore type erasure. We've already verified that this is a Planner
# with a WorldStateT and a PrimitiveTaskT, so we know that the task
# list is a list of CompoundTask[WorldStateT] and PrimitiveTaskT. We
# could scatter more unions throughout to be more explicit but
# there's no way around the type erasure that mypy uses for
# isinstance.
planning_state.plan.append(task) # type: ignore
else:
planning_state = history.pop()
else:
assert isinstance(task, CompoundTask)
# If the methods field of our current state is not None that means we're
# resuming a prior attempt to execute this task after a subtask of the
# previously selected method failed.
#
# Otherwise this is the first exectution of this task so we need to
# create the generator.
if planning_state.methods is None:
methods = task.each_valid_method(planning_state.state)
else:
methods = planning_state.methods
try:
method = next(methods)
# Push the current node back onto the stack so that we resume
# handling this task when we pop back to this state.
resume_tasks: deque[Task[WorldStateT]] = deque([task])
resume_tasks.extend(planning_state.tasks_to_process)
history.push(
PlanningState(
planning_state.state.clone(),
resume_tasks,
planning_state.plan,
methods,
)
)
planning_state.methods = None
planning_state.tasks_to_process.extendleft(reversed(method))
except StopIteration:
try:
planning_state = history.pop()
except IndexError:
# No valid plan was found.
return None
return PlanningResult(planning_state.plan, planning_state.state)

View File

@@ -14,10 +14,10 @@ class BuildingIncome:
name: str
category: str
number: int
income_per_building: int
income_per_building: float
@property
def income(self) -> int:
def income(self) -> float:
return self.number * self.income_per_building

View File

@@ -1,131 +0,0 @@
"""Inventory management APIs."""
from __future__ import annotations
from collections import defaultdict
from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING, Type
from dcs.unittype import FlyingType
from game.dcs.aircrafttype import AircraftType
from gen.flights.flight import Flight
if TYPE_CHECKING:
from game.theater import ControlPoint
class ControlPointAircraftInventory:
"""Aircraft inventory for a single control point."""
def __init__(self, control_point: ControlPoint) -> None:
self.control_point = control_point
self.inventory: Dict[AircraftType, int] = defaultdict(int)
def add_aircraft(self, aircraft: AircraftType, count: int) -> None:
"""Adds aircraft to the inventory.
Args:
aircraft: The type of aircraft to add.
count: The number of aircraft to add.
"""
self.inventory[aircraft] += count
def remove_aircraft(self, aircraft: AircraftType, count: int) -> None:
"""Removes aircraft from the inventory.
Args:
aircraft: The type of aircraft to remove.
count: The number of aircraft to remove.
Raises:
ValueError: The control point cannot fulfill the requested number of
aircraft.
"""
available = self.inventory[aircraft]
if available < count:
raise ValueError(
f"Cannot remove {count} {aircraft} from "
f"{self.control_point.name}. Only have {available}."
)
self.inventory[aircraft] -= count
def available(self, aircraft: AircraftType) -> int:
"""Returns the number of available aircraft of the given type.
Args:
aircraft: The type of aircraft to query.
"""
try:
return self.inventory[aircraft]
except KeyError:
return 0
@property
def types_available(self) -> Iterator[AircraftType]:
"""Iterates over all available aircraft types."""
for aircraft, count in self.inventory.items():
if count > 0:
yield aircraft
@property
def all_aircraft(self) -> Iterator[Tuple[AircraftType, int]]:
"""Iterates over all available aircraft types, including amounts."""
for aircraft, count in self.inventory.items():
if count > 0:
yield aircraft, count
def clear(self) -> None:
"""Clears all aircraft from the inventory."""
self.inventory.clear()
class GlobalAircraftInventory:
"""Game-wide aircraft inventory."""
def __init__(self, control_points: Iterable[ControlPoint]) -> None:
self.inventories: Dict[ControlPoint, ControlPointAircraftInventory] = {
cp: ControlPointAircraftInventory(cp) for cp in control_points
}
def reset(self) -> None:
"""Clears all control points and their inventories."""
for inventory in self.inventories.values():
inventory.clear()
def set_from_control_point(self, control_point: ControlPoint) -> None:
"""Set the control point's aircraft inventory.
If the inventory for the given control point has already been set for
the turn, it will be overwritten.
"""
inventory = self.inventories[control_point]
for aircraft, count in control_point.base.aircraft.items():
inventory.add_aircraft(aircraft, count)
def for_control_point(
self, control_point: ControlPoint
) -> ControlPointAircraftInventory:
"""Returns the inventory specific to the given control point."""
return self.inventories[control_point]
@property
def available_types_for_player(self) -> Iterator[AircraftType]:
"""Iterates over all aircraft types available to the player."""
seen: Set[AircraftType] = set()
for control_point, inventory in self.inventories.items():
if control_point.captured:
for aircraft in inventory.types_available:
if not control_point.can_operate(aircraft):
continue
if aircraft not in seen:
seen.add(aircraft)
yield aircraft
def claim_for_flight(self, flight: Flight) -> None:
"""Removes aircraft from the inventory for the given flight."""
inventory = self.for_control_point(flight.from_cp)
inventory.remove_aircraft(flight.unit_type, flight.count)
def return_from_flight(self, flight: Flight) -> None:
"""Returns a flight's aircraft to the inventory."""
inventory = self.for_control_point(flight.from_cp)
inventory.add_aircraft(flight.unit_type, flight.count)

View File

@@ -56,10 +56,12 @@ class GameStats:
for cp in game.theater.controlpoints:
if cp.captured:
turn_data.allied_units.aircraft_count += sum(cp.base.aircraft.values())
for squadron in cp.squadrons:
turn_data.allied_units.aircraft_count += squadron.owned_aircraft
turn_data.allied_units.vehicles_count += sum(cp.base.armor.values())
else:
turn_data.enemy_units.aircraft_count += sum(cp.base.aircraft.values())
for squadron in cp.squadrons:
turn_data.enemy_units.aircraft_count += squadron.owned_aircraft
turn_data.enemy_units.vehicles_count += sum(cp.base.armor.values())
self.data_per_turn.append(turn_data)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Iterable, List, Set, TYPE_CHECKING, cast
from typing import List, Set, TYPE_CHECKING, cast
from dcs import Mission
from dcs.action import DoScript, DoScriptFile
@@ -16,25 +16,30 @@ 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 AircraftConflictGenerator, FlightData
from gen.airfields import AIRFIELD_DATA
from gen.airsupportgen import AirSupport, AirSupportConflictGenerator
from gen.armor import GroundConflictGenerator, JtacInfo
from gen.airsupport import AirSupport
from gen.airsupportgen import AirSupportConflictGenerator
from gen.armor import GroundConflictGenerator
from gen.beacons import load_beacons_for_terrain
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
from gen.cargoshipgen import CargoShipGenerator
from gen.conflictgen import Conflict
from gen.convoygen import ConvoyGenerator
from gen.environmentgen import EnvironmentGenerator
from gen.flights.flight import FlightType
from gen.forcedoptionsgen import ForcedOptionsGenerator
from gen.groundobjectsgen import GroundObjectsGenerator
from gen.kneeboard import KneeboardGenerator
from gen.lasercoderegistry import LaserCodeRegistry
from gen.naming import namegen
from gen.radios import RadioFrequency, RadioRegistry
from gen.tacan import TacanRegistry
from gen.tacan import TacanRegistry, TacanUsage
from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator
from gen.visualgen import VisualGenerator
from .. import db
from ..theater import Airfield, FrontLine
from ..theater.bullseye import Bullseye
from ..unitmap import UnitMap
if TYPE_CHECKING:
@@ -50,6 +55,7 @@ class Operation:
groundobjectgen: GroundObjectsGenerator
radio_registry: RadioRegistry
tacan_registry: TacanRegistry
laser_code_registry: LaserCodeRegistry
game: Game
trigger_radius = TRIGGER_RADIUS_MEDIUM
is_quick = None
@@ -58,8 +64,8 @@ class Operation:
enemy_awacs_enabled = True
ca_slots = 1
unit_map: UnitMap
jtacs: List[JtacInfo] = []
plugin_scripts: List[str] = []
air_support = AirSupport()
@classmethod
def prepare(cls, game: Game) -> None:
@@ -81,10 +87,10 @@ class Operation:
return Conflict(
cls.game.theater,
FrontLine(player_cp, enemy_cp),
cls.game.player_faction.name,
cls.game.enemy_faction.name,
cls.current_mission.country(cls.game.player_country),
cls.current_mission.country(cls.game.enemy_country),
cls.game.blue.faction.name,
cls.game.red.faction.name,
cls.current_mission.country(cls.game.blue.country_name),
cls.current_mission.country(cls.game.red.country_name),
mid_point,
)
@@ -95,14 +101,17 @@ class Operation:
@classmethod
def _setup_mission_coalitions(cls) -> None:
cls.current_mission.coalition["blue"] = Coalition(
"blue", bullseye=cls.game.blue_bullseye.to_pydcs()
"blue", bullseye=cls.game.blue.bullseye.to_pydcs()
)
cls.current_mission.coalition["red"] = Coalition(
"red", bullseye=cls.game.red_bullseye.to_pydcs()
"red", bullseye=cls.game.red.bullseye.to_pydcs()
)
cls.current_mission.coalition["neutrals"] = Coalition(
"neutrals", bullseye=Bullseye(Point(0, 0)).to_pydcs()
)
p_country = cls.game.player_country
e_country = cls.game.enemy_country
p_country = cls.game.blue.country_name
e_country = cls.game.red.country_name
cls.current_mission.coalition["blue"].add_country(
country_dict[db.country_id_from_name(p_country)]()
)
@@ -110,6 +119,16 @@ class Operation:
country_dict[db.country_id_from_name(e_country)]()
)
belligerents = [
db.country_id_from_name(p_country),
db.country_id_from_name(e_country),
]
for country in country_dict.keys():
if country not in belligerents:
cls.current_mission.coalition["neutrals"].add_country(
country_dict[country]()
)
@classmethod
def inject_lua_trigger(cls, contents: str, comment: str) -> None:
trigger = TriggerStart(comment=comment)
@@ -146,8 +165,7 @@ class Operation:
def notify_info_generators(
cls,
groundobjectgen: GroundObjectsGenerator,
airsupportgen: AirSupportConflictGenerator,
jtacs: List[JtacInfo],
air_support: AirSupport,
airgen: AircraftConflictGenerator,
) -> None:
"""Generates subscribed MissionInfoGenerator objects (currently kneeboards and briefings)"""
@@ -160,15 +178,15 @@ class Operation:
for dynamic_runway in groundobjectgen.runways.values():
gen.add_dynamic_runway(dynamic_runway)
for tanker in airsupportgen.air_support.tankers:
for tanker in air_support.tankers:
if tanker.blue:
gen.add_tanker(tanker)
for aewc in airsupportgen.air_support.awacs:
for aewc in air_support.awacs:
if aewc.blue:
gen.add_awacs(aewc)
for jtac in jtacs:
for jtac in air_support.jtacs:
if jtac.blue:
gen.add_jtac(jtac)
@@ -193,6 +211,10 @@ class Operation:
for frequency in unique_map_frequencies:
cls.radio_registry.reserve(frequency)
@classmethod
def create_laser_code_registry(cls) -> None:
cls.laser_code_registry = LaserCodeRegistry()
@classmethod
def assign_channels_to_flights(
cls, flights: List[FlightData], air_support: AirSupport
@@ -220,7 +242,7 @@ class Operation:
if beacon.channel is None:
logging.error(f"TACAN beacon has no channel: {beacon.callsign}")
else:
cls.tacan_registry.reserve(beacon.tacan_channel)
cls.tacan_registry.mark_unavailable(beacon.tacan_channel)
@classmethod
def _create_radio_registry(
@@ -268,7 +290,7 @@ class Operation:
and cls.game.settings.perf_destroyed_units
):
cls.current_mission.static_group(
country=cls.current_mission.country(cls.game.player_country),
country=cls.current_mission.country(cls.game.blue.country_name),
name="",
_type=utype,
hidden=True,
@@ -280,18 +302,22 @@ class Operation:
@classmethod
def generate(cls) -> UnitMap:
"""Build the final Mission to be exported"""
cls.air_support = AirSupport()
cls.create_unit_map()
cls.create_radio_registries()
cls.create_laser_code_registry()
# 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()
# Generate ground conflicts first so the JTACs get the first laser code (1688)
# rather than the first player flight with a TGP.
cls._generate_ground_conflicts()
cls._generate_air_units()
cls.assign_channels_to_flights(
cls.airgen.flights, cls.airsupportgen.air_support
)
cls._generate_ground_conflicts()
# Triggers
triggersgen = TriggersGenerator(cls.current_mission, cls.game)
@@ -311,7 +337,7 @@ class Operation:
if cls.game.settings.perf_smoke_gen:
visualgen.generate()
cls.generate_lua(cls.airgen, cls.airsupportgen, cls.jtacs)
cls.generate_lua(cls.airgen, cls.air_support)
# Inject Plugins Lua Scripts and data
cls.plugin_scripts.clear()
@@ -323,9 +349,7 @@ class Operation:
cls.assign_channels_to_flights(
cls.airgen.flights, cls.airsupportgen.air_support
)
cls.notify_info_generators(
cls.groundobjectgen, cls.airsupportgen, cls.jtacs, cls.airgen
)
cls.notify_info_generators(cls.groundobjectgen, cls.air_support, cls.airgen)
cls.reset_naming_ids()
return cls.unit_map
@@ -341,6 +365,7 @@ class Operation:
cls.game,
cls.radio_registry,
cls.tacan_registry,
cls.air_support,
)
cls.airsupportgen.generate()
@@ -351,39 +376,40 @@ class Operation:
cls.game,
cls.radio_registry,
cls.tacan_registry,
cls.laser_code_registry,
cls.unit_map,
air_support=cls.airsupportgen.air_support,
helipads=cls.groundobjectgen.helipads,
)
cls.airgen.clear_parking_slots()
cls.airgen.generate_flights(
cls.current_mission.country(cls.game.player_country),
cls.game.blue_ato,
cls.current_mission.country(cls.game.blue.country_name),
cls.game.blue.ato,
cls.groundobjectgen.runways,
)
cls.airgen.generate_flights(
cls.current_mission.country(cls.game.enemy_country),
cls.game.red_ato,
cls.current_mission.country(cls.game.red.country_name),
cls.game.red.ato,
cls.groundobjectgen.runways,
)
cls.airgen.spawn_unused_aircraft(
cls.current_mission.country(cls.game.player_country),
cls.current_mission.country(cls.game.enemy_country),
cls.current_mission.country(cls.game.blue.country_name),
cls.current_mission.country(cls.game.red.country_name),
)
@classmethod
def _generate_ground_conflicts(cls) -> None:
"""For each frontline in the Operation, generate the ground conflicts and JTACs"""
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_faction.name,
cls.game.enemy_faction.name,
cls.current_mission.country(cls.game.player_country),
cls.current_mission.country(cls.game.enemy_country),
cls.game.blue.faction.name,
cls.game.red.faction.name,
cls.current_mission.country(cls.game.blue.country_name),
cls.current_mission.country(cls.game.red.country_name),
front_line,
cls.game.theater,
)
@@ -397,10 +423,13 @@ class Operation:
player_gp,
enemy_gp,
player_cp.stances[enemy_cp.id],
enemy_cp.stances[player_cp.id],
cls.unit_map,
cls.radio_registry,
cls.air_support,
cls.laser_code_registry,
)
ground_conflict_gen.generate()
cls.jtacs.extend(ground_conflict_gen.jtacs)
@classmethod
def _generate_transports(cls) -> None:
@@ -414,10 +443,7 @@ class Operation:
@classmethod
def generate_lua(
cls,
airgen: AircraftConflictGenerator,
airsupportgen: AirSupportConflictGenerator,
jtacs: List[JtacInfo],
cls, airgen: AircraftConflictGenerator, air_support: AirSupport
) -> None:
# TODO: Refactor this
luaData = {
@@ -430,7 +456,7 @@ class Operation:
"BlueAA": {},
} # type: ignore
for i, tanker in enumerate(airsupportgen.air_support.tankers):
for i, tanker in enumerate(air_support.tankers):
luaData["Tankers"][i] = {
"dcsGroupName": tanker.group_name,
"callsign": tanker.callsign,
@@ -439,20 +465,21 @@ class Operation:
"tacan": str(tanker.tacan.number) + tanker.tacan.band.name,
}
for i, awacs in enumerate(airsupportgen.air_support.awacs):
for i, awacs in enumerate(air_support.awacs):
luaData["AWACs"][i] = {
"dcsGroupName": awacs.group_name,
"callsign": awacs.callsign,
"radio": awacs.freq.mhz,
}
for i, jtac in enumerate(jtacs):
for i, jtac in enumerate(air_support.jtacs):
luaData["JTACs"][i] = {
"dcsGroupName": jtac.group_name,
"callsign": jtac.callsign,
"zone": jtac.region,
"dcsUnit": jtac.unit_name,
"laserCode": jtac.code,
"radio": jtac.freq.mhz,
}
flight_count = 0
for flight in airgen.flights:
@@ -569,7 +596,8 @@ class Operation:
zone = data["zone"]
laserCode = data["laserCode"]
dcsUnit = data["dcsUnit"]
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n"
radio = data["radio"]
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}', radio='{radio}' }}, \n"
lua += "}"
# Process the Target Points

23
game/orderedset.py Normal file
View File

@@ -0,0 +1,23 @@
from collections import Iterator, Iterable
from typing import Generic, TypeVar, Optional
ValueT = TypeVar("ValueT")
class OrderedSet(Generic[ValueT]):
def __init__(self, initial_data: Optional[Iterable[ValueT]] = None) -> None:
if initial_data is None:
initial_data = []
self._data: dict[ValueT, None] = {v: None for v in initial_data}
def __iter__(self) -> Iterator[ValueT]:
yield from self._data
def __contains__(self, item: ValueT) -> bool:
return item in self._data
def add(self, item: ValueT) -> None:
self._data[item] = None
def clear(self) -> None:
self._data.clear()

View File

@@ -1,15 +1,16 @@
from __future__ import annotations
from dcs import Point
from game.utils import Heading
class PointWithHeading(Point):
def __init__(self) -> None:
super(PointWithHeading, self).__init__(0, 0)
self.heading = 0
self.heading: Heading = Heading.from_degrees(0)
@staticmethod
def from_point(point: Point, heading: int) -> PointWithHeading:
def from_point(point: Point, heading: Heading) -> PointWithHeading:
p = PointWithHeading()
p.x = point.x
p.y = point.y

View File

@@ -7,11 +7,11 @@ from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
from game import db
from game.data.groundunitclass import GroundUnitClass
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction
from game.squadrons import Squadron
from game.theater import ControlPoint, MissionTarget
from game.utils import Distance
from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
@@ -25,15 +25,13 @@ FRONTLINE_RESERVES_FACTOR = 1.3
@dataclass(frozen=True)
class AircraftProcurementRequest:
near: MissionTarget
range: Distance
task_capability: FlightType
number: int
def __str__(self) -> str:
task = self.task_capability.value
distance = self.range.nautical_miles
target = self.near.name
return f"{self.number} ship {task} within {distance} nm of {target}"
return f"{self.number} ship {task} near {target}"
class ProcurementAi:
@@ -72,9 +70,11 @@ class ProcurementAi:
return 1
for cp in self.owned_points:
cp_ground_units = cp.allocated_ground_units(self.game.transfers)
cp_ground_units = cp.allocated_ground_units(
self.game.coalition_for(self.is_player).transfers
)
armor_investment += cp_ground_units.total_value
cp_aircraft = cp.allocated_aircraft(self.game)
cp_aircraft = cp.allocated_aircraft()
aircraft_investment += cp_aircraft.total_value
total_investment = aircraft_investment + armor_investment
@@ -97,37 +97,10 @@ class ProcurementAi:
budget -= armor_budget
budget += self.reinforce_front_line(armor_budget)
# Don't sell overstock aircraft until after we've bought runways and
# front lines. Any budget we free up should be earmarked for aircraft.
if not self.is_player:
budget += self.sell_incomplete_squadrons()
if self.manage_aircraft:
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/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/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)
for aircraft, available in inventory.all_aircraft:
# We only ever plan even groups, so the odd aircraft is unlikely
# to get used.
if available % 2 == 0:
continue
inventory.remove_aircraft(aircraft, 1)
total += aircraft.price
return total
def repair_runways(self, budget: float) -> float:
for control_point in self.owned_points:
if budget < db.RUNWAY_REPAIR_COST:
@@ -180,7 +153,7 @@ class ProcurementAi:
break
budget -= unit.price
cp.pending_unit_deliveries.order({unit: 1})
cp.ground_unit_orders.order({unit: 1})
return budget
@@ -209,67 +182,29 @@ class ProcurementAi:
return GroundUnitClass.Tank
return worst_balanced
def _affordable_aircraft_for_task(
self,
task: FlightType,
airbase: ControlPoint,
number: int,
max_price: float,
) -> Optional[AircraftType]:
best_choice: Optional[AircraftType] = None
for unit in aircraft_for_task(task):
if unit not in self.faction.aircrafts:
continue
if unit.price * number > max_price:
continue
if not airbase.can_operate(unit):
continue
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
return best_choice
def affordable_aircraft_for(
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
) -> Optional[AircraftType]:
return self._affordable_aircraft_for_task(
request.task_capability, airbase, request.number, budget
)
@staticmethod
def fulfill_aircraft_request(
self, request: AircraftProcurementRequest, budget: float
squadrons: list[Squadron], quantity: int, 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.
for squadron in squadrons:
price = squadron.aircraft.price * quantity
if price > budget:
continue
budget -= unit.price * request.number
airbase.pending_unit_deliveries.order({unit: request.number})
squadron.pending_deliveries += quantity
budget -= price
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)):
for request in self.game.coalition_for(self.is_player).procurement_requests:
squadrons = list(self.best_squadrons_for(request))
if not squadrons:
# No airbases in range of this request. Skip it.
continue
budget, fulfilled = self.fulfill_aircraft_request(request, budget)
budget, fulfilled = self.fulfill_aircraft_request(
squadrons, request.number, 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
@@ -286,19 +221,21 @@ class ProcurementAi:
else:
return self.game.theater.enemy_points()
def best_airbases_for(
def best_squadrons_for(
self, request: AircraftProcurementRequest
) -> Iterator[ControlPoint]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
) -> Iterator[Squadron]:
threatened = []
for cp in distance_cache.operational_airfields_within(request.range):
if not cp.is_friendly(self.is_player):
for squadron in self.air_wing.best_squadrons_for(
request.near, request.task_capability, request.number, this_turn=False
):
if not squadron.can_provide_pilots(request.number):
continue
if cp.unclaimed_parking(self.game) < request.number:
if squadron.location.unclaimed_parking() < request.number:
continue
if self.threat_zones.threatened(cp.position):
threatened.append(cp)
yield cp
if self.threat_zones.threatened(squadron.location.position):
threatened.append(squadron)
continue
yield squadron
yield from threatened
def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
@@ -316,7 +253,9 @@ class ProcurementAi:
continue
purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR
allocated = cp.allocated_ground_units(self.game.transfers)
allocated = cp.allocated_ground_units(
self.game.coalition_for(self.is_player).transfers
)
if allocated.total >= purchase_target:
# Control point is already sufficiently defended.
continue
@@ -343,7 +282,9 @@ class ProcurementAi:
if not cp.can_recruit_ground_units(self.game):
continue
allocated = cp.allocated_ground_units(self.game.transfers)
allocated = cp.allocated_ground_units(
self.game.coalition_for(self.is_player).transfers
)
if allocated.total >= self.game.settings.reserves_procurement_target:
continue
@@ -356,7 +297,9 @@ class ProcurementAi:
def cost_ratio_of_ground_unit(
self, control_point: ControlPoint, unit_class: GroundUnitClass
) -> float:
allocations = control_point.allocated_ground_units(self.game.transfers)
allocations = control_point.allocated_ground_units(
self.game.coalition_for(self.is_player).transfers
)
class_cost = 0
total_cost = 0
for unit_type, count in allocations.all.items():

186
game/purchaseadapter.py Normal file
View File

@@ -0,0 +1,186 @@
from abc import abstractmethod
from typing import TypeVar, Generic, Any
from game import Game
from game.coalition import Coalition
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.dcs.unittype import UnitType
from game.squadrons import Squadron
from game.theater import ControlPoint
ItemType = TypeVar("ItemType")
class TransactionError(RuntimeError):
def __init__(self, message: str) -> None:
super().__init__(message)
class PurchaseAdapter(Generic[ItemType]):
def __init__(self, coalition: Coalition) -> None:
self.coalition = coalition
def buy(self, item: ItemType, quantity: int) -> None:
for _ in range(quantity):
if self.has_pending_sales(item):
self.do_cancel_sale(item)
elif self.can_buy(item):
self.do_purchase(item)
else:
raise TransactionError(f"Cannot buy more {item}")
self.coalition.adjust_budget(-self.price_of(item))
def sell(self, item: ItemType, quantity: int) -> None:
for _ in range(quantity):
if self.has_pending_orders(item):
self.do_cancel_purchase(item)
elif self.can_sell(item):
self.do_sale(item)
else:
raise TransactionError(f"Cannot sell more {item}")
self.coalition.adjust_budget(self.price_of(item))
def has_pending_orders(self, item: ItemType) -> bool:
return self.pending_delivery_quantity(item) > 0
def has_pending_sales(self, item: ItemType) -> bool:
return self.pending_delivery_quantity(item) < 0
@abstractmethod
def current_quantity_of(self, item: ItemType) -> int:
...
@abstractmethod
def pending_delivery_quantity(self, item: ItemType) -> int:
...
def expected_quantity_next_turn(self, item: ItemType) -> int:
return self.current_quantity_of(item) + self.pending_delivery_quantity(item)
def can_buy(self, item: ItemType) -> bool:
return self.coalition.budget >= self.price_of(item)
def can_sell_or_cancel(self, item: ItemType) -> bool:
return self.can_sell(item) or self.has_pending_orders(item)
@abstractmethod
def can_sell(self, item: ItemType) -> bool:
...
@abstractmethod
def do_purchase(self, item: ItemType) -> None:
...
@abstractmethod
def do_cancel_purchase(self, item: ItemType) -> None:
...
@abstractmethod
def do_sale(self, item: ItemType) -> None:
...
@abstractmethod
def do_cancel_sale(self, item: ItemType) -> None:
...
@abstractmethod
def price_of(self, item: ItemType) -> int:
...
@abstractmethod
def name_of(self, item: ItemType, multiline: bool = False) -> str:
...
@abstractmethod
def unit_type_of(self, item: ItemType) -> UnitType[Any]:
...
class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]):
def __init__(self, control_point: ControlPoint) -> None:
super().__init__(control_point.coalition)
self.control_point = control_point
def pending_delivery_quantity(self, item: Squadron) -> int:
return item.pending_deliveries
def current_quantity_of(self, item: Squadron) -> int:
return item.owned_aircraft
def can_buy(self, item: Squadron) -> bool:
return super().can_buy(item) and self.control_point.unclaimed_parking() > 0
def can_sell(self, item: Squadron) -> bool:
return item.untasked_aircraft > 0
def do_purchase(self, item: Squadron) -> None:
item.pending_deliveries += 1
def do_cancel_purchase(self, item: Squadron) -> None:
item.pending_deliveries -= 1
def do_sale(self, item: Squadron) -> None:
item.untasked_aircraft -= 1
item.pending_deliveries -= 1
def do_cancel_sale(self, item: Squadron) -> None:
item.untasked_aircraft += 1
item.pending_deliveries += 1
def price_of(self, item: Squadron) -> int:
return item.aircraft.price
def name_of(self, item: Squadron, multiline: bool = False) -> str:
if multiline:
separator = "<br />"
else:
separator = " "
return separator.join([item.aircraft.name, str(item)])
def unit_type_of(self, item: Squadron) -> AircraftType:
return item.aircraft
class GroundUnitPurchaseAdapter(PurchaseAdapter[GroundUnitType]):
def __init__(
self, control_point: ControlPoint, coalition: Coalition, game: Game
) -> None:
super().__init__(coalition)
self.control_point = control_point
self.game = game
def pending_delivery_quantity(self, item: GroundUnitType) -> int:
return self.control_point.ground_unit_orders.pending_orders(item)
def current_quantity_of(self, item: GroundUnitType) -> int:
return self.control_point.base.total_units_of_type(item)
def can_buy(self, item: GroundUnitType) -> bool:
return super().can_buy(item) and self.control_point.has_ground_unit_source(
self.game
)
def can_sell(self, item: GroundUnitType) -> bool:
return False
def do_purchase(self, item: GroundUnitType) -> None:
self.control_point.ground_unit_orders.order({item: 1})
def do_cancel_purchase(self, item: GroundUnitType) -> None:
self.control_point.ground_unit_orders.sell({item: 1})
def do_sale(self, item: GroundUnitType) -> None:
raise TransactionError("Sale of ground units not allowed")
def do_cancel_sale(self, item: GroundUnitType) -> None:
raise TransactionError("Sale of ground units not allowed")
def price_of(self, item: GroundUnitType) -> int:
return item.price
def name_of(self, item: GroundUnitType, multiline: bool = False) -> str:
return f"{item}"
def unit_type_of(self, item: GroundUnitType) -> GroundUnitType:
return item

View File

@@ -4,7 +4,8 @@ from dataclasses import dataclass
from typing import Optional, Any, TYPE_CHECKING
if TYPE_CHECKING:
from gen import FlightData, AirSupport
from gen.aircraft import FlightData
from gen.airsupport import AirSupport
class RadioChannelAllocator:
@@ -72,6 +73,9 @@ class CommonRadioChannelAllocator(RadioChannelAllocator):
for awacs in air_support.awacs:
flight.assign_channel(radio_id, next(channel_alloc), awacs.freq)
for jtac in air_support.jtacs:
flight.assign_channel(radio_id, next(channel_alloc), jtac.freq)
if flight.arrival != flight.departure and flight.arrival.atc is not None:
flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc)

View File

@@ -1,115 +0,0 @@
from dataclasses import dataclass, field
from datetime import timedelta
from enum import Enum, unique
from typing import Dict, Optional, Any
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:
# Difficulty settings
player_skill: str = "Good"
enemy_skill: str = "Average"
ai_pilot_levelling: bool = True
enemy_vehicle_skill: str = "Average"
map_coalition_visibility: ForcedOptions.Views = ForcedOptions.Views.All
labels: str = "Full"
only_player_takeoff: bool = True # Legacy parameter do not use
night_disabled: bool = False
external_views_allowed: bool = True
supercarrier: bool = False
generate_marks: bool = True
manpads: bool = True
version: Optional[str] = None
player_income_multiplier: float = 1.0
enemy_income_multiplier: float = 1.0
#: Feature flag for squadron limits.
enable_squadron_pilot_limits: bool = False
#: The maximum number of pilots a squadron can have at one time. Changing this after
#: the campaign has started will have no immediate effect; pilots already in the
#: squadron will not be removed if the limit is lowered and pilots will not be
#: immediately created if the limit is raised.
squadron_pilot_limit: int = 12
#: The number of pilots a squadron can replace per turn.
squadron_replenishment_rate: int = 4
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 = True
disable_legacy_tanker: 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
perf_culling_distance: int = 100
perf_do_not_cull_carrier = True
# LUA Plugins system
plugins: Dict[str, bool] = field(default_factory=dict)
# Cheating
show_red_ato: bool = False
enable_frontline_cheats: bool = False
enable_base_capture_cheat: bool = False
never_delay_player_flights: bool = False
@staticmethod
def plugin_settings_key(identifier: str) -> str:
return f"plugins.{identifier}"
def initialize_plugin_option(self, identifier: str, default_value: bool) -> None:
try:
self.plugin_option(identifier)
except KeyError:
self.set_plugin_option(identifier, default_value)
def plugin_option(self, identifier: str) -> bool:
return self.plugins[self.plugin_settings_key(identifier)]
def set_plugin_option(self, identifier: str, enabled: bool) -> None:
self.plugins[self.plugin_settings_key(identifier)] = enabled
def __setstate__(self, state: dict[str, Any]) -> None:
# __setstate__ is called with the dict of the object being unpickled. We
# can provide save compatibility for new settings options (which
# normally would not be present in the unpickled object) by creating a
# new settings object, updating it with the unpickled state, and
# updating our dict with that.
new_state = Settings().__dict__
new_state.update(state)
self.__dict__.update(new_state)

View File

@@ -0,0 +1,7 @@
from .booleanoption import BooleanOption
from .boundedfloatoption import BoundedFloatOption
from .boundedintoption import BoundedIntOption
from .choicesoption import ChoicesOption
from .minutesoption import MinutesOption
from .optiondescription import OptionDescription
from .settings import AutoAtoBehavior, Settings

View File

@@ -0,0 +1,37 @@
from dataclasses import dataclass, field
from typing import Any, Optional
from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY
@dataclass(frozen=True)
class BooleanOption(OptionDescription):
invert: bool
def boolean_option(
text: str,
page: str,
section: str,
default: bool,
invert: bool = False,
detail: Optional[str] = None,
tooltip: Optional[str] = None,
causes_expensive_game_update: bool = False,
**kwargs: Any,
) -> bool:
return field(
metadata={
SETTING_DESCRIPTION_KEY: BooleanOption(
page,
section,
text,
detail,
tooltip,
causes_expensive_game_update,
invert,
)
},
default=default,
**kwargs,
)

View File

@@ -0,0 +1,42 @@
from dataclasses import dataclass, field
from typing import Any, Optional
from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY
@dataclass(frozen=True)
class BoundedFloatOption(OptionDescription):
min: float
max: float
divisor: int
def bounded_float_option(
text: str,
page: str,
section: str,
default: float,
min: float,
max: float,
divisor: int,
detail: Optional[str] = None,
tooltip: Optional[str] = None,
**kwargs: Any,
) -> float:
return field(
metadata={
SETTING_DESCRIPTION_KEY: BoundedFloatOption(
page,
section,
text,
detail,
tooltip,
causes_expensive_game_update=False,
min=min,
max=max,
divisor=divisor,
)
},
default=default,
**kwargs,
)

View File

@@ -0,0 +1,39 @@
from dataclasses import dataclass, field
from typing import Any, Optional
from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY
@dataclass(frozen=True)
class BoundedIntOption(OptionDescription):
min: int
max: int
def bounded_int_option(
text: str,
page: str,
section: str,
default: int,
min: int,
max: int,
detail: Optional[str] = None,
tooltip: Optional[str] = None,
**kwargs: Any,
) -> int:
return field(
metadata={
SETTING_DESCRIPTION_KEY: BoundedIntOption(
page,
section,
text,
detail,
tooltip,
causes_expensive_game_update=False,
min=min,
max=max,
)
},
default=default,
**kwargs,
)

View File

@@ -0,0 +1,46 @@
from dataclasses import dataclass, field
from typing import Any, Generic, Iterable, Mapping, Optional, TypeVar, Union
from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY
ValueT = TypeVar("ValueT")
@dataclass(frozen=True)
class ChoicesOption(OptionDescription, Generic[ValueT]):
choices: dict[str, ValueT]
def text_for_value(self, value: ValueT) -> str:
for text, _value in self.choices.items():
if value == _value:
return text
raise ValueError(f"{self} does not contain {value}")
def choices_option(
text: str,
page: str,
section: str,
default: ValueT,
choices: Union[Iterable[str], Mapping[str, ValueT]],
detail: Optional[str] = None,
tooltip: Optional[str] = None,
**kwargs: Any,
) -> ValueT:
if not isinstance(choices, Mapping):
choices = {c: c for c in choices}
return field(
metadata={
SETTING_DESCRIPTION_KEY: ChoicesOption(
page,
section,
text,
detail,
tooltip,
causes_expensive_game_update=False,
choices=dict(choices),
)
},
default=default,
**kwargs,
)

View File

@@ -0,0 +1,40 @@
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any, Optional
from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY
@dataclass(frozen=True)
class MinutesOption(OptionDescription):
min: int
max: int
def minutes_option(
text: str,
page: str,
section: str,
default: timedelta,
min: int,
max: int,
detail: Optional[str] = None,
tooltip: Optional[str] = None,
**kwargs: Any,
) -> timedelta:
return field(
metadata={
SETTING_DESCRIPTION_KEY: MinutesOption(
page,
section,
text,
detail,
tooltip,
causes_expensive_game_update=False,
min=min,
max=max,
)
},
default=default,
**kwargs,
)

View File

@@ -0,0 +1,15 @@
from dataclasses import dataclass
from typing import Optional
SETTING_DESCRIPTION_KEY = "DCS_LIBERATION_SETTING_DESCRIPTION_KEY"
@dataclass(frozen=True)
class OptionDescription:
page: str
section: str
text: str
detail: Optional[str]
tooltip: Optional[str]
causes_expensive_game_update: bool

486
game/settings/settings.py Normal file
View File

@@ -0,0 +1,486 @@
from collections import Iterator
from dataclasses import Field, dataclass, field, fields
from datetime import timedelta
from enum import Enum, unique
from typing import Any, Dict, Optional
from dcs.forcedoptions import ForcedOptions
from .booleanoption import boolean_option
from .boundedfloatoption import bounded_float_option
from .boundedintoption import bounded_int_option
from .choicesoption import choices_option
from .minutesoption import minutes_option
from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY
from .skilloption import skill_option
@unique
class AutoAtoBehavior(Enum):
Disabled = "Disabled"
Never = "Never assign player pilots"
Default = "No preference"
Prefer = "Prefer player pilots"
DIFFICULTY_PAGE = "Difficulty"
AI_DIFFICULTY_SECTION = "AI Difficulty"
MISSION_DIFFICULTY_SECTION = "Mission Difficulty"
MISSION_RESTRICTIONS_SECTION = "Mission Restrictions"
CAMPAIGN_MANAGEMENT_PAGE = "Campaign Management"
GENERAL_SECTION = "General"
PILOTS_AND_SQUADRONS_SECTION = "Pilots and Squadrons"
HQ_AUTOMATION_SECTION = "HQ Automation"
MISSION_GENERATOR_PAGE = "Mission Generator"
GAMEPLAY_SECTION = "Gameplay"
# TODO: Make sections a type and add headers.
# This section had the header: "Disabling settings below may improve performance, but
# will impact the overall quality of the experience."
PERFORMANCE_SECTION = "Performance"
@dataclass
class Settings:
version: Optional[str] = None
# Difficulty settings
# AI Difficulty
player_skill: str = skill_option(
"Player coalition skill",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
default="Good",
)
enemy_skill: str = skill_option(
"Enemy coalition skill",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
default="Average",
)
enemy_vehicle_skill: str = skill_option(
"Enemy AA and vehicles skill",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
default="Average",
)
player_income_multiplier: float = bounded_float_option(
"Player income multiplier",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
min=0,
max=5,
divisor=10,
default=1.0,
)
enemy_income_multiplier: float = bounded_float_option(
"Enemy income multiplier",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
min=0,
max=5,
divisor=10,
default=1.0,
)
invulnerable_player_pilots: bool = boolean_option(
"Player pilots cannot be killed",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
detail=(
"Aircraft are vulnerable, but the player's pilot will be returned to the "
"squadron at the end of the mission"
),
default=True,
)
# Mission Difficulty
manpads: bool = boolean_option(
"Manpads on frontlines",
page=DIFFICULTY_PAGE,
section=MISSION_DIFFICULTY_SECTION,
default=True,
)
night_disabled: bool = boolean_option(
"No night missions",
page=DIFFICULTY_PAGE,
section=MISSION_DIFFICULTY_SECTION,
default=False,
)
# Mission Restrictions
labels: str = choices_option(
"In game labels",
page=DIFFICULTY_PAGE,
section=MISSION_RESTRICTIONS_SECTION,
choices=["Full", "Abbreviated", "Dot Only", "Neutral Dot", "Off"],
default="Full",
)
map_coalition_visibility: ForcedOptions.Views = choices_option(
"Map visibility options",
page=DIFFICULTY_PAGE,
section=MISSION_RESTRICTIONS_SECTION,
choices={
"All": ForcedOptions.Views.All,
"Fog of war": ForcedOptions.Views.Allies,
"Allies only": ForcedOptions.Views.OnlyAllies,
"Own aircraft only": ForcedOptions.Views.MyAircraft,
"Map only": ForcedOptions.Views.OnlyMap,
},
default=ForcedOptions.Views.All,
)
external_views_allowed: bool = boolean_option(
"Allow external views",
DIFFICULTY_PAGE,
MISSION_RESTRICTIONS_SECTION,
default=True,
)
battle_damage_assessment: Optional[bool] = choices_option(
"Battle damage assessment",
page=DIFFICULTY_PAGE,
section=MISSION_RESTRICTIONS_SECTION,
choices={"Player preference": None, "Enforced on": True, "Enforced off": False},
default=None,
)
# Campaign management
# General
restrict_weapons_by_date: bool = boolean_option(
"Restrict weapons by date (WIP)",
page=CAMPAIGN_MANAGEMENT_PAGE,
section=GENERAL_SECTION,
default=False,
detail=(
"Restricts weapon availability based on the campaign date. Data is "
"extremely incomplete so does not affect all weapons."
),
)
disable_legacy_aewc: bool = boolean_option(
"Spawn invulnerable, always-available AEW&C aircraft (deprecated)",
page=CAMPAIGN_MANAGEMENT_PAGE,
section=GENERAL_SECTION,
default=True,
invert=True,
detail=(
"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."
),
)
disable_legacy_tanker: bool = boolean_option(
"Spawn invulnerable, always-available tanker aircraft (deprecated)",
page=CAMPAIGN_MANAGEMENT_PAGE,
section=GENERAL_SECTION,
default=True,
invert=True,
detail=(
"If checked, an invulnerable friendly tanker aircraft that begins the "
"mission on station will be be spawned. This behavior will be removed in a "
"future release."
),
)
# Pilots and Squadrons
ai_pilot_levelling: bool = boolean_option(
"Allow AI pilot leveling",
CAMPAIGN_MANAGEMENT_PAGE,
PILOTS_AND_SQUADRONS_SECTION,
default=True,
detail=(
"Set whether or not AI pilots will level up after completing a number of"
" sorties. Since pilot level affects the AI skill, you may wish to disable"
" this, lest you face an Ace!"
),
)
#: Feature flag for squadron limits.
enable_squadron_pilot_limits: bool = boolean_option(
"Enable per-squadron pilot limits (WIP)",
CAMPAIGN_MANAGEMENT_PAGE,
PILOTS_AND_SQUADRONS_SECTION,
default=False,
detail=(
"If set, squadrons will be limited to a maximum number of pilots and dead "
"pilots will replenish at a fixed rate, each defined with the settings"
"below. Auto-purchase may buy aircraft for which there are no pilots"
"available, so this feature is still a work-in-progress."
),
)
#: The maximum number of pilots a squadron can have at one time. Changing this after
#: the campaign has started will have no immediate effect; pilots already in the
#: squadron will not be removed if the limit is lowered and pilots will not be
#: immediately created if the limit is raised.
squadron_pilot_limit: int = bounded_int_option(
"Maximum number of pilots per squadron",
CAMPAIGN_MANAGEMENT_PAGE,
PILOTS_AND_SQUADRONS_SECTION,
default=12,
min=12,
max=72,
detail=(
"Sets the maximum number of pilots a squadron may have active. "
"Changing this value will not have an immediate effect, but will alter "
"replenishment for future turns."
),
)
#: The number of pilots a squadron can replace per turn.
squadron_replenishment_rate: int = bounded_int_option(
"Squadron pilot replenishment rate",
CAMPAIGN_MANAGEMENT_PAGE,
PILOTS_AND_SQUADRONS_SECTION,
default=4,
min=1,
max=20,
detail=(
"Sets the maximum number of pilots that will be recruited to each squadron "
"at the end of each turn. Squadrons will not recruit new pilots beyond the "
"pilot limit, but each squadron with room for more pilots will recruit "
"this many pilots each turn up to the limit."
),
)
# HQ Automation
automate_runway_repair: bool = boolean_option(
"Automate runway repairs",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=False,
)
automate_front_line_reinforcements: bool = boolean_option(
"Automate front-line purchases",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=False,
)
automate_aircraft_reinforcements: bool = boolean_option(
"Automate aircraft purchases",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=False,
)
auto_ato_behavior: AutoAtoBehavior = choices_option(
"Automatic package planning behavior",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=AutoAtoBehavior.Default,
choices={v.value: v for v in AutoAtoBehavior},
detail=(
"Aircraft auto-purchase is directed by the auto-planner, so disabling "
"auto-planning disables auto-purchase."
),
)
auto_ato_player_missions_asap: bool = boolean_option(
"Automatically generated packages with players are scheduled ASAP",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=True,
)
automate_front_line_stance: bool = boolean_option(
"Automatically manage front line stances",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=True,
)
reserves_procurement_target: int = 10
# Mission Generator
# Gameplay
supercarrier: bool = boolean_option(
"Use supercarrier module",
MISSION_GENERATOR_PAGE,
GAMEPLAY_SECTION,
default=False,
)
generate_marks: bool = boolean_option(
"Put objective markers on the map",
MISSION_GENERATOR_PAGE,
GAMEPLAY_SECTION,
default=True,
)
generate_dark_kneeboard: bool = boolean_option(
"Generate dark kneeboard",
MISSION_GENERATOR_PAGE,
GAMEPLAY_SECTION,
default=False,
detail=(
"Dark kneeboard for night missions. This will likely make the kneeboard on "
"the pilot leg unreadable."
),
)
never_delay_player_flights: bool = boolean_option(
"Player flights ignore TOT and spawn immediately",
MISSION_GENERATOR_PAGE,
GAMEPLAY_SECTION,
default=False,
detail=(
"Does not adjust package waypoint times. Should not be used if players "
"have runway or in-air starts."
),
tooltip=(
"Always spawns player aircraft immediately, even if their start time is "
"more than 10 minutes after the start of the mission. <strong>This does "
"not alter the timing of your mission. Your TOT will not change. This "
"option only allows the player to wait on the ground.</strong>"
),
)
default_start_type: str = choices_option(
"Default start type for AI aircraft",
page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION,
choices=["Cold", "Warm", "Runway", "In Flight"],
default="Cold",
detail=(
"Warning: Options other than Cold will significantly reduce the number of "
"targets available for OCA/Aircraft missions, and OCA/Aircraft flights "
"will not be included in automatically planned OCA packages."
),
)
# Mission specific
desired_player_mission_duration: timedelta = minutes_option(
"Desired mission duration",
page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION,
default=timedelta(minutes=60),
min=30,
max=150,
)
# Performance
perf_smoke_gen: bool = boolean_option(
"Smoke visual effect on the front line",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
perf_smoke_spacing: int = bounded_int_option(
"Smoke generator spacing (higher means less smoke)",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=1600,
min=800,
max=24000,
)
perf_red_alert_state: bool = boolean_option(
"SAM starts in red alert mode",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
perf_artillery: bool = boolean_option(
"Artillery strikes",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
perf_moving_units: bool = boolean_option(
"Moving ground units",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
perf_infantry: bool = boolean_option(
"Generate infantry squads alongside vehicles",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
perf_destroyed_units: bool = boolean_option(
"Generate carcasses for units destroyed in previous turns",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
# Performance culling
perf_culling: bool = boolean_option(
"Culling of distant units enabled",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=False,
)
perf_culling_distance: int = bounded_int_option(
"Culling distance (km)",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=100,
min=10,
max=10000,
)
perf_do_not_cull_carrier: bool = boolean_option(
"Do not cull carrier's surroundings",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
causes_expensive_game_update=True,
)
# Cheating. Not using auto settings because the same page also has buttons which do
# not alter settings.
show_red_ato: bool = False
enable_frontline_cheats: bool = False
enable_base_capture_cheat: bool = False
# LUA Plugins system
plugins: Dict[str, bool] = field(default_factory=dict)
only_player_takeoff: bool = True # Legacy parameter do not use
@staticmethod
def plugin_settings_key(identifier: str) -> str:
return f"plugins.{identifier}"
def initialize_plugin_option(self, identifier: str, default_value: bool) -> None:
try:
self.plugin_option(identifier)
except KeyError:
self.set_plugin_option(identifier, default_value)
def plugin_option(self, identifier: str) -> bool:
return self.plugins[self.plugin_settings_key(identifier)]
def set_plugin_option(self, identifier: str, enabled: bool) -> None:
self.plugins[self.plugin_settings_key(identifier)] = enabled
def __setstate__(self, state: dict[str, Any]) -> None:
# __setstate__ is called with the dict of the object being unpickled. We
# can provide save compatibility for new settings options (which
# normally would not be present in the unpickled object) by creating a
# new settings object, updating it with the unpickled state, and
# updating our dict with that.
new_state = Settings().__dict__
new_state.update(state)
self.__dict__.update(new_state)
@classmethod
def _field_description(cls, settings_field: Field[Any]) -> OptionDescription:
return settings_field.metadata[SETTING_DESCRIPTION_KEY]
@classmethod
def pages(cls) -> Iterator[str]:
seen: set[str] = set()
for settings_field in cls._user_fields():
description = cls._field_description(settings_field)
if description.page not in seen:
yield description.page
seen.add(description.page)
@classmethod
def sections(cls, page: str) -> Iterator[str]:
seen: set[str] = set()
for settings_field in cls._user_fields():
description = cls._field_description(settings_field)
if description.page == page and description.section not in seen:
yield description.section
seen.add(description.section)
@classmethod
def fields(cls, page: str, section: str) -> Iterator[tuple[str, OptionDescription]]:
for settings_field in cls._user_fields():
description = cls._field_description(settings_field)
if description.page == page and description.section == section:
yield settings_field.name, description
@classmethod
def _user_fields(cls) -> Iterator[Field[Any]]:
for settings_field in fields(cls):
if SETTING_DESCRIPTION_KEY in settings_field.metadata:
yield settings_field

View File

@@ -0,0 +1,24 @@
from typing import Any, Optional
from .choicesoption import choices_option
def skill_option(
text: str,
page: str,
section: str,
default: str,
detail: Optional[str] = None,
tooltip: Optional[str] = None,
**kwargs: Any,
) -> str:
return choices_option(
text,
page,
section,
default,
["Average", "Good", "High", "Excellent"],
detail=detail,
tooltip=tooltip,
**kwargs,
)

View File

@@ -1,456 +0,0 @@
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 (
Tuple,
TYPE_CHECKING,
Optional,
Iterator,
Sequence,
Any,
)
import yaml
from faker import Faker
from game.dcs.aircrafttype import AircraftType
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: Optional[str]
country: str
role: str
aircraft: AircraftType
livery: Optional[str]
mission_types: tuple[FlightType, ...]
#: The pool of pilots that have not yet been assigned to the squadron. This only
#: happens when a preset squadron defines more preset pilots than the squadron limit
#: allows. This pool will be consumed before random pilots are generated.
pilot_pool: list[Pilot]
current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False)
available_pilots: list[Pilot] = field(
default_factory=list, 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:
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
raise ValueError("Squadrons can only be created with active pilots.")
self._recruit_pilots(self.game.settings.squadron_pilot_limit)
self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str:
if self.nickname is None:
return self.name
return f'{self.name} "{self.nickname}"'
@property
def pilot_limits_enabled(self) -> bool:
return self.game.settings.enable_squadron_pilot_limits
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
if self.pilot_limits_enabled:
return None
self._recruit_pilots(1)
return self.available_pilots.pop()
def claim_available_pilot(self) -> Optional[Pilot]:
if not self.available_pilots:
return self.claim_new_pilot_if_allowed()
# 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.
#
# If they only *prefer* players and we're out of players, just return an AI
# pilot.
if not prefer_players:
return self.claim_new_pilot_if_allowed()
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 _recruit_pilots(self, count: int) -> None:
new_pilots = self.pilot_pool[:count]
self.pilot_pool = self.pilot_pool[count:]
count -= len(new_pilots)
new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)])
self.current_roster.extend(new_pilots)
self.available_pilots.extend(new_pilots)
def replenish_lost_pilots(self) -> None:
if not self.pilot_limits_enabled:
return
replenish_count = min(
self.game.settings.squadron_replenishment_rate,
self._number_of_unfilled_pilot_slots,
)
if replenish_count > 0:
self._recruit_pilots(replenish_count)
def return_all_pilots(self) -> None:
self.available_pilots = list(self.active_pilots)
@staticmethod
def send_on_leave(pilot: Pilot) -> None:
pilot.send_on_leave()
def return_from_leave(self, pilot: Pilot) -> None:
if not self.has_unfilled_pilot_slots:
raise RuntimeError(
f"Cannot return {pilot} from leave because {self} is full"
)
pilot.return_from_leave()
@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.current_roster if p.status == status]
def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster 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_inactive(self) -> int:
return len(self.current_roster)
@property
def _number_of_unfilled_pilot_slots(self) -> int:
return self.game.settings.squadron_pilot_limit - len(self.active_pilots)
@property
def number_of_available_pilots(self) -> int:
return len(self.available_pilots)
def can_provide_pilots(self, count: int) -> bool:
return not self.pilot_limits_enabled or self.number_of_available_pilots >= count
@property
def has_available_pilots(self) -> bool:
return not self.pilot_limits_enabled or bool(self.available_pilots)
@property
def has_unfilled_pilot_slots(self) -> bool:
return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
def pilot_at_index(self, index: int) -> Pilot:
return self.current_roster[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(encoding="utf8") as squadron_file:
data = yaml.safe_load(squadron_file)
name = data["aircraft"]
try:
unit_type = AircraftType.named(name)
except KeyError as ex:
raise KeyError(f"Could not find any aircraft named {name}") from ex
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.get("nickname"),
country=data["country"],
role=data["role"],
aircraft=unit_type,
livery=data.get("livery"),
mission_types=tuple(mission_types),
pilot_pool=pilots,
game=game,
player=player,
)
def __setstate__(self, state: dict[str, Any]) -> None:
# TODO: Remove save compat.
if "auto_assignable_mission_types" not in state:
state["auto_assignable_mission_types"] = set(state["mission_types"])
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[AircraftType, list[Squadron]]:
squadrons: dict[AircraftType, 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)),
pilot_pool=[],
game=game,
player=player,
)
]
def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]:
return self.squadrons[aircraft]
def can_auto_plan(self, task: FlightType) -> bool:
try:
next(self.auto_assignable_for_task(task))
return True
except StopIteration:
return False
def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]:
for squadron in self.iter_squadrons():
if squadron.can_auto_assign(task):
yield squadron
def auto_assignable_for_task_with_type(
self, aircraft: AircraftType, task: FlightType
) -> Iterator[Squadron]:
for squadron in self.squadrons_for(aircraft):
if squadron.can_auto_assign(task) and squadron.has_available_pilots:
yield squadron
def squadron_for(self, aircraft: AircraftType) -> 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 replenish(self) -> None:
for squadron in self.iter_squadrons():
squadron.replenish_lost_pilots()
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

View File

@@ -0,0 +1,3 @@
from .airwing import AirWing
from .pilot import Pilot
from .squadron import Squadron

107
game/squadrons/airwing.py Normal file
View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import itertools
from collections import defaultdict
from typing import Sequence, Iterator, TYPE_CHECKING, Optional
from game.dcs.aircrafttype import AircraftType
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from .squadron import Squadron
from ..theater import ControlPoint, MissionTarget
if TYPE_CHECKING:
from gen.flights.flight import FlightType
class AirWing:
def __init__(self, player: bool) -> None:
self.player = player
self.squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
def add_squadron(self, squadron: Squadron) -> None:
self.squadrons[squadron.aircraft].append(squadron)
def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]:
return self.squadrons[aircraft]
def can_auto_plan(self, task: FlightType) -> bool:
try:
next(self.auto_assignable_for_task(task))
return True
except StopIteration:
return False
def best_squadrons_for(
self, location: MissionTarget, task: FlightType, size: int, this_turn: bool
) -> list[Squadron]:
airfield_cache = ObjectiveDistanceCache.get_closest_airfields(location)
best_aircraft = aircraft_for_task(task)
ordered: list[Squadron] = []
for control_point in airfield_cache.operational_airfields:
if control_point.captured != self.player:
continue
capable_at_base = []
for squadron in control_point.squadrons:
if squadron.can_auto_assign_mission(location, task, size, this_turn):
capable_at_base.append(squadron)
ordered.extend(
sorted(
capable_at_base,
key=lambda s: best_aircraft.index(s.aircraft),
)
)
return ordered
def best_squadron_for(
self, location: MissionTarget, task: FlightType, size: int, this_turn: bool
) -> Optional[Squadron]:
for squadron in self.best_squadrons_for(location, task, size, this_turn):
return squadron
return None
@property
def available_aircraft_types(self) -> Iterator[AircraftType]:
for aircraft, squadrons in self.squadrons.items():
for squadron in squadrons:
if squadron.untasked_aircraft:
yield aircraft
break
def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]:
for squadron in self.iter_squadrons():
if squadron.can_auto_assign(task):
yield squadron
def auto_assignable_for_task_at(
self, task: FlightType, base: ControlPoint
) -> Iterator[Squadron]:
for squadron in self.iter_squadrons():
if squadron.can_auto_assign(task) and squadron.location == base:
yield squadron
def squadron_for(self, aircraft: AircraftType) -> 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 populate_for_turn_0(self) -> None:
for squadron in self.iter_squadrons():
squadron.populate_for_turn_0()
def end_turn(self) -> None:
for squadron in self.iter_squadrons():
squadron.end_turn()
def reset(self) -> None:
for squadron in self.iter_squadrons():
squadron.return_all_pilots_and_aircraft()
@property
def size(self) -> int:
return sum(len(s) for s in self.squadrons.values())

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import dataclasses
from dataclasses import dataclass
from game.dcs.aircrafttype import AircraftType
@dataclass(frozen=True)
class OperatingBases:
shore: bool
carrier: bool
lha: bool
@classmethod
def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases:
if aircraft.dcs_unit_type.helicopter:
# Helicopters operate from anywhere by default.
return OperatingBases(shore=True, carrier=True, lha=True)
if aircraft.lha_capable:
# Marine aircraft operate from LHAs and the shore by default.
return OperatingBases(shore=True, carrier=False, lha=True)
if aircraft.carrier_capable:
# Carrier aircraft operate from carriers by default.
return OperatingBases(shore=False, carrier=True, lha=False)
# And the rest are only capable of shore operation.
return OperatingBases(shore=True, carrier=False, lha=False)
@classmethod
def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases:
return dataclasses.replace(
OperatingBases.default_for_aircraft(aircraft), **data
)

51
game/squadrons/pilot.py Normal file
View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import unique, Enum
from faker import Faker
@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())

435
game/squadrons/squadron.py Normal file
View File

@@ -0,0 +1,435 @@
from __future__ import annotations
import logging
from collections import Iterable
from dataclasses import dataclass, field
from typing import Optional, Sequence, TYPE_CHECKING
from faker import Faker
from game.settings import AutoAtoBehavior, Settings
from gen.ato import Package
from gen.flights.flight import Flight, FlightType
from gen.flights.flightplan import FlightPlanBuilder
from .pilot import Pilot, PilotStatus
from ..utils import meters
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
from game.dcs.aircrafttype import AircraftType
from game.theater import ControlPoint, ConflictTheater, MissionTarget
from .operatingbases import OperatingBases
from .squadrondef import SquadronDef
@dataclass
class Squadron:
name: str
nickname: Optional[str]
country: str
role: str
aircraft: AircraftType
livery: Optional[str]
mission_types: tuple[FlightType, ...]
operating_bases: OperatingBases
#: The pool of pilots that have not yet been assigned to the squadron. This only
#: happens when a preset squadron defines more preset pilots than the squadron limit
#: allows. This pool will be consumed before random pilots are generated.
pilot_pool: list[Pilot]
current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False)
available_pilots: list[Pilot] = field(
default_factory=list, init=False, hash=False, compare=False
)
auto_assignable_mission_types: set[FlightType] = field(
init=False, hash=False, compare=False
)
coalition: Coalition = field(hash=False, compare=False)
settings: Settings = field(hash=False, compare=False)
location: ControlPoint
destination: Optional[ControlPoint] = field(
init=False, hash=False, compare=False, default=None
)
owned_aircraft: int = field(init=False, hash=False, compare=False, default=0)
untasked_aircraft: int = field(init=False, hash=False, compare=False, default=0)
pending_deliveries: int = field(init=False, hash=False, compare=False, default=0)
def __post_init__(self) -> None:
self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str:
if self.nickname is None:
return self.name
return f'{self.name} "{self.nickname}"'
def __hash__(self) -> int:
return hash(
(
self.name,
self.nickname,
self.country,
self.role,
self.aircraft,
)
)
@property
def player(self) -> bool:
return self.coalition.player
def assign_to_base(self, base: ControlPoint) -> None:
self.location = base
logging.debug(f"Assigned {self} to {base}")
@property
def pilot_limits_enabled(self) -> bool:
return self.settings.enable_squadron_pilot_limits
def set_allowed_mission_types(self, mission_types: Iterable[FlightType]) -> None:
self.mission_types = tuple(mission_types)
self.auto_assignable_mission_types.intersection_update(self.mission_types)
def set_auto_assignable_mission_types(
self, mission_types: Iterable[FlightType]
) -> None:
self.auto_assignable_mission_types = set(self.mission_types).intersection(
mission_types
)
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
if self.pilot_limits_enabled:
return None
self._recruit_pilots(1)
return self.available_pilots.pop()
def claim_available_pilot(self) -> Optional[Pilot]:
if not self.available_pilots:
return self.claim_new_pilot_if_allowed()
# For opfor, so player/AI option is irrelevant.
if not self.player:
return self.available_pilots.pop()
preference = self.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.
#
# If they only *prefer* players and we're out of players, just return an AI
# pilot.
if not prefer_players:
return self.claim_new_pilot_if_allowed()
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 _recruit_pilots(self, count: int) -> None:
new_pilots = self.pilot_pool[:count]
self.pilot_pool = self.pilot_pool[count:]
count -= len(new_pilots)
new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)])
self.current_roster.extend(new_pilots)
self.available_pilots.extend(new_pilots)
def populate_for_turn_0(self) -> None:
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
raise ValueError("Squadrons can only be created with active pilots.")
self._recruit_pilots(self.settings.squadron_pilot_limit)
def end_turn(self) -> None:
if self.destination is not None:
self.relocate_to(self.destination)
self.replenish_lost_pilots()
self.deliver_orders()
def replenish_lost_pilots(self) -> None:
if not self.pilot_limits_enabled:
return
replenish_count = min(
self.settings.squadron_replenishment_rate,
self._number_of_unfilled_pilot_slots,
)
if replenish_count > 0:
self._recruit_pilots(replenish_count)
def return_all_pilots_and_aircraft(self) -> None:
self.available_pilots = list(self.active_pilots)
self.untasked_aircraft = self.owned_aircraft
@staticmethod
def send_on_leave(pilot: Pilot) -> None:
pilot.send_on_leave()
def return_from_leave(self, pilot: Pilot) -> None:
if not self.has_unfilled_pilot_slots:
raise RuntimeError(
f"Cannot return {pilot} from leave because {self} is full"
)
pilot.return_from_leave()
@property
def faker(self) -> Faker:
return self.coalition.faker
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster if p.status == status]
def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster if p.status != status]
@property
def max_size(self) -> int:
return self.settings.squadron_pilot_limit
@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_inactive(self) -> int:
return len(self.current_roster)
@property
def _number_of_unfilled_pilot_slots(self) -> int:
return self.max_size - len(self.active_pilots)
@property
def number_of_available_pilots(self) -> int:
return len(self.available_pilots)
def can_provide_pilots(self, count: int) -> bool:
return not self.pilot_limits_enabled or self.number_of_available_pilots >= count
@property
def has_available_pilots(self) -> bool:
return not self.pilot_limits_enabled or bool(self.available_pilots)
@property
def has_unfilled_pilot_slots(self) -> bool:
return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
def can_auto_assign_mission(
self, location: MissionTarget, task: FlightType, size: int, this_turn: bool
) -> bool:
if not self.can_auto_assign(task):
return False
if this_turn and not self.can_fulfill_flight(size):
return False
distance_to_target = meters(location.distance_to(self.location))
return distance_to_target <= self.aircraft.max_mission_range
def operates_from(self, control_point: ControlPoint) -> bool:
if not control_point.can_operate(self.aircraft):
return False
if control_point.is_carrier:
return self.operating_bases.carrier
elif control_point.is_lha:
return self.operating_bases.lha
else:
return self.operating_bases.shore
def pilot_at_index(self, index: int) -> Pilot:
return self.current_roster[index]
def claim_inventory(self, count: int) -> None:
if self.untasked_aircraft < count:
raise ValueError(
f"Cannot remove {count} from {self.name}. Only have "
f"{self.untasked_aircraft}."
)
self.untasked_aircraft -= count
def can_fulfill_flight(self, count: int) -> bool:
return self.can_provide_pilots(count) and self.untasked_aircraft >= count
def refund_orders(self, count: Optional[int] = None) -> None:
if count is None:
count = self.pending_deliveries
self.coalition.adjust_budget(self.aircraft.price * count)
self.pending_deliveries -= count
def deliver_orders(self) -> None:
self.cancel_overflow_orders()
self.owned_aircraft += self.pending_deliveries
self.pending_deliveries = 0
def relocate_to(self, destination: ControlPoint) -> None:
self.location = destination
if self.location == self.destination:
self.destination = None
def cancel_overflow_orders(self) -> None:
if self.pending_deliveries <= 0:
return
overflow = -self.location.unclaimed_parking()
if overflow > 0:
sell_count = min(overflow, self.pending_deliveries)
logging.debug(
f"{self.location} is overfull by {overflow} aircraft. Cancelling "
f"orders for {sell_count} aircraft to make room."
)
self.refund_orders(sell_count)
@property
def max_fulfillable_aircraft(self) -> int:
return max(self.number_of_available_pilots, self.untasked_aircraft)
@property
def expected_size_next_turn(self) -> int:
return self.owned_aircraft + self.pending_deliveries
@property
def arrival(self) -> ControlPoint:
return self.location if self.destination is None else self.destination
def plan_relocation(
self, destination: ControlPoint, theater: ConflictTheater
) -> None:
if destination == self.location:
logging.warning(
f"Attempted to plan relocation of {self} to current location "
f"{destination}. Ignoring."
)
return
if destination == self.destination:
logging.warning(
f"Attempted to plan relocation of {self} to current destination "
f"{destination}. Ignoring."
)
return
if self.expected_size_next_turn >= destination.unclaimed_parking():
raise RuntimeError(f"Not enough parking for {self} at {destination}.")
if not destination.can_operate(self.aircraft):
raise RuntimeError(f"{self} cannot operate at {destination}.")
self.destination = destination
self.replan_ferry_flights(theater)
def cancel_relocation(self) -> None:
if self.destination is None:
logging.warning(
f"Attempted to cancel relocation of squadron with no transfer order. "
"Ignoring."
)
return
if self.expected_size_next_turn >= self.location.unclaimed_parking():
raise RuntimeError(f"Not enough parking for {self} at {self.location}.")
self.destination = None
self.cancel_ferry_flights()
def replan_ferry_flights(self, theater: ConflictTheater) -> None:
self.cancel_ferry_flights()
self.plan_ferry_flights(theater)
def cancel_ferry_flights(self) -> None:
for package in self.coalition.ato.packages:
# Copy the list so our iterator remains consistent throughout the removal.
for flight in list(package.flights):
if flight.squadron == self and flight.flight_type is FlightType.FERRY:
package.remove_flight(flight)
flight.return_pilots_and_aircraft()
if not package.flights:
self.coalition.ato.remove_package(package)
def plan_ferry_flights(self, theater: ConflictTheater) -> None:
if self.destination is None:
raise RuntimeError(
f"Cannot plan ferry flights for {self} because there is no destination."
)
remaining = self.untasked_aircraft
if not remaining:
return
package = Package(self.destination)
builder = FlightPlanBuilder(package, self.coalition, theater)
while remaining:
size = min(remaining, self.aircraft.max_group_size)
self.plan_ferry_flight(builder, package, size)
remaining -= size
package.set_tot_asap()
self.coalition.ato.add_package(package)
def plan_ferry_flight(
self, builder: FlightPlanBuilder, package: Package, size: int
) -> None:
start_type = self.location.required_aircraft_start_type
if start_type is None:
start_type = self.settings.default_start_type
flight = Flight(
package,
self.coalition.country_name,
self,
size,
FlightType.FERRY,
start_type,
divert=None,
)
package.add_flight(flight)
builder.populate_flight_plan(flight)
@classmethod
def create_from(
cls,
squadron_def: SquadronDef,
base: ControlPoint,
coalition: Coalition,
game: Game,
) -> Squadron:
return Squadron(
squadron_def.name,
squadron_def.nickname,
squadron_def.country,
squadron_def.role,
squadron_def.aircraft,
squadron_def.livery,
squadron_def.mission_types,
squadron_def.operating_bases,
squadron_def.pilot_pool,
coalition,
game.settings,
base,
)

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
import logging
from collections import Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, TYPE_CHECKING
import yaml
from game.dcs.aircrafttype import AircraftType
from game.squadrons.operatingbases import OperatingBases
from game.squadrons.pilot import Pilot
if TYPE_CHECKING:
from gen.flights.flight import FlightType
from game.theater import ControlPoint
@dataclass
class SquadronDef:
name: str
nickname: Optional[str]
country: str
role: str
aircraft: AircraftType
livery: Optional[str]
mission_types: tuple[FlightType, ...]
operating_bases: OperatingBases
pilot_pool: list[Pilot]
auto_assignable_mission_types: set[FlightType] = field(
init=False, hash=False, compare=False
)
def __post_init__(self) -> None:
self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str:
if self.nickname is None:
return self.name
return f'{self.name} "{self.nickname}"'
def set_allowed_mission_types(self, mission_types: Iterable[FlightType]) -> None:
self.mission_types = tuple(mission_types)
self.auto_assignable_mission_types.intersection_update(self.mission_types)
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
def operates_from(self, control_point: ControlPoint) -> bool:
if not control_point.can_operate(self.aircraft):
return False
if control_point.is_carrier:
return self.operating_bases.carrier
elif control_point.is_lha:
return self.operating_bases.lha
else:
return self.operating_bases.shore
@classmethod
def from_yaml(cls, path: Path) -> SquadronDef:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
from gen.flights.flight import FlightType
with path.open(encoding="utf8") as squadron_file:
data = yaml.safe_load(squadron_file)
name = data["aircraft"]
try:
unit_type = AircraftType.named(name)
except KeyError as ex:
raise KeyError(f"Could not find any aircraft named {name}") from ex
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 SquadronDef(
name=data["name"],
nickname=data.get("nickname"),
country=data["country"],
role=data["role"],
aircraft=unit_type,
livery=data.get("livery"),
mission_types=tuple(mission_types),
operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),
pilot_pool=pilots,
)

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import logging
from collections import defaultdict
from pathlib import Path
from typing import Iterator, Tuple, TYPE_CHECKING
from game.dcs.aircrafttype import AircraftType
from .squadrondef import SquadronDef
if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
class SquadronDefLoader:
def __init__(self, game: Game, coalition: Coalition) -> None:
self.game = game
self.coalition = coalition
@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[AircraftType, list[SquadronDef]]:
squadrons: dict[AircraftType, list[SquadronDef]] = defaultdict(list)
country = self.coalition.country_name
faction = self.coalition.faction
any_country = country.startswith("Combined Joint Task Forces ")
for directory in self.squadron_directories():
for path, squadron_def in self.load_squadrons_from(directory):
if not any_country and squadron_def.country != country:
logging.debug(
"Not using squadron for non-matching country (is "
f"{squadron_def.country}, need {country}: {path}"
)
continue
if squadron_def.aircraft not in faction.aircrafts:
logging.debug(
f"Not using squadron because {faction.name} cannot use "
f"{squadron_def.aircraft}: {path}"
)
continue
logging.debug(
f"Found {squadron_def.name} {squadron_def.aircraft} "
f"{squadron_def.role} compatible with {faction.name}"
)
squadrons[squadron_def.aircraft].append(squadron_def)
return squadrons
@staticmethod
def load_squadrons_from(directory: Path) -> Iterator[Tuple[Path, SquadronDef]]:
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, SquadronDef.from_yaml(squadron_path)
except Exception as ex:
raise RuntimeError(
f"Failed to load squadron defined by {squadron_path}"
) from ex

Some files were not shown because too many files have changed in this diff Show More