Compare commits

...

415 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
Khopa
1b9da9cdd8 Fixed mypy error after merge 2021-06-26 20:00:10 +02: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
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
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
759 changed files with 20458 additions and 9338 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,19 +1,106 @@
# 5.0.0
Saves from 3.x are not compatible with 5.0.
Saves from 4.x are not compatible with 5.0.
## Features/Improvements
* **[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
# 4.0.1
* **[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.
Saves from 4.0.0 are compatible with 4.0.1.
# 4.1.1
Saves from 4.1.0 are compatible with 4.1.1.
## Fixes
* **[Campaign]** Fixed broken support for Mariana Islands map.
* **[Mission Generation]** Fix SAM sites pointing towards the center of the conflict.
* **[Flight Planning]** No longer using Su-34 for CAP missions.
# 4.1.0
Saves from 4.0.0 are compatible with 4.1.0.
## Features/Improvements
* **[Campaign]** Air defense sites now generate a fixed number of launchers per type.
* **[Campaign]** Added support for Mariana Islands map.
* **[Campaign AI]** Adjustments to aircraft selection priorities for most mission types.
* **[Engine]** Support for DCS 2.7.4.9632 and newer, including the Marianas map, F-16 JSOWs, NASAMS, and Tin Shield EWR.
* **[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
* **[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.
* **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet).
* **[UI]** Google search link added to unit information when there is no information provided.
* **[UI]** Control point name displayed with ground object group name on map.
* **[UI]** Buy or Replace will now show the correct price for generated ground objects like sams.
* **[UI]** Improved logging for frontline movement to be more descriptive about what happened and why.
* **[UI]** Brought ruler map module into source, which should fix file integrity issues with the module.
## Fixes
* **[Campaign]** Fixed the Silkworm generator to include launchers and not all radars.
* **[Data]** Fixed Introduction dates for targeting pods (ATFLIR and LITENING were both a few years too early).
* **[Data]** Removed SA-10 from Syria 2011 faction.
* **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money
* **[Flight Planning]** Helicopters are now correctly identified, and will fly ingress/CAS/BAI/egress and similar at low altitude.
* **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles.
* **[Mission Generation]** The lua data for other plugins is now generated correctly
* **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs
* **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation.
* **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured.
* **[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.
* **[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.
# 4.0.0
Saves from 3.x are not compatible with 4.0.

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

@@ -25,6 +25,7 @@ class AlicCodes:
AirDefence.SNR_75V.id: 126,
AirDefence.HQ_7_LN_SP.id: 127,
AirDefence.HQ_7_STR_SP.id: 128,
AirDefence.RLS_19J6.id: 130,
AirDefence.Roland_ADS.id: 201,
AirDefence.Patriot_str.id: 202,
AirDefence.Hawk_sr.id: 203,
@@ -33,6 +34,7 @@ class AlicCodes:
AirDefence.Hawk_cwar.id: 206,
AirDefence.Gepard.id: 207,
AirDefence.Vulcan.id: 208,
AirDefence.NASAMS_Radar_MPQ64F1.id: 209,
}
@classmethod

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

@@ -29,8 +29,9 @@ from dcs.ships import (
CV_1143_5,
)
from dcs.terrain.terrain import Airport
from dcs.unit import Ship
from dcs.unitgroup import ShipGroup, StaticGroup
from dcs.unittype import UnitType
from dcs.unittype import UnitType, FlyingType, ShipType, VehicleType
from dcs.vehicles import (
vehicle_map,
)
@@ -255,7 +256,7 @@ Aircraft livery overrides. Syntax as follows:
`Identifier` is aircraft identifier (as used troughout the file) and "LiveryName" (with double quotes)
is livery name as found in mission editor.
"""
PLANE_LIVERY_OVERRIDES = {
PLANE_LIVERY_OVERRIDES: dict[Type[FlyingType], str] = {
FA_18C_hornet: "VFA-34", # default livery for the hornet is blue angels one
}
@@ -317,6 +318,8 @@ REWARDS = {
"comms": 10,
"oil": 10,
"derrick": 8,
"village": 0.25,
"allycamp": 0.5,
}
"""
@@ -326,7 +329,7 @@ REWARDS = {
StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point]
def upgrade_to_supercarrier(unit, name: str):
def upgrade_to_supercarrier(unit: Type[ShipType], name: str) -> Type[ShipType]:
if unit == Stennis:
if name == "CVN-71 Theodore Roosevelt":
return CVN_71
@@ -359,7 +362,15 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]:
return None
def country_id_from_name(name):
def vehicle_type_from_name(name: str) -> Type[VehicleType]:
return vehicle_map[name]
def ship_type_from_name(name: str) -> Type[ShipType]:
return ship_map[name]
def country_id_from_name(name: str) -> int:
for k, v in country_dict.items():
if v.name == name:
return k
@@ -372,7 +383,7 @@ class DefaultLiveries:
OH_58D.Liveries = DefaultLiveries
F_16C_50.Liveries = DefaultLiveries
F_16C_50.Liveries = DefaultLiveries # type: ignore
P_51D_30_NA.Liveries = DefaultLiveries
Ju_88A4.Liveries = DefaultLiveries
B_17G.Liveries = DefaultLiveries

View File

@@ -29,12 +29,20 @@ from game.radio.channels import (
ViggenRadioChannelAllocator,
NoOpChannelAllocator,
)
from game.utils import Distance, Speed, feet, kph, knots
from game.utils import (
Distance,
SPEED_OF_SOUND_AT_SEA_LEVEL,
Speed,
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)
@@ -98,7 +106,7 @@ class PatrolConfig:
@classmethod
def from_data(cls, data: dict[str, Any]) -> PatrolConfig:
altitude = data.get("altitude", None)
speed = data.get("altitude", None)
speed = data.get("speed", None)
return PatrolConfig(
feet(altitude) if altitude is not None else None,
knots(speed) if speed is not None else None,
@@ -106,18 +114,55 @@ class PatrolConfig:
@dataclass(frozen=True)
class AircraftType(UnitType[FlyingType]):
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]]):
carrier_capable: bool
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]
@@ -139,17 +184,94 @@ class AircraftType(UnitType[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)
@property
def preferred_patrol_altitude(self) -> Distance:
if self.patrol_altitude is not None:
return self.patrol_altitude
else:
# Estimate based on max speed.
# Aircaft with max speed 600 kph will prefer patrol at 10 000 ft
# Aircraft with max speed 2800 kph will prefer pratrol at 33 000 ft
altitude_for_lowest_speed = feet(10 * 1000)
altitude_for_highest_speed = feet(33 * 1000)
lowest_speed = kph(600)
highest_speed = kph(2800)
factor = (self.max_speed - lowest_speed).kph / (
highest_speed - lowest_speed
).kph
altitude = (
altitude_for_lowest_speed
+ (altitude_for_highest_speed - altitude_for_lowest_speed) * factor
)
logging.debug(
f"Preferred patrol altitude for {self.dcs_unit_type.id}: {altitude.feet}"
)
rounded_altitude = feet(round(1000 * round(altitude.feet / 1000)))
return max(
altitude_for_lowest_speed,
min(altitude_for_highest_speed, rounded_altitude),
)
def preferred_patrol_speed(self, altitude: Distance) -> Speed:
"""Preferred true airspeed when patrolling"""
if self.patrol_speed is not None:
return self.patrol_speed
else:
# Estimate based on max speed.
max_speed = self.max_speed
if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.6:
# Fast airplanes, should manage pretty high patrol speed
return (
Speed.from_mach(0.85, altitude)
if altitude.feet > 20000
else Speed.from_mach(0.7, altitude)
)
elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.2:
# Medium-fast like F/A-18C
return (
Speed.from_mach(0.8, altitude)
if altitude.feet > 20000
else Speed.from_mach(0.65, altitude)
)
elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 0.7:
# Semi-fast like airliners or similar
return (
Speed.from_mach(0.5, altitude)
if altitude.feet > 20000
else Speed.from_mach(0.4, altitude)
)
else:
# Slow like warbirds or helicopters
# Use whichever is slowest - mach 0.35 or 70% of max speed
logging.debug(f"{self.name} max_speed * 0.7 is {max_speed * 0.7}")
return min(Speed.from_mach(0.35, altitude), max_speed * 0.7)
def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
from gen.radios import ChannelInUseError, MHz
from gen.radios import ChannelInUseError, kHz
if self.intra_flight_radio is not None:
return radio_registry.alloc_for_radio(self.intra_flight_radio)
freq = MHz(self.dcs_unit_type.radio_frequency)
# The default radio frequency is set in megahertz. For some aircraft, it is a
# floating point value. For all current aircraft, adjusting to kilohertz will be
# sufficient to convert to an integer.
in_khz = float(self.dcs_unit_type.radio_frequency) * 1000
if not in_khz.is_integer():
logging.warning(
f"Found unexpected sub-kHz default radio for {self}: {in_khz} kHz. "
"Truncating to integer. The truncated frequency may not be valid for "
"the aircraft."
)
freq = kHz(int(in_khz))
try:
radio_registry.reserve(freq)
except ChannelInUseError:
@@ -207,7 +329,7 @@ class AircraftType(UnitType[FlyingType]):
logging.warning(f"No data for {aircraft.id}; it will not be available")
return
with data_path.open() as data_file:
with data_path.open(encoding="utf-8") as data_file:
data = yaml.safe_load(data_file)
try:
@@ -218,6 +340,25 @@ class AircraftType(UnitType[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:
@@ -229,7 +370,10 @@ class AircraftType(UnitType[FlyingType]):
yield AircraftType(
dcs_unit_type=aircraft,
name=variant,
description=data.get("description", "No data."),
description=data.get(
"description",
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
),
year_introduced=introduction,
country_of_origin=data.get("origin", "No data."),
manufacturer=data.get("manufacturer", "No data."),
@@ -242,6 +386,8 @@ class AircraftType(UnitType[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

@@ -15,7 +15,7 @@ from game.dcs.unittype import UnitType
@dataclass(frozen=True)
class GroundUnitType(UnitType[VehicleType]):
class GroundUnitType(UnitType[Type[VehicleType]]):
unit_class: Optional[GroundUnitClass]
spawn_weight: int
@@ -67,7 +67,7 @@ class GroundUnitType(UnitType[VehicleType]):
logging.warning(f"No data for {vehicle.id}; it will not be available")
return
with data_path.open() as data_file:
with data_path.open(encoding="utf-8") as data_file:
data = yaml.safe_load(data_file)
try:
@@ -88,7 +88,10 @@ class GroundUnitType(UnitType[VehicleType]):
unit_class=unit_class,
spawn_weight=data.get("spawn_weight", 0),
name=variant,
description=data.get("description", "No data."),
description=data.get(
"description",
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
),
year_introduced=introduction,
country_of_origin=data.get("origin", "No data."),
manufacturer=data.get("manufacturer", "No data."),

View File

@@ -4,12 +4,12 @@ from typing import TypeVar, Generic, Type
from dcs.unittype import UnitType as DcsUnitType
DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=DcsUnitType)
DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType])
@dataclass(frozen=True)
class UnitType(Generic[DcsUnitTypeT]):
dcs_unit_type: Type[DcsUnitTypeT]
dcs_unit_type: DcsUnitTypeT
name: str
description: str
year_introduced: str

View File

@@ -15,9 +15,9 @@ from typing import (
Iterator,
List,
TYPE_CHECKING,
Union,
)
from game import db
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.theater import Airfield, ControlPoint
@@ -77,8 +77,8 @@ class GroundLosses:
player_airlifts: List[AirliftUnits] = field(default_factory=list)
enemy_airlifts: List[AirliftUnits] = field(default_factory=list)
player_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
player_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
enemy_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
player_buildings: List[Building] = field(default_factory=list)
enemy_buildings: List[Building] = field(default_factory=list)
@@ -104,8 +104,9 @@ class StateData:
#: Names of vehicle (and ship) units that were killed during the mission.
killed_ground_units: List[str]
#: Names of static units that were destroyed during the mission.
destroyed_statics: List[str]
#: List of descriptions of destroyed statics. Format of each element is a mapping of
#: the coordinate type ("x", "y", "z", "type", "orientation") to the value.
destroyed_statics: List[dict[str, Union[float, str]]]
#: Mangled names of bases that were captured during the mission.
base_capture_events: List[str]
@@ -134,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()
@@ -164,7 +163,7 @@ class Debriefing:
yield from self.ground_losses.enemy_airlifts
@property
def ground_object_losses(self) -> Iterator[GroundObjectUnit]:
def ground_object_losses(self) -> Iterator[GroundObjectUnit[Any]]:
yield from self.ground_losses.player_ground_objects
yield from self.ground_losses.enemy_ground_objects
@@ -370,13 +369,13 @@ class PollDebriefingFileThread(threading.Thread):
self.game = game
self.unit_map = unit_map
def stop(self):
def stop(self) -> None:
self._stop_event.set()
def stopped(self):
def stopped(self) -> bool:
return self._stop_event.is_set()
def run(self):
def run(self) -> None:
if os.path.isfile("state.json"):
last_modified = os.path.getmtime("state.json")
else:
@@ -387,7 +386,7 @@ class PollDebriefingFileThread(threading.Thread):
os.path.isfile("state.json")
and os.path.getmtime("state.json") > last_modified
):
with open("state.json", "r") as json_file:
with open("state.json", "r", encoding="utf-8") as json_file:
json_data = json.load(json_file)
debriefing = Debriefing(json_data, self.game, self.unit_map)
self.callback(debriefing)
@@ -401,7 +400,7 @@ class PollDebriefingFileThread(threading.Thread):
def wait_for_debriefing(
callback: Callable[[Debriefing], None], game: Game, unit_map
callback: Callable[[Debriefing], None], game: Game, unit_map: UnitMap
) -> PollDebriefingFileThread:
thread = PollDebriefingFileThread(callback, game, unit_map)
thread.start()

View File

@@ -1,14 +1,10 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .event import Event
if TYPE_CHECKING:
from game.theater import ConflictTheater
class AirWarEvent(Event):
"""Event handler for the air battle"""
def __str__(self):
def __str__(self) -> str:
return "AirWar"

View File

@@ -5,16 +5,13 @@ from typing import List, TYPE_CHECKING, Type
from dcs.mapping import Point
from dcs.task import Task
from dcs.unittype import VehicleType
from game import persistency
from game.debriefing import AirLosses, Debriefing
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:
@@ -38,13 +35,13 @@ class Event:
def __init__(
self,
game,
game: Game,
from_cp: ControlPoint,
target_cp: ControlPoint,
location: Point,
attacker_name: str,
defender_name: str,
):
) -> None:
self.game = game
self.from_cp = from_cp
self.to_cp = target_cp
@@ -54,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]]:
@@ -68,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 (
@@ -128,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:
@@ -155,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:
@@ -220,21 +164,18 @@ class Event:
for loss in debriefing.ground_object_losses:
# TODO: This should be stored in the TGO, not in the pydcs Group.
if not hasattr(loss.group, "units_losts"):
loss.group.units_losts = []
loss.group.units_losts = [] # type: ignore
loss.group.units.remove(loss.unit)
loss.group.units_losts.append(loss.unit)
loss.group.units_losts.append(loss.unit) # type: ignore
def commit_building_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.building_losses:
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
@@ -246,38 +187,35 @@ 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)
except Exception:
logging.exception(f"Could not process base capture {captured}")
def commit(self, debriefing: Debriefing):
def commit(self, debriefing: Debriefing) -> None:
logging.info("Committing mission results")
self.commit_air_losses(debriefing)
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
# -------------------------
@@ -298,15 +236,16 @@ class Event:
delta = 0.0
player_won = True
status_msg: str = ""
ally_casualties = debriefing.casualty_count(cp)
enemy_casualties = debriefing.casualty_count(enemy_cp)
ally_units_alive = cp.base.total_armor
enemy_units_alive = enemy_cp.base.total_armor
print(ally_units_alive)
print(enemy_units_alive)
print(ally_casualties)
print(enemy_casualties)
print(f"Remaining allied units: {ally_units_alive}")
print(f"Remaining enemy units: {enemy_units_alive}")
print(f"Allied casualties {ally_casualties}")
print(f"Enemy casualties {enemy_casualties}")
ratio = (1.0 + enemy_casualties) / (1.0 + ally_casualties)
@@ -319,24 +258,31 @@ class Event:
if ally_units_alive == 0:
player_won = False
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"No allied units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces suffer a strong defeat."
elif enemy_units_alive == 0:
player_won = True
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"No enemy units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces win a strong victory."
elif cp.stances[enemy_cp.id] == CombatStance.RETREAT:
player_won = False
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Allied forces are retreating along the {cp.name}-{enemy_cp.name} frontline, suffering a strong defeat."
else:
if enemy_casualties > ally_casualties:
player_won = True
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Allied forces break through the {cp.name}-{enemy_cp.name} frontline, winning a strong victory"
else:
if ratio > 3:
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Enemy casualties massively outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a strong victory."
elif ratio < 1.5:
delta = MINOR_DEFEAT_INFLUENCE
status_msg = f"Enemy casualties minorly outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a minor victory."
else:
delta = DEFEAT_INFLUENCE
status_msg = f"Enemy casualties outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces claim a victory."
elif ally_casualties > enemy_casualties:
if (
@@ -346,54 +292,60 @@ class Event:
# Even with casualties if the enemy is overwhelmed, they are going to lose ground
player_won = True
delta = MINOR_DEFEAT_INFLUENCE
status_msg = f"Despite suffering losses, allied forces still outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a minor victory."
elif (
ally_units_alive > 3 * enemy_units_alive
and player_aggresive
):
player_won = True
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Despite suffering losses, allied forces still heavily outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a major victory."
else:
# But is the enemy is not outnumbered, we lose
# But if the enemy is not outnumbered, we lose
player_won = False
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces have overextended themselves, suffering a major defeat."
else:
delta = STRONG_DEFEAT_INFLUENCE
delta = DEFEAT_INFLUENCE
status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces suffer a defeat."
# No progress with defensive strategies
if player_won and cp.stances[enemy_cp.id] in [
CombatStance.DEFENSIVE,
CombatStance.AMBUSH,
]:
print("Defensive stance, progress is limited")
print(
f"Allied forces have adopted a defensive stance along the {cp.name}-{enemy_cp.name} "
f"frontline, making only limited progress."
)
delta = MINOR_DEFEAT_INFLUENCE
if player_won:
print(cp.name + " won ! factor > " + str(delta))
cp.base.affect_strength(delta)
enemy_cp.base.affect_strength(-delta)
info = Information(
# 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)
self.game.message(
"Frontline Report",
"Our ground forces from "
+ cp.name
+ " are making progress toward "
+ enemy_cp.name,
self.game.turn,
f"Our ground forces from {cp.name} reached a stalemate with enemy forces from {enemy_cp.name}.",
)
self.game.informations.append(info)
else:
print(cp.name + " lost ! factor > " + str(delta))
enemy_cp.base.affect_strength(delta)
cp.base.affect_strength(-delta)
info = Information(
"Frontline Report",
"Our ground forces from "
+ cp.name
+ " are losing ground against the enemy forces from "
+ enemy_cp.name,
self.game.turn,
)
self.game.informations.append(info)
if player_won:
print(status_msg)
cp.base.affect_strength(delta)
enemy_cp.base.affect_strength(-delta)
self.game.message(
"Frontline Report",
f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}",
)
else:
print(status_msg)
enemy_cp.base.affect_strength(delta)
cp.base.affect_strength(-delta)
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}",
)
def redeploy_units(self, cp: ControlPoint) -> None:
""" "
@@ -439,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

@@ -8,5 +8,5 @@ class FrontlineAttackEvent(Event):
future unique Event handling
"""
def __str__(self):
def __str__(self) -> str:
return "Frontline attack"

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import itertools
import logging
from dataclasses import dataclass, field
from typing import Optional, Dict, Type, List, Any, Iterator
from typing import Optional, Dict, Type, List, Any, Iterator, TYPE_CHECKING
import dcs
from dcs.countries import country_dict
@@ -25,6 +25,9 @@ from game.data.groundunitclass import GroundUnitClass
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
if TYPE_CHECKING:
from game.theater.start_generator import ModSettings
@dataclass
class Faction:
@@ -81,10 +84,10 @@ class Faction:
requirements: Dict[str, str] = field(default_factory=dict)
# possible aircraft carrier units
aircraft_carrier: List[Type[UnitType]] = field(default_factory=list)
aircraft_carrier: List[Type[ShipType]] = field(default_factory=list)
# possible helicopter carrier units
helicopter_carrier: List[Type[UnitType]] = field(default_factory=list)
helicopter_carrier: List[Type[ShipType]] = field(default_factory=list)
# Possible carrier names
carrier_names: List[str] = field(default_factory=list)
@@ -257,7 +260,7 @@ class Faction:
if unit.unit_class is unit_class:
yield unit
def apply_mod_settings(self, mod_settings) -> Faction:
def apply_mod_settings(self, mod_settings: ModSettings) -> Faction:
# aircraft
if not mod_settings.a4_skyhawk:
self.remove_aircraft("A-4E-C")
@@ -319,17 +322,17 @@ class Faction:
self.remove_air_defenses("KS19Generator")
return self
def remove_aircraft(self, name):
def remove_aircraft(self, name: str) -> None:
for i in self.aircrafts:
if i.dcs_unit_type.id == name:
self.aircrafts.remove(i)
def remove_air_defenses(self, name):
def remove_air_defenses(self, name: str) -> None:
for i in self.air_defenses:
if i == name:
self.air_defenses.remove(i)
def remove_vehicle(self, name):
def remove_vehicle(self, name: str) -> None:
for i in self.frontline_units:
if i.dcs_unit_type.id == name:
self.frontline_units.remove(i)
@@ -342,7 +345,7 @@ def load_ship(name: str) -> Optional[Type[ShipType]]:
return None
def load_all_ships(data) -> List[Type[ShipType]]:
def load_all_ships(data: list[str]) -> List[Type[ShipType]]:
items = []
for name in data:
item = load_ship(name)

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,50 +1,49 @@
from game.dcs.aircrafttype import AircraftType
from __future__ import annotations
import itertools
import logging
import random
import sys
import math
from collections import Iterator
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Any, List
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 pydcs_extensions.a4ec.a4ec import A_4E_C
from faker import Faker
from game import db
from game.inventory import GlobalAircraftInventory
from game.models.game_stats import GameStats
from game.plugins import LuaPluginManager
from gen import aircraft, naming
from gen import naming
from gen.ato import AirTaskingOrder
from gen.conflictgen import Conflict
from gen.flights.ai_flight_planner import CoalitionMissionPlanner
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 .theater import ConflictTheater
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 = {
@@ -91,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,
@@ -99,155 +99,114 @@ 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.game_stats.update(self)
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[str] = []
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):
@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 _roll(self, prob, mult):
if self.settings.version == "dev":
# always generate all events for dev
return 100
else:
return random.randint(1, 100) <= prob * mult
def _generate_player_event(self, event_class, player_cp, enemy_cp):
def _generate_player_event(
self, event_class: Type[Event], player_cp: ControlPoint, enemy_cp: ControlPoint
) -> None:
self.events.append(
event_class(
self,
player_cp,
enemy_cp,
enemy_cp.position,
self.player_faction.name,
self.enemy_faction.name,
self.blue.faction.name,
self.red.faction.name,
)
)
def _generate_events(self):
@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(
FrontlineAttackEvent,
@@ -255,27 +214,21 @@ 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):
self.budget += Income(self, player=True).total
def adjust_budget(self, amount: float, player: bool) -> None:
self.coalition_for(player).adjust_budget(amount)
def process_enemy_income(self):
# TODO: Clean up save compat.
if not hasattr(self, "enemy_budget"):
self.enemy_budget = 0
self.enemy_budget += Income(self, player=False).total
def initiate_event(self, event: Event) -> UnitMap:
@staticmethod
def initiate_event(event: Event) -> UnitMap:
# assert event in self.events
logging.info("Generating {} (regular)".format(event))
return event.generate()
def finish_event(self, event: Event, debriefing: Debriefing):
def finish_event(self, event: Event, debriefing: Debriefing) -> None:
logging.info("Finishing event {}".format(event))
event.commit(debriefing)
@@ -284,16 +237,6 @@ class Game:
else:
logging.info("finish_event: event not in the events!")
def is_player_attack(self, event):
if isinstance(event, Event):
return (
event
and event.attacker_name
and event.attacker_name == self.player_faction.name
)
else:
raise RuntimeError(f"{event} was passed when an Event type was expected")
def on_load(self, game_still_initializing: bool = False) -> None:
if not hasattr(self, "name_generator"):
self.name_generator = naming.namegen
@@ -308,54 +251,68 @@ 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:
self.informations.append(
Information("End of turn #" + str(self.turn), "-" * 40, 0)
)
"""Finalizes the current turn and advances to the next turn.
This handles the turn-end portion of passing a turn. Initialization of the next
turn is handled by `initialize_turn`. These are separate processes because while
turns may be initialized more than once under some circumstances (see the
documentation for `initialize_turn`), `finish_turn` performs the work that
should be guaranteed to happen only once per turn:
* Turn counter increment.
* Delivering units ordered the previous turn.
* Transfer progress.
* Squadron replenishment.
* Income distribution.
* Base strength (front line position) adjustment.
* Weather/time-of-day generation.
Some actions (like transit network assembly) will happen both here and in
`initialize_turn`. We need the network to be up to date so we can account for
base captures when processing the transfers that occurred last turn, but we also
need it to be up to date in the case of a re-initialization in `initialize_turn`
(such as to account for a cheat base capture) so that orders are only placed
where a supply route exists to the destination. This is a relatively cheap
operation so duplicating the effort is not a problem.
Args:
skipped: True if the turn was skipped.
"""
self.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:
self.turn = 0
"""Initialization for the first turn of the game."""
self.blue.preinit_turn_0()
self.red.preinit_turn_0()
self.initialize_turn()
def pass_turn(self, no_action: bool = False) -> None:
"""Ends the current turn and initializes the new turn.
Called both when skipping a turn or by ending the turn as the result of combat.
Args:
no_action: True if the turn was skipped.
"""
logging.info("Pass turn")
with logged_duration("Turn finalization"):
self.finish_turn(no_action)
@@ -365,7 +322,7 @@ class Game:
# Autosave progress
persistency.autosave(self)
def check_win_loss(self):
def check_win_loss(self) -> TurnState:
player_airbases = {
cp for cp in self.theater.player_points() if cp.runway_is_operational()
}
@@ -382,24 +339,50 @@ 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) -> None:
def initialize_turn(self, for_red: bool = True, for_blue: bool = True) -> None:
"""Performs turn initialization for the specified players.
Turn initialization performs all of the beginning-of-turn actions. *End-of-turn*
processing happens in `pass_turn` (despite the name, it's called both for
skipping the turn and ending the turn after combat).
Special care needs to be taken here because initialization can occur more than
once per turn. A number of events can require re-initializing a turn:
* Cheat capture. Bases changing hands invalidates many missions in both ATOs,
purchase orders, threat zones, transit networks, etc. Practically speaking,
after a base capture the turn needs to be treated as fully new. The game might
even be over after a capture.
* Cheat front line position. CAS missions are no longer in the correct location,
and the ground planner may also need changes.
* Selling/buying units at TGOs. Selling a TGO might leave missions in the ATO
with invalid targets. Buying a new SAM (or even replacing some units in a SAM)
potentially changes the threat zone and may alter mission priorities and
flight planning.
Most of the work is delegated to initialize_turn_for, which handles the
coalition-specific turn initialization. In some cases only one coalition will be
(re-) initialized. This is the case when buying or selling TGO units, since we
don't want to force the player to redo all their planning just because they
repaired a SAM, but should replan opfor when that happens. On the other hand,
base captures are significant enough (and likely enough to be the first thing
the player does in a turn) that we replan blue as well. Front lines are less
impactful but also likely to be early, so they also cause a blue replan.
Args:
for_red: True if opfor should be re-initialized.
for_blue: True if the player coalition should be re-initialized.
"""
self.events = []
self._generate_events()
self.set_bullseye()
# Update statistics
self.game_stats.update(self)
self.blue_air_wing.reset()
self.red_air_wing.reset()
self.aircraft_inventory.reset()
for cp in self.theater.controlpoints:
self.aircraft_inventory.set_from_control_point(cp)
# Check for win or loss condition
turn_state = self.check_win_loss()
if turn_state in (TurnState.LOSS, TurnState.WIN):
@@ -410,62 +393,23 @@ class Game:
self.compute_conflicts_position()
with logged_duration("Threat zone computation"):
self.compute_threat_zones()
with logged_duration("Transit network identification"):
self.compute_transit_networks()
# Plan Coalition specific turn
if for_blue:
self.blue.initialize_turn()
if for_red:
self.red.initialize_turn()
# Plan GroundWar
self.ground_planners = {}
self.blue_procurement_requests.clear()
self.red_procurement_requests.clear()
with logged_duration("Procurement of airlift assets"):
self.transfers.order_airlift_assets()
with logged_duration("Transport planning"):
self.transfers.plan_transports()
with logged_duration("Blue mission planning"):
if self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled:
blue_planner = CoalitionMissionPlanner(self, is_player=True)
blue_planner.plan_missions()
with logged_duration("Red mission planning"):
red_planner = CoalitionMissionPlanner(self, is_player=False)
red_planner.plan_missions()
for cp in self.theater.controlpoints:
if cp.has_frontline:
gplanner = GroundPlanner(cp, self)
gplanner.plan_groundwar()
self.ground_planners[cp.id] = gplanner
self.plan_procurement()
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 spend proportionally based on how much is already invested
self.budget = ProcurementAi(
self,
for_player=True,
faction=self.player_faction,
manage_runways=self.settings.automate_runway_repair,
manage_front_line=self.settings.automate_front_line_reinforcements,
manage_aircraft=self.settings.automate_aircraft_reinforcements,
).spend_budget(self.budget)
self.enemy_budget = ProcurementAi(
self,
for_player=False,
faction=self.enemy_faction,
manage_runways=True,
manage_front_line=True,
manage_aircraft=True,
).spend_budget(self.enemy_budget)
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:
@@ -475,48 +419,36 @@ class Game:
def current_day(self) -> date:
return self.date + timedelta(days=self.turn // 4)
def next_unit_id(self):
def next_unit_id(self) -> int:
"""
Next unit id for pre-generated units
"""
self.current_unit_id += 1
return self.current_unit_id
def next_group_id(self):
def next_group_id(self) -> int:
"""
Next unit id for pre-generated units
"""
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):
def compute_conflicts_position(self) -> None:
"""
Compute the current conflict center position(s), mainly used for culling calculation
:return: List of points of interests
@@ -539,7 +471,7 @@ class Game:
# If there is no conflict take the center point between the two nearest opposing bases
if len(zones) == 0:
cpoint = None
min_distance = sys.maxsize
min_distance = math.inf
for cp in self.theater.player_points():
for cp2 in self.theater.enemy_points():
d = cp.position.distance_to_point(cp2.position)
@@ -557,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,
@@ -575,15 +507,15 @@ class Game:
self.__culling_zones = zones
def add_destroyed_units(self, data):
pos = Point(data["x"], data["z"])
def add_destroyed_units(self, data: dict[str, Union[float, str]]) -> None:
pos = Point(cast(float, data["x"]), cast(float, data["z"]))
if self.theater.is_on_land(pos):
self.__destroyed_units.append(data)
def get_destroyed_units(self):
def get_destroyed_units(self) -> list[dict[str, Union[float, str]]]:
return self.__destroyed_units
def position_culled(self, pos):
def position_culled(self, pos: Point) -> bool:
"""
Check if unit can be generated at given position depending on culling performance settings
:param pos: Position you are tryng to spawn stuff at
@@ -596,38 +528,17 @@ class Game:
return False
return True
def get_culling_zones(self):
def get_culling_zones(self) -> list[Point]:
"""
Check culling points
:return: List of culling zones
"""
return self.__culling_zones
# 1 = red, 2 = blue
def get_player_coalition_id(self):
return 2
def get_enemy_coalition_id(self):
return 1
def get_player_coalition(self):
return Coalition.Blue
def get_enemy_coalition(self):
return Coalition.Red
def get_player_color(self):
return "blue"
def get_enemy_color(self):
return "red"
def process_win_loss(self, turn_state: TurnState):
def process_win_loss(self, turn_state: TurnState) -> None:
if turn_state is TurnState.WIN:
return self.message(
"Congratulations, you are victorious! Start a new campaign to continue."
self.message(
"Congratulations, you are victorious! Start a new campaign to continue."
)
elif turn_state is TurnState.LOSS:
return self.message(
"Game Over, you lose. Start a new campaign to continue."
)
self.message("Game Over, you lose. Start a new campaign to continue.")

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,109 +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, 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, 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, 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, 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) -> 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) -> 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, int] = {}
bought_units: dict[GroundUnitType, int] = {}
units_needing_transfer: dict[GroundUnitType, int] = {}
sold_units: dict[UnitType, 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

@@ -2,13 +2,13 @@ import datetime
class Information:
def __init__(self, title="", text="", turn=0):
def __init__(self, title: str = "", text: str = "", turn: int = 0) -> None:
self.title = title
self.text = text
self.turn = turn
self.timestamp = datetime.datetime.now()
def __str__(self):
def __str__(self) -> str:
return "[{}][{}] {} {}".format(
self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
if self.timestamp is not None

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

@@ -1,13 +0,0 @@
class DestroyedUnit:
"""
Store info about a destroyed unit
"""
x: int
y: int
name: str
def __init__(self, x, y, name):
self.x = x
self.y = y
self.name = name

View File

@@ -1,4 +1,9 @@
from typing import List
from __future__ import annotations
from typing import List, TYPE_CHECKING
if TYPE_CHECKING:
from game import Game
class FactionTurnMetadata:
@@ -10,7 +15,7 @@ class FactionTurnMetadata:
vehicles_count: int = 0
sam_count: int = 0
def __init__(self):
def __init__(self) -> None:
self.aircraft_count = 0
self.vehicles_count = 0
self.sam_count = 0
@@ -24,7 +29,7 @@ class GameTurnMetadata:
allied_units: FactionTurnMetadata
enemy_units: FactionTurnMetadata
def __init__(self):
def __init__(self) -> None:
self.allied_units = FactionTurnMetadata()
self.enemy_units = FactionTurnMetadata()
@@ -34,23 +39,29 @@ class GameStats:
Store statistics for the current game
"""
def __init__(self):
def __init__(self) -> None:
self.data_per_turn: List[GameTurnMetadata] = []
def update(self, game):
def update(self, game: Game) -> None:
"""
Save data for current turn
:param game: Game we want to save the data about
"""
# Remove the current turn if its just an update for this turn
if 0 < game.turn < len(self.data_per_turn):
del self.data_per_turn[-1]
turn_data = GameTurnMetadata()
for cp in game.theater.controlpoints:
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
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,32 +64,18 @@ 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):
with open("resources/default_options.lua", "r") as f:
def prepare(cls, game: Game) -> None:
with open("resources/default_options.lua", "r", encoding="utf-8") as f:
options_dict = loads(f.read())["options"]
cls._set_mission(Mission(game.theater.terrain))
cls.game = game
cls._setup_mission_coalitions()
cls.current_mission.options.load_from_dict(options_dict)
@classmethod
def conflicts(cls) -> Iterable[Conflict]:
assert cls.game
for frontline in cls.game.theater.conflicts():
yield Conflict(
cls.game.theater,
frontline,
cls.game.player_faction.name,
cls.game.enemy_faction.name,
cls.game.player_country,
cls.game.enemy_country,
frontline.position,
)
@classmethod
def air_conflict(cls) -> Conflict:
assert cls.game
@@ -95,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.game.player_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,
)
@@ -107,16 +99,19 @@ class Operation:
cls.current_mission = mission
@classmethod
def _setup_mission_coalitions(cls):
def _setup_mission_coalitions(cls) -> None:
cls.current_mission.coalition["blue"] = Coalition(
"blue", bullseye=cls.game.blue_bullseye.to_pydcs()
"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)]()
)
@@ -124,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)
@@ -160,10 +165,9 @@ 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)"""
gens: List[MissionInfoGenerator] = [
@@ -174,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)
@@ -207,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
@@ -234,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(
@@ -251,7 +259,7 @@ class Operation:
# beacon list.
@classmethod
def _generate_ground_units(cls):
def _generate_ground_units(cls) -> None:
cls.groundobjectgen = GroundObjectsGenerator(
cls.current_mission,
cls.game,
@@ -266,18 +274,23 @@ class Operation:
"""Add destroyed units to the Mission"""
for d in cls.game.get_destroyed_units():
try:
utype = db.unit_type_from_name(d["type"])
type_name = d["type"]
if not isinstance(type_name, str):
raise TypeError(
"Expected the type of the destroyed static to be a string"
)
utype = db.unit_type_from_name(type_name)
except KeyError:
continue
pos = Point(d["x"], d["z"])
pos = Point(cast(float, d["x"]), cast(float, d["z"]))
if (
utype is not None
and not cls.game.position_culled(pos)
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,
@@ -289,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)
@@ -320,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()
@@ -332,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
@@ -350,6 +365,7 @@ class Operation:
cls.game,
cls.radio_registry,
cls.tacan_registry,
cls.air_support,
)
cls.airsupportgen.generate()
@@ -360,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,
)
@@ -406,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:
@@ -418,15 +438,12 @@ class Operation:
CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate()
@classmethod
def reset_naming_ids(cls):
def reset_naming_ids(cls) -> None:
namegen.reset_numbers()
@classmethod
def generate_lua(
cls,
airgen: AircraftConflictGenerator,
airsupportgen: AirSupportConflictGenerator,
jtacs: List[JtacInfo],
cls, airgen: AircraftConflictGenerator, air_support: AirSupport
) -> None:
# TODO: Refactor this
luaData = {
@@ -439,8 +456,8 @@ class Operation:
"BlueAA": {},
} # type: ignore
for tanker in airsupportgen.air_support.tankers:
luaData["Tankers"][tanker.callsign] = {
for i, tanker in enumerate(air_support.tankers):
luaData["Tankers"][i] = {
"dcsGroupName": tanker.group_name,
"callsign": tanker.callsign,
"variant": tanker.variant,
@@ -448,23 +465,23 @@ class Operation:
"tacan": str(tanker.tacan.number) + tanker.tacan.band.name,
}
if airsupportgen.air_support.awacs:
for awacs in airsupportgen.air_support.awacs:
luaData["AWACs"][awacs.callsign] = {
"dcsGroupName": awacs.group_name,
"callsign": awacs.callsign,
"radio": awacs.freq.mhz,
}
for i, awacs in enumerate(air_support.awacs):
luaData["AWACs"][i] = {
"dcsGroupName": awacs.group_name,
"callsign": awacs.callsign,
"radio": awacs.freq.mhz,
}
for jtac in jtacs:
luaData["JTACs"][jtac.callsign] = {
for i, jtac in enumerate(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:
if flight.friendly and flight.flight_type in [
FlightType.ANTISHIP,
@@ -485,7 +502,7 @@ class Operation:
elif hasattr(flightTarget, "name"):
flightTargetName = flightTarget.name
flightTargetType = flightType + " TGT (Airbase)"
luaData["TargetPoints"][flightTargetName] = {
luaData["TargetPoints"][flight_count] = {
"name": flightTargetName,
"type": flightTargetType,
"position": {
@@ -493,6 +510,7 @@ class Operation:
"y": flightTarget.position.y,
},
}
flight_count += 1
for cp in cls.game.theater.controlpoints:
for ground_object in cp.ground_objects:
@@ -578,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,19 @@
from __future__ import annotations
import logging
import os
import pickle
import shutil
from pathlib import Path
from typing import Optional
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from game import Game
_dcs_saved_game_folder: Optional[str] = None
def setup(user_folder: str):
def setup(user_folder: str) -> None:
global _dcs_saved_game_folder
_dcs_saved_game_folder = user_folder
if not save_dir().exists():
@@ -38,7 +42,7 @@ def mission_path_for(name: str) -> str:
return os.path.join(base_path(), "Missions", name)
def load_game(path):
def load_game(path: str) -> Optional[Game]:
with open(path, "rb") as f:
try:
save = pickle.load(f)
@@ -49,7 +53,7 @@ def load_game(path):
return None
def save_game(game) -> bool:
def save_game(game: Game) -> bool:
try:
with open(_temporary_save_file(), "wb") as f:
pickle.dump(game, f)
@@ -60,7 +64,7 @@ def save_game(game) -> bool:
return False
def autosave(game) -> bool:
def autosave(game: Game) -> bool:
"""
Autosave to the autosave location
:param game: Game to save

View File

@@ -38,7 +38,7 @@ class PluginSettings:
self.settings = Settings()
self.initialize_settings()
def set_settings(self, settings: Settings):
def set_settings(self, settings: Settings) -> None:
self.settings = settings
self.initialize_settings()
@@ -146,7 +146,7 @@ class LuaPlugin(PluginSettings):
return cls(definition)
def set_settings(self, settings: Settings):
def set_settings(self, settings: Settings) -> None:
super().set_settings(settings)
for option in self.definition.options:
option.set_settings(self.settings)

View File

@@ -1,13 +1,16 @@
from __future__ import annotations
from dcs import Point
from game.utils import Heading
class PointWithHeading(Point):
def __init__(self):
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):
def from_point(point: Point, heading: Heading) -> PointWithHeading:
p = PointWithHeading()
p.x = point.x
p.y = point.y

9
game/positioned.py Normal file
View File

@@ -0,0 +1,9 @@
from typing import Protocol
from dcs import Point
class Positioned(Protocol):
@property
def position(self) -> Point:
raise NotImplementedError

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():

View File

@@ -5,7 +5,8 @@ import timeit
from collections import defaultdict
from contextlib import contextmanager
from datetime import timedelta
from typing import Iterator
from types import TracebackType
from typing import Iterator, Optional, Type
@contextmanager
@@ -23,7 +24,12 @@ class MultiEventTracer:
def __enter__(self) -> MultiEventTracer:
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
for event, duration in self.events.items():
logging.debug("%s took %s", event, duration)

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)

48
game/savecompat.py Normal file
View File

@@ -0,0 +1,48 @@
"""Tools for aiding in save compat removal after compatibility breaks."""
from collections import Callable
from typing import TypeVar
from game.version import MAJOR_VERSION
ReturnT = TypeVar("ReturnT")
class DeprecatedSaveCompatError(RuntimeError):
def __init__(self, function_name: str) -> None:
super().__init__(
f"{function_name} has save compat code for a different major version."
)
def has_save_compat_for(
major: int,
) -> Callable[[Callable[..., ReturnT]], Callable[..., ReturnT]]:
"""Declares a function or method as having save compat code for a given version.
If the function has save compatibility for the current major version, there is no
change in behavior.
If the function has save compatibility for a *different* (future or past) major
version, DeprecatedSaveCompatError will be raised during startup. Since a break in
save compatibility is the definition of a major version break, there's no need to
keep around old save compat code; it only serves to mask initialization bugs.
Args:
major: The major version for which the decorated function has save
compatibility.
Returns:
The decorated function or method.
Raises:
DeprecatedSaveCompatError: The decorated function has save compat code for
another version of liberation, and that code (and the decorator declaring it)
should be removed from this branch.
"""
def decorator(func: Callable[..., ReturnT]) -> Callable[..., ReturnT]:
if major != MAJOR_VERSION:
raise DeprecatedSaveCompatError(func.__name__)
return func
return decorator

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
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) -> 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,
)

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