mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
Compare commits
421 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9832b5d82 | ||
|
|
147136c41a | ||
|
|
6204dd4e72 | ||
|
|
19daa1737e | ||
|
|
0b40f872bf | ||
|
|
733e31c35a | ||
|
|
fc9a1ad145 | ||
|
|
94801f859a | ||
|
|
3cd1b2f80a | ||
|
|
a9287809a3 | ||
|
|
ced97dff1e | ||
|
|
c418d70d96 | ||
|
|
1085cbbd9d | ||
|
|
c39d58f60b | ||
|
|
f6985d9f70 | ||
|
|
eb6afbdf02 | ||
|
|
15218d0241 | ||
|
|
005bbcb812 | ||
|
|
dc3e7e790b | ||
|
|
9303e1cb9e | ||
|
|
7158a5e60d | ||
|
|
bcf8ee9d42 | ||
|
|
7a3ca43514 | ||
|
|
707c0793a3 | ||
|
|
0591f74a11 | ||
|
|
d484e133ce | ||
|
|
d2004e5b11 | ||
|
|
2fdc9bbb2d | ||
|
|
61b2d7c68e | ||
|
|
aab8454fe6 | ||
|
|
9f204d0334 | ||
|
|
b4ba200f95 | ||
|
|
32f8d8d0b7 | ||
|
|
9f7c9eec14 | ||
|
|
697aef2aef | ||
|
|
95580a6cef | ||
|
|
9e1a642eb2 | ||
|
|
93c5cb767e | ||
|
|
0f23a526eb | ||
|
|
7fb19649e1 | ||
|
|
65b2db0ece | ||
|
|
4416463ec8 | ||
|
|
c887a35d0b | ||
|
|
9cb7617352 | ||
|
|
e36e213b76 | ||
|
|
b737f4e00c | ||
|
|
d3d4acd806 | ||
|
|
0d1c292b23 | ||
|
|
7e620acb4f | ||
|
|
aadff131a3 | ||
|
|
68a09ea81b | ||
|
|
f1920d3bad | ||
|
|
e7796be586 | ||
|
|
37fc571fa1 | ||
|
|
a7ee431768 | ||
|
|
f7b34d2d79 | ||
|
|
e5c44a1f16 | ||
|
|
e4b67ffb8a | ||
|
|
f54993f579 | ||
|
|
b9230b6fb6 | ||
|
|
dbd9776aff | ||
|
|
e7e43a95fb | ||
|
|
96b1f7cc3a | ||
|
|
e5d199f20b | ||
|
|
fc467b62e0 | ||
|
|
84b61b271b | ||
|
|
6571db3001 | ||
|
|
3d98aaaae0 | ||
|
|
581df54fe2 | ||
|
|
e362b15c02 | ||
|
|
738b0dffc5 | ||
|
|
56899985cd | ||
|
|
781d611f17 | ||
|
|
d901df4fa0 | ||
|
|
40a733e147 | ||
|
|
6e4d1e9e5c | ||
|
|
47531cd9fa | ||
|
|
1395dcea71 | ||
|
|
fd6d81e3ad | ||
|
|
4ba5fa4766 | ||
|
|
ca0a100d9a | ||
|
|
08c007b7bf | ||
|
|
e0aeabb63a | ||
|
|
d9479be12e | ||
|
|
3f5b93a5c1 | ||
|
|
05163d41a0 | ||
|
|
5dfba64ac3 | ||
|
|
76a077d38f | ||
|
|
caad080b01 | ||
|
|
35f6cd8f17 | ||
|
|
89aa0985bc | ||
|
|
747ccae023 | ||
|
|
06242a433c | ||
|
|
14a0310d25 | ||
|
|
80bf5149fc | ||
|
|
80d1ae0950 | ||
|
|
cb68ff0df3 | ||
|
|
63d05ea696 | ||
|
|
7c05e94619 | ||
|
|
f4987e05e4 | ||
|
|
4c13704630 | ||
|
|
3e373b706f | ||
|
|
5aefa4ae55 | ||
|
|
7f92db75df | ||
|
|
7a94db5404 | ||
|
|
a30bfd2a45 | ||
|
|
33cfcd211a | ||
|
|
0edcd31367 | ||
|
|
ca98183e94 | ||
|
|
8ff6e5688a | ||
|
|
dad5e7c146 | ||
|
|
8d56e2d3bb | ||
|
|
a2494e467e | ||
|
|
8ae63796cf | ||
|
|
6a002a7d43 | ||
|
|
0b060d3110 | ||
|
|
bc26eb3f5e | ||
|
|
d6026681ee | ||
|
|
9ab8e15dd1 | ||
|
|
febc901518 | ||
|
|
26066ef8f4 | ||
|
|
f548b27ac5 | ||
|
|
181f8583d5 | ||
|
|
e6e557c18a | ||
|
|
a6a872eebb | ||
|
|
d07cb46741 | ||
|
|
df3bd146da | ||
|
|
93ec459460 | ||
|
|
6e96b4fbb0 | ||
|
|
79b1d949ce | ||
|
|
5ca12373d8 | ||
|
|
7e0125725c | ||
|
|
5eb25e02a4 | ||
|
|
435bac3847 | ||
|
|
6e879b297e | ||
|
|
bf4f882214 | ||
|
|
d0dec86a6d | ||
|
|
96c7e20488 | ||
|
|
35408f7a53 | ||
|
|
62df761d9c | ||
|
|
9ff9fa5b69 | ||
|
|
d6f586bbe3 | ||
|
|
e63520265c | ||
|
|
4d4f4d1930 | ||
|
|
387f72ace2 | ||
|
|
93abee2e35 | ||
|
|
94580bad88 | ||
|
|
b35e0c26d6 | ||
|
|
7d95e3e2fe | ||
|
|
9c40506728 | ||
|
|
60859d39c1 | ||
|
|
8740df65c4 | ||
|
|
26b15c66b8 | ||
|
|
04ac3d51b3 | ||
|
|
1c609e1b70 | ||
|
|
5de863fbb8 | ||
|
|
11d8f139f8 | ||
|
|
65947129f0 | ||
|
|
d85d73a6d0 | ||
|
|
28e3c3d817 | ||
|
|
9907432a0a | ||
|
|
ade81f4548 | ||
|
|
aaf23d52bc | ||
|
|
b5dd2767a7 | ||
|
|
5043a1b8ac | ||
|
|
cade8f4a19 | ||
|
|
82e2f1c2b5 | ||
|
|
6cfa52740e | ||
|
|
26f83cf269 | ||
|
|
4cbadb1d81 | ||
|
|
f950230fb5 | ||
|
|
6384f2feb2 | ||
|
|
f648add735 | ||
|
|
470cdd64d4 | ||
|
|
17ee2385f3 | ||
|
|
25ee1dfc45 | ||
|
|
859af22d2c | ||
|
|
da1a820d45 | ||
|
|
bc861380f0 | ||
|
|
e3dcf8e547 | ||
|
|
9805c482c2 | ||
|
|
7d314f8ee4 | ||
|
|
10aee31c7d | ||
|
|
6f1ee9561b | ||
|
|
8f531d247d | ||
|
|
f5bee7cc5d | ||
|
|
d8ce0a6c24 | ||
|
|
be634ab961 | ||
|
|
b0047784dc | ||
|
|
d6ba45a42b | ||
|
|
291822b5c8 | ||
|
|
d3a56fa893 | ||
|
|
7d347eee94 | ||
|
|
bcdf2ad887 | ||
|
|
22900407dd | ||
|
|
84c9098dba | ||
|
|
63e2c30e44 | ||
|
|
02ae5d9ad7 | ||
|
|
91fb0cea58 | ||
|
|
3c921198af | ||
|
|
7a3c9a707f | ||
|
|
3b319ca481 | ||
|
|
7adbe71475 | ||
|
|
06f51573e1 | ||
|
|
b2c38f454f | ||
|
|
c9ed0352b7 | ||
|
|
b8ef4fc726 | ||
|
|
394627e8d5 | ||
|
|
f513e27dc6 | ||
|
|
fe6ff6a1ae | ||
|
|
88a5179653 | ||
|
|
a357097c49 | ||
|
|
c4be00d11b | ||
|
|
93b83f2bc8 | ||
|
|
a2e98f485c | ||
|
|
e04f23bf15 | ||
|
|
f11689f4cb | ||
|
|
ea726bbf06 | ||
|
|
59673e7911 | ||
|
|
625c4f2dfb | ||
|
|
6a47e84699 | ||
|
|
14d3e743d4 | ||
|
|
dcaee350cf | ||
|
|
d205e66fe4 | ||
|
|
62a40db9ea | ||
|
|
75498fe061 | ||
|
|
5ca344e87b | ||
|
|
03d8448def | ||
|
|
a2630fc75f | ||
|
|
c50dcaaafb | ||
|
|
3499ba5ffa | ||
|
|
f5f11ff3ac | ||
|
|
0ec375ad89 | ||
|
|
ee3bdf9fd7 | ||
|
|
1760532168 | ||
|
|
97ee6ba19f | ||
|
|
d96f7a267c | ||
|
|
98f92f9ab2 | ||
|
|
8b04dd878d | ||
|
|
643dafd2c8 | ||
|
|
bf879a6141 | ||
|
|
fa1caa86d6 | ||
|
|
181ac27299 | ||
|
|
313d8f5065 | ||
|
|
ee1fa78ecd | ||
|
|
60172d0e52 | ||
|
|
e514cb1397 | ||
|
|
c640a53190 | ||
|
|
6ef3bd5095 | ||
|
|
47add7848f | ||
|
|
0b7acbc94e | ||
|
|
04974c3c0b | ||
|
|
014ae5faf5 | ||
|
|
e25aac774f | ||
|
|
c11b4149a0 | ||
|
|
091d523516 | ||
|
|
2ab456b9c1 | ||
|
|
bb36944a8a | ||
|
|
62e43f1169 | ||
|
|
dc22e0a577 | ||
|
|
51f578b9e3 | ||
|
|
2a8f34ea25 | ||
|
|
26a71f4f0e | ||
|
|
a366aebbb2 | ||
|
|
1ce8d9c2b7 | ||
|
|
17e539e524 | ||
|
|
177f357492 | ||
|
|
723e191f10 | ||
|
|
cb6bffe3ec | ||
|
|
1ebc289fb7 | ||
|
|
d271428f9d | ||
|
|
54777a9045 | ||
|
|
226a171550 | ||
|
|
227134d9c1 | ||
|
|
b3f5afe36b | ||
|
|
2c6c2e5fbd | ||
|
|
d4eefd0b55 | ||
|
|
110866cd34 | ||
|
|
279572ae09 | ||
|
|
485229b92f | ||
|
|
8e670e1a3c | ||
|
|
bce6a170b8 | ||
|
|
a0fdfa11e2 | ||
|
|
7303c8fa20 | ||
|
|
855fa1347b | ||
|
|
5758436dbf | ||
|
|
a343bdef23 | ||
|
|
d3b0ae30eb | ||
|
|
2c7e877086 | ||
|
|
12cdb8646c | ||
|
|
f89ac52bf3 | ||
|
|
5e858731d2 | ||
|
|
30f6c14030 | ||
|
|
78e07fec83 | ||
|
|
1cf871c150 | ||
|
|
8e821c50e5 | ||
|
|
3024bd6479 | ||
|
|
105d1abba8 | ||
|
|
3b7077e593 | ||
|
|
d581af3b8a | ||
|
|
e8929467e0 | ||
|
|
234dc52c7e | ||
|
|
ea74471307 | ||
|
|
b870198281 | ||
|
|
389037e6bf | ||
|
|
167d048232 | ||
|
|
59871ac36c | ||
|
|
bd3205a442 | ||
|
|
00a2d77543 | ||
|
|
1b000e83df | ||
|
|
3787ec3039 | ||
|
|
e341599a11 | ||
|
|
e50d49d7ba | ||
|
|
0cb3702eb9 | ||
|
|
09749bc088 | ||
|
|
0b152c201d | ||
|
|
fe470ad9c6 | ||
|
|
d22bfd0804 | ||
|
|
1ba3f2fbb4 | ||
|
|
688b7f4469 | ||
|
|
a8a37fa3e7 | ||
|
|
b7bc9efb52 | ||
|
|
74cd3a2627 | ||
|
|
e4cb0f7c28 | ||
|
|
66d741d0b3 | ||
|
|
819bd92d9a | ||
|
|
16b7ff2bb4 | ||
|
|
19b9b8b0ea | ||
|
|
f5cb293e44 | ||
|
|
f22ad17651 | ||
|
|
9b9905c5a3 | ||
|
|
8a6b7b172c | ||
|
|
4901443b7a | ||
|
|
789806637c | ||
|
|
270301958a | ||
|
|
1157d264ee | ||
|
|
87399c9ada | ||
|
|
cc4db1deff | ||
|
|
77b88de40e | ||
|
|
7baba78bca | ||
|
|
95f169bb58 | ||
|
|
7a9a8e1b4b | ||
|
|
046dccc4d5 | ||
|
|
15b0c26c5d | ||
|
|
7200c687af | ||
|
|
9b11eaa9bc | ||
|
|
d6504c3a09 | ||
|
|
15cb1839b0 | ||
|
|
633615c90d | ||
|
|
115bd8b22a | ||
|
|
c51fa5c1df | ||
|
|
a5104dd9ec | ||
|
|
901c867d7f | ||
|
|
5c06700873 | ||
|
|
0bc645edeb | ||
|
|
1e5cb94feb | ||
|
|
53122ff149 | ||
|
|
da75f41b01 | ||
|
|
63e65744f1 | ||
|
|
b8cce988f8 | ||
|
|
85bbda4234 | ||
|
|
3f96334065 | ||
|
|
79ce31c920 | ||
|
|
0e5df2fd21 | ||
|
|
57319bd78b | ||
|
|
5bfab913fb | ||
|
|
0027c64e57 | ||
|
|
bc38563618 | ||
|
|
493e69eb40 | ||
|
|
15be9e3ce1 | ||
|
|
64f6a10ced | ||
|
|
2cfd56c8da | ||
|
|
60d11ffe2a | ||
|
|
01f2d37fb8 | ||
|
|
8e6464eb90 | ||
|
|
2e6b98e9de | ||
|
|
d095732a4e | ||
|
|
b83f3c41d7 | ||
|
|
e497c7dc6c | ||
|
|
1a52154b5a | ||
|
|
6072b0181d | ||
|
|
e3f9f611eb | ||
|
|
34f7b2c53d | ||
|
|
8472897e5f | ||
|
|
7c5f92b3c5 | ||
|
|
5d89ba769d | ||
|
|
bdcbf81443 | ||
|
|
e5afa27b4a | ||
|
|
51646639ff | ||
|
|
428833ce38 | ||
|
|
4cb4058ded | ||
|
|
20d35762a6 | ||
|
|
c0e8e240a1 | ||
|
|
850cb415c7 | ||
|
|
3e42484be3 | ||
|
|
adaf049eb9 | ||
|
|
bed1417c54 | ||
|
|
98a10b59b9 | ||
|
|
f5174281c7 | ||
|
|
7ec4073ee2 | ||
|
|
80f81e3775 | ||
|
|
d8c34a7057 | ||
|
|
595a5ac111 | ||
|
|
024407e1e2 | ||
|
|
9d63bcbf69 | ||
|
|
092ef88cab | ||
|
|
16b3d76684 | ||
|
|
43b6cb8bb1 | ||
|
|
009debd4e8 | ||
|
|
6c87421c26 | ||
|
|
21eff6cb87 | ||
|
|
00bcec498c | ||
|
|
baac660f1d | ||
|
|
63898ead87 | ||
|
|
18f95acf87 | ||
|
|
cb136b8d9d | ||
|
|
4ecc44da17 | ||
|
|
8c8fc7cc26 | ||
|
|
17211e1597 | ||
|
|
f76c8a80b5 | ||
|
|
7a6ca986db |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -31,7 +31,7 @@ body:
|
||||
If the bug was found in a test/development build, select "Test build" or "Development build"
|
||||
and provide a link to the applicable build in the field below.
|
||||
options:
|
||||
- v1.1
|
||||
- v1.2.1
|
||||
- Test build
|
||||
- Development build
|
||||
- type: input
|
||||
|
||||
@@ -19,5 +19,3 @@ runs:
|
||||
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
|
||||
|
||||
1
.github/workflows/lint.yml
vendored
1
.github/workflows/lint.yml
vendored
@@ -13,6 +13,7 @@ jobs:
|
||||
with:
|
||||
src: "."
|
||||
options: "--check"
|
||||
version: "23.9.1"
|
||||
|
||||
mypy:
|
||||
name: Type checking
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
(Github Readme Banner and Splash screen Artwork by Andriy Dankovych, CC BY-SA 4.0)
|
||||
|
||||
[](https://patreon.com/khopa)
|
||||
[](https://patreon.com/dcsliberation)
|
||||
|
||||
[](https://github.com/dcs-retribution/dcs-retribution/releases)
|
||||
|
||||
|
||||
121
changelog.md
121
changelog.md
@@ -1,3 +1,77 @@
|
||||
# Retribution v1.3.0
|
||||
#### Note: Re-save your missions in DCS' Mission Editor to avoid possible crashes due to datalink (usually the case when F-16C blk50s are used) when hosting missions on a dedicated server.
|
||||
|
||||
## Features/Improvements
|
||||
* **[Engine]** Support for DCS v2.9.3.51704
|
||||
* **[Package Planning]** Option to "Auto-Create" package
|
||||
* **[Modding]** Custom weapons injection system (definition in aircraft's yaml file)
|
||||
* **[Payload Editor]** Ability to save/back-up payloads
|
||||
* **[Options]** New option in Settings: CAS engagement range (nmi)
|
||||
* **[Options]** New option in Settings: Convert untasked OPFOR aircraft into client slots
|
||||
* **[Options]** Split the **Disable idle aircraft at airfields** setting into **Disable untasked BLUFOR aircraft at airfields** and **Disable untasked OPFOR aircraft at airfields**
|
||||
* **[Options]** Split off the **Automatic AWACS package planning** and **Automatic Theater tanker package planning** settings from **Automatic package planning behavior** so players can choose to have AWACS and theater tankers auto-planned, while managing everything else themselves
|
||||
* **[Modding]** Updated support for Su-30 mod to V2.7.3 Beta
|
||||
* **[Modding]** Updated support for Su-57 mod to build-04
|
||||
* **[Modding]** Updated support for F-4B/C Phantom mod to 2.8.7.204
|
||||
* **[Modding]** Updated Community A-4E-C mod version support to 2.2.0 release.
|
||||
* **[Modding]** Added F/A-18E/F Super Hornet AI Tanker mod support (Chiller Juice Studios SuperBug Tanker AI version 1.4)
|
||||
* **[Modding]** Added VSN Super Étendard mod support (v2.5.5)
|
||||
* **[Modding]** Added F9F Panther mod support (version v2.8.7.101)
|
||||
* **[Modding]** Updated Irondome support to IDF Assets Pack V1.1, adding support for the David's Sling
|
||||
* **[Radios]** Added HF-FM band for AN/ARC-222
|
||||
* **[Radios]** Ability to define preset channels for radios on squadron level (for human pilots only)
|
||||
* **[Mission Planning]** Avoid helicopters being assigned as escort to planes and vice-versa
|
||||
* **[Mission Planning]** Allow attack helicopters to escort other helicopters
|
||||
* **[UI]** Allow changing waypoint names in FlightEdit's waypoints tab
|
||||
* **[Waypoints]** Allow user to add navigation waypoints where possible without degrading to a custom flight-plan
|
||||
* **[Campaign Management]** Improve squadron retreat logic to account for parking-slot sizes
|
||||
* **[Autoplanner]** Support for auto-planning Air Assaults
|
||||
* **[UI]** Improved frequency selector to support all modeled bands for every aircraft's intra-flight radio
|
||||
* **[Options]** New options in Settings: Helicopter waypoint altitude (feet AGL) for combat & cruise waypoints
|
||||
* **[Options]** New options in Settings: Spawn ground power trucks at ground starts in airbases/roadbases
|
||||
* **[Options]** Option for hiding TGOs (with IADS roles) on MFD
|
||||
* **[Plugins]** Splash Damage 2.1 with Clusters and Ship Radar effects.
|
||||
* **[COMMs]** Aircraft-specific callsigns will now also be used.
|
||||
* **[COMMs]** Ability to set a specific callsign to a flight.
|
||||
* **[Mission Generator]** Channel terrain fix on exclusion zones, sea zones and inclusion zones
|
||||
* **[Options]** Cheat-option for accessing Air Wing Config Dialog after campaign start (re-initializes turn if applied, thus plan your mission ___after___ making changes)
|
||||
* **[Options]** Option to enable unlimited fuel for AI (player and non-player flights)
|
||||
* **[Mission Generator]** F-15E Strike targets are automatically added as Mission Set 1
|
||||
* **[Mission Generator]** Set F-14's IP waypoint according to the flight-plan's ingress point
|
||||
* **[Mission Generator]** Automatically de-spawn aircraft when arrival/divert is an off-map spawn
|
||||
* **[Options]** Option to de-spawn AI flights in the air if their start-type was manually set to In-Flight
|
||||
* **[Config]** Preference setting to use custom Liberation payloads instead of prioritizing Retribution's default
|
||||
* **[Config]** Preference setting to configure the server-port on which Retribution's back-end will run
|
||||
* **[Options]** Made AI jettisoning empty fuel tanks optional (disabled by default)
|
||||
* **[Options]** Add option (so it can be disabled when fixed in DCS) to force air-starts (except for the slots that work) at Nevatim due to https://forum.dcs.world/topic/335545-29-nevatim-ramp-starts-still-bugged/
|
||||
* **[Cheat]** Add cheat option to manually manage REDFOR's TGOs
|
||||
* **[UX]** Buy/Replace TGOs for free before the campaign has started
|
||||
* **[Data]** Ability to define "cruise" & "combat" altitudes for airplanes
|
||||
* **[Options]** Option to randomize altitudes for flights with airplanes
|
||||
* **[Options]** Options to configure/override maximum mission distance for airplanes & helicopters
|
||||
|
||||
## Fixes
|
||||
* **[Mission Generation]** Anti-ship strikes should use "group attack" in their attack-task
|
||||
* **[New Game Wizard]** Faction selection overview doesn't update when inverting map
|
||||
* **[New Game Wizard]** Aircraft mods are now handled better when they are disabled
|
||||
* **[Payloads]** Added/Updated (missing) payloads
|
||||
* **[Aircraft Tasking]** Revised aircraft tasking, filtering out incompatible tasks for several aircraft
|
||||
* **[Data]** Corrected the class of the USS Samuel Chase from Logistics to LandingShip, in order to prevent it being spawned as part of AAA sites.
|
||||
* **[Mission Generation]** Helicopters oscillating due to over-speeding
|
||||
* **[Mission Generation]** Fix infinite loop when using "Fast-Forward to first contact"
|
||||
* **[Capture Logic]** Release all parking slots when an airbase is captured
|
||||
* **[Modding]** Swedish Military Assets Pack air defence presets are now correctly removed from the faction when the mod is disabled.
|
||||
* **[Mission Generation]** Naval aircraft not always returning to carrier
|
||||
* **[Mission Generation]** AI AirLift aircraft crashing into terrain due to insufficient waypoints
|
||||
* **[Mission Generation]** Fix friendly AI shooting at fires on the front-line
|
||||
|
||||
# Retribution v1.2.1 (hotfix)
|
||||
|
||||
## Fixes
|
||||
* **[Flight Plans]** SEAD Sweep not being auto-planned
|
||||
* **[Modding]** Python-4 no longer overwrites AIM-9X
|
||||
* **[Modding]** Restore original amount of pylons to F-16C when "ejecting" sufa mod
|
||||
|
||||
# Retribution v1.2.0
|
||||
|
||||
## Features/Improvements
|
||||
@@ -17,7 +91,7 @@
|
||||
* AEW&C threat buffer distance (nmi)
|
||||
* Theater tanker threat buffer distance (nmi)
|
||||
* **[Options]** Improved the option to configure OPFOR autoplanner aggressiveness. The AI might now take even more risks and plan missions against defended targets.
|
||||
* Added three new options in Settings:
|
||||
* **[Options]** Added three new options in Settings:
|
||||
* Autoplanner plans refueling flights for Strike packages
|
||||
* Autoplanner plans refueling flights for OCA packages
|
||||
* Autoplanner plans refueling flights for DEAD packages
|
||||
@@ -169,10 +243,53 @@ BAI/ANTISHIP/DEAD/STRIKE/BARCAP/CAS/OCA/AIR-ASSAULT (main) missions
|
||||
# Liberation:
|
||||
## Features/Improvements
|
||||
|
||||
* **[Engine]** Support for DCS 2.8.6.41363, including F-15E support.
|
||||
* **[Data]** Added support for the ARA Veinticinco de Mayo.
|
||||
* **[Data]** Changed display name of the AI-only F-15E Strike Eagle for clarity.
|
||||
* **[Flight Planning]** Improved IP selection for targets that are near the center of a threat zone.
|
||||
* **[Flight Planning]** Moved CAS ingress point off the front line so that the AI begins their target search earlier.
|
||||
* **[Flight Planning]** Loadouts and aircraft properties can now be set per-flight member. Warning: AI flights should not use mixed loadouts.
|
||||
* **[Flight Planning]** Laser codes that are pre-assigned to weapons at mission start can now be chosen from a list in the loadout UI. This does not affect the aircraft's TGP, just the weapons. Currently only implemented for the F-15E S4+ and F-16C.
|
||||
* **[Mission Generation]** Configured target and initial points for F-15E S4+.
|
||||
* **[Modding]** Factions can now specify the ship type to be used for cargo shipping. The Handy Wind will be used by default, but WW2 factions can pick something more appropriate.
|
||||
* **[Modding]** Unit variants can now set a display name separate from their ID.
|
||||
* **[UI]** An error will be displayed when invalid fast-forward options are selected rather than beginning a never ending simulation.
|
||||
* **[UI]** Added cheats for instantly repairing and destroying runways.
|
||||
* **[UI]** Improved usability of the flight properties UI. It now shows human-readable names and uses more appropriate UI elements.
|
||||
* **[UI]** The map now shows the real front line bounds.
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[Campaign]** Fixed error when canceling squadron transfer if the current location would be exactly full.
|
||||
* **[Data]** Fixed the class of the Samuel Chase so it can't be picked for a AAA or SHORAD site.
|
||||
* **[Data]** Allow CH-47D, CH-53E and UH-60A to operate from carriers and LHAs.
|
||||
* **[Data]** Added the F-15E's LANTIRN to the list of known targeting pods. Player F-15E flight with TGPs will now be assigned laser codes.
|
||||
* **[Flight Planning]** Patrolling flight plans (CAS, CAP, refueling, etc) now handle TOT offsets.
|
||||
* **[Mission Generation]** Restored previous AI behavior for anti-ship missions. A DCS update caused only a single aircraft in a flight to attack. The full flight will now attack like they used to.
|
||||
* **[Mission Generation]** Fix generation of OCA Runway missions to allow LGBs to be used.
|
||||
* **[Mission Generation]** Fixed AI flights flying far too slowly toward NAV points.
|
||||
* **[Mission Generation]** Fixed "division by zero" error on mission generation when a flight has an "In-Flight" start type and starts on top of a mission waypoint.
|
||||
* **[Modding]** Unit variants can now actually override base unit type properties.
|
||||
* **[Plugins]** Fixed Lua errors in Skynet plugin that would occur whenever one coalition had no IADS nodes.
|
||||
* **[UI]** Fixed deleting waypoints in custom flight plans deleting the wrong waypoint.
|
||||
* **[UI]** Fixed flight properties UI to support F-15E S4+ laser codes.
|
||||
* **[UI]** Fixed UI bug where altering an "ahead of package" TOT offset would change the offset back to a "behind pacakge" offset.
|
||||
* **[UI]** Fixed bug where changing TOT offsets could result in flight startup times that are in the past.
|
||||
* **[UI]** Flight plan paths are now drawn behind all other map elements, fixing rare cases where they could prevent other UI elements from being clickable.
|
||||
|
||||
# 8.1.0
|
||||
|
||||
Saves from 8.0.0 are compatible with 8.1.0
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
* **[Engine]** Support for DCS 2.8.6.41363, including F-15E support.
|
||||
* **[UI]** Flight loadout/properties tab is now scrollable.
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[Campaign]** Fixed liveries for premade squadrons all being off-by-one.
|
||||
* **[UI]** Fixed numbering of waypoints in the map and flight dialog (first waypoint is now 0 rather than 1).
|
||||
|
||||
# 8.0.0
|
||||
|
||||
Saves from 7.x are not compatible with 8.0.
|
||||
|
||||
1988
client/package-lock.json
generated
1988
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
||||
"@types/react": "^18.0.21",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react-redux": "^7.1.24",
|
||||
"axios": "^1.1.2",
|
||||
"axios": "^1.6.0",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"esri-leaflet": "^3.0.8",
|
||||
"leaflet": "^1.9.2",
|
||||
@@ -62,11 +62,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rtk-query/codegen-openapi": "^1.0.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^3.3.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/leaflet": "^1.8.0",
|
||||
"@types/redux-logger": "^3.0.9",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"electron": "^21.1.0",
|
||||
"electron": "^22.3.25",
|
||||
"electron-is-dev": "^2.0.0",
|
||||
"generate-license-file": "^2.0.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
|
||||
@@ -50,14 +50,6 @@ const injectedRtkApi = api.injectEndpoints({
|
||||
url: `/debug/waypoint-geometries/hold/${queryArg.flightId}`,
|
||||
}),
|
||||
}),
|
||||
getDebugIpZones: build.query<
|
||||
GetDebugIpZonesApiResponse,
|
||||
GetDebugIpZonesApiArg
|
||||
>({
|
||||
query: (queryArg) => ({
|
||||
url: `/debug/waypoint-geometries/ip/${queryArg.flightId}`,
|
||||
}),
|
||||
}),
|
||||
getDebugJoinZones: build.query<
|
||||
GetDebugJoinZonesApiResponse,
|
||||
GetDebugJoinZonesApiArg
|
||||
@@ -245,11 +237,6 @@ export type GetDebugHoldZonesApiResponse =
|
||||
export type GetDebugHoldZonesApiArg = {
|
||||
flightId: string;
|
||||
};
|
||||
export type GetDebugIpZonesApiResponse =
|
||||
/** status 200 Successful Response */ IpZones;
|
||||
export type GetDebugIpZonesApiArg = {
|
||||
flightId: string;
|
||||
};
|
||||
export type GetDebugJoinZonesApiResponse =
|
||||
/** status 200 Successful Response */ JoinZones;
|
||||
export type GetDebugJoinZonesApiArg = {
|
||||
@@ -379,12 +366,6 @@ export type HoldZones = {
|
||||
permissibleZones: LatLng[][][];
|
||||
preferredLines: LatLng[][];
|
||||
};
|
||||
export type IpZones = {
|
||||
homeBubble: LatLng[][];
|
||||
ipBubble: LatLng[][];
|
||||
permissibleZone: LatLng[][];
|
||||
safeZones: LatLng[][][];
|
||||
};
|
||||
export type JoinZones = {
|
||||
homeBubble: LatLng[][];
|
||||
targetBubble: LatLng[][];
|
||||
@@ -426,6 +407,7 @@ export type Tgo = {
|
||||
detection_ranges: number[];
|
||||
dead: boolean;
|
||||
sidc: string;
|
||||
task?: string[];
|
||||
};
|
||||
export type SupplyRoute = {
|
||||
id: string;
|
||||
@@ -497,7 +479,6 @@ export const {
|
||||
useSetControlPointDestinationMutation,
|
||||
useClearControlPointDestinationMutation,
|
||||
useGetDebugHoldZonesQuery,
|
||||
useGetDebugIpZonesQuery,
|
||||
useGetDebugJoinZonesQuery,
|
||||
useListFlightsQuery,
|
||||
useGetFlightByIdQuery,
|
||||
|
||||
@@ -30,11 +30,6 @@ export const liberationApi = _liberationApi.enhanceEndpoints({
|
||||
{ type: Tags.FLIGHT_PLAN, id: arg.flightId },
|
||||
],
|
||||
},
|
||||
getDebugIpZones: {
|
||||
providesTags: (result, error, arg) => [
|
||||
{ type: Tags.FLIGHT_PLAN, id: arg.flightId },
|
||||
],
|
||||
},
|
||||
getDebugJoinZones: {
|
||||
providesTags: (result, error, arg) => [
|
||||
{ type: Tags.FLIGHT_PLAN, id: arg.flightId },
|
||||
|
||||
@@ -57,6 +57,7 @@ describe("AirDefenseRangeLayer", () => {
|
||||
detection_ranges: [20],
|
||||
dead: false,
|
||||
sidc: "",
|
||||
task: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -86,6 +87,7 @@ describe("AirDefenseRangeLayer", () => {
|
||||
detection_ranges: [20],
|
||||
dead: false,
|
||||
sidc: "",
|
||||
task: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -125,6 +127,7 @@ describe("AirDefenseRangeLayer", () => {
|
||||
detection_ranges: [20],
|
||||
dead: false,
|
||||
sidc: "",
|
||||
task: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Flight } from "../../api/liberationApi";
|
||||
import { useGetCommitBoundaryForFlightQuery } from "../../api/liberationApi";
|
||||
import WaypointMarker from "../waypointmarker";
|
||||
import { ReactElement } from "react";
|
||||
import { Polyline as LPolyline } from "leaflet";
|
||||
import { ReactElement, useEffect, useRef } from "react";
|
||||
import { Polyline } from "react-leaflet";
|
||||
|
||||
const BLUE_PATH = "#0084ff";
|
||||
@@ -27,16 +28,44 @@ const pathColor = (props: FlightPlanProps) => {
|
||||
function FlightPlanPath(props: FlightPlanProps) {
|
||||
const color = pathColor(props);
|
||||
const waypoints = props.flight.waypoints;
|
||||
|
||||
const polylineRef = useRef<LPolyline | null>(null);
|
||||
|
||||
// Flight paths should be drawn under everything else. There seems to be an
|
||||
// issue where `interactive: false` doesn't do as its told (there's nuance,
|
||||
// see the bug for details). It looks better if we draw the other elements on
|
||||
// top of the flight plans anyway, so just push the flight plan to the back.
|
||||
//
|
||||
// https://github.com/dcs-liberation/dcs_liberation/issues/3295
|
||||
//
|
||||
// It's not possible to z-index a polyline (and leaflet says it never will be,
|
||||
// because this is a limitation of SVG, not leaflet:
|
||||
// https://github.com/Leaflet/Leaflet/issues/185), so we need to use
|
||||
// bringToBack() to push the flight paths to the back of the drawing once
|
||||
// they've been added to the map. They'll still draw on top of the map, but
|
||||
// behind everything than was added before them. Anything added after always
|
||||
// goes on top.
|
||||
useEffect(() => {
|
||||
if (props.selected) {
|
||||
polylineRef.current?.bringToFront();
|
||||
}
|
||||
else {
|
||||
polylineRef.current?.bringToBack();
|
||||
}
|
||||
});
|
||||
|
||||
if (waypoints == null) {
|
||||
return <></>;
|
||||
}
|
||||
const points = waypoints
|
||||
.filter((waypoint) => waypoint.include_in_path)
|
||||
.map((waypoint) => waypoint.position);
|
||||
|
||||
return (
|
||||
<Polyline
|
||||
positions={points}
|
||||
pathOptions={{ color: color, interactive: false }}
|
||||
ref={polylineRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,8 +95,12 @@ describe("FlightPlansLayer", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockPolyline).toHaveBeenCalledTimes(2);
|
||||
expect(mockLayerGroup).toBeCalledTimes(1);
|
||||
|
||||
// For some reason passing ref to PolyLine causes it and its group to be
|
||||
// redrawn, so these numbers don't match what you'd expect from the test.
|
||||
// It probably needs to be rewritten without mocks.
|
||||
expect(mockPolyline).toHaveBeenCalledTimes(3);
|
||||
expect(mockLayerGroup).toBeCalledTimes(2);
|
||||
});
|
||||
it("are not drawn if wrong coalition", () => {
|
||||
renderWithProviders(<FlightPlansLayer blue={true} />, {
|
||||
|
||||
@@ -53,6 +53,18 @@ export default function LiberationMap() {
|
||||
<LayersControl.Overlay name="Air defenses" checked>
|
||||
<TgosLayer categories={["aa"]} />
|
||||
</LayersControl.Overlay>
|
||||
<LayersControl.Overlay name="LORAD" >
|
||||
<TgosLayer categories={["aa"]} task={"LORAD"} />
|
||||
</LayersControl.Overlay>
|
||||
<LayersControl.Overlay name="MERAD" >
|
||||
<TgosLayer categories={["aa"]} task={"MERAD"} />
|
||||
</LayersControl.Overlay>
|
||||
<LayersControl.Overlay name="SHORAD" >
|
||||
<TgosLayer categories={["aa"]} task={"SHORAD"} />
|
||||
</LayersControl.Overlay>
|
||||
<LayersControl.Overlay name="AAA" >
|
||||
<TgosLayer categories={["aa"]} task={"AAA"} />
|
||||
</LayersControl.Overlay>
|
||||
<LayersControl.Overlay name="Factories" checked>
|
||||
<TgosLayer categories={["factory"]} />
|
||||
</LayersControl.Overlay>
|
||||
|
||||
@@ -6,14 +6,21 @@ import { LayerGroup } from "react-leaflet";
|
||||
interface TgosLayerProps {
|
||||
categories?: string[];
|
||||
exclude?: true;
|
||||
task?: string;
|
||||
}
|
||||
|
||||
export default function TgosLayer(props: TgosLayerProps) {
|
||||
const allTgos = Object.values(useAppSelector(selectTgos).tgos);
|
||||
const categoryFilter = props.categories ?? [];
|
||||
const taskFilter = props.task ?? "";
|
||||
const tgos = allTgos.filter(
|
||||
(tgo) => categoryFilter.includes(tgo.category) === !(props.exclude ?? false)
|
||||
);
|
||||
(tgo) => {
|
||||
if (taskFilter && tgo.task){
|
||||
return taskFilter === tgo.task[0]
|
||||
}
|
||||
return categoryFilter.includes(tgo.category) === !(props.exclude ?? false)
|
||||
}
|
||||
);
|
||||
return (
|
||||
<LayerGroup>
|
||||
{tgos.map((tgo) => {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useGetDebugIpZonesQuery } from "../../api/liberationApi";
|
||||
import { LayerGroup, Polygon } from "react-leaflet";
|
||||
|
||||
interface IpZonesProps {
|
||||
flightId: string;
|
||||
}
|
||||
|
||||
function IpZones(props: IpZonesProps) {
|
||||
const { data, error, isLoading } = useGetDebugIpZonesQuery({
|
||||
flightId: props.flightId,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error("Error while loading waypoint IP zone info", error);
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
console.log("Waypoint IP zone returned empty response");
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Polygon
|
||||
positions={data.homeBubble}
|
||||
color="#ffff00"
|
||||
fillOpacity={0.1}
|
||||
interactive={false}
|
||||
/>
|
||||
<Polygon
|
||||
positions={data.ipBubble}
|
||||
color="#bb89ff"
|
||||
fillOpacity={0.1}
|
||||
interactive={false}
|
||||
/>
|
||||
<Polygon
|
||||
positions={data.permissibleZone}
|
||||
color="#ffffff"
|
||||
fillOpacity={0.1}
|
||||
interactive={false}
|
||||
/>
|
||||
|
||||
{data.safeZones.map((zone, idx) => {
|
||||
return (
|
||||
<Polygon
|
||||
key={idx}
|
||||
positions={zone}
|
||||
color="#80BA80"
|
||||
fillOpacity={0.1}
|
||||
interactive={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface IpZonesLayerProps {
|
||||
flightId: string | null;
|
||||
}
|
||||
|
||||
export function IpZonesLayer(props: IpZonesLayerProps) {
|
||||
return (
|
||||
<LayerGroup>
|
||||
{props.flightId ? <IpZones flightId={props.flightId} /> : <></>}
|
||||
</LayerGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { selectSelectedFlightId } from "../../api/flightsSlice";
|
||||
import { useAppSelector } from "../../app/hooks";
|
||||
import { HoldZonesLayer } from "./HoldZones";
|
||||
import { IpZonesLayer } from "./IpZones";
|
||||
import { JoinZonesLayer } from "./JoinZones";
|
||||
import { LayersControl } from "react-leaflet";
|
||||
|
||||
@@ -16,9 +15,6 @@ export function WaypointDebugZonesControls() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayersControl.Overlay name="IP zones">
|
||||
<IpZonesLayer flightId={selectedFlightId} />
|
||||
</LayersControl.Overlay>
|
||||
<LayersControl.Overlay name="Join zones">
|
||||
<JoinZonesLayer flightId={selectedFlightId} />
|
||||
</LayersControl.Overlay>
|
||||
|
||||
0
game/aircraftparkinglocation.py
Normal file
0
game/aircraftparkinglocation.py
Normal file
@@ -191,11 +191,12 @@ class ForceGroup:
|
||||
location: PresetLocation,
|
||||
control_point: ControlPoint,
|
||||
game: Game,
|
||||
task: Optional[GroupTask],
|
||||
) -> TheaterGroundObject:
|
||||
"""Create a random TheaterGroundObject from the available templates"""
|
||||
layout = random.choice(self.layouts)
|
||||
return self.create_ground_object_for_layout(
|
||||
layout, name, location, control_point, game
|
||||
layout, name, location, control_point, game, task
|
||||
)
|
||||
|
||||
def create_ground_object_for_layout(
|
||||
@@ -205,9 +206,10 @@ class ForceGroup:
|
||||
location: PresetLocation,
|
||||
control_point: ControlPoint,
|
||||
game: Game,
|
||||
task: Optional[GroupTask],
|
||||
) -> TheaterGroundObject:
|
||||
"""Create a TheaterGroundObject for the given template"""
|
||||
go = layout.create_ground_object(name, location, control_point)
|
||||
go = layout.create_ground_object(name, location, control_point, task)
|
||||
# Generate all groups using the randomization if it defined
|
||||
for tgo_group in layout.groups:
|
||||
for unit_group in tgo_group.unit_groups:
|
||||
@@ -287,7 +289,7 @@ class ForceGroup:
|
||||
unit.id = game.next_unit_id()
|
||||
# Add unit name escaped so that we do not have scripting issues later
|
||||
unit.name = escape_string_for_lua(
|
||||
unit.unit_type.name if unit.unit_type else unit.type.name
|
||||
unit.unit_type.variant_id if unit.unit_type else unit.type.name
|
||||
)
|
||||
unit.position = PointWithHeading.from_point(
|
||||
ground_object.position + unit.position,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .flight import Flight
|
||||
from .flighttype import FlightType
|
||||
from .flight import Flight
|
||||
from .flightwaypoint import FlightWaypoint
|
||||
from .package import Package
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, List, Optional, TYPE_CHECKING
|
||||
|
||||
@@ -8,11 +10,15 @@ from dcs import Point
|
||||
from dcs.planes import C_101CC, C_101EB, Su_33, FA_18C_hornet
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.theater import ControlPoint, MissionTarget
|
||||
from pydcs_extensions.hercules.hercules import Hercules
|
||||
from .flightmembers import FlightMembers
|
||||
from .flightroster import FlightRoster
|
||||
from .flightstate import FlightState, Navigating, Uninitialized
|
||||
from .flightstate.killed import Killed
|
||||
from .loadouts import Loadout, Weapon
|
||||
from .flighttype import FlightType
|
||||
from .loadouts import Weapon
|
||||
from ..radio.CallsignContainer import CallsignContainer
|
||||
from ..radio.RadioFrequencyContainer import RadioFrequencyContainer
|
||||
from ..radio.TacanContainer import TacanContainer
|
||||
from ..radio.radios import RadioFrequency
|
||||
@@ -29,10 +35,10 @@ if TYPE_CHECKING:
|
||||
from game.sim.gameupdateevents import GameUpdateEvents
|
||||
from game.sim.simulationresults import SimulationResults
|
||||
from game.squadrons import Squadron, Pilot
|
||||
from game.theater import ControlPoint
|
||||
from game.transfers import TransferOrder
|
||||
from game.data.weapons import WeaponType
|
||||
from .flightmember import FlightMember
|
||||
from .flightplans.flightplan import FlightPlan
|
||||
from .flighttype import FlightType
|
||||
from .flightwaypoint import FlightWaypoint
|
||||
from .package import Package
|
||||
from .starttype import StartType
|
||||
@@ -40,7 +46,9 @@ if TYPE_CHECKING:
|
||||
F18_TGP_PYLON: int = 4
|
||||
|
||||
|
||||
class Flight(SidcDescribable, RadioFrequencyContainer, TacanContainer):
|
||||
class Flight(
|
||||
SidcDescribable, RadioFrequencyContainer, TacanContainer, CallsignContainer
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
package: Package,
|
||||
@@ -54,37 +62,33 @@ class Flight(SidcDescribable, RadioFrequencyContainer, TacanContainer):
|
||||
roster: Optional[FlightRoster] = None,
|
||||
frequency: Optional[RadioFrequency] = None,
|
||||
channel: Optional[TacanChannel] = None,
|
||||
callsign: Optional[str] = None,
|
||||
callsign_tcn: Optional[str] = None,
|
||||
claim_inv: bool = True,
|
||||
) -> None:
|
||||
self.id = uuid.uuid4()
|
||||
self.package = package
|
||||
self.coalition = squadron.coalition
|
||||
self.squadron = squadron
|
||||
self.flight_type = flight_type
|
||||
if claim_inv:
|
||||
self.squadron.claim_inventory(count)
|
||||
if roster is None:
|
||||
self.roster = FlightRoster(self.squadron, initial_size=count)
|
||||
self.roster = FlightMembers(self, initial_size=count)
|
||||
else:
|
||||
self.roster = roster
|
||||
self.roster = FlightMembers.from_roster(self, roster)
|
||||
self.divert = divert
|
||||
self.flight_type = flight_type
|
||||
self.loadout = Loadout.default_for(self)
|
||||
if self.squadron.aircraft.name == "F-15I Ra'am":
|
||||
self.loadout.pylons[16] = Weapon.with_clsid(
|
||||
"{IDF_MODS_PROJECT_F-15I_Raam_Dome}"
|
||||
)
|
||||
|
||||
self.start_type = start_type
|
||||
self.use_custom_loadout = False
|
||||
self.custom_name = custom_name
|
||||
self.group_id: int = 0
|
||||
|
||||
self.frequency = frequency
|
||||
if self.unit_type.dcs_unit_type.tacan:
|
||||
self.tacan = channel
|
||||
self.tcn_name = callsign
|
||||
self.tcn_name = callsign_tcn
|
||||
|
||||
self.initialize_fuel()
|
||||
self.use_same_loadout_for_all_members = True
|
||||
|
||||
# Only used by transport missions.
|
||||
self.cargo = cargo
|
||||
@@ -111,11 +115,35 @@ class Flight(SidcDescribable, RadioFrequencyContainer, TacanContainer):
|
||||
is_f18 = self.squadron.aircraft.dcs_unit_type.id == FA_18C_hornet.id
|
||||
on_land = not self.squadron.location.is_fleet
|
||||
if on_land and is_f18 and self.coalition.game.settings.atflir_autoswap:
|
||||
self.loadout.pylons[F18_TGP_PYLON] = Weapon.with_clsid(
|
||||
str(
|
||||
FA_18C_hornet.Pylon4.AN_AAQ_28_LITENING___Targeting_Pod_[1]["clsid"]
|
||||
for fm in self.roster.members:
|
||||
fm.loadout.pylons[F18_TGP_PYLON] = Weapon.with_clsid(
|
||||
str(
|
||||
FA_18C_hornet.Pylon4.AN_AAQ_28_LITENING___Targeting_Pod[1][
|
||||
"clsid"
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# altitude offset for planes
|
||||
offset_factor = self.coalition.game.settings.max_plane_altitude_offset
|
||||
offset_factor = random.randint(0, offset_factor)
|
||||
self.plane_altitude_offset = 1000 * offset_factor * random.choice([-1, 1])
|
||||
|
||||
@property
|
||||
def available_callsigns(self) -> List[str]:
|
||||
callsigns = set()
|
||||
dcs_unit = self.squadron.aircraft.dcs_unit_type
|
||||
category = dcs_unit.category
|
||||
category = "Air" if category == "Interceptor" else category
|
||||
for name in self.squadron.coalition.faction.country.callsign[category]:
|
||||
callsigns.add(name)
|
||||
if hasattr(dcs_unit, "callnames"):
|
||||
country_name = self.squadron.coalition.faction.country.name
|
||||
for c in dcs_unit.callnames:
|
||||
if "Combined Joint Task Forces" in country_name or c == country_name:
|
||||
for name in dcs_unit.callnames[c]:
|
||||
callsigns.add(name)
|
||||
return sorted(callsigns)
|
||||
|
||||
@property
|
||||
def flight_plan(self) -> FlightPlan[Any]:
|
||||
@@ -138,7 +166,11 @@ class Flight(SidcDescribable, RadioFrequencyContainer, TacanContainer):
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
state["state"] = Uninitialized(self, state["squadron"].settings)
|
||||
if "use_same_loadout_for_all_members" not in state:
|
||||
state["use_same_loadout_for_all_members"] = True
|
||||
self.__dict__.update(state)
|
||||
if isinstance(self.roster, FlightRoster):
|
||||
self.roster = FlightMembers.from_roster(self, self.roster)
|
||||
|
||||
@property
|
||||
def blue(self) -> bool:
|
||||
@@ -188,14 +220,17 @@ class Flight(SidcDescribable, RadioFrequencyContainer, TacanContainer):
|
||||
def is_hercules(self) -> bool:
|
||||
return self.unit_type == AircraftType.named("C-130J-30 Super Hercules")
|
||||
|
||||
@property
|
||||
def from_cp(self) -> ControlPoint:
|
||||
return self.departure
|
||||
|
||||
@property
|
||||
def points(self) -> List[FlightWaypoint]:
|
||||
return self.flight_plan.waypoints[1:]
|
||||
|
||||
@property
|
||||
def custom_targets(self) -> List[MissionTarget]:
|
||||
return [
|
||||
MissionTarget(wpt.name, wpt.position)
|
||||
for wpt in self.flight_plan.layout.custom_waypoints
|
||||
]
|
||||
|
||||
def position(self) -> Point:
|
||||
return self.state.estimate_position()
|
||||
|
||||
@@ -210,6 +245,9 @@ class Flight(SidcDescribable, RadioFrequencyContainer, TacanContainer):
|
||||
def missing_pilots(self) -> int:
|
||||
return self.roster.missing_pilots
|
||||
|
||||
def iter_members(self) -> Iterator[FlightMember]:
|
||||
yield from self.roster.members
|
||||
|
||||
def set_flight_type(self, var: FlightType) -> None:
|
||||
self.flight_type = var
|
||||
|
||||
@@ -239,6 +277,11 @@ class Flight(SidcDescribable, RadioFrequencyContainer, TacanContainer):
|
||||
elif self.departure.cptype.name in ["FARP", "FOB"] and not self.is_helo:
|
||||
self.fuel = unit_type.fuel_max * 0.75
|
||||
|
||||
def any_member_has_weapon_of_type(self, weapon_type: WeaponType) -> bool:
|
||||
return any(
|
||||
m.loadout.has_weapon_of_type(weapon_type) for m in self.iter_members()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
@@ -298,12 +341,12 @@ class Flight(SidcDescribable, RadioFrequencyContainer, TacanContainer):
|
||||
Killed(self.state.estimate_position(), self, self.squadron.settings)
|
||||
)
|
||||
events.update_flight(self)
|
||||
for pilot in self.roster.pilots:
|
||||
for pilot in self.roster.iter_pilots():
|
||||
if pilot is not None:
|
||||
results.kill_pilot(self, pilot)
|
||||
|
||||
def recreate_flight_plan(self) -> None:
|
||||
self._flight_plan_builder.regenerate()
|
||||
def recreate_flight_plan(self, dump_debug_info: bool = False) -> None:
|
||||
self._flight_plan_builder.regenerate(dump_debug_info)
|
||||
|
||||
@staticmethod
|
||||
def clone_flight(flight: Flight) -> Flight:
|
||||
|
||||
49
game/ato/flightmember.py
Normal file
49
game/ato/flightmember.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from game.ato.loadouts import Loadout
|
||||
from game.lasercodes import LaserCode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.squadrons import Pilot
|
||||
|
||||
|
||||
class FlightMember:
|
||||
def __init__(self, pilot: Pilot | None, loadout: Loadout) -> None:
|
||||
self.pilot = pilot
|
||||
self.loadout = loadout
|
||||
self.use_custom_loadout = False
|
||||
self.tgp_laser_code: LaserCode | None = None
|
||||
self.weapon_laser_code: LaserCode | None = None
|
||||
self.properties: dict[str, bool | float | int] = {}
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
if "tgp_laser_code" not in state:
|
||||
state["tgp_laser_code"] = None
|
||||
if "weapon_laser_code" not in state:
|
||||
state["weapon_laser_code"] = None
|
||||
self.__dict__.update(state)
|
||||
|
||||
def assign_tgp_laser_code(self, code: LaserCode) -> None:
|
||||
if self.tgp_laser_code is not None:
|
||||
raise RuntimeError(
|
||||
f"{self.pilot} already has already been assigned laser code "
|
||||
f"{self.tgp_laser_code}"
|
||||
)
|
||||
self.tgp_laser_code = code
|
||||
|
||||
def release_tgp_laser_code(self) -> None:
|
||||
if self.tgp_laser_code is None:
|
||||
raise RuntimeError(f"{self.pilot} has no assigned laser code")
|
||||
|
||||
if self.weapon_laser_code == self.tgp_laser_code:
|
||||
self.weapon_laser_code = None
|
||||
self.tgp_laser_code.release()
|
||||
self.tgp_laser_code = None
|
||||
|
||||
@property
|
||||
def is_player(self) -> bool:
|
||||
if self.pilot is None:
|
||||
return False
|
||||
return self.pilot.player
|
||||
100
game/ato/flightmembers.py
Normal file
100
game/ato/flightmembers.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from .flightmember import FlightMember
|
||||
from .flightroster import FlightRoster
|
||||
from .iflightroster import IFlightRoster
|
||||
from .loadouts import Loadout
|
||||
from ..data.weapons import Weapon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.squadrons import Pilot, Squadron
|
||||
from .flight import Flight
|
||||
|
||||
|
||||
class FlightMembers(IFlightRoster):
|
||||
def __init__(self, flight: Flight, initial_size: int = 0) -> None:
|
||||
self.flight = flight
|
||||
self.members: list[FlightMember] = []
|
||||
self.resize(initial_size)
|
||||
|
||||
@property
|
||||
def squadron(self) -> Squadron:
|
||||
return self.flight.squadron
|
||||
|
||||
@staticmethod
|
||||
def from_roster(flight: Flight, roster: FlightRoster) -> FlightMembers:
|
||||
members = FlightMembers(flight)
|
||||
loadout = Loadout.default_for(flight)
|
||||
if flight.squadron.aircraft.variant_id == "F-15I Ra'am":
|
||||
loadout.pylons[16] = Weapon.with_clsid("{IDF_MODS_PROJECT_F-15I_Raam_Dome}")
|
||||
members.members = [FlightMember(p, loadout) for p in roster.pilots]
|
||||
return members
|
||||
|
||||
def iter_pilots(self) -> Iterator[Pilot | None]:
|
||||
yield from (m.pilot for m in self.members)
|
||||
|
||||
def pilot_at(self, idx: int) -> Pilot | None:
|
||||
return self.members[idx].pilot
|
||||
|
||||
@property
|
||||
def max_size(self) -> int:
|
||||
return len(self.members)
|
||||
|
||||
@property
|
||||
def player_count(self) -> int:
|
||||
return len([m for m in self.members if m.is_player])
|
||||
|
||||
@property
|
||||
def missing_pilots(self) -> int:
|
||||
return len([m for m in self.members if m.pilot is None])
|
||||
|
||||
def resize(self, new_size: int) -> None:
|
||||
if self.max_size > new_size:
|
||||
for member in self.members[new_size:]:
|
||||
if (pilot := member.pilot) is not None:
|
||||
self.flight.squadron.return_pilot(pilot)
|
||||
if (code := member.tgp_laser_code) is not None:
|
||||
code.release()
|
||||
self.members = self.members[:new_size]
|
||||
return
|
||||
if self.max_size:
|
||||
loadout = self.members[0].loadout.clone()
|
||||
else:
|
||||
loadout = Loadout.default_for(self.flight)
|
||||
if self.flight.squadron.aircraft.variant_id == "F-15I Ra'am":
|
||||
loadout.pylons[16] = Weapon.with_clsid("{IDF_MODS_PROJECT_F-15I_Raam_Dome}")
|
||||
for _ in range(new_size - self.max_size):
|
||||
member = FlightMember(self.flight.squadron.claim_available_pilot(), loadout)
|
||||
member.use_custom_loadout = loadout.is_custom
|
||||
self.members.append(member)
|
||||
|
||||
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
|
||||
if pilot is not None:
|
||||
self.flight.squadron.claim_pilot(pilot)
|
||||
if (current_pilot := self.pilot_at(index)) is not None:
|
||||
self.flight.squadron.return_pilot(current_pilot)
|
||||
self.members[index].pilot = pilot
|
||||
|
||||
def clear(self) -> None:
|
||||
self.flight.squadron.return_pilots(
|
||||
[p for p in self.iter_pilots() if p is not None]
|
||||
)
|
||||
for member in self.members:
|
||||
if (code := member.tgp_laser_code) is not None:
|
||||
code.release()
|
||||
|
||||
def use_same_loadout_for_all_members(self) -> None:
|
||||
if not self.members:
|
||||
return
|
||||
loadout = self.members[0].loadout
|
||||
for member in self.members[1:]:
|
||||
# Do not clone the loadout, we want any changes in the UI to be mirrored
|
||||
# across all flight members.
|
||||
member.loadout = loadout
|
||||
|
||||
def use_distinct_loadouts_for_each_member(self) -> None:
|
||||
for member in self.members:
|
||||
member.loadout = member.loadout.clone()
|
||||
@@ -6,7 +6,7 @@ from typing import Type
|
||||
from game.ato.flightplans.ibuilder import IBuilder
|
||||
from game.ato.flightplans.patrolling import PatrollingFlightPlan, PatrollingLayout
|
||||
from game.ato.flightplans.waypointbuilder import WaypointBuilder
|
||||
from game.utils import Distance, Heading, Speed, feet, knots, meters, nautical_miles
|
||||
from game.utils import Distance, Heading, Speed, knots, meters, nautical_miles
|
||||
|
||||
|
||||
class AewcFlightPlan(PatrollingFlightPlan[PatrollingLayout]):
|
||||
@@ -49,7 +49,7 @@ class Builder(IBuilder[AewcFlightPlan, PatrollingLayout]):
|
||||
|
||||
# Station 80nm outside the threat zone.
|
||||
threat_buffer = nautical_miles(
|
||||
self.flight.coalition.game.settings.aewc_threat_buffer_min_distance
|
||||
self.coalition.game.settings.aewc_threat_buffer_min_distance
|
||||
)
|
||||
if self.threat_zones.threatened(location.position):
|
||||
orbit_distance = distance_to_threat + threat_buffer
|
||||
@@ -68,12 +68,9 @@ class Builder(IBuilder[AewcFlightPlan, PatrollingLayout]):
|
||||
orbit_heading.left.degrees, racetrack_half_distance
|
||||
)
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
builder = WaypointBuilder(self.flight)
|
||||
|
||||
if self.flight.unit_type.patrol_altitude is not None:
|
||||
altitude = self.flight.unit_type.patrol_altitude
|
||||
else:
|
||||
altitude = feet(25000)
|
||||
altitude = builder.get_patrol_altitude
|
||||
|
||||
racetrack = builder.race_track(racetrack_start, racetrack_end, altitude)
|
||||
|
||||
@@ -90,7 +87,8 @@ class Builder(IBuilder[AewcFlightPlan, PatrollingLayout]):
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> AewcFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> AewcFlightPlan:
|
||||
return AewcFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime
|
||||
from typing import Iterator, TYPE_CHECKING, Type
|
||||
|
||||
from game.ato.flightplans.standard import StandardFlightPlan, StandardLayout
|
||||
from game.theater.controlpoint import ControlPointType
|
||||
from game.theater.missiontarget import MissionTarget
|
||||
from game.utils import Distance, feet, meters
|
||||
from ._common_ctld import generate_random_ctld_point
|
||||
from .ibuilder import IBuilder
|
||||
from .formationattack import (
|
||||
FormationAttackLayout,
|
||||
FormationAttackBuilder,
|
||||
FormationAttackFlightPlan,
|
||||
)
|
||||
from .planningerror import PlanningError
|
||||
from .uizonedisplay import UiZone, UiZoneDisplay
|
||||
from .waypointbuilder import WaypointBuilder
|
||||
@@ -22,51 +26,63 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
@dataclass
|
||||
class AirAssaultLayout(StandardLayout):
|
||||
class AirAssaultLayout(FormationAttackLayout):
|
||||
# The pickup point is optional because we don't always need to load the cargo. When
|
||||
# departing from a carrier, LHA, or off-map spawn, the cargo is pre-loaded.
|
||||
pickup: FlightWaypoint | None
|
||||
nav_to_ingress: list[FlightWaypoint]
|
||||
ingress: FlightWaypoint
|
||||
drop_off: FlightWaypoint | None
|
||||
pickup: FlightWaypoint | None = None
|
||||
drop_off: FlightWaypoint | None = None
|
||||
# This is an implementation detail used by CTLD. The aircraft will not go to this
|
||||
# waypoint. It is used by CTLD as the destination for unloaded troops.
|
||||
target: FlightWaypoint
|
||||
nav_to_home: list[FlightWaypoint]
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
if self.pickup is not None:
|
||||
yield self.pickup
|
||||
yield from self.nav_to_ingress
|
||||
yield from self.nav_to
|
||||
yield self.join
|
||||
yield self.ingress
|
||||
if self.drop_off is not None:
|
||||
yield self.drop_off
|
||||
yield self.target
|
||||
yield from self.nav_to_home
|
||||
yield self.targets[0]
|
||||
yield from self.nav_from
|
||||
yield self.arrival
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
yield from self.custom_waypoints
|
||||
|
||||
|
||||
class AirAssaultFlightPlan(StandardFlightPlan[AirAssaultLayout], UiZoneDisplay):
|
||||
class AirAssaultFlightPlan(FormationAttackFlightPlan, UiZoneDisplay):
|
||||
@staticmethod
|
||||
def builder_type() -> Type[Builder]:
|
||||
return Builder
|
||||
|
||||
@property
|
||||
def is_airassault(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def tot_waypoint(self) -> FlightWaypoint:
|
||||
if self.flight.is_helo and self.layout.drop_off is not None:
|
||||
return self.layout.drop_off
|
||||
return self.layout.target
|
||||
return self.layout.targets[0]
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
if waypoint == self.tot_waypoint:
|
||||
@property
|
||||
def ingress_time(self) -> datetime:
|
||||
tot = self.tot
|
||||
travel_time = self.travel_time_between_waypoints(
|
||||
self.layout.ingress, self.tot_waypoint
|
||||
)
|
||||
return tot - travel_time
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
if waypoint is self.tot_waypoint:
|
||||
return self.tot
|
||||
elif waypoint is self.layout.ingress:
|
||||
return self.ingress_time
|
||||
return None
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -74,17 +90,21 @@ class AirAssaultFlightPlan(StandardFlightPlan[AirAssaultLayout], UiZoneDisplay):
|
||||
return meters(2500)
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
def mission_begin_on_station_time(self) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> datetime:
|
||||
return self.package.time_over_target
|
||||
|
||||
def ui_zone(self) -> UiZone:
|
||||
return UiZone(
|
||||
[self.layout.target.position],
|
||||
[self.layout.targets[0].position],
|
||||
self.ctld_target_zone_radius,
|
||||
)
|
||||
|
||||
|
||||
class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
|
||||
class Builder(FormationAttackBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
|
||||
def layout(self) -> AirAssaultLayout:
|
||||
if not self.flight.is_helo and not self.flight.is_hercules:
|
||||
raise PlanningError(
|
||||
@@ -92,10 +112,10 @@ class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
|
||||
)
|
||||
assert self.package.waypoints is not None
|
||||
|
||||
altitude = feet(1500) if self.flight.is_helo else self.doctrine.ingress_altitude
|
||||
altitude_is_agl = self.flight.is_helo
|
||||
builder = WaypointBuilder(self.flight)
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
altitude = builder.get_cruise_altitude
|
||||
altitude_is_agl = self.flight.is_helo
|
||||
|
||||
if self.flight.is_hercules or self.flight.departure.cptype in [
|
||||
ControlPointType.AIRCRAFT_CARRIER_GROUP,
|
||||
@@ -113,37 +133,46 @@ class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
|
||||
self._generate_ctld_pickup(),
|
||||
)
|
||||
)
|
||||
pickup.alt = altitude
|
||||
pickup_position = pickup.position
|
||||
|
||||
ingress = builder.ingress(
|
||||
FlightWaypointType.INGRESS_AIR_ASSAULT,
|
||||
self.package.waypoints.ingress,
|
||||
self.package.target,
|
||||
)
|
||||
|
||||
assault_area = builder.assault_area(self.package.target)
|
||||
heading = self.package.target.position.heading_between_point(pickup_position)
|
||||
if self.flight.is_hercules:
|
||||
assault_area.only_for_player = False
|
||||
assault_area.alt = feet(1000)
|
||||
|
||||
# TODO: define CTLD dropoff zones in campaign miz?
|
||||
drop_off_zone = MissionTarget(
|
||||
"Dropoff zone",
|
||||
self.package.target.position.point_from_heading(heading, 1200),
|
||||
)
|
||||
tgt = self.package.target
|
||||
if isinstance(tgt, CTLD) and tgt.ctld_zones:
|
||||
top3 = sorted(
|
||||
tgt.ctld_zones, key=lambda x: ingress.position.distance_to_point(x[0])
|
||||
)[:3]
|
||||
pos, dist = random.choice(top3)
|
||||
drop_pos = pos.random_point_within(dist)
|
||||
else:
|
||||
heading = tgt.position.heading_between_point(ingress.position)
|
||||
drop_pos = tgt.position.point_from_heading(heading, 1200)
|
||||
drop_off_zone = MissionTarget("Dropoff zone", drop_pos)
|
||||
dz = builder.dropoff_zone(drop_off_zone) if self.flight.is_helo else None
|
||||
|
||||
return AirAssaultLayout(
|
||||
departure=builder.takeoff(self.flight.departure),
|
||||
pickup=pickup,
|
||||
nav_to_ingress=builder.nav_path(
|
||||
nav_to=builder.nav_path(
|
||||
pickup_position,
|
||||
self.package.waypoints.ingress,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
),
|
||||
ingress=builder.ingress(
|
||||
FlightWaypointType.INGRESS_AIR_ASSAULT,
|
||||
self.package.waypoints.ingress,
|
||||
self.package.target,
|
||||
),
|
||||
ingress=ingress,
|
||||
drop_off=dz,
|
||||
target=assault_area,
|
||||
nav_to_home=builder.nav_path(
|
||||
targets=[assault_area],
|
||||
nav_from=builder.nav_path(
|
||||
drop_off_zone.position,
|
||||
self.flight.arrival.position,
|
||||
altitude,
|
||||
@@ -152,9 +181,14 @@ class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
hold=None,
|
||||
join=builder.join(ingress.position),
|
||||
split=builder.split(self.flight.arrival.position),
|
||||
refuel=None,
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> AirAssaultFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> AirAssaultFlightPlan:
|
||||
return AirAssaultFlightPlan(self.flight, self.layout())
|
||||
|
||||
def _generate_ctld_pickup(self) -> Point:
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Type
|
||||
|
||||
from game.theater.missiontarget import MissionTarget
|
||||
from game.utils import feet
|
||||
from game.utils import Distance
|
||||
from ._common_ctld import generate_random_ctld_point
|
||||
from .ibuilder import IBuilder
|
||||
from .planningerror import PlanningError
|
||||
from .standard import StandardFlightPlan, StandardLayout
|
||||
from .waypointbuilder import WaypointBuilder
|
||||
from ..flightwaypointtype import FlightWaypointType
|
||||
from ...theater.interfaces.CTLD import CTLD
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -21,14 +24,17 @@ if TYPE_CHECKING:
|
||||
|
||||
@dataclass
|
||||
class AirliftLayout(StandardLayout):
|
||||
nav_to_pickup: list[FlightWaypoint]
|
||||
pickup_ascent: FlightWaypoint | None
|
||||
pickup_descent: FlightWaypoint | None
|
||||
# There will not be a pickup waypoint when the pickup airfield is the departure
|
||||
# airfield for cargo planes, as the cargo is pre-loaded. Helicopters will still pick
|
||||
# up the cargo near the airfield.
|
||||
pickup: FlightWaypoint | None
|
||||
# pickup_zone will be used for player flights to create the CTLD stuff
|
||||
ctld_pickup_zone: FlightWaypoint | None
|
||||
drop_off_ascent: FlightWaypoint | None
|
||||
nav_to_drop_off: list[FlightWaypoint]
|
||||
drop_off_descent: FlightWaypoint | None
|
||||
# There will not be a drop-off waypoint when the drop-off airfield and the arrival
|
||||
# airfield is the same for a cargo plane, as planes will land to unload and we don't
|
||||
# want a double landing. Helicopters will still drop their cargo near the airfield
|
||||
@@ -36,39 +42,57 @@ class AirliftLayout(StandardLayout):
|
||||
drop_off: FlightWaypoint | None
|
||||
# drop_off_zone will be used for player flights to create the CTLD stuff
|
||||
ctld_drop_off_zone: FlightWaypoint | None
|
||||
nav_to_home: list[FlightWaypoint]
|
||||
return_ascent: FlightWaypoint
|
||||
return_descent: FlightWaypoint
|
||||
|
||||
def add_waypoint(
|
||||
self, wpt: FlightWaypoint, next_wpt: Optional[FlightWaypoint]
|
||||
) -> bool:
|
||||
new_wpt = self.get_midpoint(wpt, next_wpt)
|
||||
if wpt.waypoint_type in [
|
||||
FlightWaypointType.PICKUP_ZONE,
|
||||
FlightWaypointType.CARGO_STOP,
|
||||
]:
|
||||
self.nav_to_drop_off.insert(0, new_wpt)
|
||||
return True
|
||||
return super().add_waypoint(wpt, next_wpt)
|
||||
|
||||
def delete_waypoint(self, waypoint: FlightWaypoint) -> bool:
|
||||
if super().delete_waypoint(waypoint):
|
||||
return True
|
||||
if waypoint in self.nav_to_pickup:
|
||||
self.nav_to_pickup.remove(waypoint)
|
||||
return True
|
||||
elif waypoint in self.nav_to_drop_off:
|
||||
if waypoint in self.nav_to_drop_off:
|
||||
self.nav_to_drop_off.remove(waypoint)
|
||||
return True
|
||||
elif waypoint in self.nav_to_home:
|
||||
self.nav_to_home.remove(waypoint)
|
||||
elif super().delete_waypoint(waypoint):
|
||||
return True
|
||||
return False
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
yield from self.nav_to_pickup
|
||||
if self.pickup_ascent is not None:
|
||||
yield self.pickup_ascent
|
||||
yield from self.nav_to
|
||||
if self.pickup_descent is not None:
|
||||
yield self.pickup_descent
|
||||
if self.pickup is not None:
|
||||
yield self.pickup
|
||||
if self.ctld_pickup_zone is not None:
|
||||
yield self.ctld_pickup_zone
|
||||
if self.drop_off_ascent is not None:
|
||||
yield self.drop_off_ascent
|
||||
yield from self.nav_to_drop_off
|
||||
if self.drop_off_descent is not None:
|
||||
yield self.drop_off_descent
|
||||
if self.drop_off is not None:
|
||||
yield self.drop_off
|
||||
yield self.return_ascent
|
||||
if self.ctld_drop_off_zone is not None:
|
||||
yield self.ctld_drop_off_zone
|
||||
yield from self.nav_to_home
|
||||
yield from self.nav_from
|
||||
yield self.return_descent
|
||||
yield self.arrival
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
yield from self.custom_waypoints
|
||||
|
||||
|
||||
class AirliftFlightPlan(StandardFlightPlan[AirliftLayout]):
|
||||
@@ -84,16 +108,20 @@ class AirliftFlightPlan(StandardFlightPlan[AirliftLayout]):
|
||||
# drop-off waypoint.
|
||||
return self.layout.drop_off or self.layout.arrival
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
# TOT planning isn't really useful for transports. They're behind the front
|
||||
# lines so no need to wait for escorts or for other missions to complete.
|
||||
return None
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
def mission_begin_on_station_time(self) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> datetime:
|
||||
return self.package.time_over_target
|
||||
|
||||
|
||||
@@ -105,20 +133,52 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]):
|
||||
"Cannot plan transport mission for flight with no cargo."
|
||||
)
|
||||
|
||||
altitude = feet(1500)
|
||||
altitude_is_agl = True
|
||||
builder = WaypointBuilder(self.flight)
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
altitude = builder.get_cruise_altitude
|
||||
altitude_is_agl = self.flight.is_helo
|
||||
|
||||
pickup_ascent = None
|
||||
pickup_descent = None
|
||||
pickup = None
|
||||
drop_off_ascent = None
|
||||
drop_off_descent = None
|
||||
drop_off = None
|
||||
pickup_zone = None
|
||||
drop_off_zone = None
|
||||
|
||||
if cargo.origin != self.flight.departure:
|
||||
pickup = builder.cargo_stop(cargo.origin)
|
||||
pickup_ascent = self._create_ascent_or_descent(
|
||||
builder,
|
||||
self.flight.departure.position,
|
||||
cargo.origin.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
)
|
||||
pickup_descent = self._create_ascent_or_descent(
|
||||
builder,
|
||||
cargo.origin.position,
|
||||
self.flight.departure.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
)
|
||||
if cargo.next_stop != self.flight.arrival:
|
||||
drop_off = builder.cargo_stop(cargo.next_stop)
|
||||
drop_off_ascent = self._create_ascent_or_descent(
|
||||
builder,
|
||||
cargo.origin.position,
|
||||
cargo.next_stop.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
)
|
||||
drop_off_descent = self._create_ascent_or_descent(
|
||||
builder,
|
||||
cargo.next_stop.position,
|
||||
cargo.origin.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
)
|
||||
|
||||
if self.flight.is_helo:
|
||||
# Create CTLD Zones for Helo flights
|
||||
@@ -139,31 +199,57 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]):
|
||||
altitude_is_agl,
|
||||
)
|
||||
|
||||
return_ascent = self._create_ascent_or_descent(
|
||||
builder,
|
||||
cargo.next_stop.position
|
||||
if cargo.next_stop != self.flight.arrival
|
||||
else cargo.origin.position,
|
||||
self.flight.arrival.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
)
|
||||
return_descent = self._create_ascent_or_descent(
|
||||
builder,
|
||||
self.flight.arrival.position,
|
||||
cargo.next_stop.position
|
||||
if cargo.next_stop != self.flight.arrival
|
||||
else cargo.origin.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
)
|
||||
|
||||
return AirliftLayout(
|
||||
departure=builder.takeoff(self.flight.departure),
|
||||
nav_to_pickup=nav_to_pickup,
|
||||
pickup_ascent=pickup_ascent,
|
||||
nav_to=nav_to_pickup,
|
||||
pickup_descent=pickup_descent,
|
||||
pickup=pickup,
|
||||
ctld_pickup_zone=pickup_zone,
|
||||
drop_off_ascent=drop_off_ascent,
|
||||
nav_to_drop_off=builder.nav_path(
|
||||
cargo.origin.position,
|
||||
cargo.next_stop.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
),
|
||||
drop_off_descent=drop_off_descent,
|
||||
drop_off=drop_off,
|
||||
ctld_drop_off_zone=drop_off_zone,
|
||||
nav_to_home=builder.nav_path(
|
||||
return_ascent=return_ascent,
|
||||
nav_from=builder.nav_path(
|
||||
cargo.origin.position,
|
||||
self.flight.arrival.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
),
|
||||
return_descent=return_descent,
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> AirliftFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> AirliftFlightPlan:
|
||||
return AirliftFlightPlan(self.flight, self.layout())
|
||||
|
||||
def _generate_ctld_pickup(self) -> Point:
|
||||
@@ -177,3 +263,18 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]):
|
||||
if cargo and cargo.transport and isinstance(cargo.transport.destination, CTLD):
|
||||
return generate_random_ctld_point(cargo.transport.destination)
|
||||
raise RuntimeError("Could not generate CTLD dropoff")
|
||||
|
||||
@staticmethod
|
||||
def _create_ascent_or_descent(
|
||||
builder: WaypointBuilder,
|
||||
start: Point,
|
||||
end: Point,
|
||||
alt: Distance,
|
||||
agl: bool,
|
||||
) -> FlightWaypoint:
|
||||
distance = start.distance_to_point(end)
|
||||
rdistance = 1000 if agl else min(distance / 10, 20000)
|
||||
heading = round(start.heading_between_point(end))
|
||||
rheading = random.randint(heading - 30, heading + 30) % 360
|
||||
pos = start.point_from_heading(float(rheading), rdistance)
|
||||
return builder.nav(pos, alt, agl)
|
||||
|
||||
@@ -41,5 +41,5 @@ class Builder(FormationAttackBuilder[AntiShipFlightPlan, FormationAttackLayout])
|
||||
def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> list[StrikeTarget]:
|
||||
return [StrikeTarget(f"{g.group_name} at {tgo.name}", g) for g in tgo.groups]
|
||||
|
||||
def build(self) -> AntiShipFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> AntiShipFlightPlan:
|
||||
return AntiShipFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -39,5 +39,5 @@ class Builder(FormationAttackBuilder[BaiFlightPlan, FormationAttackLayout]):
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_BAI, targets)
|
||||
|
||||
def build(self) -> BaiFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> BaiFlightPlan:
|
||||
return BaiFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import timedelta
|
||||
from typing import Type
|
||||
|
||||
from game.theater import FrontLine
|
||||
from game.utils import Distance, Speed, feet
|
||||
from game.utils import Distance, Speed
|
||||
from .capbuilder import CapBuilder
|
||||
from .invalidobjectivelocation import InvalidObjectiveLocation
|
||||
from .patrolling import PatrollingFlightPlan, PatrollingLayout
|
||||
@@ -41,14 +40,9 @@ class Builder(CapBuilder[BarCapFlightPlan, PatrollingLayout]):
|
||||
|
||||
start_pos, end_pos = self.cap_racetrack_for_objective(location, barcap=True)
|
||||
|
||||
preferred_alt = self.flight.unit_type.preferred_patrol_altitude
|
||||
randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
|
||||
patrol_alt = max(
|
||||
self.doctrine.min_patrol_altitude,
|
||||
min(self.doctrine.max_patrol_altitude, randomized_alt),
|
||||
)
|
||||
builder = WaypointBuilder(self.flight)
|
||||
patrol_alt = builder.get_patrol_altitude
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
start, end = builder.race_track(start_pos, end_pos, patrol_alt)
|
||||
|
||||
return PatrollingLayout(
|
||||
@@ -64,7 +58,8 @@ class Builder(CapBuilder[BarCapFlightPlan, PatrollingLayout]):
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> BarCapFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> BarCapFlightPlan:
|
||||
return BarCapFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -6,13 +6,16 @@ from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Type
|
||||
|
||||
from game.theater import FrontLine
|
||||
from game.utils import Distance, Speed, kph, meters
|
||||
from game.utils import Distance, Speed, kph, dcs_to_shapely_point
|
||||
from game.utils import nautical_miles
|
||||
from .ibuilder import IBuilder
|
||||
from .invalidobjectivelocation import InvalidObjectiveLocation
|
||||
from .patrolling import PatrollingFlightPlan, PatrollingLayout
|
||||
from .uizonedisplay import UiZone, UiZoneDisplay
|
||||
from .waypointbuilder import WaypointBuilder
|
||||
from ..flightwaypointtype import FlightWaypointType
|
||||
from ...flightplan.ipsolver import IpSolver
|
||||
from ...persistency import waypoint_debug_directory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..flightwaypoint import FlightWaypoint
|
||||
@@ -20,19 +23,21 @@ if TYPE_CHECKING:
|
||||
|
||||
@dataclass
|
||||
class CasLayout(PatrollingLayout):
|
||||
target: FlightWaypoint
|
||||
ingress: FlightWaypoint
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
yield from self.nav_to
|
||||
if self.ingress:
|
||||
yield self.ingress
|
||||
yield self.patrol_start
|
||||
yield self.target
|
||||
yield self.patrol_end
|
||||
yield from self.nav_from
|
||||
yield self.arrival
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
yield from self.custom_waypoints
|
||||
|
||||
|
||||
class CasFlightPlan(PatrollingFlightPlan[CasLayout], UiZoneDisplay):
|
||||
@@ -53,12 +58,13 @@ class CasFlightPlan(PatrollingFlightPlan[CasLayout], UiZoneDisplay):
|
||||
|
||||
@property
|
||||
def engagement_distance(self) -> Distance:
|
||||
max_length = self.flight.coalition.game.settings.max_frontline_width * 1000
|
||||
return meters(max_length) / 2
|
||||
return nautical_miles(
|
||||
self.flight.coalition.game.settings.cas_engagement_range_distance
|
||||
)
|
||||
|
||||
@property
|
||||
def combat_speed_waypoints(self) -> set[FlightWaypoint]:
|
||||
return {self.layout.patrol_start, self.layout.target, self.layout.patrol_end}
|
||||
return {self.layout.ingress, self.layout.patrol_start, self.layout.patrol_end}
|
||||
|
||||
def request_escort_at(self) -> FlightWaypoint | None:
|
||||
return self.layout.patrol_start
|
||||
@@ -67,14 +73,17 @@ class CasFlightPlan(PatrollingFlightPlan[CasLayout], UiZoneDisplay):
|
||||
return self.layout.patrol_end
|
||||
|
||||
def ui_zone(self) -> UiZone:
|
||||
midpoint = (
|
||||
self.layout.patrol_start.position + self.layout.patrol_end.position
|
||||
) / 2
|
||||
return UiZone(
|
||||
[self.layout.target.position],
|
||||
[midpoint],
|
||||
self.engagement_distance,
|
||||
)
|
||||
|
||||
|
||||
class Builder(IBuilder[CasFlightPlan, CasLayout]):
|
||||
def layout(self) -> CasLayout:
|
||||
def layout(self, dump_debug_info: bool) -> CasLayout:
|
||||
location = self.package.target
|
||||
|
||||
if not isinstance(location, FrontLine):
|
||||
@@ -84,49 +93,79 @@ class Builder(IBuilder[CasFlightPlan, CasLayout]):
|
||||
FrontLineConflictDescription,
|
||||
)
|
||||
|
||||
bounds = FrontLineConflictDescription.frontline_bounds(
|
||||
location, self.theater, self.coalition.game.settings
|
||||
)
|
||||
ingress = bounds.left_position
|
||||
center = bounds.center
|
||||
egress = bounds.right_position
|
||||
bounds = FrontLineConflictDescription.frontline_bounds(location, self.theater)
|
||||
patrol_start = bounds.left_position
|
||||
patrol_end = bounds.right_position
|
||||
|
||||
ingress_distance = ingress.distance_to_point(self.flight.departure.position)
|
||||
egress_distance = egress.distance_to_point(self.flight.departure.position)
|
||||
if egress_distance < ingress_distance:
|
||||
ingress, egress = egress, ingress
|
||||
start_distance = patrol_start.distance_to_point(self.flight.departure.position)
|
||||
end_distance = patrol_end.distance_to_point(self.flight.departure.position)
|
||||
if end_distance < start_distance:
|
||||
patrol_start, patrol_end = patrol_end, patrol_start
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
builder = WaypointBuilder(self.flight)
|
||||
|
||||
is_helo = self.flight.unit_type.dcs_unit_type.helicopter
|
||||
ingress_egress_altitude = (
|
||||
self.doctrine.ingress_altitude if not is_helo else meters(50)
|
||||
ingress_egress_altitude = builder.get_combat_altitude
|
||||
use_agl_patrol_altitude = is_helo
|
||||
|
||||
ip_solver = IpSolver(
|
||||
dcs_to_shapely_point(self.flight.departure.position),
|
||||
dcs_to_shapely_point(patrol_start),
|
||||
self.doctrine,
|
||||
self.threat_zones.all,
|
||||
)
|
||||
use_agl_ingress_egress = is_helo
|
||||
ip_solver.set_debug_properties(
|
||||
waypoint_debug_directory() / "IP", self.theater.terrain
|
||||
)
|
||||
ingress_point_shapely = ip_solver.solve()
|
||||
if dump_debug_info:
|
||||
ip_solver.dump_debug_info()
|
||||
|
||||
ingress_point = patrol_start.new_in_same_map(
|
||||
ingress_point_shapely.x, ingress_point_shapely.y
|
||||
)
|
||||
|
||||
patrol_start_waypoint = builder.nav(
|
||||
patrol_start, ingress_egress_altitude, use_agl_patrol_altitude
|
||||
)
|
||||
patrol_start_waypoint.name = "FLOT START"
|
||||
patrol_start_waypoint.pretty_name = "FLOT start"
|
||||
patrol_start_waypoint.description = "FLOT boundary"
|
||||
|
||||
patrol_end_waypoint = builder.nav(
|
||||
patrol_end, ingress_egress_altitude, use_agl_patrol_altitude
|
||||
)
|
||||
patrol_end_waypoint.name = "FLOT END"
|
||||
patrol_end_waypoint.pretty_name = "FLOT end"
|
||||
patrol_end_waypoint.description = "FLOT boundary"
|
||||
|
||||
ingress = builder.ingress(
|
||||
FlightWaypointType.INGRESS_CAS, ingress_point, location
|
||||
)
|
||||
ingress.description = f"Ingress to provide CAS at {location}"
|
||||
|
||||
return CasLayout(
|
||||
departure=builder.takeoff(self.flight.departure),
|
||||
nav_to=builder.nav_path(
|
||||
self.flight.departure.position,
|
||||
ingress,
|
||||
ingress_point,
|
||||
ingress_egress_altitude,
|
||||
use_agl_ingress_egress,
|
||||
use_agl_patrol_altitude,
|
||||
),
|
||||
nav_from=builder.nav_path(
|
||||
egress,
|
||||
patrol_end,
|
||||
self.flight.arrival.position,
|
||||
ingress_egress_altitude,
|
||||
use_agl_ingress_egress,
|
||||
use_agl_patrol_altitude,
|
||||
),
|
||||
patrol_start=builder.ingress(
|
||||
FlightWaypointType.INGRESS_CAS, ingress, location
|
||||
),
|
||||
target=builder.cas(center),
|
||||
patrol_end=builder.egress(egress, location),
|
||||
ingress=ingress,
|
||||
patrol_start=patrol_start_waypoint,
|
||||
patrol_end=patrol_end_waypoint,
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> CasFlightPlan:
|
||||
return CasFlightPlan(self.flight, self.layout())
|
||||
def build(self, dump_debug_info: bool = False) -> CasFlightPlan:
|
||||
return CasFlightPlan(self.flight, self.layout(dump_debug_info))
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Type
|
||||
|
||||
from .flightplan import FlightPlan, Layout
|
||||
@@ -17,8 +17,6 @@ if TYPE_CHECKING:
|
||||
|
||||
@dataclass
|
||||
class CustomLayout(Layout):
|
||||
custom_waypoints: list[FlightWaypoint]
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
yield from self.custom_waypoints
|
||||
@@ -29,6 +27,10 @@ class CustomFlightPlan(FlightPlan[CustomLayout]):
|
||||
def builder_type() -> Type[Builder]:
|
||||
return Builder
|
||||
|
||||
@property
|
||||
def is_custom(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def tot_waypoint(self) -> FlightWaypoint:
|
||||
target_types = (
|
||||
@@ -42,16 +44,20 @@ class CustomFlightPlan(FlightPlan[CustomLayout]):
|
||||
return waypoint
|
||||
return self.layout.departure
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
if waypoint == self.tot_waypoint:
|
||||
return self.package.time_over_target + self.tot_offset
|
||||
return self.tot
|
||||
return None
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
def mission_begin_on_station_time(self) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> datetime:
|
||||
return self.package.time_over_target
|
||||
|
||||
|
||||
@@ -65,8 +71,8 @@ class Builder(IBuilder[CustomFlightPlan, CustomLayout]):
|
||||
self.waypoints = waypoints
|
||||
|
||||
def layout(self) -> CustomLayout:
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
builder = WaypointBuilder(self.flight)
|
||||
return CustomLayout(builder.takeoff(self.flight.departure), self.waypoints)
|
||||
|
||||
def build(self) -> CustomFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> CustomFlightPlan:
|
||||
return CustomFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -37,5 +37,5 @@ class Builder(FormationAttackBuilder[DeadFlightPlan, FormationAttackLayout]):
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_DEAD)
|
||||
|
||||
def build(self) -> DeadFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> DeadFlightPlan:
|
||||
return DeadFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -2,12 +2,15 @@ from __future__ import annotations
|
||||
|
||||
from typing import Type
|
||||
|
||||
from .airassault import AirAssaultLayout
|
||||
from .airlift import AirliftLayout
|
||||
from .formationattack import (
|
||||
FormationAttackBuilder,
|
||||
FormationAttackFlightPlan,
|
||||
FormationAttackLayout,
|
||||
)
|
||||
from .waypointbuilder import WaypointBuilder
|
||||
from .. import FlightType
|
||||
|
||||
|
||||
class EscortFlightPlan(FormationAttackFlightPlan):
|
||||
@@ -20,41 +23,81 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]):
|
||||
def layout(self) -> FormationAttackLayout:
|
||||
assert self.package.waypoints is not None
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
builder = WaypointBuilder(self.flight)
|
||||
ingress, target = builder.escort(
|
||||
self.package.waypoints.ingress, self.package.target
|
||||
)
|
||||
ingress.only_for_player = True
|
||||
target.only_for_player = True
|
||||
hold = builder.hold(self._hold_point())
|
||||
join = builder.join(self.package.waypoints.join)
|
||||
split = builder.split(self.package.waypoints.split)
|
||||
refuel = builder.refuel(self.package.waypoints.refuel)
|
||||
hold = None
|
||||
if not self.flight.is_helo:
|
||||
hold = builder.hold(self._hold_point())
|
||||
|
||||
join_pos = (
|
||||
self.package.waypoints.ingress
|
||||
if self.flight.is_helo
|
||||
else self.package.waypoints.join
|
||||
)
|
||||
join = builder.join(join_pos)
|
||||
|
||||
split = builder.split(self._get_split())
|
||||
|
||||
is_helo = builder.flight.is_helo
|
||||
initial = builder.escort_hold(
|
||||
self.package.waypoints.initial, self.doctrine.ingress_altitude
|
||||
target.position if is_helo else self.package.waypoints.initial,
|
||||
)
|
||||
|
||||
pf = self.package.primary_flight
|
||||
if pf and pf.flight_type in [FlightType.AIR_ASSAULT, FlightType.TRANSPORT]:
|
||||
layout = pf.flight_plan.layout
|
||||
assert isinstance(layout, AirAssaultLayout) or isinstance(
|
||||
layout, AirliftLayout
|
||||
)
|
||||
if isinstance(layout, AirliftLayout):
|
||||
ascent = layout.pickup_ascent or layout.drop_off_ascent
|
||||
assert ascent is not None
|
||||
join = builder.join(ascent.position)
|
||||
if layout.pickup and layout.drop_off_ascent:
|
||||
join = builder.join(layout.drop_off_ascent.position)
|
||||
elif layout.pickup:
|
||||
join = builder.join(layout.pickup.position)
|
||||
split = builder.split(layout.arrival.position)
|
||||
if layout.drop_off:
|
||||
initial = builder.escort_hold(
|
||||
layout.drop_off.position,
|
||||
)
|
||||
|
||||
refuel = self._build_refuel(builder)
|
||||
|
||||
departure = builder.takeoff(self.flight.departure)
|
||||
nav_to = builder.nav_path(
|
||||
hold.position if hold else departure.position,
|
||||
join.position,
|
||||
builder.get_cruise_altitude,
|
||||
)
|
||||
|
||||
nav_from = builder.nav_path(
|
||||
refuel.position if refuel else split.position,
|
||||
self.flight.arrival.position,
|
||||
builder.get_cruise_altitude,
|
||||
)
|
||||
|
||||
return FormationAttackLayout(
|
||||
departure=builder.takeoff(self.flight.departure),
|
||||
departure=departure,
|
||||
hold=hold,
|
||||
nav_to=builder.nav_path(
|
||||
hold.position, join.position, self.doctrine.ingress_altitude
|
||||
),
|
||||
nav_to=nav_to,
|
||||
join=join,
|
||||
ingress=ingress,
|
||||
initial=initial,
|
||||
targets=[target],
|
||||
split=split,
|
||||
refuel=refuel,
|
||||
nav_from=builder.nav_path(
|
||||
refuel.position,
|
||||
self.flight.arrival.position,
|
||||
self.doctrine.ingress_altitude,
|
||||
),
|
||||
nav_from=nav_from,
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> EscortFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> EscortFlightPlan:
|
||||
return EscortFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Type
|
||||
|
||||
from game.utils import feet
|
||||
@@ -17,23 +17,14 @@ if TYPE_CHECKING:
|
||||
|
||||
@dataclass
|
||||
class FerryLayout(StandardLayout):
|
||||
nav_to_destination: list[FlightWaypoint]
|
||||
|
||||
def delete_waypoint(self, waypoint: FlightWaypoint) -> bool:
|
||||
if super().delete_waypoint(waypoint):
|
||||
return True
|
||||
if waypoint in self.nav_to_destination:
|
||||
self.nav_to_destination.remove(waypoint)
|
||||
return True
|
||||
return False
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
yield from self.nav_to_destination
|
||||
yield from self.nav_to
|
||||
yield self.arrival
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
yield from self.custom_waypoints
|
||||
|
||||
|
||||
class FerryFlightPlan(StandardFlightPlan[FerryLayout]):
|
||||
@@ -45,16 +36,20 @@ class FerryFlightPlan(StandardFlightPlan[FerryLayout]):
|
||||
def tot_waypoint(self) -> FlightWaypoint:
|
||||
return self.layout.arrival
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
# TOT planning isn't really useful for ferries. They're behind the front
|
||||
# lines so no need to wait for escorts or for other missions to complete.
|
||||
return None
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
def mission_begin_on_station_time(self) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> datetime:
|
||||
return self.package.time_over_target
|
||||
|
||||
|
||||
@@ -66,17 +61,17 @@ class Builder(IBuilder[FerryFlightPlan, FerryLayout]):
|
||||
f"{self.flight.departure}"
|
||||
)
|
||||
|
||||
altitude_is_agl = self.flight.unit_type.dcs_unit_type.helicopter
|
||||
builder = WaypointBuilder(self.flight)
|
||||
altitude_is_agl = self.flight.is_helo
|
||||
altitude = (
|
||||
feet(1500)
|
||||
feet(self.coalition.game.settings.heli_cruise_alt_agl)
|
||||
if altitude_is_agl
|
||||
else self.flight.unit_type.preferred_patrol_altitude
|
||||
else builder.get_patrol_altitude
|
||||
)
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
return FerryLayout(
|
||||
departure=builder.takeoff(self.flight.departure),
|
||||
nav_to_destination=builder.nav_path(
|
||||
nav_to=builder.nav_path(
|
||||
self.flight.departure.position,
|
||||
self.flight.arrival.position,
|
||||
altitude,
|
||||
@@ -85,7 +80,9 @@ class Builder(IBuilder[FerryFlightPlan, FerryLayout]):
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
nav_from=[],
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> FerryFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> FerryFlightPlan:
|
||||
return FerryFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -8,11 +8,10 @@ generating the waypoints for the mission.
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from abc import ABC
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from functools import cached_property
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Generic, TYPE_CHECKING, TypeGuard, TypeVar
|
||||
|
||||
from game.typeguard import self_type_guard
|
||||
@@ -20,10 +19,9 @@ from game.utils import Distance, Speed, meters
|
||||
from .planningerror import PlanningError
|
||||
from ..flightwaypointtype import FlightWaypointType
|
||||
from ..starttype import StartType
|
||||
from ..traveltime import GroundSpeed, TravelTime
|
||||
from ..traveltime import GroundSpeed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.dcs.aircrafttype import FuelConsumption
|
||||
from game.theater import ControlPoint
|
||||
from ..flight import Flight
|
||||
from ..flightwaypoint import FlightWaypoint
|
||||
@@ -32,18 +30,11 @@ if TYPE_CHECKING:
|
||||
from .loiter import LoiterFlightPlan
|
||||
from .patrolling import PatrollingFlightPlan
|
||||
|
||||
INGRESS_TYPES = {
|
||||
FlightWaypointType.INGRESS_CAS,
|
||||
FlightWaypointType.INGRESS_ESCORT,
|
||||
FlightWaypointType.INGRESS_SEAD,
|
||||
FlightWaypointType.INGRESS_STRIKE,
|
||||
FlightWaypointType.INGRESS_DEAD,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Layout(ABC):
|
||||
departure: FlightWaypoint
|
||||
custom_waypoints: list[FlightWaypoint]
|
||||
|
||||
@property
|
||||
def waypoints(self) -> list[FlightWaypoint]:
|
||||
@@ -81,15 +72,6 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
"""A list of all waypoints in the flight plan, in order."""
|
||||
return list(self.iter_waypoints())
|
||||
|
||||
def get_index_of_wpt_by_type(self, wpt_type: FlightWaypointType) -> int:
|
||||
index = 0
|
||||
for wpt in self.waypoints:
|
||||
if wpt and not wpt.only_for_player:
|
||||
index += 1
|
||||
if wpt.waypoint_type == wpt_type:
|
||||
return index
|
||||
return -1
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
"""Iterates over all waypoints in the flight plan, in order."""
|
||||
yield from self.layout.iter_waypoints()
|
||||
@@ -123,6 +105,19 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
#
|
||||
# Plus, it's a loiter point so there's no reason to hurry.
|
||||
factor = 0.75
|
||||
elif (
|
||||
self.flight.is_helo
|
||||
and (
|
||||
a.waypoint_type == FlightWaypointType.JOIN
|
||||
or "INGRESS" in a.waypoint_type.name
|
||||
or a.waypoint_type == FlightWaypointType.CUSTOM
|
||||
)
|
||||
and self.package.primary_flight
|
||||
and not self.package.primary_flight.flight_plan.is_airassault
|
||||
):
|
||||
# Helicopter flights should be slowed down between JOIN & INGRESS
|
||||
# to allow the escort to keep up while engaging targets along the way.
|
||||
factor = 0.50
|
||||
# TODO: Adjust if AGL.
|
||||
# We don't have an exact heightmap, but we should probably be performing
|
||||
# *some* adjustment for NTTR since the minimum altitude of the map is
|
||||
@@ -167,42 +162,9 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def tot(self) -> timedelta:
|
||||
def tot(self) -> datetime:
|
||||
return self.package.time_over_target + self.tot_offset
|
||||
|
||||
@cached_property
|
||||
def bingo_fuel(self) -> int:
|
||||
"""Bingo fuel value for the FlightPlan"""
|
||||
if (fuel := self.flight.unit_type.fuel_consumption) is not None:
|
||||
return self._bingo_estimate(fuel)
|
||||
return self._legacy_bingo_estimate()
|
||||
|
||||
def _bingo_estimate(self, fuel: FuelConsumption) -> int:
|
||||
distance_to_arrival = self.max_distance_from(self.flight.arrival)
|
||||
fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles
|
||||
bingo = fuel_consumed + fuel.min_safe
|
||||
return math.ceil(bingo / 100) * 100
|
||||
|
||||
def _legacy_bingo_estimate(self) -> int:
|
||||
distance_to_arrival = self.max_distance_from(self.flight.arrival)
|
||||
|
||||
bingo = 1000.0 # Minimum Emergency Fuel
|
||||
bingo += 500 # Visual Traffic
|
||||
bingo += 15 * distance_to_arrival.nautical_miles
|
||||
|
||||
# TODO: Per aircraft tweaks.
|
||||
|
||||
if self.flight.divert is not None:
|
||||
max_divert_distance = self.max_distance_from(self.flight.divert)
|
||||
bingo += 10 * max_divert_distance.nautical_miles
|
||||
|
||||
return round(bingo / 100) * 100
|
||||
|
||||
@cached_property
|
||||
def joker_fuel(self) -> int:
|
||||
"""Joker fuel value for the FlightPlan"""
|
||||
return self.bingo_fuel + 1000
|
||||
|
||||
def max_distance_from(self, cp: ControlPoint) -> Distance:
|
||||
"""Returns the farthest waypoint of the flight plan from a ControlPoint.
|
||||
:arg cp The ControlPoint to measure distance from.
|
||||
@@ -231,20 +193,37 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
)
|
||||
|
||||
for previous_waypoint, waypoint in self.edges(until=destination):
|
||||
total += self.travel_time_between_waypoints(previous_waypoint, waypoint)
|
||||
return total
|
||||
total += self.total_time_between_waypoints(previous_waypoint, waypoint)
|
||||
|
||||
# Trim microseconds. Our simulation tick rate is 1 second, so anything that
|
||||
# takes 100.1 or 100.9 seconds will take 100 seconds. DCS doesn't handle
|
||||
# sub-second resolution for tasks anyway, nor are they interesting from a
|
||||
# mission planning perspective, so there's little value to keeping them in the
|
||||
# model.
|
||||
return timedelta(seconds=math.floor(total.total_seconds()))
|
||||
|
||||
def total_time_between_waypoints(
|
||||
self, a: FlightWaypoint, b: FlightWaypoint
|
||||
) -> timedelta:
|
||||
"""Returns the total time spent between a and b.
|
||||
|
||||
The total time between waypoints differs from the travel time in that it may
|
||||
include additional time for actions such as loitering.
|
||||
"""
|
||||
return self.travel_time_between_waypoints(a, b)
|
||||
|
||||
def travel_time_between_waypoints(
|
||||
self, a: FlightWaypoint, b: FlightWaypoint
|
||||
) -> timedelta:
|
||||
return TravelTime.between_points(
|
||||
a.position, b.position, self.speed_between_waypoints(a, b)
|
||||
)
|
||||
error_factor = 1.05
|
||||
speed = self.speed_between_waypoints(a, b)
|
||||
distance = meters(a.position.distance_to_point(b.position))
|
||||
return timedelta(hours=distance.nautical_miles / speed.knots * error_factor)
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def request_escort_at(self) -> FlightWaypoint | None:
|
||||
@@ -267,34 +246,24 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
if waypoint == end:
|
||||
return
|
||||
|
||||
def takeoff_time(self) -> timedelta:
|
||||
def takeoff_time(self) -> datetime:
|
||||
return self.tot - self._travel_time_to_waypoint(self.tot_waypoint)
|
||||
|
||||
def startup_time(self) -> timedelta:
|
||||
start_time = (
|
||||
self.takeoff_time() - self.estimate_startup() - self.estimate_ground_ops()
|
||||
def minimum_duration_from_start_to_tot(self) -> timedelta:
|
||||
return (
|
||||
self._travel_time_to_waypoint(self.tot_waypoint)
|
||||
+ self.estimate_startup()
|
||||
+ self.estimate_ground_ops()
|
||||
+ self.estimate_takeoff_time()
|
||||
)
|
||||
|
||||
# In case FP math has given us some barely below zero time, round to
|
||||
# zero.
|
||||
if math.isclose(start_time.total_seconds(), 0):
|
||||
start_time = timedelta()
|
||||
|
||||
# Trim microseconds. DCS doesn't handle sub-second resolution for tasks,
|
||||
# and they're not interesting from a mission planning perspective so we
|
||||
# don't want them in the UI.
|
||||
#
|
||||
# Round down so *barely* above zero start times are just zero.
|
||||
start_time = timedelta(seconds=math.floor(start_time.total_seconds()))
|
||||
|
||||
# Feature request #1309: Carrier planes should start at +1s
|
||||
# This is a workaround to a DCS problem: some AI planes spawn on
|
||||
# the 'sixpack' when start_time is zero and cause a deadlock.
|
||||
# Workaround: force the start_time to 1 second for these planes.
|
||||
if self.flight.from_cp.is_fleet and start_time.total_seconds() == 0:
|
||||
start_time = timedelta(seconds=1)
|
||||
|
||||
return start_time
|
||||
def startup_time(self) -> datetime:
|
||||
return (
|
||||
self.takeoff_time()
|
||||
- self.estimate_startup()
|
||||
- self.estimate_ground_ops()
|
||||
- self.estimate_takeoff_time()
|
||||
)
|
||||
|
||||
def estimate_startup(self) -> timedelta:
|
||||
if self.flight.start_type is StartType.COLD:
|
||||
@@ -308,13 +277,32 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
def estimate_ground_ops(self) -> timedelta:
|
||||
if self.flight.start_type in {StartType.RUNWAY, StartType.IN_FLIGHT}:
|
||||
return timedelta()
|
||||
if self.flight.from_cp.is_fleet:
|
||||
if self.flight.departure.is_fleet or self.flight.departure.is_fob:
|
||||
return timedelta(minutes=2)
|
||||
else:
|
||||
return timedelta(minutes=8)
|
||||
|
||||
def estimate_takeoff_time(self) -> timedelta:
|
||||
if self.flight.departure.is_offmap:
|
||||
return timedelta()
|
||||
return timedelta(seconds=30)
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
def is_airassault(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def mission_begin_on_station_time(self) -> datetime | None:
|
||||
"""The time that the mission is first on-station."""
|
||||
...
|
||||
|
||||
@property
|
||||
def is_custom(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> datetime:
|
||||
"""The time that the mission is complete and the flight RTBs."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from functools import cached_property
|
||||
from typing import Any, TYPE_CHECKING, TypeGuard, Optional
|
||||
|
||||
@@ -10,7 +10,6 @@ from game.typeguard import self_type_guard
|
||||
from game.utils import Speed
|
||||
from .flightplan import FlightPlan
|
||||
from .loiter import LoiterFlightPlan, LoiterLayout
|
||||
from ..traveltime import GroundSpeed, TravelTime
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..flightwaypoint import FlightWaypoint
|
||||
@@ -18,24 +17,16 @@ if TYPE_CHECKING:
|
||||
|
||||
@dataclass
|
||||
class FormationLayout(LoiterLayout, ABC):
|
||||
nav_to: list[FlightWaypoint]
|
||||
join: Optional[FlightWaypoint]
|
||||
join: FlightWaypoint
|
||||
split: FlightWaypoint
|
||||
refuel: Optional[FlightWaypoint]
|
||||
nav_from: list[FlightWaypoint]
|
||||
|
||||
def delete_waypoint(self, waypoint: FlightWaypoint) -> bool:
|
||||
if super().delete_waypoint(waypoint):
|
||||
return True
|
||||
if waypoint in self.nav_to:
|
||||
self.nav_to.remove(waypoint)
|
||||
return True
|
||||
elif waypoint in self.nav_from:
|
||||
self.nav_from.remove(waypoint)
|
||||
return True
|
||||
elif waypoint == self.refuel:
|
||||
if waypoint == self.refuel:
|
||||
self.refuel = None
|
||||
return True
|
||||
elif super().delete_waypoint(waypoint):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -84,31 +75,39 @@ class FormationFlightPlan(LoiterFlightPlan, ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def join_time(self) -> timedelta:
|
||||
def join_time(self) -> datetime:
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def split_time(self) -> timedelta:
|
||||
def split_time(self) -> datetime:
|
||||
...
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
if waypoint == self.layout.join:
|
||||
return self.join_time + self.tot_offset
|
||||
return self.join_time
|
||||
elif waypoint == self.layout.split:
|
||||
return self.split_time + self.tot_offset
|
||||
return self.split_time
|
||||
return None
|
||||
|
||||
@property
|
||||
def push_time(self) -> timedelta:
|
||||
return self.join_time - TravelTime.between_points(
|
||||
self.layout.hold.position,
|
||||
self.layout.join.position,
|
||||
GroundSpeed.for_flight(self.flight, self.layout.hold.alt),
|
||||
def push_time(self) -> datetime:
|
||||
hold2join_time = (
|
||||
self.travel_time_between_waypoints(
|
||||
self.layout.hold,
|
||||
self.layout.join,
|
||||
)
|
||||
if self.layout.hold
|
||||
else timedelta(0)
|
||||
)
|
||||
return self.join_time - hold2join_time
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
def mission_begin_on_station_time(self) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> datetime:
|
||||
return self.split_time
|
||||
|
||||
@self_type_guard
|
||||
|
||||
@@ -3,14 +3,15 @@ from __future__ import annotations
|
||||
from abc import ABC
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, TypeVar, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
from dcs import Point
|
||||
|
||||
from game.flightplan import HoldZoneGeometry
|
||||
from game.theater import MissionTarget
|
||||
from game.utils import Speed, meters
|
||||
from game.utils import Speed, meters, nautical_miles
|
||||
from .flightplan import FlightPlan
|
||||
from .formation import FormationFlightPlan, FormationLayout
|
||||
from .ibuilder import IBuilder
|
||||
@@ -80,18 +81,18 @@ class FormationAttackFlightPlan(FormationFlightPlan, ABC):
|
||||
return total
|
||||
|
||||
@property
|
||||
def join_time(self) -> timedelta:
|
||||
travel_time = self.travel_time_between_waypoints(
|
||||
def join_time(self) -> datetime:
|
||||
travel_time = self.total_time_between_waypoints(
|
||||
self.layout.join, self.layout.ingress
|
||||
)
|
||||
return self.ingress_time - travel_time
|
||||
|
||||
@property
|
||||
def split_time(self) -> timedelta:
|
||||
travel_time_ingress = self.travel_time_between_waypoints(
|
||||
def split_time(self) -> datetime:
|
||||
travel_time_ingress = self.total_time_between_waypoints(
|
||||
self.layout.ingress, self.target_area_waypoint
|
||||
)
|
||||
travel_time_egress = self.travel_time_between_waypoints(
|
||||
travel_time_egress = self.total_time_between_waypoints(
|
||||
self.target_area_waypoint, self.layout.split
|
||||
)
|
||||
minutes_at_target = 0.75 * len(self.layout.targets)
|
||||
@@ -104,22 +105,22 @@ class FormationAttackFlightPlan(FormationFlightPlan, ABC):
|
||||
)
|
||||
|
||||
@property
|
||||
def ingress_time(self) -> timedelta:
|
||||
def ingress_time(self) -> datetime:
|
||||
tot = self.tot
|
||||
travel_time = self.travel_time_between_waypoints(
|
||||
travel_time = self.total_time_between_waypoints(
|
||||
self.layout.ingress, self.target_area_waypoint
|
||||
)
|
||||
return tot - travel_time
|
||||
|
||||
@property
|
||||
def initial_time(self) -> timedelta:
|
||||
def initial_time(self) -> datetime:
|
||||
tot = self.tot
|
||||
travel_time = self.travel_time_between_waypoints(
|
||||
self.layout.initial, self.target_area_waypoint
|
||||
)
|
||||
return tot - travel_time
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
if waypoint == self.layout.ingress:
|
||||
return self.ingress_time
|
||||
elif waypoint == self.layout.initial:
|
||||
@@ -134,14 +135,16 @@ class FormationAttackLayout(FormationLayout):
|
||||
ingress: FlightWaypoint
|
||||
targets: list[FlightWaypoint]
|
||||
initial: Optional[FlightWaypoint] = None
|
||||
lineup: Optional[FlightWaypoint] = None
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
if self.hold:
|
||||
yield self.hold
|
||||
yield from self.nav_to
|
||||
if self.join:
|
||||
yield self.join
|
||||
yield self.join
|
||||
if self.lineup:
|
||||
yield self.lineup
|
||||
yield self.ingress
|
||||
if self.initial is not None:
|
||||
yield self.initial
|
||||
@@ -154,6 +157,7 @@ class FormationAttackLayout(FormationLayout):
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
yield from self.custom_waypoints
|
||||
|
||||
|
||||
FlightPlanT = TypeVar("FlightPlanT", bound=FlightPlan[FormationAttackLayout])
|
||||
@@ -167,7 +171,7 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
|
||||
targets: list[StrikeTarget] | None = None,
|
||||
) -> FormationAttackLayout:
|
||||
assert self.package.waypoints is not None
|
||||
builder = WaypointBuilder(self.flight, self.coalition, targets)
|
||||
builder = WaypointBuilder(self.flight, targets)
|
||||
|
||||
target_waypoints: list[FlightWaypoint] = []
|
||||
if targets is not None:
|
||||
@@ -183,18 +187,14 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
|
||||
)
|
||||
|
||||
hold = None
|
||||
join = None
|
||||
if (
|
||||
self.flight is self.package.primary_flight
|
||||
or self.package.primary_flight
|
||||
and isinstance(
|
||||
self.package.primary_flight.flight_plan, FormationAttackFlightPlan
|
||||
)
|
||||
):
|
||||
if not self.flight.is_helo:
|
||||
hold = builder.hold(self._hold_point())
|
||||
join = builder.join(self.package.waypoints.join)
|
||||
split = builder.split(self.package.waypoints.split)
|
||||
refuel = builder.refuel(self.package.waypoints.refuel)
|
||||
join_pos = self.package.waypoints.join
|
||||
if self.flight.is_helo:
|
||||
join_pos = self.package.waypoints.ingress
|
||||
join = builder.join(join_pos)
|
||||
split = builder.split(self._get_split())
|
||||
refuel = self._build_refuel(builder)
|
||||
|
||||
ingress = builder.ingress(
|
||||
ingress_type, self.package.waypoints.ingress, self.package.target
|
||||
@@ -206,30 +206,63 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
|
||||
elif ingress_type == FlightWaypointType.INGRESS_SEAD_SWEEP:
|
||||
initial = builder.sead_sweep(self.package.target)
|
||||
|
||||
lineup = None
|
||||
if self.flight.flight_type == FlightType.STRIKE:
|
||||
hdg = self.package.target.position.heading_between_point(ingress.position)
|
||||
pos = ingress.position.point_from_heading(hdg, nautical_miles(10).meters)
|
||||
lineup = builder.nav(pos, builder.get_combat_altitude)
|
||||
|
||||
is_helo = self.flight.is_helo
|
||||
ingress_egress_altitude = builder.get_combat_altitude
|
||||
use_agl_ingress_egress = is_helo
|
||||
|
||||
return FormationAttackLayout(
|
||||
departure=builder.takeoff(self.flight.departure),
|
||||
hold=hold,
|
||||
nav_to=builder.nav_path(
|
||||
hold.position if hold else self.flight.departure.position,
|
||||
join.position if join else ingress.position,
|
||||
self.doctrine.ingress_altitude,
|
||||
ingress_egress_altitude,
|
||||
use_agl_ingress_egress,
|
||||
),
|
||||
join=join,
|
||||
lineup=lineup,
|
||||
ingress=ingress,
|
||||
initial=initial,
|
||||
targets=target_waypoints,
|
||||
split=split,
|
||||
refuel=refuel,
|
||||
nav_from=builder.nav_path(
|
||||
refuel.position,
|
||||
refuel.position if refuel else split.position,
|
||||
self.flight.arrival.position,
|
||||
self.doctrine.ingress_altitude,
|
||||
ingress_egress_altitude,
|
||||
use_agl_ingress_egress,
|
||||
),
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def _build_refuel(self, builder: WaypointBuilder) -> Optional[FlightWaypoint]:
|
||||
refuel: Optional[FlightWaypoint] = None
|
||||
can_plan = self.flight.coalition.air_wing.can_auto_plan(FlightType.REFUELING)
|
||||
if not self.flight.is_helo and can_plan and self.package.waypoints:
|
||||
refuel = builder.refuel(self.package.waypoints.refuel)
|
||||
return refuel
|
||||
|
||||
@property
|
||||
def primary_flight_is_air_assault(self) -> bool:
|
||||
if self.flight is self.package.primary_flight:
|
||||
# Can't call self.package.primary_flight.flight_plan here
|
||||
# because the flight-plan wasn't created yet.
|
||||
# Calling the fligh_plan property would result in infinite recursion
|
||||
return self.flight.flight_type == FlightType.AIR_ASSAULT
|
||||
else:
|
||||
assert self.package.primary_flight is not None
|
||||
fp = self.package.primary_flight.flight_plan
|
||||
return fp.is_airassault
|
||||
|
||||
@staticmethod
|
||||
def target_waypoint(
|
||||
flight: Flight, builder: WaypointBuilder, target: StrikeTarget
|
||||
@@ -265,3 +298,13 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
|
||||
return HoldZoneGeometry(
|
||||
target, origin, ip, join, self.coalition, self.theater
|
||||
).find_best_hold_point()
|
||||
|
||||
def _get_split(self) -> Point:
|
||||
assert self.package.waypoints is not None
|
||||
assert self.package.primary_flight is not None
|
||||
split_pos = (
|
||||
self.package.primary_flight.arrival.position
|
||||
if self.package.primary_flight.is_helo
|
||||
else self.package.waypoints.split
|
||||
)
|
||||
return split_pos
|
||||
|
||||
@@ -25,6 +25,7 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
|
||||
def __init__(self, flight: Flight) -> None:
|
||||
self.flight = flight
|
||||
self._flight_plan: FlightPlanT | None = None
|
||||
self.settings = self.flight.coalition.game.settings
|
||||
|
||||
def get_or_build(self) -> FlightPlanT:
|
||||
if self._flight_plan is None:
|
||||
@@ -32,10 +33,10 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
|
||||
assert self._flight_plan is not None
|
||||
return self._flight_plan
|
||||
|
||||
def regenerate(self) -> None:
|
||||
def regenerate(self, dump_debug_info: bool = False) -> None:
|
||||
try:
|
||||
self._generate_package_waypoints_if_needed()
|
||||
self._flight_plan = self.build()
|
||||
self._generate_package_waypoints_if_needed(dump_debug_info)
|
||||
self._flight_plan = self.build(dump_debug_info)
|
||||
except NavMeshError as ex:
|
||||
color = "blue" if self.flight.squadron.player else "red"
|
||||
raise PlanningError(
|
||||
@@ -43,10 +44,15 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
|
||||
f"{self.flight.departure} to {self.package.target}"
|
||||
) from ex
|
||||
|
||||
def _generate_package_waypoints_if_needed(self) -> None:
|
||||
if self.package.waypoints is None:
|
||||
def _generate_package_waypoints_if_needed(self, dump_debug_info: bool) -> None:
|
||||
# Package waypoints are only valid for offensive missions. Skip this if the
|
||||
# target is friendly.
|
||||
if self.package.target.is_friendly(self.is_player):
|
||||
return
|
||||
|
||||
if self.package.waypoints is None or dump_debug_info:
|
||||
self.package.waypoints = PackageWaypoints.create(
|
||||
self.package, self.coalition
|
||||
self.package, self.coalition, dump_debug_info
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -54,11 +60,7 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
|
||||
return self.flight.departure.theater
|
||||
|
||||
@abstractmethod
|
||||
def layout(self) -> LayoutT:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def build(self) -> FlightPlanT:
|
||||
def build(self, dump_debug_info: bool = False) -> FlightPlanT:
|
||||
...
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any, TYPE_CHECKING, TypeGuard, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, TYPE_CHECKING, TypeGuard
|
||||
from typing import Optional
|
||||
|
||||
from game.typeguard import self_type_guard
|
||||
from .flightplan import FlightPlan
|
||||
@@ -25,18 +26,18 @@ class LoiterFlightPlan(StandardFlightPlan[Any], ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def push_time(self) -> timedelta:
|
||||
def push_time(self) -> datetime:
|
||||
...
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
if waypoint == self.layout.hold:
|
||||
return self.push_time + self.tot_offset
|
||||
return self.push_time
|
||||
return None
|
||||
|
||||
def travel_time_between_waypoints(
|
||||
def total_time_between_waypoints(
|
||||
self, a: FlightWaypoint, b: FlightWaypoint
|
||||
) -> timedelta:
|
||||
travel_time = super().travel_time_between_waypoints(a, b)
|
||||
travel_time = super().total_time_between_waypoints(a, b)
|
||||
if a != self.layout.hold:
|
||||
return travel_time
|
||||
return travel_time + self.hold_duration
|
||||
|
||||
@@ -32,5 +32,5 @@ class Builder(FormationAttackBuilder[OcaAircraftFlightPlan, FormationAttackLayou
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_OCA_AIRCRAFT)
|
||||
|
||||
def build(self) -> OcaAircraftFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> OcaAircraftFlightPlan:
|
||||
return OcaAircraftFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -32,5 +32,5 @@ class Builder(FormationAttackBuilder[OcaRunwayFlightPlan, FormationAttackLayout]
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_OCA_RUNWAY)
|
||||
|
||||
def build(self) -> OcaRunwayFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> OcaRunwayFlightPlan:
|
||||
return OcaRunwayFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Type
|
||||
|
||||
from dcs import Point
|
||||
|
||||
from game.utils import Distance, Heading, feet, meters
|
||||
from game.utils import Distance, Heading, meters
|
||||
from .ibuilder import IBuilder
|
||||
from .patrolling import PatrollingLayout
|
||||
from .refuelingflightplan import RefuelingFlightPlan
|
||||
@@ -39,7 +39,7 @@ class PackageRefuelingFlightPlan(RefuelingFlightPlan):
|
||||
)
|
||||
|
||||
@property
|
||||
def patrol_start_time(self) -> timedelta:
|
||||
def patrol_start_time(self) -> datetime:
|
||||
altitude = self.flight.unit_type.patrol_altitude
|
||||
|
||||
if altitude is None:
|
||||
@@ -59,10 +59,10 @@ class PackageRefuelingFlightPlan(RefuelingFlightPlan):
|
||||
"REFUEL", FlightWaypointType.REFUEL, refuel, altitude
|
||||
)
|
||||
|
||||
delay_target_to_split: timedelta = self.travel_time_between_waypoints(
|
||||
delay_target_to_split: timedelta = self.total_time_between_waypoints(
|
||||
self.target_area_waypoint(), split_waypoint
|
||||
)
|
||||
delay_split_to_refuel: timedelta = self.travel_time_between_waypoints(
|
||||
delay_split_to_refuel: timedelta = self.total_time_between_waypoints(
|
||||
split_waypoint, refuel_waypoint
|
||||
)
|
||||
|
||||
@@ -96,13 +96,9 @@ class Builder(IBuilder[PackageRefuelingFlightPlan, PatrollingLayout]):
|
||||
home_heading.degrees, racetrack_half_distance
|
||||
)
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
builder = WaypointBuilder(self.flight)
|
||||
|
||||
tanker_type = self.flight.unit_type
|
||||
if tanker_type.patrol_altitude is not None:
|
||||
altitude = tanker_type.patrol_altitude
|
||||
else:
|
||||
altitude = feet(21000)
|
||||
altitude = builder.get_patrol_altitude
|
||||
|
||||
racetrack = builder.race_track(racetrack_start, racetrack_end, altitude)
|
||||
|
||||
@@ -119,7 +115,8 @@ class Builder(IBuilder[PackageRefuelingFlightPlan, PatrollingLayout]):
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> PackageRefuelingFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> PackageRefuelingFlightPlan:
|
||||
return PackageRefuelingFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, TYPE_CHECKING, TypeGuard, TypeVar
|
||||
|
||||
from game.ato.flightplans.standard import StandardFlightPlan, StandardLayout
|
||||
@@ -18,21 +18,8 @@ if TYPE_CHECKING:
|
||||
|
||||
@dataclass
|
||||
class PatrollingLayout(StandardLayout):
|
||||
nav_to: list[FlightWaypoint]
|
||||
patrol_start: FlightWaypoint
|
||||
patrol_end: FlightWaypoint
|
||||
nav_from: list[FlightWaypoint]
|
||||
|
||||
def delete_waypoint(self, waypoint: FlightWaypoint) -> bool:
|
||||
if super().delete_waypoint(waypoint):
|
||||
return True
|
||||
if waypoint in self.nav_to:
|
||||
self.nav_to.remove(waypoint)
|
||||
return True
|
||||
elif waypoint in self.nav_from:
|
||||
self.nav_from.remove(waypoint)
|
||||
return True
|
||||
return False
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
@@ -44,6 +31,7 @@ class PatrollingLayout(StandardLayout):
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
yield from self.custom_waypoints
|
||||
|
||||
|
||||
LayoutT = TypeVar("LayoutT", bound=PatrollingLayout)
|
||||
@@ -72,22 +60,22 @@ class PatrollingFlightPlan(StandardFlightPlan[LayoutT], UiZoneDisplay, ABC):
|
||||
"""
|
||||
|
||||
@property
|
||||
def patrol_start_time(self) -> timedelta:
|
||||
return self.package.time_over_target + self.tot_offset
|
||||
def patrol_start_time(self) -> datetime:
|
||||
return self.tot
|
||||
|
||||
@property
|
||||
def patrol_end_time(self) -> timedelta:
|
||||
def patrol_end_time(self) -> datetime:
|
||||
# TODO: This is currently wrong for CAS.
|
||||
# CAS missions end when they're winchester or bingo. We need to
|
||||
# configure push tasks for the escorts rather than relying on timing.
|
||||
return self.patrol_start_time + self.patrol_duration
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
if waypoint == self.layout.patrol_start:
|
||||
return self.patrol_start_time
|
||||
return None
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
if waypoint == self.layout.patrol_end:
|
||||
return self.patrol_end_time
|
||||
return None
|
||||
@@ -101,7 +89,11 @@ class PatrollingFlightPlan(StandardFlightPlan[LayoutT], UiZoneDisplay, ABC):
|
||||
return self.layout.patrol_start
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
def mission_begin_on_station_time(self) -> datetime | None:
|
||||
return self.patrol_start_time
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> datetime:
|
||||
return self.patrol_end_time
|
||||
|
||||
@self_type_guard
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Type
|
||||
|
||||
from game.utils import feet
|
||||
@@ -18,16 +18,16 @@ if TYPE_CHECKING:
|
||||
@dataclass
|
||||
class RtbLayout(StandardLayout):
|
||||
abort_location: FlightWaypoint
|
||||
nav_to_destination: list[FlightWaypoint]
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
yield self.abort_location
|
||||
yield from self.nav_to_destination
|
||||
yield from self.nav_to
|
||||
yield self.arrival
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
yield from self.custom_waypoints
|
||||
|
||||
|
||||
class RtbFlightPlan(StandardFlightPlan[RtbLayout]):
|
||||
@@ -43,15 +43,19 @@ class RtbFlightPlan(StandardFlightPlan[RtbLayout]):
|
||||
def tot_waypoint(self) -> FlightWaypoint:
|
||||
return self.layout.abort_location
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
return None
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
return timedelta()
|
||||
def mission_begin_on_station_time(self) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> datetime:
|
||||
return self.tot
|
||||
|
||||
|
||||
class Builder(IBuilder[RtbFlightPlan, RtbLayout]):
|
||||
@@ -62,13 +66,13 @@ class Builder(IBuilder[RtbFlightPlan, RtbLayout]):
|
||||
current_position = self.flight.state.estimate_position()
|
||||
current_altitude, altitude_reference = self.flight.state.estimate_altitude()
|
||||
|
||||
altitude_is_agl = self.flight.unit_type.dcs_unit_type.helicopter
|
||||
builder = WaypointBuilder(self.flight)
|
||||
altitude_is_agl = self.flight.is_helo
|
||||
altitude = (
|
||||
feet(1500)
|
||||
feet(self.coalition.game.settings.heli_cruise_alt_agl)
|
||||
if altitude_is_agl
|
||||
else self.flight.unit_type.preferred_patrol_altitude
|
||||
else builder.get_patrol_altitude
|
||||
)
|
||||
builder = WaypointBuilder(self.flight, self.flight.coalition)
|
||||
abort_point = builder.nav(
|
||||
current_position, current_altitude, altitude_reference == "RADIO"
|
||||
)
|
||||
@@ -78,7 +82,7 @@ class Builder(IBuilder[RtbFlightPlan, RtbLayout]):
|
||||
return RtbLayout(
|
||||
departure=builder.takeoff(self.flight.departure),
|
||||
abort_location=abort_point,
|
||||
nav_to_destination=builder.nav_path(
|
||||
nav_to=builder.nav_path(
|
||||
current_position,
|
||||
self.flight.arrival.position,
|
||||
altitude,
|
||||
@@ -87,7 +91,9 @@ class Builder(IBuilder[RtbFlightPlan, RtbLayout]):
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
nav_from=[],
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> RtbFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> RtbFlightPlan:
|
||||
return RtbFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -24,5 +24,5 @@ class Builder(FormationAttackBuilder[SeadFlightPlan, FormationAttackLayout]):
|
||||
def layout(self) -> FormationAttackLayout:
|
||||
return self._build(FlightWaypointType.INGRESS_SEAD)
|
||||
|
||||
def build(self) -> SeadFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> SeadFlightPlan:
|
||||
return SeadFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -24,5 +24,5 @@ class Builder(FormationAttackBuilder[SeadSweepFlightPlan, FormationAttackLayout]
|
||||
def layout(self) -> FormationAttackLayout:
|
||||
return self._build(FlightWaypointType.INGRESS_SEAD_SWEEP)
|
||||
|
||||
def build(self) -> SeadSweepFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> SeadSweepFlightPlan:
|
||||
return SeadSweepFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
from typing import TYPE_CHECKING, TypeVar, Optional
|
||||
|
||||
from game.ato.flightplans.flightplan import FlightPlan, Layout
|
||||
from .waypointbuilder import WaypointBuilder
|
||||
from ..flightwaypointtype import FlightWaypointType
|
||||
from ...utils import feet
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..flightwaypoint import FlightWaypoint
|
||||
@@ -15,11 +19,60 @@ class StandardLayout(Layout, ABC):
|
||||
arrival: FlightWaypoint
|
||||
divert: FlightWaypoint | None
|
||||
bullseye: FlightWaypoint
|
||||
nav_to: list[FlightWaypoint]
|
||||
nav_from: list[FlightWaypoint]
|
||||
|
||||
def add_waypoint(
|
||||
self, wpt: FlightWaypoint, next_wpt: Optional[FlightWaypoint]
|
||||
) -> bool:
|
||||
new_wpt = self.get_midpoint(wpt, next_wpt)
|
||||
if wpt.waypoint_type in [FlightWaypointType.TAKEOFF, FlightWaypointType.LOITER]:
|
||||
self.nav_to.insert(0, new_wpt)
|
||||
return True
|
||||
elif wpt.waypoint_type in [
|
||||
FlightWaypointType.SPLIT,
|
||||
FlightWaypointType.REFUEL,
|
||||
FlightWaypointType.PATROL,
|
||||
FlightWaypointType.EGRESS,
|
||||
]:
|
||||
self.nav_from.insert(0, new_wpt)
|
||||
return True
|
||||
elif wpt.waypoint_type is FlightWaypointType.NAV:
|
||||
if wpt in self.nav_to:
|
||||
index = self.nav_to.index(wpt) + 1
|
||||
self.nav_to.insert(index, new_wpt)
|
||||
return True
|
||||
elif wpt in self.nav_from:
|
||||
index = self.nav_from.index(wpt) + 1
|
||||
self.nav_from.insert(index, new_wpt)
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_midpoint(
|
||||
wpt: FlightWaypoint, next_wpt: Optional[FlightWaypoint]
|
||||
) -> FlightWaypoint:
|
||||
new_pos = deepcopy(wpt.position)
|
||||
next_alt = feet(20000)
|
||||
if next_wpt:
|
||||
new_pos = wpt.position.lerp(next_wpt.position, 0.5)
|
||||
next_alt = next_wpt.alt
|
||||
new_wpt = WaypointBuilder.nav(new_pos, max(wpt.alt, next_alt))
|
||||
return new_wpt
|
||||
|
||||
def delete_waypoint(self, waypoint: FlightWaypoint) -> bool:
|
||||
if waypoint is self.divert:
|
||||
self.divert = None
|
||||
return True
|
||||
elif waypoint in self.nav_to:
|
||||
self.nav_to.remove(waypoint)
|
||||
return True
|
||||
elif waypoint in self.nav_from:
|
||||
self.nav_from.remove(waypoint)
|
||||
return True
|
||||
elif waypoint in self.custom_waypoints:
|
||||
self.custom_waypoints.remove(waypoint)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from .formationattack import (
|
||||
from .invalidobjectivelocation import InvalidObjectiveLocation
|
||||
from .waypointbuilder import StrikeTarget
|
||||
from ..flightwaypointtype import FlightWaypointType
|
||||
from ...theater.theatergroup import SceneryUnit
|
||||
|
||||
|
||||
class StrikeFlightPlan(FormationAttackFlightPlan):
|
||||
@@ -28,9 +29,12 @@ class Builder(FormationAttackBuilder[StrikeFlightPlan, FormationAttackLayout]):
|
||||
|
||||
targets: list[StrikeTarget] = []
|
||||
for idx, unit in enumerate(location.strike_targets):
|
||||
targets.append(StrikeTarget(f"{unit.type.id} #{idx}", unit))
|
||||
name = unit.type.id
|
||||
if isinstance(unit, SceneryUnit):
|
||||
name = unit.name
|
||||
targets.append(StrikeTarget(f"{name} #{idx}", unit))
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_STRIKE, targets)
|
||||
|
||||
def build(self) -> StrikeFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> StrikeFlightPlan:
|
||||
return StrikeFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Iterator, TYPE_CHECKING, Type
|
||||
|
||||
from dcs import Point
|
||||
@@ -10,7 +10,6 @@ from game.utils import Heading
|
||||
from .ibuilder import IBuilder
|
||||
from .loiter import LoiterFlightPlan, LoiterLayout
|
||||
from .waypointbuilder import WaypointBuilder
|
||||
from ..traveltime import GroundSpeed, TravelTime
|
||||
from ...flightplan import HoldZoneGeometry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -36,6 +35,7 @@ class SweepLayout(LoiterLayout):
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
yield from self.custom_waypoints
|
||||
|
||||
|
||||
class SweepFlightPlan(LoiterFlightPlan):
|
||||
@@ -55,38 +55,41 @@ class SweepFlightPlan(LoiterFlightPlan):
|
||||
return -timedelta(minutes=5)
|
||||
|
||||
@property
|
||||
def sweep_start_time(self) -> timedelta:
|
||||
travel_time = self.travel_time_between_waypoints(
|
||||
def sweep_start_time(self) -> datetime:
|
||||
travel_time = self.total_time_between_waypoints(
|
||||
self.layout.sweep_start, self.layout.sweep_end
|
||||
)
|
||||
return self.sweep_end_time - travel_time
|
||||
|
||||
@property
|
||||
def sweep_end_time(self) -> timedelta:
|
||||
def sweep_end_time(self) -> datetime:
|
||||
return self.tot
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
if waypoint == self.layout.sweep_start:
|
||||
return self.sweep_start_time + self.tot_offset
|
||||
return self.sweep_start_time
|
||||
if waypoint == self.layout.sweep_end:
|
||||
return self.sweep_end_time + self.tot_offset
|
||||
return self.sweep_end_time
|
||||
return None
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
if waypoint == self.layout.hold:
|
||||
return self.push_time + self.tot_offset
|
||||
return self.push_time
|
||||
return None
|
||||
|
||||
@property
|
||||
def push_time(self) -> timedelta:
|
||||
return self.sweep_end_time - TravelTime.between_points(
|
||||
self.layout.hold.position,
|
||||
self.layout.sweep_end.position,
|
||||
GroundSpeed.for_flight(self.flight, self.layout.hold.alt),
|
||||
def push_time(self) -> datetime:
|
||||
return self.sweep_end_time - self.travel_time_between_waypoints(
|
||||
self.layout.hold,
|
||||
self.layout.sweep_end,
|
||||
)
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
def mission_begin_on_station_time(self) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> datetime:
|
||||
return self.sweep_end_time
|
||||
|
||||
|
||||
@@ -101,27 +104,28 @@ class Builder(IBuilder[SweepFlightPlan, SweepLayout]):
|
||||
heading.degrees, -self.doctrine.sweep_distance.meters
|
||||
)
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude)
|
||||
builder = WaypointBuilder(self.flight)
|
||||
altitude = builder.get_patrol_altitude
|
||||
|
||||
start, end = builder.sweep(start_pos, target, altitude)
|
||||
|
||||
hold = builder.hold(self._hold_point())
|
||||
|
||||
return SweepLayout(
|
||||
departure=builder.takeoff(self.flight.departure),
|
||||
hold=hold,
|
||||
nav_to=builder.nav_path(
|
||||
hold.position, start.position, self.doctrine.ingress_altitude
|
||||
),
|
||||
nav_to=builder.nav_path(hold.position, start.position, altitude),
|
||||
nav_from=builder.nav_path(
|
||||
end.position,
|
||||
self.flight.arrival.position,
|
||||
self.doctrine.ingress_altitude,
|
||||
altitude,
|
||||
),
|
||||
sweep_start=start,
|
||||
sweep_end=end,
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def _hold_point(self) -> Point:
|
||||
@@ -134,5 +138,5 @@ class Builder(IBuilder[SweepFlightPlan, SweepLayout]):
|
||||
target, origin, ip, join, self.coalition, self.theater
|
||||
).find_best_hold_point()
|
||||
|
||||
def build(self) -> SweepFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> SweepFlightPlan:
|
||||
return SweepFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Type
|
||||
|
||||
from game.utils import Distance, Speed, feet
|
||||
from game.utils import Distance, Speed
|
||||
from .capbuilder import CapBuilder
|
||||
from .patrolling import PatrollingFlightPlan, PatrollingLayout
|
||||
from .waypointbuilder import WaypointBuilder
|
||||
@@ -31,6 +30,15 @@ class TarCapLayout(PatrollingLayout):
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
yield from self.custom_waypoints
|
||||
|
||||
def delete_waypoint(self, waypoint: FlightWaypoint) -> bool:
|
||||
if waypoint == self.refuel:
|
||||
self.refuel = None
|
||||
return True
|
||||
elif super().delete_waypoint(waypoint):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TarCapFlightPlan(PatrollingFlightPlan[TarCapLayout]):
|
||||
@@ -63,20 +71,20 @@ class TarCapFlightPlan(PatrollingFlightPlan[TarCapLayout]):
|
||||
def default_tot_offset(self) -> timedelta:
|
||||
return -timedelta(minutes=2)
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
if waypoint == self.layout.patrol_end:
|
||||
return self.patrol_end_time
|
||||
return super().depart_time_for_waypoint(waypoint)
|
||||
|
||||
@property
|
||||
def patrol_start_time(self) -> timedelta:
|
||||
def patrol_start_time(self) -> datetime:
|
||||
start = self.package.escort_start_time
|
||||
if start is not None:
|
||||
return start + self.tot_offset
|
||||
return self.tot
|
||||
|
||||
@property
|
||||
def patrol_end_time(self) -> timedelta:
|
||||
def patrol_end_time(self) -> datetime:
|
||||
end = self.package.escort_end_time
|
||||
if end is not None:
|
||||
return end
|
||||
@@ -87,14 +95,9 @@ class Builder(CapBuilder[TarCapFlightPlan, TarCapLayout]):
|
||||
def layout(self) -> TarCapLayout:
|
||||
location = self.package.target
|
||||
|
||||
preferred_alt = self.flight.unit_type.preferred_patrol_altitude
|
||||
randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
|
||||
patrol_alt = max(
|
||||
self.doctrine.min_patrol_altitude,
|
||||
min(self.doctrine.max_patrol_altitude, randomized_alt),
|
||||
)
|
||||
builder = WaypointBuilder(self.flight)
|
||||
patrol_alt = builder.get_patrol_altitude
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False)
|
||||
|
||||
start, end = builder.race_track(orbit0p, orbit1p, patrol_alt)
|
||||
@@ -120,7 +123,8 @@ class Builder(CapBuilder[TarCapFlightPlan, TarCapLayout]):
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> TarCapFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> TarCapFlightPlan:
|
||||
return TarCapFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
from typing import Type
|
||||
|
||||
from game.utils import Heading, feet, meters, nautical_miles
|
||||
from game.utils import Heading, meters, nautical_miles
|
||||
from .ibuilder import IBuilder
|
||||
from .patrolling import PatrollingLayout
|
||||
from .refuelingflightplan import RefuelingFlightPlan
|
||||
@@ -37,7 +37,7 @@ class Builder(IBuilder[TheaterRefuelingFlightPlan, PatrollingLayout]):
|
||||
|
||||
# Station 70nm outside the threat zone.
|
||||
threat_buffer = nautical_miles(
|
||||
self.flight.coalition.game.settings.tanker_threat_buffer_min_distance
|
||||
self.coalition.game.settings.tanker_threat_buffer_min_distance
|
||||
)
|
||||
if self.threat_zones.threatened(location.position):
|
||||
orbit_distance = distance_to_threat + threat_buffer
|
||||
@@ -56,13 +56,9 @@ class Builder(IBuilder[TheaterRefuelingFlightPlan, PatrollingLayout]):
|
||||
orbit_heading.left.degrees, racetrack_half_distance
|
||||
)
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
builder = WaypointBuilder(self.flight)
|
||||
|
||||
tanker_type = self.flight.unit_type
|
||||
if tanker_type.patrol_altitude is not None:
|
||||
altitude = tanker_type.patrol_altitude
|
||||
else:
|
||||
altitude = feet(21000)
|
||||
altitude = builder.get_patrol_altitude
|
||||
|
||||
racetrack = builder.race_track(racetrack_start, racetrack_end, altitude)
|
||||
|
||||
@@ -79,7 +75,8 @@ class Builder(IBuilder[TheaterRefuelingFlightPlan, PatrollingLayout]):
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
custom_waypoints=list(),
|
||||
)
|
||||
|
||||
def build(self) -> TheaterRefuelingFlightPlan:
|
||||
def build(self, dump_debug_info: bool = False) -> TheaterRefuelingFlightPlan:
|
||||
return TheaterRefuelingFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -10,11 +10,11 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
Tuple,
|
||||
Union,
|
||||
Literal,
|
||||
)
|
||||
|
||||
from dcs.mapping import Point, Vector2
|
||||
|
||||
from game.ato.flightplans._common_ctld import generate_random_ctld_point
|
||||
from game.ato.flightwaypoint import AltitudeReference, FlightWaypoint
|
||||
from game.ato.flightwaypointtype import FlightWaypointType
|
||||
from game.theater import (
|
||||
@@ -24,11 +24,11 @@ from game.theater import (
|
||||
TheaterGroundObject,
|
||||
TheaterUnit,
|
||||
)
|
||||
from game.theater.interfaces.CTLD import CTLD
|
||||
from game.utils import Distance, meters, nautical_miles, feet
|
||||
|
||||
AGL_TRANSITION_ALT = 5000
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
from game.transfers import MultiGroupTransport
|
||||
from game.theater.theatergroup import TheaterGroup
|
||||
from game.ato.flight import Flight
|
||||
@@ -44,20 +44,43 @@ class WaypointBuilder:
|
||||
def __init__(
|
||||
self,
|
||||
flight: Flight,
|
||||
coalition: Coalition,
|
||||
targets: Optional[List[StrikeTarget]] = None,
|
||||
) -> None:
|
||||
coalition = flight.coalition
|
||||
self.flight = flight
|
||||
self.doctrine = coalition.doctrine
|
||||
self.threat_zones = coalition.opponent.threat_zone
|
||||
self.navmesh = coalition.nav_mesh
|
||||
self.targets = targets
|
||||
self._bullseye = coalition.bullseye
|
||||
self.settings = self.flight.coalition.game.settings
|
||||
|
||||
@property
|
||||
def is_helo(self) -> bool:
|
||||
return self.flight.is_helo
|
||||
|
||||
@property
|
||||
def get_patrol_altitude(self) -> Distance:
|
||||
return self.get_altitude(self.flight.unit_type.preferred_patrol_altitude)
|
||||
|
||||
@property
|
||||
def get_cruise_altitude(self) -> Distance:
|
||||
return self.get_altitude(self.flight.unit_type.preferred_cruise_altitude)
|
||||
|
||||
@property
|
||||
def get_combat_altitude(self) -> Distance:
|
||||
return self.get_altitude(self.flight.unit_type.preferred_combat_altitude)
|
||||
|
||||
def get_altitude(self, alt: Distance) -> Distance:
|
||||
randomized_alt = feet(round(alt.feet + self.flight.plane_altitude_offset))
|
||||
altitude = max(
|
||||
self.doctrine.min_combat_altitude,
|
||||
min(self.doctrine.max_combat_altitude, randomized_alt),
|
||||
)
|
||||
return (
|
||||
feet(self.settings.heli_combat_alt_agl) if self.flight.is_helo else altitude
|
||||
)
|
||||
|
||||
def takeoff(self, departure: ControlPoint) -> FlightWaypoint:
|
||||
"""Create takeoff waypoint for the given arrival airfield or carrier.
|
||||
|
||||
@@ -74,7 +97,7 @@ class WaypointBuilder:
|
||||
"NAV",
|
||||
FlightWaypointType.NAV,
|
||||
position,
|
||||
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
|
||||
self.get_cruise_altitude,
|
||||
description="Enter theater",
|
||||
pretty_name="Enter theater",
|
||||
)
|
||||
@@ -101,7 +124,7 @@ class WaypointBuilder:
|
||||
"NAV",
|
||||
FlightWaypointType.NAV,
|
||||
position,
|
||||
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
|
||||
self.get_cruise_altitude,
|
||||
description="Exit theater",
|
||||
pretty_name="Exit theater",
|
||||
)
|
||||
@@ -127,12 +150,10 @@ class WaypointBuilder:
|
||||
return None
|
||||
|
||||
position = divert.position
|
||||
altitude_type: AltitudeReference
|
||||
altitude_type: AltitudeReference = "BARO"
|
||||
if isinstance(divert, OffMapSpawn):
|
||||
altitude = (
|
||||
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude
|
||||
)
|
||||
altitude_type = "BARO"
|
||||
altitude = self.get_cruise_altitude
|
||||
altitude_type = "RADIO" if self.is_helo else altitude_type
|
||||
else:
|
||||
altitude = meters(0)
|
||||
altitude_type = "RADIO"
|
||||
@@ -162,14 +183,15 @@ class WaypointBuilder:
|
||||
|
||||
def hold(self, position: Point) -> FlightWaypoint:
|
||||
alt_type: AltitudeReference = "BARO"
|
||||
if self.is_helo:
|
||||
if self.is_helo or self.get_combat_altitude.feet <= AGL_TRANSITION_ALT:
|
||||
alt_type = "RADIO"
|
||||
|
||||
return FlightWaypoint(
|
||||
"HOLD",
|
||||
FlightWaypointType.LOITER,
|
||||
position,
|
||||
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
|
||||
# TODO: dedicated altitude setting for holding
|
||||
self.get_cruise_altitude if self.is_helo else self.get_combat_altitude,
|
||||
alt_type,
|
||||
description="Wait until push time",
|
||||
pretty_name="Hold",
|
||||
@@ -177,14 +199,14 @@ class WaypointBuilder:
|
||||
|
||||
def join(self, position: Point) -> FlightWaypoint:
|
||||
alt_type: AltitudeReference = "BARO"
|
||||
if self.is_helo:
|
||||
if self.is_helo or self.get_cruise_altitude.feet <= AGL_TRANSITION_ALT:
|
||||
alt_type = "RADIO"
|
||||
|
||||
return FlightWaypoint(
|
||||
"JOIN",
|
||||
FlightWaypointType.JOIN,
|
||||
position,
|
||||
meters(80) if self.is_helo else self.doctrine.ingress_altitude,
|
||||
self.get_cruise_altitude,
|
||||
alt_type,
|
||||
description="Rendezvous with package",
|
||||
pretty_name="Join",
|
||||
@@ -192,14 +214,14 @@ class WaypointBuilder:
|
||||
|
||||
def refuel(self, position: Point) -> FlightWaypoint:
|
||||
alt_type: AltitudeReference = "BARO"
|
||||
if self.is_helo:
|
||||
if self.is_helo or self.get_cruise_altitude.feet <= AGL_TRANSITION_ALT:
|
||||
alt_type = "RADIO"
|
||||
|
||||
return FlightWaypoint(
|
||||
"REFUEL",
|
||||
FlightWaypointType.REFUEL,
|
||||
position,
|
||||
meters(80) if self.is_helo else self.doctrine.ingress_altitude,
|
||||
self.get_cruise_altitude,
|
||||
alt_type,
|
||||
description="Refuel from tanker",
|
||||
pretty_name="Refuel",
|
||||
@@ -207,14 +229,14 @@ class WaypointBuilder:
|
||||
|
||||
def split(self, position: Point) -> FlightWaypoint:
|
||||
alt_type: AltitudeReference = "BARO"
|
||||
if self.is_helo:
|
||||
if self.is_helo or self.get_combat_altitude.feet <= AGL_TRANSITION_ALT:
|
||||
alt_type = "RADIO"
|
||||
|
||||
return FlightWaypoint(
|
||||
"SPLIT",
|
||||
FlightWaypointType.SPLIT,
|
||||
position,
|
||||
meters(80) if self.is_helo else self.doctrine.ingress_altitude,
|
||||
self.get_combat_altitude,
|
||||
alt_type,
|
||||
description="Depart from package",
|
||||
pretty_name="Split",
|
||||
@@ -226,16 +248,26 @@ class WaypointBuilder:
|
||||
position: Point,
|
||||
objective: MissionTarget,
|
||||
) -> FlightWaypoint:
|
||||
alt = self.doctrine.ingress_altitude
|
||||
alt = self.get_combat_altitude
|
||||
alt_type: AltitudeReference = "BARO"
|
||||
if self.is_helo or self.flight.is_hercules:
|
||||
alt_type = "RADIO"
|
||||
alt = meters(60) if self.is_helo else feet(1000)
|
||||
alt = (
|
||||
feet(self.flight.coalition.game.settings.heli_combat_alt_agl)
|
||||
if self.is_helo
|
||||
else feet(1000)
|
||||
)
|
||||
elif alt.feet <= AGL_TRANSITION_ALT:
|
||||
alt_type = "RADIO"
|
||||
|
||||
heading = objective.position.heading_between_point(position)
|
||||
|
||||
return FlightWaypoint(
|
||||
"INGRESS",
|
||||
ingress_type,
|
||||
position,
|
||||
objective.position.point_from_heading(heading, nautical_miles(5).meters)
|
||||
if self.is_helo
|
||||
else position,
|
||||
alt,
|
||||
alt_type,
|
||||
description=f"INGRESS on {objective.name}",
|
||||
@@ -245,14 +277,14 @@ class WaypointBuilder:
|
||||
|
||||
def egress(self, position: Point, target: MissionTarget) -> FlightWaypoint:
|
||||
alt_type: AltitudeReference = "BARO"
|
||||
if self.is_helo:
|
||||
if self.is_helo or self.get_combat_altitude.feet <= AGL_TRANSITION_ALT:
|
||||
alt_type = "RADIO"
|
||||
|
||||
return FlightWaypoint(
|
||||
"EGRESS",
|
||||
FlightWaypointType.EGRESS,
|
||||
position,
|
||||
meters(60) if self.is_helo else self.doctrine.ingress_altitude,
|
||||
self.get_combat_altitude,
|
||||
alt_type,
|
||||
description=f"EGRESS from {target.name}",
|
||||
pretty_name=f"EGRESS from {target.name}",
|
||||
@@ -290,11 +322,15 @@ class WaypointBuilder:
|
||||
return self._target_area(f"STRIKE {target.name}", target)
|
||||
|
||||
def sead_area(self, target: MissionTarget) -> FlightWaypoint:
|
||||
alt_type: AltitudeReference = "BARO"
|
||||
if self.get_combat_altitude.feet <= AGL_TRANSITION_ALT:
|
||||
alt_type = "RADIO"
|
||||
|
||||
return self._target_area(
|
||||
f"SEAD on {target.name}",
|
||||
target,
|
||||
altitude=self.doctrine.ingress_altitude,
|
||||
alt_type="BARO",
|
||||
altitude=self.get_combat_altitude,
|
||||
alt_type=alt_type,
|
||||
)
|
||||
|
||||
def dead_area(self, target: MissionTarget) -> FlightWaypoint:
|
||||
@@ -349,7 +385,9 @@ class WaypointBuilder:
|
||||
"CAS",
|
||||
FlightWaypointType.CAS,
|
||||
position,
|
||||
meters(60) if self.is_helo else meters(1000),
|
||||
feet(self.flight.coalition.game.settings.heli_combat_alt_agl)
|
||||
if self.is_helo
|
||||
else meters(1000),
|
||||
"RADIO",
|
||||
description="Provide CAS",
|
||||
pretty_name="CAS",
|
||||
@@ -363,11 +401,13 @@ class WaypointBuilder:
|
||||
position: Position of the waypoint.
|
||||
altitude: Altitude of the racetrack.
|
||||
"""
|
||||
baro: AltitudeReference = "BARO"
|
||||
return FlightWaypoint(
|
||||
"RACETRACK START",
|
||||
FlightWaypointType.PATROL_TRACK,
|
||||
position,
|
||||
altitude,
|
||||
"RADIO" if altitude.feet <= AGL_TRANSITION_ALT else baro,
|
||||
description="Orbit between this point and the next point",
|
||||
pretty_name="Race-track start",
|
||||
)
|
||||
@@ -380,11 +420,13 @@ class WaypointBuilder:
|
||||
position: Position of the waypoint.
|
||||
altitude: Altitude of the racetrack.
|
||||
"""
|
||||
baro: AltitudeReference = "BARO"
|
||||
return FlightWaypoint(
|
||||
"RACETRACK END",
|
||||
FlightWaypointType.PATROL,
|
||||
position,
|
||||
altitude,
|
||||
"RADIO" if altitude.feet <= AGL_TRANSITION_ALT else baro,
|
||||
description="Orbit between this point and the previous point",
|
||||
pretty_name="Race-track end",
|
||||
)
|
||||
@@ -412,38 +454,39 @@ class WaypointBuilder:
|
||||
start: Position of the waypoint.
|
||||
altitude: Altitude of the racetrack.
|
||||
"""
|
||||
|
||||
baro: AltitudeReference = "BARO"
|
||||
return FlightWaypoint(
|
||||
"ORBIT",
|
||||
FlightWaypointType.LOITER,
|
||||
start,
|
||||
altitude,
|
||||
"RADIO" if altitude.feet <= AGL_TRANSITION_ALT else baro,
|
||||
description="Anchor and hold at this point",
|
||||
pretty_name="Orbit",
|
||||
)
|
||||
|
||||
def sead_search(self, target: MissionTarget) -> FlightWaypoint:
|
||||
hold = self._sead_search_point(target)
|
||||
|
||||
baro: AltitudeReference = "BARO"
|
||||
return FlightWaypoint(
|
||||
"SEAD Search",
|
||||
FlightWaypointType.NAV,
|
||||
hold,
|
||||
self.doctrine.ingress_altitude,
|
||||
alt_type="BARO",
|
||||
self.get_combat_altitude,
|
||||
"RADIO" if self.get_combat_altitude.feet <= AGL_TRANSITION_ALT else baro,
|
||||
description="Anchor and search from this point",
|
||||
pretty_name="SEAD Search",
|
||||
)
|
||||
|
||||
def sead_sweep(self, target: MissionTarget) -> FlightWaypoint:
|
||||
hold = self._sead_search_point(target)
|
||||
|
||||
baro: AltitudeReference = "BARO"
|
||||
return FlightWaypoint(
|
||||
"SEAD Sweep",
|
||||
FlightWaypointType.NAV,
|
||||
hold,
|
||||
self.doctrine.ingress_altitude,
|
||||
alt_type="BARO",
|
||||
self.get_combat_altitude,
|
||||
"RADIO" if self.get_combat_altitude.feet <= AGL_TRANSITION_ALT else baro,
|
||||
description="Anchor and search from this point",
|
||||
pretty_name="SEAD Sweep",
|
||||
)
|
||||
@@ -471,20 +514,24 @@ class WaypointBuilder:
|
||||
)
|
||||
return hold
|
||||
|
||||
@staticmethod
|
||||
def escort_hold(start: Point, altitude: Distance) -> FlightWaypoint:
|
||||
def escort_hold(self, start: Point) -> FlightWaypoint:
|
||||
"""Creates custom waypoint for escort flights that need to hold.
|
||||
|
||||
Args:
|
||||
start: Position of the waypoint.
|
||||
altitude: Altitude of the holding pattern.
|
||||
"""
|
||||
altitude = self.get_combat_altitude
|
||||
|
||||
alt_type: Literal["BARO", "RADIO"] = "BARO"
|
||||
if self.is_helo or altitude.feet <= AGL_TRANSITION_ALT:
|
||||
alt_type = "RADIO"
|
||||
|
||||
return FlightWaypoint(
|
||||
"ESCORT HOLD",
|
||||
FlightWaypointType.CUSTOM,
|
||||
start,
|
||||
altitude,
|
||||
alt_type=alt_type,
|
||||
description="Anchor and hold at this point",
|
||||
pretty_name="Escort Hold",
|
||||
)
|
||||
@@ -497,11 +544,13 @@ class WaypointBuilder:
|
||||
position: Position of the waypoint.
|
||||
altitude: Altitude of the sweep in meters.
|
||||
"""
|
||||
baro: AltitudeReference = "BARO"
|
||||
return FlightWaypoint(
|
||||
"SWEEP START",
|
||||
FlightWaypointType.INGRESS_SWEEP,
|
||||
position,
|
||||
altitude,
|
||||
"RADIO" if altitude.feet <= AGL_TRANSITION_ALT else baro,
|
||||
description="Proceed to the target and engage enemy aircraft",
|
||||
pretty_name="Sweep start",
|
||||
)
|
||||
@@ -514,11 +563,13 @@ class WaypointBuilder:
|
||||
position: Position of the waypoint.
|
||||
altitude: Altitude of the sweep in meters.
|
||||
"""
|
||||
baro: AltitudeReference = "BARO"
|
||||
return FlightWaypoint(
|
||||
"SWEEP END",
|
||||
FlightWaypointType.EGRESS,
|
||||
position,
|
||||
altitude,
|
||||
"RADIO" if altitude.feet <= AGL_TRANSITION_ALT else baro,
|
||||
description="End of sweep",
|
||||
pretty_name="Sweep end",
|
||||
)
|
||||
@@ -547,7 +598,7 @@ class WaypointBuilder:
|
||||
target: The mission target.
|
||||
"""
|
||||
alt_type: AltitudeReference = "BARO"
|
||||
if self.is_helo:
|
||||
if self.is_helo or self.get_combat_altitude.feet <= AGL_TRANSITION_ALT:
|
||||
alt_type = "RADIO"
|
||||
|
||||
# This would preferably be no points at all, and instead the Escort task
|
||||
@@ -561,7 +612,7 @@ class WaypointBuilder:
|
||||
"TARGET",
|
||||
FlightWaypointType.TARGET_GROUP_LOC,
|
||||
target.position,
|
||||
meters(60) if self.is_helo else self.doctrine.ingress_altitude,
|
||||
self.get_combat_altitude,
|
||||
alt_type,
|
||||
description="Escort the package",
|
||||
pretty_name="Target area",
|
||||
@@ -583,17 +634,19 @@ class WaypointBuilder:
|
||||
pretty_name="Pick-up zone",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def dropoff_zone(drop_off: MissionTarget) -> FlightWaypoint:
|
||||
def dropoff_zone(self, drop_off: MissionTarget) -> FlightWaypoint:
|
||||
"""Creates a dropoff landing zone waypoint
|
||||
This waypoint is used to generate the Trigger Zone used for AirAssault and
|
||||
AirLift using the CTLD plugin (see LogisticsGenerator)
|
||||
"""
|
||||
heli_alt = feet(self.flight.coalition.game.settings.heli_cruise_alt_agl)
|
||||
altitude = heli_alt if self.flight.is_helo else meters(0)
|
||||
|
||||
return FlightWaypoint(
|
||||
"DROPOFFZONE",
|
||||
FlightWaypointType.DROPOFF_ZONE,
|
||||
drop_off.position,
|
||||
meters(0),
|
||||
altitude,
|
||||
"RADIO",
|
||||
description=f"Drop off cargo at {drop_off.name}",
|
||||
pretty_name="Drop-off zone",
|
||||
@@ -604,14 +657,10 @@ class WaypointBuilder:
|
||||
"""Creates a cargo stop waypoint.
|
||||
This waypoint is used by AirLift as a landing and stopover waypoint
|
||||
"""
|
||||
if isinstance(control_point, CTLD) and control_point.ctld_zones:
|
||||
pos = generate_random_ctld_point(control_point)
|
||||
else:
|
||||
pos = control_point.position
|
||||
return FlightWaypoint(
|
||||
"CARGOSTOP",
|
||||
FlightWaypointType.CARGO_STOP,
|
||||
pos,
|
||||
control_point.position,
|
||||
meters(0),
|
||||
"RADIO",
|
||||
description=f"Stop for cargo at {control_point.name}",
|
||||
@@ -631,7 +680,7 @@ class WaypointBuilder:
|
||||
altitude_is_agl: True for altitude is AGL. False if altitude is MSL.
|
||||
"""
|
||||
alt_type: AltitudeReference = "BARO"
|
||||
if altitude_is_agl:
|
||||
if altitude_is_agl or altitude.feet <= AGL_TRANSITION_ALT:
|
||||
alt_type = "RADIO"
|
||||
|
||||
return FlightWaypoint(
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from game.ato.iflightroster import IFlightRoster
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.squadrons import Squadron, Pilot
|
||||
|
||||
|
||||
class FlightRoster:
|
||||
class FlightRoster(IFlightRoster):
|
||||
def __init__(self, squadron: Squadron, initial_size: int = 0) -> None:
|
||||
self.squadron = squadron
|
||||
self._squadron = squadron
|
||||
self.pilots: list[Optional[Pilot]] = []
|
||||
self.resize(initial_size)
|
||||
|
||||
@property
|
||||
def squadron(self) -> Squadron:
|
||||
return self._squadron
|
||||
|
||||
def iter_pilots(self) -> Iterator[Pilot | None]:
|
||||
yield from self.pilots
|
||||
|
||||
def pilot_at(self, idx: int) -> Pilot | None:
|
||||
return self.pilots[idx]
|
||||
|
||||
@property
|
||||
def max_size(self) -> int:
|
||||
return len(self.pilots)
|
||||
|
||||
@property
|
||||
def player_count(self) -> int:
|
||||
return len([p for p in self.pilots if p is not None and p.player])
|
||||
|
||||
@property
|
||||
def missing_pilots(self) -> int:
|
||||
return len([p for p in self.pilots if p is None])
|
||||
|
||||
def resize(self, new_size: int) -> None:
|
||||
if self.max_size > new_size:
|
||||
self.squadron.return_pilots(
|
||||
|
||||
@@ -21,6 +21,39 @@ class FlightState(ABC):
|
||||
self.settings = settings
|
||||
self.avoid_further_combat = False
|
||||
|
||||
def reinitialize(self, now: datetime) -> None:
|
||||
from game.ato.flightstate import WaitingForStart
|
||||
|
||||
start_time = self.flight.flight_plan.startup_time()
|
||||
if start_time <= now:
|
||||
self._set_active_flight_state(start_time)
|
||||
else:
|
||||
self.flight.set_state(
|
||||
WaitingForStart(self.flight, self.settings, start_time)
|
||||
)
|
||||
|
||||
def _set_active_flight_state(self, now: datetime) -> None:
|
||||
from game.ato.flightstate import StartUp
|
||||
from game.ato.flightstate import Taxi
|
||||
from game.ato.flightstate import Takeoff
|
||||
from game.ato.flightstate import Navigating
|
||||
|
||||
match self.flight.start_type:
|
||||
case StartType.COLD:
|
||||
self.flight.set_state(StartUp(self.flight, self.settings, now))
|
||||
case StartType.WARM:
|
||||
self.flight.set_state(Taxi(self.flight, self.settings, now))
|
||||
case StartType.RUNWAY:
|
||||
self.flight.set_state(Takeoff(self.flight, self.settings, now))
|
||||
case StartType.IN_FLIGHT:
|
||||
self.flight.set_state(
|
||||
Navigating(self.flight, self.settings, waypoint_index=0)
|
||||
)
|
||||
case _:
|
||||
raise ValueError(
|
||||
f"Unknown start type {self.flight.start_type} for {self.flight}"
|
||||
)
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
return True
|
||||
|
||||
@@ -26,6 +26,7 @@ class InFlight(FlightState, ABC):
|
||||
settings: Settings,
|
||||
waypoint_index: int,
|
||||
has_aborted: bool = False,
|
||||
elapsed_time: timedelta = timedelta(),
|
||||
) -> None:
|
||||
super().__init__(flight, settings)
|
||||
waypoints = self.flight.flight_plan.waypoints
|
||||
@@ -35,7 +36,7 @@ class InFlight(FlightState, ABC):
|
||||
# TODO: Error checking for flight plans without landing waypoints.
|
||||
self.next_waypoint = waypoints[self.waypoint_index + 1]
|
||||
self.total_time_to_next_waypoint = self.travel_time_between_waypoints()
|
||||
self.elapsed_time = timedelta()
|
||||
self.elapsed_time = elapsed_time
|
||||
self.current_waypoint_elapsed = False
|
||||
|
||||
@property
|
||||
@@ -51,17 +52,9 @@ class InFlight(FlightState, ABC):
|
||||
return index <= self.waypoint_index
|
||||
|
||||
def travel_time_between_waypoints(self) -> timedelta:
|
||||
travel_time = self.flight.flight_plan.travel_time_between_waypoints(
|
||||
return self.flight.flight_plan.travel_time_between_waypoints(
|
||||
self.current_waypoint, self.next_waypoint
|
||||
)
|
||||
if self.current_waypoint.waypoint_type is FlightWaypointType.LOITER:
|
||||
# Loiter time is already built into travel_time_between_waypoints. If we're
|
||||
# at a loiter point but still a regular InFlight (Loiter overrides this
|
||||
# method) that means we're traveling from the loiter point but no longer
|
||||
# loitering.
|
||||
assert self.flight.flight_plan.is_loiter(self.flight.flight_plan)
|
||||
travel_time -= self.flight.flight_plan.hold_duration
|
||||
return travel_time
|
||||
|
||||
@abstractmethod
|
||||
def estimate_position(self) -> Point:
|
||||
|
||||
@@ -29,6 +29,11 @@ class Navigating(InFlight):
|
||||
events.update_flight_position(self.flight, self.estimate_position())
|
||||
|
||||
def progress(self) -> float:
|
||||
# if next waypoint is very close, assume we reach it immediately to avoid divide
|
||||
# by zero error
|
||||
if self.total_time_to_next_waypoint.total_seconds() < 1:
|
||||
return 1.0
|
||||
|
||||
return (
|
||||
self.elapsed_time.total_seconds()
|
||||
/ self.total_time_to_next_waypoint.total_seconds()
|
||||
|
||||
@@ -24,7 +24,7 @@ class StartUp(AtDeparture):
|
||||
) -> None:
|
||||
if time < self.completion_time:
|
||||
return
|
||||
self.flight.set_state(Taxi(self.flight, self.settings, time))
|
||||
self.flight.set_state(Taxi(self.flight, self.settings, self.completion_time))
|
||||
|
||||
@property
|
||||
def is_waiting_for_start(self) -> bool:
|
||||
|
||||
@@ -19,14 +19,19 @@ class Takeoff(AtDeparture):
|
||||
def __init__(self, flight: Flight, settings: Settings, now: datetime) -> None:
|
||||
super().__init__(flight, settings)
|
||||
# TODO: Not accounted for in FlightPlan, can cause discrepancy without loiter.
|
||||
self.completion_time = now + timedelta(seconds=30)
|
||||
self.completion_time = now + flight.flight_plan.estimate_takeoff_time()
|
||||
|
||||
def on_game_tick(
|
||||
self, events: GameUpdateEvents, time: datetime, duration: timedelta
|
||||
) -> None:
|
||||
if time < self.completion_time:
|
||||
return
|
||||
self.flight.set_state(Navigating(self.flight, self.settings, waypoint_index=0))
|
||||
rollover = time - self.completion_time
|
||||
self.flight.set_state(
|
||||
Navigating(
|
||||
self.flight, self.settings, waypoint_index=0, elapsed_time=rollover
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_waiting_for_start(self) -> bool:
|
||||
@@ -45,7 +50,8 @@ class Takeoff(AtDeparture):
|
||||
def should_halt_sim(self) -> bool:
|
||||
if (
|
||||
self.flight.client_count > 0
|
||||
and self.settings.player_mission_interrupts_sim_at is StartType.RUNWAY
|
||||
and self.settings.player_mission_interrupts_sim_at
|
||||
is [StartType.COLD, StartType.WARM, StartType.RUNWAY]
|
||||
):
|
||||
logging.info(
|
||||
f"Interrupting simulation because {self.flight} has players and has "
|
||||
|
||||
@@ -24,7 +24,7 @@ class Taxi(AtDeparture):
|
||||
) -> None:
|
||||
if time < self.completion_time:
|
||||
return
|
||||
self.flight.set_state(Takeoff(self.flight, self.settings, time))
|
||||
self.flight.set_state(Takeoff(self.flight, self.settings, self.completion_time))
|
||||
|
||||
@property
|
||||
def is_waiting_for_start(self) -> bool:
|
||||
@@ -37,7 +37,8 @@ class Taxi(AtDeparture):
|
||||
def should_halt_sim(self) -> bool:
|
||||
if (
|
||||
self.flight.client_count > 0
|
||||
and self.settings.player_mission_interrupts_sim_at is StartType.WARM
|
||||
and self.settings.player_mission_interrupts_sim_at
|
||||
in [StartType.COLD, StartType.WARM]
|
||||
):
|
||||
logging.info(
|
||||
f"Interrupting simulation because {self.flight} has players and has "
|
||||
|
||||
@@ -20,7 +20,8 @@ class Uninitialized(FlightState):
|
||||
def on_game_tick(
|
||||
self, events: GameUpdateEvents, time: datetime, duration: timedelta
|
||||
) -> None:
|
||||
raise RuntimeError("Attempted to simulate flight that is not fully initialized")
|
||||
self.reinitialize(time)
|
||||
self.flight.state.on_game_tick(events, time, duration)
|
||||
|
||||
@property
|
||||
def is_waiting_for_start(self) -> bool:
|
||||
@@ -35,7 +36,6 @@ class Uninitialized(FlightState):
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
delay = self.flight.flight_plan.startup_time()
|
||||
if self.flight.start_type is StartType.COLD:
|
||||
action = "Starting up"
|
||||
elif self.flight.start_type is StartType.WARM:
|
||||
@@ -46,4 +46,4 @@ class Uninitialized(FlightState):
|
||||
action = "In flight"
|
||||
else:
|
||||
raise ValueError(f"Unhandled StartType: {self.flight.start_type}")
|
||||
return f"{action} in {delay}"
|
||||
return f"{action} at {self.flight.flight_plan.startup_time():%H:%M:%S}"
|
||||
|
||||
@@ -95,6 +95,10 @@ class FlightType(Enum):
|
||||
FlightType.SEAD_SWEEP,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_escort_type(self) -> bool:
|
||||
return self in {FlightType.ESCORT, FlightType.SEAD_ESCORT}
|
||||
|
||||
@property
|
||||
def entity_type(self) -> AirEntity:
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from datetime import datetime
|
||||
from typing import Literal, TYPE_CHECKING
|
||||
|
||||
from dcs import Point
|
||||
@@ -43,8 +43,8 @@ class FlightWaypoint:
|
||||
# generation). We do it late so that we don't need to propagate changes
|
||||
# to waypoint times whenever the player alters the package TOT or the
|
||||
# flight's offset in the UI.
|
||||
tot: timedelta | None = None
|
||||
departure_time: timedelta | None = None
|
||||
tot: datetime | None = None
|
||||
departure_time: datetime | None = None
|
||||
|
||||
@property
|
||||
def x(self) -> float:
|
||||
|
||||
@@ -25,7 +25,7 @@ class FlightWaypointType(IntEnum):
|
||||
INGRESS_STRIKE = 5 # Ingress strike (For generator, means that this should have bombing on next TARGET_POINT points)
|
||||
INGRESS_SEAD = 6 # Ingress sead (For generator, means that this should attack groups on TARGET_GROUP_LOC points)
|
||||
INGRESS_CAS = 7 # Ingress cas (should start CAS task)
|
||||
CAS = 8 # Should do CAS there
|
||||
CAS = 8 # Unused.
|
||||
EGRESS = 9 # Should stop attack
|
||||
DESCENT_POINT = 10 # Should start descending to pattern alt
|
||||
LANDING_POINT = 11 # Should land there
|
||||
|
||||
39
game/ato/iflightroster.py
Normal file
39
game/ato/iflightroster.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, TYPE_CHECKING, Iterator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.squadrons import Pilot, Squadron
|
||||
|
||||
|
||||
class IFlightRoster(ABC):
|
||||
@abstractmethod
|
||||
def iter_pilots(self) -> Iterator[Pilot | None]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def pilot_at(self, idx: int) -> Pilot | None:
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def squadron(self) -> Squadron:
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def max_size(self) -> int:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def resize(self, new_size: int) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
...
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
@@ -10,6 +11,7 @@ from dcs.unittype import FlyingType
|
||||
from game.data.weapons import Pylon, Weapon, WeaponType
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from .flighttype import FlightType
|
||||
from ..persistency import prefer_liberation_payloads
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .flight import Flight
|
||||
@@ -35,6 +37,11 @@ class Loadout:
|
||||
def derive_custom(self, name: str) -> Loadout:
|
||||
return Loadout(name, self.pylons, self.date, is_custom=True)
|
||||
|
||||
def clone(self) -> Loadout:
|
||||
return Loadout(
|
||||
self.name, dict(self.pylons), copy.deepcopy(self.date), self.is_custom
|
||||
)
|
||||
|
||||
def has_weapon_of_type(self, weapon_type: WeaponType) -> bool:
|
||||
for weapon in self.pylons.values():
|
||||
if weapon is not None and weapon.weapon_group.type is weapon_type:
|
||||
@@ -155,7 +162,10 @@ class Loadout:
|
||||
# last - the first element in the tuple will be tried first, then the second,
|
||||
# etc.
|
||||
loadout_names = {
|
||||
t: [f"Retribution {t.value}", f"Liberation {t.value}"] for t in FlightType
|
||||
t: [f"Liberation {t.value}", f"Retribution {t.value}"]
|
||||
if prefer_liberation_payloads()
|
||||
else [f"Retribution {t.value}", f"Liberation {t.value}"]
|
||||
for t in FlightType
|
||||
}
|
||||
legacy_names = {
|
||||
FlightType.TARCAP: (
|
||||
|
||||
@@ -2,7 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional, TYPE_CHECKING
|
||||
|
||||
from game.db import Database
|
||||
@@ -42,8 +43,11 @@ class Package(RadioFrequencyContainer):
|
||||
self.auto_asap = auto_asap
|
||||
self.flights: list[Flight] = []
|
||||
|
||||
# Desired TOT as an offset from mission start.
|
||||
self.time_over_target: timedelta = timedelta()
|
||||
# Desired TOT as an offset from mission start. Obviously datetime.min is bogus,
|
||||
# but it's going to be replaced by whatever is scheduling the package very soon.
|
||||
# TODO: Constructor should maybe take the current time and use that to preserve
|
||||
# the old behavior?
|
||||
self.time_over_target: datetime = datetime.min
|
||||
self.waypoints: PackageWaypoints | None = None
|
||||
|
||||
@property
|
||||
@@ -71,7 +75,7 @@ class Package(RadioFrequencyContainer):
|
||||
# TODO: Should depend on the type of escort.
|
||||
# SEAD might be able to leave before CAP.
|
||||
@property
|
||||
def escort_start_time(self) -> Optional[timedelta]:
|
||||
def escort_start_time(self) -> datetime | None:
|
||||
times = []
|
||||
for flight in self.flights:
|
||||
waypoint = flight.flight_plan.request_escort_at()
|
||||
@@ -90,7 +94,7 @@ class Package(RadioFrequencyContainer):
|
||||
return None
|
||||
|
||||
@property
|
||||
def escort_end_time(self) -> Optional[timedelta]:
|
||||
def escort_end_time(self) -> datetime | None:
|
||||
times = []
|
||||
for flight in self.flights:
|
||||
waypoint = flight.flight_plan.dismiss_escort_at()
|
||||
@@ -112,7 +116,7 @@ class Package(RadioFrequencyContainer):
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> Optional[timedelta]:
|
||||
def mission_departure_time(self) -> datetime | None:
|
||||
times = []
|
||||
for flight in self.flights:
|
||||
times.append(flight.flight_plan.mission_departure_time)
|
||||
@@ -120,8 +124,8 @@ class Package(RadioFrequencyContainer):
|
||||
return max(times)
|
||||
return None
|
||||
|
||||
def set_tot_asap(self) -> None:
|
||||
self.time_over_target = TotEstimator(self).earliest_tot()
|
||||
def set_tot_asap(self, now: datetime) -> None:
|
||||
self.time_over_target = TotEstimator(self).earliest_tot(now)
|
||||
|
||||
def add_flight(self, flight: Flight) -> None:
|
||||
"""Adds a flight to the package."""
|
||||
@@ -228,8 +232,9 @@ class Package(RadioFrequencyContainer):
|
||||
@staticmethod
|
||||
def clone_package(package: Package) -> Package:
|
||||
clone = Package(package.target, package._db, package.auto_asap)
|
||||
clone.time_over_target = package.time_over_target
|
||||
clone.time_over_target = deepcopy(package.time_over_target)
|
||||
for f in package.flights:
|
||||
cf = Flight.clone_flight(f)
|
||||
cf.package = clone
|
||||
clone.add_flight(cf)
|
||||
return clone
|
||||
|
||||
@@ -2,13 +2,16 @@ from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dcs import Point
|
||||
|
||||
from game.ato.flightplans.waypointbuilder import WaypointBuilder
|
||||
from game.flightplan import IpZoneGeometry, JoinZoneGeometry
|
||||
from game.flightplan import JoinZoneGeometry
|
||||
from game.flightplan.ipsolver import IpSolver
|
||||
from game.flightplan.refuelzonegeometry import RefuelZoneGeometry
|
||||
from game.persistency import waypoint_debug_directory
|
||||
from game.utils import dcs_to_shapely_point
|
||||
from game.utils import nautical_miles
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -25,15 +28,28 @@ class PackageWaypoints:
|
||||
refuel: Point
|
||||
|
||||
@staticmethod
|
||||
def create(package: Package, coalition: Coalition) -> PackageWaypoints:
|
||||
def create(
|
||||
package: Package, coalition: Coalition, dump_debug_info: bool
|
||||
) -> PackageWaypoints:
|
||||
origin = package.departure_closest_to_target()
|
||||
|
||||
# Start by picking the best IP for the attack.
|
||||
ingress_point = IpZoneGeometry(
|
||||
package.target.position,
|
||||
origin.position,
|
||||
coalition,
|
||||
).find_best_ip()
|
||||
ip_solver = IpSolver(
|
||||
dcs_to_shapely_point(origin.position),
|
||||
dcs_to_shapely_point(package.target.position),
|
||||
coalition.doctrine,
|
||||
coalition.opponent.threat_zone.all,
|
||||
)
|
||||
ip_solver.set_debug_properties(
|
||||
waypoint_debug_directory() / "IP", coalition.game.theater.terrain
|
||||
)
|
||||
ingress_point_shapely = ip_solver.solve()
|
||||
if dump_debug_info:
|
||||
ip_solver.dump_debug_info()
|
||||
|
||||
ingress_point = origin.position.new_in_same_map(
|
||||
ingress_point_shapely.x, ingress_point_shapely.y
|
||||
)
|
||||
|
||||
hdg = package.target.position.heading_between_point(ingress_point)
|
||||
# Generate a waypoint randomly between 7 & 9 NM
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import timedelta
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dcs.mapping import Point
|
||||
|
||||
from game.utils import (
|
||||
Distance,
|
||||
SPEED_OF_SOUND_AT_SEA_LEVEL,
|
||||
Speed,
|
||||
mach,
|
||||
meters,
|
||||
)
|
||||
from game.utils import Distance, SPEED_OF_SOUND_AT_SEA_LEVEL, Speed, mach, meters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .flight import Flight
|
||||
@@ -39,16 +30,8 @@ class GroundSpeed:
|
||||
# as it can at sea level. This probably isn't great assumption, but
|
||||
# might. be sufficient given the wiggle room. We can come up with
|
||||
# another heuristic if needed.
|
||||
cruise_mach = max_speed.mach() * 0.85
|
||||
return mach(cruise_mach, altitude)
|
||||
|
||||
|
||||
class TravelTime:
|
||||
@staticmethod
|
||||
def between_points(a: Point, b: Point, speed: Speed) -> timedelta:
|
||||
error_factor = 1.05
|
||||
distance = meters(a.distance_to_point(b))
|
||||
return timedelta(hours=distance.nautical_miles / speed.knots * error_factor)
|
||||
cruise_mach = max_speed.mach() * (0.60 if flight.is_helo else 0.85)
|
||||
return mach(cruise_mach, altitude if not flight.is_helo else meters(0))
|
||||
|
||||
|
||||
# TODO: Most if not all of this should move into FlightPlan.
|
||||
@@ -56,44 +39,25 @@ class TotEstimator:
|
||||
def __init__(self, package: Package) -> None:
|
||||
self.package = package
|
||||
|
||||
def earliest_tot(self) -> timedelta:
|
||||
def earliest_tot(self, now: datetime) -> datetime:
|
||||
if not self.package.flights:
|
||||
return timedelta(0)
|
||||
return now
|
||||
|
||||
earliest_tot = max(
|
||||
(self.earliest_tot_for_flight(f) for f in self.package.flights)
|
||||
)
|
||||
|
||||
# Trim microseconds. DCS doesn't handle sub-second resolution for tasks,
|
||||
# and they're not interesting from a mission planning perspective so we
|
||||
# don't want them in the UI.
|
||||
#
|
||||
# Round up so we don't get negative start times.
|
||||
return timedelta(seconds=math.ceil(earliest_tot.total_seconds()))
|
||||
return max(self.earliest_tot_for_flight(f, now) for f in self.package.flights)
|
||||
|
||||
@staticmethod
|
||||
def earliest_tot_for_flight(flight: Flight) -> timedelta:
|
||||
"""Estimate the fastest time from mission start to the target position.
|
||||
def earliest_tot_for_flight(flight: Flight, now: datetime) -> datetime:
|
||||
"""Estimate the earliest time the flight can reach the target position.
|
||||
|
||||
For BARCAP flights, this is time to the racetrack start. This ensures that
|
||||
they are on station at the same time any other package members reach
|
||||
their ingress point.
|
||||
|
||||
For other mission types this is the time to the mission target.
|
||||
The interpretation of the TOT depends on the flight plan type. See the various
|
||||
FlightPlan implementations for details.
|
||||
|
||||
Args:
|
||||
flight: The flight to get the earliest TOT time for.
|
||||
flight: The flight to get the earliest TOT for.
|
||||
now: The current mission time.
|
||||
|
||||
Returns:
|
||||
The earliest possible TOT for the given flight in seconds. Returns 0
|
||||
if an ingress point cannot be found.
|
||||
The earliest possible TOT for the given flight.
|
||||
"""
|
||||
# Clear the TOT, calculate the startup time. Negating the result gives
|
||||
# the earliest possible start time.
|
||||
orig_tot = flight.package.time_over_target
|
||||
try:
|
||||
flight.package.time_over_target = timedelta()
|
||||
time = flight.flight_plan.startup_time()
|
||||
finally:
|
||||
flight.package.time_over_target = orig_tot
|
||||
return -time
|
||||
flight_time = flight.flight_plan.minimum_duration_from_start_to_tot()
|
||||
return now + flight_time
|
||||
|
||||
@@ -40,11 +40,6 @@ class SquadronConfig:
|
||||
secondary = [FlightType(s) for s in secondary_raw]
|
||||
|
||||
max_size = data.get("size", DEFAULT_SQUADRON_SIZE)
|
||||
if max_size is None:
|
||||
raise RuntimeError(
|
||||
"Squadron's size is defined in campaign but is missing a value:\n"
|
||||
f"{data}"
|
||||
)
|
||||
|
||||
return SquadronConfig(
|
||||
FlightType(data["primary"]),
|
||||
|
||||
@@ -98,7 +98,7 @@ class DefaultSquadronAssigner:
|
||||
)
|
||||
return None
|
||||
|
||||
if aircraft not in self.coalition.faction.aircrafts:
|
||||
if aircraft not in self.coalition.faction.all_aircrafts:
|
||||
return None
|
||||
|
||||
lo = self.coalition.faction.liveries_overrides
|
||||
|
||||
@@ -25,7 +25,7 @@ class SquadronDefGenerator:
|
||||
) -> Optional[SquadronDef]:
|
||||
aircraft_choice: Optional[AircraftType] = None
|
||||
for aircraft in AircraftType.priority_list_for_task(task):
|
||||
if aircraft not in self.faction.aircrafts:
|
||||
if aircraft not in self.faction.all_aircrafts:
|
||||
continue
|
||||
if not control_point.can_operate(aircraft):
|
||||
continue
|
||||
@@ -48,6 +48,7 @@ class SquadronDefGenerator:
|
||||
aircraft=aircraft,
|
||||
livery=None,
|
||||
auto_assignable_mission_types=set(aircraft.iter_task_capabilities()),
|
||||
radio_presets={},
|
||||
operating_bases=OperatingBases.default_for_aircraft(aircraft),
|
||||
female_pilot_percentage=6,
|
||||
pilot_pool=[],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
from faker import Faker
|
||||
@@ -25,6 +26,7 @@ if TYPE_CHECKING:
|
||||
from .data.doctrine import Doctrine
|
||||
from .factions.faction import Faction
|
||||
from .game import Game
|
||||
from .lasercodes import LaserCodeRegistry
|
||||
from .sim import GameUpdateEvents
|
||||
|
||||
|
||||
@@ -85,6 +87,10 @@ class Coalition:
|
||||
assert self._navmesh is not None
|
||||
return self._navmesh
|
||||
|
||||
@property
|
||||
def laser_code_registry(self) -> LaserCodeRegistry:
|
||||
return self.game.laser_code_registry
|
||||
|
||||
def __getstate__(self) -> dict[str, Any]:
|
||||
state = self.__dict__.copy()
|
||||
# Avoid persisting any volatile types that can be deterministically
|
||||
@@ -177,10 +183,10 @@ class Coalition:
|
||||
with logged_duration("Procurement of airlift assets"):
|
||||
self.transfers.order_airlift_assets()
|
||||
with logged_duration("Transport planning"):
|
||||
self.transfers.plan_transports()
|
||||
self.transfers.plan_transports(self.game.conditions.start_time)
|
||||
|
||||
if not is_turn_0 or not self.game.settings.enable_squadron_aircraft_limits:
|
||||
self.plan_missions()
|
||||
if not is_turn_0:
|
||||
self.plan_missions(self.game.conditions.start_time)
|
||||
self.plan_procurement()
|
||||
|
||||
def refund_outstanding_orders(self) -> None:
|
||||
@@ -196,16 +202,16 @@ class Coalition:
|
||||
for squadron in self.air_wing.iter_squadrons():
|
||||
squadron.refund_orders()
|
||||
|
||||
def plan_missions(self) -> None:
|
||||
def plan_missions(self, now: datetime) -> 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)
|
||||
TheaterCommander(self.game, self.player).plan_missions(now, tracer)
|
||||
with tracer.trace(f"{color} mission scheduling"):
|
||||
MissionScheduler(
|
||||
self, self.game.settings.desired_player_mission_duration
|
||||
).schedule_missions()
|
||||
).schedule_missions(now)
|
||||
|
||||
def plan_procurement(self) -> None:
|
||||
# The first turn needs to buy a *lot* of aircraft to fill CAPs, so it gets much
|
||||
|
||||
@@ -3,12 +3,14 @@ from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
from game.ato.flighttype import FlightType
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.theater import MissionTarget
|
||||
|
||||
|
||||
class EscortType(Enum):
|
||||
AirToAir = auto()
|
||||
Sead = auto()
|
||||
Refuel = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -32,6 +34,8 @@ class ProposedFlight:
|
||||
#: field is None.
|
||||
escort_type: Optional[EscortType] = field(default=None)
|
||||
|
||||
preferred_type: Optional[AircraftType] = field(default=None)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.task} {self.num_aircraft} ship"
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from typing import Iterator, Dict, TYPE_CHECKING
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Iterator, TYPE_CHECKING
|
||||
|
||||
from game.ato.flighttype import FlightType
|
||||
from game.ato.traveltime import TotEstimator
|
||||
@@ -19,7 +19,7 @@ class MissionScheduler:
|
||||
self.coalition = coalition
|
||||
self.desired_mission_length = desired_mission_length
|
||||
|
||||
def schedule_missions(self) -> None:
|
||||
def schedule_missions(self, now: datetime) -> None:
|
||||
"""Identifies and plans mission for the turn."""
|
||||
|
||||
def start_time_generator(
|
||||
@@ -35,7 +35,7 @@ class MissionScheduler:
|
||||
FlightType.TARCAP,
|
||||
}
|
||||
|
||||
previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta)
|
||||
previous_cap_end_time: dict[MissionTarget, datetime] = defaultdict(now.replace)
|
||||
non_dca_packages = [
|
||||
p for p in self.coalition.ato.packages if p.primary_task not in dca_types
|
||||
]
|
||||
@@ -47,7 +47,7 @@ class MissionScheduler:
|
||||
margin=5 * 60,
|
||||
)
|
||||
for package in self.coalition.ato.packages:
|
||||
tot = TotEstimator(package).earliest_tot()
|
||||
tot = TotEstimator(package).earliest_tot(now)
|
||||
if package.primary_task in dca_types:
|
||||
previous_end_time = previous_cap_end_time[package.target]
|
||||
if tot > previous_end_time:
|
||||
@@ -65,7 +65,7 @@ class MissionScheduler:
|
||||
continue
|
||||
previous_cap_end_time[package.target] = departure_time
|
||||
elif package.auto_asap:
|
||||
package.set_tot_asap()
|
||||
package.set_tot_asap(now)
|
||||
else:
|
||||
# But other packages should be spread out a bit. Note that take
|
||||
# times are delayed, but all aircraft will become active at
|
||||
|
||||
@@ -140,7 +140,7 @@ class ObjectiveFinder:
|
||||
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
|
||||
Vulnerability is defined as any enemy CP within threat range of the
|
||||
CP.
|
||||
"""
|
||||
for cp in self.friendly_control_points():
|
||||
@@ -167,6 +167,24 @@ class ObjectiveFinder:
|
||||
yield cp
|
||||
break
|
||||
|
||||
def vulnerable_enemy_control_points(self) -> Iterator[ControlPoint]:
|
||||
"""Iterates over enemy CPs that are vulnerable to Air Assault.
|
||||
Vulnerability is defined as any unit being alive in the CP's "blocking_capture" groups.
|
||||
"""
|
||||
for cp in self.enemy_control_points():
|
||||
include = True
|
||||
for tgo in cp.connected_objectives:
|
||||
if tgo.distance_to(cp) > cp.CAPTURE_DISTANCE.meters:
|
||||
continue
|
||||
for u in tgo.units:
|
||||
if u.is_vehicle and u.alive:
|
||||
include = False
|
||||
break
|
||||
if not include:
|
||||
break
|
||||
if include:
|
||||
yield cp
|
||||
|
||||
def oca_targets(self, min_aircraft: int) -> Iterator[ControlPoint]:
|
||||
parking_type = ParkingType()
|
||||
parking_type.include_rotary_wing = True
|
||||
|
||||
@@ -10,9 +10,10 @@ from ..ato.starttype import StartType
|
||||
from ..db.database import Database
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.squadrons.airwing import AirWing
|
||||
from game.ato.closestairfields import ClosestAirfields
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.lasercodes import LaserCodeRegistry
|
||||
from game.squadrons.airwing import AirWing
|
||||
from .missionproposals import ProposedFlight
|
||||
|
||||
|
||||
@@ -24,6 +25,7 @@ class PackageBuilder:
|
||||
location: MissionTarget,
|
||||
closest_airfields: ClosestAirfields,
|
||||
air_wing: AirWing,
|
||||
laser_code_registry: LaserCodeRegistry,
|
||||
flight_db: Database[Flight],
|
||||
is_player: bool,
|
||||
start_type: StartType,
|
||||
@@ -33,9 +35,10 @@ class PackageBuilder:
|
||||
self.is_player = is_player
|
||||
self.package = Package(location, flight_db, auto_asap=asap)
|
||||
self.air_wing = air_wing
|
||||
self.laser_code_registry = laser_code_registry
|
||||
self.start_type = start_type
|
||||
|
||||
def plan_flight(self, plan: ProposedFlight) -> bool:
|
||||
def plan_flight(self, plan: ProposedFlight, ignore_range: bool) -> 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
|
||||
@@ -43,8 +46,16 @@ class PackageBuilder:
|
||||
caller should return any previously planned flights to the inventory
|
||||
using release_planned_aircraft.
|
||||
"""
|
||||
pf = self.package.primary_flight
|
||||
heli = pf.is_helo if pf else False
|
||||
squadron = self.air_wing.best_squadron_for(
|
||||
self.package.target, plan.task, plan.num_aircraft, this_turn=True
|
||||
self.package.target,
|
||||
plan.task,
|
||||
plan.num_aircraft,
|
||||
heli,
|
||||
this_turn=True,
|
||||
preferred_type=plan.preferred_type,
|
||||
ignore_range=ignore_range,
|
||||
)
|
||||
if squadron is None:
|
||||
return False
|
||||
@@ -60,6 +71,11 @@ class PackageBuilder:
|
||||
start_type,
|
||||
divert=self.find_divert_field(squadron.aircraft, squadron.location),
|
||||
)
|
||||
for member in flight.iter_members():
|
||||
if member.is_player:
|
||||
member.assign_tgp_laser_code(
|
||||
self.laser_code_registry.alloc_laser_code()
|
||||
)
|
||||
self.package.add_flight(flight)
|
||||
return True
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Dict, Iterable, Optional, Set, TYPE_CHECKING
|
||||
|
||||
from game.ato.airtaaskingorder import AirTaskingOrder
|
||||
@@ -80,13 +81,17 @@ class PackageFulfiller:
|
||||
builder: PackageBuilder,
|
||||
missing_types: Set[FlightType],
|
||||
purchase_multiplier: int,
|
||||
ignore_range: bool = False,
|
||||
) -> None:
|
||||
if not builder.plan_flight(flight):
|
||||
if not builder.plan_flight(flight, ignore_range):
|
||||
pf = builder.package.primary_flight
|
||||
heli = pf.is_helo if pf else False
|
||||
missing_types.add(flight.task)
|
||||
purchase_order = AircraftProcurementRequest(
|
||||
near=mission.location,
|
||||
task_capability=flight.task,
|
||||
number=flight.num_aircraft * purchase_multiplier,
|
||||
heli=heli,
|
||||
)
|
||||
# Reserves are planned for critical missions, so prioritize those orders
|
||||
# over aircraft needed for non-critical missions.
|
||||
@@ -132,13 +137,16 @@ class PackageFulfiller:
|
||||
self,
|
||||
mission: ProposedMission,
|
||||
purchase_multiplier: int,
|
||||
now: datetime,
|
||||
tracer: MultiEventTracer,
|
||||
ignore_range: bool = False,
|
||||
) -> 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.coalition.laser_code_registry,
|
||||
self.flight_db,
|
||||
self.is_player,
|
||||
self.default_start_type,
|
||||
@@ -151,17 +159,17 @@ class PackageFulfiller:
|
||||
missing_types: Set[FlightType] = set()
|
||||
escorts = []
|
||||
for proposed_flight in mission.flights:
|
||||
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
|
||||
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.
|
||||
missing_types.add(proposed_flight.task)
|
||||
break
|
||||
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,
|
||||
@@ -169,6 +177,7 @@ class PackageFulfiller:
|
||||
builder,
|
||||
missing_types,
|
||||
purchase_multiplier,
|
||||
ignore_range,
|
||||
)
|
||||
|
||||
if missing_types:
|
||||
@@ -221,6 +230,6 @@ class PackageFulfiller:
|
||||
|
||||
if package.has_players and self.player_missions_asap:
|
||||
package.auto_asap = True
|
||||
package.set_tot_asap()
|
||||
package.set_tot_asap(now)
|
||||
|
||||
return package
|
||||
|
||||
15
game/commander/tasks/compound/attackships.py
Normal file
15
game/commander/tasks/compound/attackships.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from collections.abc import Iterator
|
||||
|
||||
from game.commander.tasks.primitive.antiship import PlanAntiShip
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
class AttackShips(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for ship in state.enemy_ships:
|
||||
# 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 ship.has_live_radar_sam:
|
||||
yield [PlanAntiShip(ship)]
|
||||
@@ -7,6 +7,7 @@ from game.commander.tasks.compound.destroyenemygroundunits import (
|
||||
from game.commander.tasks.compound.reduceenemyfrontlinecapacity import (
|
||||
ReduceEnemyFrontLineCapacity,
|
||||
)
|
||||
from game.commander.tasks.primitive.airassault import PlanAirAssault
|
||||
from game.commander.tasks.primitive.breakthroughattack import BreakthroughAttack
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
@@ -22,6 +23,7 @@ class CaptureBase(CompoundTask[TheaterState]):
|
||||
yield [DestroyEnemyGroundUnits(self.front_line)]
|
||||
if self.worth_destroying_ammo_depots(state):
|
||||
yield [ReduceEnemyFrontLineCapacity(self.enemy_cp(state))]
|
||||
yield [PlanAirAssault(self.enemy_cp(state))]
|
||||
|
||||
def enemy_cp(self, state: TheaterState) -> ControlPoint:
|
||||
return self.front_line.control_point_hostile_to(state.context.coalition.player)
|
||||
|
||||
@@ -6,6 +6,7 @@ from game.commander.tasks.compound.attackairinfrastructure import (
|
||||
)
|
||||
from game.commander.tasks.compound.attackbattlepositions import AttackBattlePositions
|
||||
from game.commander.tasks.compound.attackbuildings import AttackBuildings
|
||||
from game.commander.tasks.compound.attackships import AttackShips
|
||||
from game.commander.tasks.compound.capturebases import CaptureBases
|
||||
from game.commander.tasks.compound.defendbases import DefendBases
|
||||
from game.commander.tasks.compound.degradeiads import DegradeIads
|
||||
@@ -25,10 +26,11 @@ class PlanNextAction(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
yield [TheaterSupport()]
|
||||
yield [ProtectAirSpace()]
|
||||
yield [CaptureBases()]
|
||||
yield [DefendBases()]
|
||||
yield [InterdictReinforcements()]
|
||||
yield [DegradeIads()]
|
||||
yield [AttackBattlePositions()]
|
||||
yield [CaptureBases()]
|
||||
yield [AttackAirInfrastructure(self.aircraft_cold_start)]
|
||||
yield [AttackBuildings()]
|
||||
yield [DegradeIads()]
|
||||
yield [AttackShips()]
|
||||
|
||||
@@ -2,10 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import operator
|
||||
import random
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum, auto, unique
|
||||
import random
|
||||
from typing import Generic, Iterator, Optional, TYPE_CHECKING, TypeVar, Union
|
||||
|
||||
from game.ato.flighttype import FlightType
|
||||
@@ -105,6 +105,7 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
|
||||
self.package = fulfiller.plan_mission(
|
||||
ProposedMission(self.target, self.flights),
|
||||
self.purchase_multiplier,
|
||||
state.context.now,
|
||||
state.context.tracer,
|
||||
)
|
||||
return self.package is not None
|
||||
@@ -112,6 +113,7 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
|
||||
def propose_common_escorts(self) -> None:
|
||||
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
|
||||
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
|
||||
self.propose_flight(FlightType.SEAD_SWEEP, 2, EscortType.Sead)
|
||||
|
||||
def iter_iads_ranges(
|
||||
self, state: TheaterState, range_type: RangeType
|
||||
|
||||
@@ -11,6 +11,11 @@ from game.theater import MissionTarget
|
||||
@dataclass
|
||||
class PlanAewc(PackagePlanningTask[MissionTarget]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if (
|
||||
state.context.coalition.player
|
||||
and not state.context.settings.auto_ato_behavior_awacs
|
||||
):
|
||||
return False
|
||||
if not super().preconditions_met(state):
|
||||
return False
|
||||
return self.target in state.aewc_targets
|
||||
|
||||
26
game/commander/tasks/primitive/airassault.py
Normal file
26
game/commander/tasks/primitive/airassault.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.ato.flighttype import FlightType
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater import ControlPoint
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanAirAssault(PackagePlanningTask[ControlPoint]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if self.target not in state.vulnerable_control_points:
|
||||
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.vulnerable_control_points.remove(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
size = self.get_flight_size()
|
||||
self.propose_flight(FlightType.AIR_ASSAULT, size)
|
||||
self.propose_common_escorts()
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from random import randint
|
||||
|
||||
from game.ato.flighttype import FlightType
|
||||
from game.commander.missionproposals import EscortType
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.transfers import CargoShip
|
||||
@@ -24,4 +24,4 @@ class PlanAntiShipping(PackagePlanningTask[CargoShip]):
|
||||
def propose_flights(self) -> None:
|
||||
size = self.get_flight_size()
|
||||
self.propose_flight(FlightType.ANTISHIP, size)
|
||||
self.propose_common_escorts()
|
||||
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from random import randint
|
||||
|
||||
from game.ato.flighttype import FlightType
|
||||
from game.commander.missionproposals import EscortType
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater import FrontLine
|
||||
@@ -32,4 +32,5 @@ class PlanCas(PackagePlanningTask[FrontLine]):
|
||||
def propose_flights(self) -> None:
|
||||
size = self.get_flight_size()
|
||||
self.propose_flight(FlightType.CAS, size)
|
||||
self.propose_flight(FlightType.TARCAP, 2)
|
||||
self.propose_flight(FlightType.TARCAP, 2, EscortType.AirToAir)
|
||||
self.propose_flight(FlightType.SEAD_SWEEP, 2, EscortType.Sead)
|
||||
|
||||
@@ -40,9 +40,8 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]):
|
||||
# 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.
|
||||
self.propose_common_escorts()
|
||||
if self.target.has_live_radar_sam:
|
||||
self.propose_flight(FlightType.SEAD, 2)
|
||||
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
|
||||
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
|
||||
self.propose_flight(FlightType.SEAD, 2, EscortType.Sead)
|
||||
if self.target.control_point.coalition.game.settings.autoplan_tankers_for_dead:
|
||||
self.propose_flight(FlightType.REFUELING, 1)
|
||||
self.propose_flight(FlightType.REFUELING, 1, EscortType.Refuel)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.ato.flighttype import FlightType
|
||||
from game.commander.missionproposals import EscortType
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater import ControlPoint
|
||||
@@ -25,9 +26,9 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]):
|
||||
def propose_flights(self) -> None:
|
||||
size = self.get_flight_size()
|
||||
if self.target.cptype == self.target.cptype.AIRBASE:
|
||||
self.propose_flight(FlightType.OCA_RUNWAY, size)
|
||||
self.propose_flight(FlightType.OCA_RUNWAY, size) # type:ignore[unreachable]
|
||||
if self.aircraft_cold_start:
|
||||
self.propose_flight(FlightType.OCA_AIRCRAFT, 2)
|
||||
self.propose_common_escorts()
|
||||
if self.target.coalition.game.settings.autoplan_tankers_for_oca:
|
||||
self.propose_flight(FlightType.REFUELING, 1)
|
||||
self.propose_flight(FlightType.REFUELING, 1, EscortType.Refuel)
|
||||
|
||||
@@ -11,6 +11,11 @@ from game.theater import MissionTarget
|
||||
@dataclass
|
||||
class PlanRefueling(PackagePlanningTask[MissionTarget]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if (
|
||||
state.context.coalition.player
|
||||
and not state.context.settings.auto_ato_behavior_tankers
|
||||
):
|
||||
return False
|
||||
if not super().preconditions_met(state):
|
||||
return False
|
||||
return self.target in state.refueling_targets
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.ato.flighttype import FlightType
|
||||
from game.commander.missionproposals import EscortType
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater.theatergroundobject import TheaterGroundObject
|
||||
@@ -22,7 +23,7 @@ class PlanStrike(PackagePlanningTask[TheaterGroundObject]):
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
tgt_count = self.target.alive_unit_count
|
||||
self.propose_flight(FlightType.STRIKE, min(4, (tgt_count // 2) + 1))
|
||||
self.propose_flight(FlightType.STRIKE, min(4, (tgt_count // 2) + tgt_count % 2))
|
||||
self.propose_common_escorts()
|
||||
if self.target.coalition.game.settings.autoplan_tankers_for_strike:
|
||||
self.propose_flight(FlightType.REFUELING, 1)
|
||||
self.propose_flight(FlightType.REFUELING, 1, EscortType.Refuel)
|
||||
|
||||
@@ -54,6 +54,7 @@ https://en.wikipedia.org/wiki/Hierarchical_task_network
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from game.ato.starttype import StartType
|
||||
@@ -77,8 +78,8 @@ class TheaterCommander(Planner[TheaterState, TheaterCommanderTask]):
|
||||
self.game = game
|
||||
self.player = player
|
||||
|
||||
def plan_missions(self, tracer: MultiEventTracer) -> None:
|
||||
state = TheaterState.from_game(self.game, self.player, tracer)
|
||||
def plan_missions(self, now: datetime, tracer: MultiEventTracer) -> None:
|
||||
state = TheaterState.from_game(self.game, self.player, now, tracer)
|
||||
while True:
|
||||
result = self.plan(state)
|
||||
if result is None:
|
||||
|
||||
@@ -5,6 +5,7 @@ import itertools
|
||||
import math
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional, TYPE_CHECKING, Union
|
||||
|
||||
from game.commander.battlepositions import BattlePositions
|
||||
@@ -36,6 +37,7 @@ class PersistentContext:
|
||||
coalition: Coalition
|
||||
theater: ConflictTheater
|
||||
turn: int
|
||||
now: datetime
|
||||
settings: Settings
|
||||
tracer: MultiEventTracer
|
||||
|
||||
@@ -60,6 +62,7 @@ class TheaterState(WorldState["TheaterState"]):
|
||||
strike_targets: list[TheaterGroundObject]
|
||||
enemy_barcaps: list[ControlPoint]
|
||||
threat_zones: ThreatZones
|
||||
vulnerable_control_points: list[ControlPoint]
|
||||
|
||||
def _rebuild_threat_zones(self) -> None:
|
||||
"""Recreates the theater's threat zones based on the current planned state."""
|
||||
@@ -133,18 +136,25 @@ class TheaterState(WorldState["TheaterState"]):
|
||||
# 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,
|
||||
vulnerable_control_points=self.vulnerable_control_points,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_game(
|
||||
cls, game: Game, player: bool, tracer: MultiEventTracer
|
||||
cls, game: Game, player: bool, now: datetime, tracer: MultiEventTracer
|
||||
) -> TheaterState:
|
||||
coalition = game.coalition_for(player)
|
||||
finder = ObjectiveFinder(game, player)
|
||||
ordered_capturable_points = finder.prioritized_unisolated_points()
|
||||
|
||||
context = PersistentContext(
|
||||
game.db, coalition, game.theater, game.turn, game.settings, tracer
|
||||
game.db,
|
||||
coalition,
|
||||
game.theater,
|
||||
game.turn,
|
||||
now,
|
||||
game.settings,
|
||||
tracer,
|
||||
)
|
||||
|
||||
# Plan enough rounds of CAP that the target has coverage over the expected
|
||||
@@ -181,4 +191,5 @@ class TheaterState(WorldState["TheaterState"]):
|
||||
strike_targets=list(finder.strike_targets()),
|
||||
enemy_barcaps=list(game.theater.control_points_for(not player)),
|
||||
threat_zones=game.threat_zone_for(not player),
|
||||
vulnerable_control_points=list(finder.vulnerable_enemy_control_points()),
|
||||
)
|
||||
|
||||
@@ -18,14 +18,14 @@ class GroundUnitProcurementRatios:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Doctrine:
|
||||
name: str
|
||||
|
||||
cas: bool
|
||||
cap: bool
|
||||
sead: bool
|
||||
strike: bool
|
||||
antiship: bool
|
||||
|
||||
rendezvous_altitude: Distance
|
||||
|
||||
#: The minimum distance between the departure airfield and the hold point.
|
||||
hold_distance: Distance
|
||||
|
||||
@@ -44,11 +44,14 @@ class Doctrine:
|
||||
#: target.
|
||||
min_ingress_distance: Distance
|
||||
|
||||
ingress_altitude: Distance
|
||||
|
||||
min_patrol_altitude: Distance
|
||||
max_patrol_altitude: Distance
|
||||
pattern_altitude: Distance
|
||||
|
||||
min_cruise_altitude: Distance
|
||||
max_cruise_altitude: Distance
|
||||
|
||||
min_combat_altitude: Distance
|
||||
max_combat_altitude: Distance
|
||||
|
||||
#: The duration that CAP flights will remain on-station.
|
||||
cap_duration: timedelta
|
||||
@@ -89,21 +92,23 @@ class Doctrine:
|
||||
|
||||
|
||||
MODERN_DOCTRINE = Doctrine(
|
||||
"modern",
|
||||
cap=True,
|
||||
cas=True,
|
||||
sead=True,
|
||||
strike=True,
|
||||
antiship=True,
|
||||
rendezvous_altitude=feet(25000),
|
||||
hold_distance=nautical_miles(25),
|
||||
push_distance=nautical_miles(20),
|
||||
join_distance=nautical_miles(20),
|
||||
max_ingress_distance=nautical_miles(45),
|
||||
min_ingress_distance=nautical_miles(10),
|
||||
ingress_altitude=feet(20000),
|
||||
min_patrol_altitude=feet(15000),
|
||||
max_patrol_altitude=feet(33000),
|
||||
pattern_altitude=feet(5000),
|
||||
min_cruise_altitude=feet(10000),
|
||||
max_cruise_altitude=feet(40000),
|
||||
min_combat_altitude=feet(1000),
|
||||
max_combat_altitude=feet(35000),
|
||||
cap_duration=timedelta(minutes=30),
|
||||
cap_min_track_length=nautical_miles(15),
|
||||
cap_max_track_length=nautical_miles(40),
|
||||
@@ -131,21 +136,23 @@ MODERN_DOCTRINE = Doctrine(
|
||||
)
|
||||
|
||||
COLDWAR_DOCTRINE = Doctrine(
|
||||
name="coldwar",
|
||||
cap=True,
|
||||
cas=True,
|
||||
sead=True,
|
||||
strike=True,
|
||||
antiship=True,
|
||||
rendezvous_altitude=feet(22000),
|
||||
hold_distance=nautical_miles(15),
|
||||
push_distance=nautical_miles(10),
|
||||
join_distance=nautical_miles(10),
|
||||
max_ingress_distance=nautical_miles(30),
|
||||
min_ingress_distance=nautical_miles(10),
|
||||
ingress_altitude=feet(18000),
|
||||
min_patrol_altitude=feet(10000),
|
||||
max_patrol_altitude=feet(24000),
|
||||
pattern_altitude=feet(5000),
|
||||
min_cruise_altitude=feet(10000),
|
||||
max_cruise_altitude=feet(30000),
|
||||
min_combat_altitude=feet(1000),
|
||||
max_combat_altitude=feet(25000),
|
||||
cap_duration=timedelta(minutes=30),
|
||||
cap_min_track_length=nautical_miles(12),
|
||||
cap_max_track_length=nautical_miles(24),
|
||||
@@ -173,6 +180,7 @@ COLDWAR_DOCTRINE = Doctrine(
|
||||
)
|
||||
|
||||
WWII_DOCTRINE = Doctrine(
|
||||
name="ww2",
|
||||
cap=True,
|
||||
cas=True,
|
||||
sead=False,
|
||||
@@ -181,13 +189,14 @@ WWII_DOCTRINE = Doctrine(
|
||||
hold_distance=nautical_miles(10),
|
||||
push_distance=nautical_miles(5),
|
||||
join_distance=nautical_miles(5),
|
||||
rendezvous_altitude=feet(10000),
|
||||
max_ingress_distance=nautical_miles(7),
|
||||
min_ingress_distance=nautical_miles(5),
|
||||
ingress_altitude=feet(8000),
|
||||
min_patrol_altitude=feet(4000),
|
||||
max_patrol_altitude=feet(15000),
|
||||
pattern_altitude=feet(5000),
|
||||
min_cruise_altitude=feet(5000),
|
||||
max_cruise_altitude=feet(30000),
|
||||
min_combat_altitude=feet(1000),
|
||||
max_combat_altitude=feet(10000),
|
||||
cap_duration=timedelta(minutes=30),
|
||||
cap_min_track_length=nautical_miles(8),
|
||||
cap_max_track_length=nautical_miles(18),
|
||||
@@ -212,3 +221,9 @@ WWII_DOCTRINE = Doctrine(
|
||||
sead_escort_engagement_range=nautical_miles(10),
|
||||
escort_engagement_range=nautical_miles(5),
|
||||
)
|
||||
|
||||
ALL_DOCTRINES = [
|
||||
COLDWAR_DOCTRINE,
|
||||
MODERN_DOCTRINE,
|
||||
WWII_DOCTRINE,
|
||||
]
|
||||
|
||||
@@ -38,6 +38,7 @@ TRACK_RADARS = {
|
||||
AirDefence.Kub_1S91_str,
|
||||
AirDefence.Snr_s_125_tr,
|
||||
AirDefence.S_300PS_40B6M_tr,
|
||||
AirDefence.S_300PS_5H63C_30H6_tr,
|
||||
AirDefence.Hawk_tr,
|
||||
AirDefence.Patriot_str,
|
||||
AirDefence.SNR_75V,
|
||||
@@ -53,30 +54,36 @@ TRACK_RADARS = {
|
||||
}
|
||||
|
||||
LAUNCHER_TRACKER_PAIRS = {
|
||||
AirDefence.Kub_2P25_ln: AirDefence.Kub_1S91_str,
|
||||
AirDefence.X_5p73_s_125_ln: AirDefence.Snr_s_125_tr,
|
||||
AirDefence.S_300PS_5P85C_ln: AirDefence.S_300PS_40B6M_tr,
|
||||
AirDefence.S_300PS_5P85D_ln: AirDefence.S_300PS_40B6M_tr,
|
||||
AirDefence.Hawk_ln: AirDefence.Hawk_tr,
|
||||
AirDefence.Patriot_ln: AirDefence.Patriot_str,
|
||||
AirDefence.S_75M_Volhov: AirDefence.SNR_75V,
|
||||
AirDefence.Rapier_fsa_launcher: AirDefence.Rapier_fsa_blindfire_radar,
|
||||
AirDefence.HQ_7_LN_SP: AirDefence.HQ_7_STR_SP,
|
||||
AirDefence.S_200_Launcher: AirDefence.RPC_5N62V,
|
||||
AirDefence.NASAMS_LN_B: AirDefence.NASAMS_Radar_MPQ64F1,
|
||||
AirDefence.NASAMS_LN_C: AirDefence.NASAMS_Radar_MPQ64F1,
|
||||
hds.SAM_SA_2__V759__LN_SM_90: AirDefence.SNR_75V,
|
||||
hds.SAM_HQ_2_LN_SM_90: AirDefence.SNR_75V,
|
||||
hds.SAM_SA_3__V_601P__LN_5P73: AirDefence.Snr_s_125_tr,
|
||||
hds.SAM_SA_10B_S_300PS_5P85SE_LN: hds.SAM_SA_10B_S_300PS_30N6_TR,
|
||||
hds.SAM_SA_10B_S_300PS_5P85SU_LN: hds.SAM_SA_10B_S_300PS_30N6_TR,
|
||||
hds.SAM_SA_12_S_300V_9A82_LN: hds.SAM_SA_12_S_300V_9S32_TR,
|
||||
hds.SAM_SA_12_S_300V_9A83_LN: hds.SAM_SA_12_S_300V_9S32_TR,
|
||||
hds.SAM_SA_20_S_300PMU1_LN_5P85CE: hds.SAM_SA_20_S_300PMU1_TR_30N6E,
|
||||
hds.SAM_SA_20_S_300PMU1_LN_5P85DE: hds.SAM_SA_20_S_300PMU1_TR_30N6E,
|
||||
hds.SAM_SA_20B_S_300PMU2_LN_5P85SE2: hds.SAM_SA_20B_S_300PMU2_TR_92H6E_truck,
|
||||
hds.SAM_SA_23_S_300VM_9A82ME_LN: hds.SAM_SA_23_S_300VM_9S32ME_TR,
|
||||
hds.SAM_SA_23_S_300VM_9A83ME_LN: hds.SAM_SA_23_S_300VM_9S32ME_TR,
|
||||
AirDefence.Kub_2P25_ln: (AirDefence.Kub_1S91_str,),
|
||||
AirDefence.X_5p73_s_125_ln: (AirDefence.Snr_s_125_tr,),
|
||||
AirDefence.S_300PS_5P85C_ln: (
|
||||
AirDefence.S_300PS_40B6M_tr,
|
||||
AirDefence.S_300PS_5H63C_30H6_tr,
|
||||
),
|
||||
AirDefence.S_300PS_5P85D_ln: (
|
||||
AirDefence.S_300PS_40B6M_tr,
|
||||
AirDefence.S_300PS_5H63C_30H6_tr,
|
||||
),
|
||||
AirDefence.Hawk_ln: (AirDefence.Hawk_tr,),
|
||||
AirDefence.Patriot_ln: (AirDefence.Patriot_str,),
|
||||
AirDefence.S_75M_Volhov: (AirDefence.SNR_75V,),
|
||||
AirDefence.Rapier_fsa_launcher: (AirDefence.Rapier_fsa_blindfire_radar,),
|
||||
AirDefence.HQ_7_LN_SP: (AirDefence.HQ_7_STR_SP,),
|
||||
AirDefence.S_200_Launcher: (AirDefence.RPC_5N62V,),
|
||||
AirDefence.NASAMS_LN_B: (AirDefence.NASAMS_Radar_MPQ64F1,),
|
||||
AirDefence.NASAMS_LN_C: (AirDefence.NASAMS_Radar_MPQ64F1,),
|
||||
hds.SAM_SA_2__V759__LN_SM_90: (AirDefence.SNR_75V,),
|
||||
hds.SAM_HQ_2_LN_SM_90: (AirDefence.SNR_75V,),
|
||||
hds.SAM_SA_3__V_601P__LN_5P73: (AirDefence.Snr_s_125_tr,),
|
||||
hds.SAM_SA_10B_S_300PS_5P85SE_LN: (hds.SAM_SA_10B_S_300PS_30N6_TR,),
|
||||
hds.SAM_SA_10B_S_300PS_5P85SU_LN: (hds.SAM_SA_10B_S_300PS_30N6_TR,),
|
||||
hds.SAM_SA_12_S_300V_9A82_LN: (hds.SAM_SA_12_S_300V_9S32_TR,),
|
||||
hds.SAM_SA_12_S_300V_9A83_LN: (hds.SAM_SA_12_S_300V_9S32_TR,),
|
||||
hds.SAM_SA_20_S_300PMU1_LN_5P85CE: (hds.SAM_SA_20_S_300PMU1_TR_30N6E,),
|
||||
hds.SAM_SA_20_S_300PMU1_LN_5P85DE: (hds.SAM_SA_20_S_300PMU1_TR_30N6E,),
|
||||
hds.SAM_SA_20B_S_300PMU2_LN_5P85SE2: (hds.SAM_SA_20B_S_300PMU2_TR_92H6E_truck,),
|
||||
hds.SAM_SA_23_S_300VM_9A82ME_LN: (hds.SAM_SA_23_S_300VM_9S32ME_TR,),
|
||||
hds.SAM_SA_23_S_300VM_9A83ME_LN: (hds.SAM_SA_23_S_300VM_9S32ME_TR,),
|
||||
}
|
||||
|
||||
UNITS_WITH_RADAR = {
|
||||
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from typing import Iterator, Optional, Any, ClassVar
|
||||
|
||||
import yaml
|
||||
from dcs.unitgroup import FlyingGroup
|
||||
from dcs.flyingunit import FlyingUnit
|
||||
from dcs.weapons_data import weapon_ids
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
@@ -278,10 +278,10 @@ class Pylon:
|
||||
# configuration.
|
||||
return weapon in self.allowed or weapon.clsid == "<CLEAN>"
|
||||
|
||||
def equip(self, group: FlyingGroup[Any], weapon: Weapon) -> None:
|
||||
def equip(self, unit: FlyingUnit, weapon: Weapon) -> None:
|
||||
if not self.can_equip(weapon):
|
||||
logging.error(f"Pylon {self.number} cannot equip {weapon.name}")
|
||||
group.load_pylon(self.make_pydcs_assignment(weapon), self.number)
|
||||
unit.load_pylon(self.make_pydcs_assignment(weapon), self.number)
|
||||
|
||||
def make_pydcs_assignment(self, weapon: Weapon) -> PydcsWeaponAssignment:
|
||||
return self.number, weapon.pydcs_data
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
@@ -10,11 +11,15 @@ from typing import Any, ClassVar, Dict, Iterator, Optional, TYPE_CHECKING, Type
|
||||
import yaml
|
||||
from dcs.helicopters import helicopter_map
|
||||
from dcs.planes import plane_map
|
||||
from dcs.unitpropertydescription import UnitPropertyDescription
|
||||
from dcs.unittype import FlyingType
|
||||
from dcs.weapons_data import weapon_ids
|
||||
|
||||
from game.ato import FlightType
|
||||
from game.data.units import UnitClass
|
||||
from game.dcs.unitproperty import UnitProperty
|
||||
from game.dcs.lasercodeconfig import LaserCodeConfig
|
||||
from game.dcs.unittype import UnitType
|
||||
from game.persistency import user_custom_weapon_injections_dir
|
||||
from game.radio.channels import (
|
||||
ApacheChannelNamer,
|
||||
ChannelNamer,
|
||||
@@ -50,7 +55,6 @@ from game.utils import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.ato import FlightType
|
||||
from game.missiongenerator.aircraft.flightdata import FlightData
|
||||
from game.missiongenerator.missiondata import MissionData
|
||||
from game.radio.radios import Radio, RadioFrequency, RadioRegistry
|
||||
@@ -128,6 +132,21 @@ class PatrolConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AltitudesConfig:
|
||||
cruise: Optional[Distance]
|
||||
combat: Optional[Distance]
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: dict[str, Any]) -> AltitudesConfig:
|
||||
cruise = data.get("cruise", None)
|
||||
combat = data.get("combat", None)
|
||||
return AltitudesConfig(
|
||||
feet(cruise) if cruise is not None else None,
|
||||
feet(combat) if combat is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FuelConsumption:
|
||||
#: The estimated taxi fuel requirement, in pounds.
|
||||
@@ -178,6 +197,9 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
patrol_altitude: Optional[Distance]
|
||||
patrol_speed: Optional[Speed]
|
||||
|
||||
cruise_altitude: Optional[Distance]
|
||||
combat_altitude: Optional[Distance]
|
||||
|
||||
#: 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
|
||||
@@ -204,15 +226,25 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
has_built_in_target_pod: bool
|
||||
|
||||
task_priorities: dict[FlightType, int]
|
||||
laser_code_configs: list[LaserCodeConfig]
|
||||
|
||||
use_f15e_waypoint_names: bool
|
||||
|
||||
_by_name: ClassVar[dict[str, AircraftType]] = {}
|
||||
_by_unit_type: ClassVar[dict[type[FlyingType], list[AircraftType]]] = defaultdict(
|
||||
list
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
enrich = {}
|
||||
for t in self.task_priorities:
|
||||
if t == FlightType.SEAD:
|
||||
enrich[FlightType.SEAD_SWEEP] = self.task_priorities[t]
|
||||
self.task_priorities.update(enrich)
|
||||
|
||||
@classmethod
|
||||
def register(cls, unit_type: AircraftType) -> None:
|
||||
cls._by_name[unit_type.name] = unit_type
|
||||
cls._by_name[unit_type.variant_id] = unit_type
|
||||
cls._by_unit_type[unit_type.dcs_unit_type].append(unit_type)
|
||||
|
||||
@property
|
||||
@@ -231,33 +263,13 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
def max_speed(self) -> Speed:
|
||||
return kph(self.dcs_unit_type.max_speed)
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def preferred_patrol_altitude(self) -> Distance:
|
||||
if self.patrol_altitude is not None:
|
||||
if self.patrol_altitude:
|
||||
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),
|
||||
)
|
||||
# TODO: somehow make the upper and lower limit configurable
|
||||
return self.preferred_altitude(10, 33, "patrol")
|
||||
|
||||
def preferred_patrol_speed(self, altitude: Distance) -> Speed:
|
||||
"""Preferred true airspeed when patrolling"""
|
||||
@@ -289,9 +301,51 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
)
|
||||
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)
|
||||
# Use whichever is slowest - mach 0.35 or 50% of max speed
|
||||
logging.debug(
|
||||
f"{self.display_name} max_speed * 0.5 is {max_speed * 0.5}"
|
||||
)
|
||||
return min(Speed.from_mach(0.35, altitude), max_speed * 0.5)
|
||||
|
||||
@cached_property
|
||||
def preferred_cruise_altitude(self) -> Distance:
|
||||
if self.cruise_altitude:
|
||||
return self.cruise_altitude
|
||||
else:
|
||||
# TODO: somehow make the upper and lower limit configurable
|
||||
return self.preferred_altitude(20, 20, "cruise")
|
||||
|
||||
@cached_property
|
||||
def preferred_combat_altitude(self) -> Distance:
|
||||
if self.combat_altitude:
|
||||
return self.combat_altitude
|
||||
else:
|
||||
# TODO: somehow make the upper and lower limit configurable
|
||||
return self.preferred_altitude(20, 20, "combat")
|
||||
|
||||
def preferred_altitude(self, low: int, high: int, type: str) -> Distance:
|
||||
# Estimate based on max speed.
|
||||
# Aircraft with max speed 600 kph will prefer low
|
||||
# Aircraft with max speed 2800 kph will prefer high
|
||||
altitude_for_lowest_speed = feet(low * 1000)
|
||||
altitude_for_highest_speed = feet(high * 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 {type} 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 alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
|
||||
from game.radio.radios import ChannelInUseError, kHz
|
||||
@@ -326,8 +380,18 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
def channel_name(self, radio_id: int, channel_id: int) -> str:
|
||||
return self.channel_namer.channel_name(radio_id, channel_id)
|
||||
|
||||
def iter_props(self) -> Iterator[UnitProperty[Any]]:
|
||||
return UnitProperty.for_aircraft(self.dcs_unit_type)
|
||||
@cached_property
|
||||
def laser_code_prop_ids(self) -> set[str]:
|
||||
laser_code_props: set[str] = set()
|
||||
for laser_code_config in self.laser_code_configs:
|
||||
laser_code_props.update(laser_code_config.iter_prop_ids())
|
||||
return laser_code_props
|
||||
|
||||
def iter_props(self) -> Iterator[UnitPropertyDescription]:
|
||||
yield from self.dcs_unit_type.properties.values()
|
||||
|
||||
def should_show_prop(self, prop_id: str) -> bool:
|
||||
return prop_id not in self.laser_code_prop_ids
|
||||
|
||||
def capable_of(self, task: FlightType) -> bool:
|
||||
return task in self.task_priorities
|
||||
@@ -336,16 +400,24 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
return self.task_priorities[task]
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# Save compat: the `name` field has been renamed `variant_id`.
|
||||
if "name" in state:
|
||||
state["variant_id"] = state.pop("name")
|
||||
|
||||
# Update any existing models with new data on load.
|
||||
updated = AircraftType.named(state["name"])
|
||||
updated = AircraftType.named(state["variant_id"])
|
||||
state.update(updated.__dict__)
|
||||
self.__dict__.update(state)
|
||||
|
||||
@staticmethod
|
||||
def _migrator() -> Dict[str, str]:
|
||||
return {"F-15E Strike Eagle (AI)": "F-15E Strike Eagle"}
|
||||
|
||||
@classmethod
|
||||
def named(cls, name: str) -> AircraftType:
|
||||
if not cls._loaded:
|
||||
cls._load_all()
|
||||
return cls._by_name[name]
|
||||
return cls._by_name[cls._migrator().get(name, name)]
|
||||
|
||||
@classmethod
|
||||
def for_dcs_type(cls, dcs_unit_type: Type[FlyingType]) -> Iterator[AircraftType]:
|
||||
@@ -378,11 +450,11 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
|
||||
@staticmethod
|
||||
def _set_props_overrides(
|
||||
config: Dict[str, Any], aircraft: Type[FlyingType], data_path: Path
|
||||
config: Dict[str, Any], aircraft: Type[FlyingType]
|
||||
) -> None:
|
||||
if aircraft.property_defaults is None:
|
||||
logging.warning(
|
||||
f"'{data_path.name}' attempted to set default prop that does not exist."
|
||||
f"'{aircraft.id}' attempted to set default prop that does not exist."
|
||||
)
|
||||
else:
|
||||
for k in config:
|
||||
@@ -390,28 +462,25 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
aircraft.property_defaults[k] = config[k]
|
||||
else:
|
||||
logging.warning(
|
||||
f"'{data_path.name}' attempted to set default prop '{k}' that does not exist"
|
||||
f"'{aircraft.id}' attempted to set default prop '{k}' that does not exist"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _each_variant_of(cls, aircraft: Type[FlyingType]) -> Iterator[AircraftType]:
|
||||
# Replace slashes with underscores because slashes are not allowed in filenames
|
||||
aircraft_id = aircraft.id.replace("/", "_")
|
||||
data_path = Path("resources/units/aircraft") / f"{aircraft_id}.yaml"
|
||||
if not data_path.exists():
|
||||
logging.warning(f"No data for {aircraft_id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
def _data_directory(cls) -> Path:
|
||||
return Path("resources/units/aircraft")
|
||||
|
||||
@classmethod
|
||||
def _variant_from_dict(
|
||||
cls, aircraft: Type[FlyingType], variant_id: str, data: dict[str, Any]
|
||||
) -> AircraftType:
|
||||
try:
|
||||
price = data["price"]
|
||||
except KeyError as ex:
|
||||
raise KeyError(f"Missing required price field: {data_path}") from ex
|
||||
raise KeyError(f"Missing required price field") from ex
|
||||
|
||||
radio_config = RadioConfig.from_data(data.get("radios", {}))
|
||||
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
|
||||
altitudes_config = AltitudesConfig.from_data(data.get("altitudes", {}))
|
||||
|
||||
try:
|
||||
mission_range = nautical_miles(int(data["max_range"]))
|
||||
@@ -420,7 +489,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
nautical_miles(50) if aircraft.helicopter else nautical_miles(150)
|
||||
)
|
||||
logging.warning(
|
||||
f"{aircraft_id} does not specify a max_range. Defaulting to "
|
||||
f"{variant_id} does not specify a max_range. Defaulting to "
|
||||
f"{mission_range.nautical_miles}NM"
|
||||
)
|
||||
|
||||
@@ -451,7 +520,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
|
||||
prop_overrides = data.get("default_overrides")
|
||||
if prop_overrides is not None:
|
||||
cls._set_props_overrides(prop_overrides, aircraft, data_path)
|
||||
cls._set_props_overrides(prop_overrides, aircraft)
|
||||
|
||||
from game.ato.flighttype import FlightType
|
||||
|
||||
@@ -459,43 +528,77 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
for task_name, priority in data.get("tasks", {}).items():
|
||||
task_priorities[FlightType(task_name)] = priority
|
||||
|
||||
if FlightType.SEAD in task_priorities:
|
||||
task_priorities[FlightType.SEAD_SWEEP] = task_priorities[FlightType.SEAD]
|
||||
display_name = data.get("display_name", variant_id)
|
||||
return AircraftType(
|
||||
dcs_unit_type=aircraft,
|
||||
variant_id=variant_id,
|
||||
display_name=display_name,
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{display_name.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {display_name}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=price,
|
||||
carrier_capable=data.get("carrier_capable", False),
|
||||
lha_capable=data.get("lha_capable", False),
|
||||
always_keeps_gun=data.get("always_keeps_gun", False),
|
||||
gunfighter=data.get("gunfighter", False),
|
||||
max_group_size=data.get("max_group_size", aircraft.group_size_max),
|
||||
patrol_altitude=patrol_config.altitude,
|
||||
patrol_speed=patrol_config.speed,
|
||||
cruise_altitude=altitudes_config.cruise,
|
||||
combat_altitude=altitudes_config.combat,
|
||||
max_mission_range=mission_range,
|
||||
fuel_consumption=fuel_consumption,
|
||||
default_livery=data.get("default_livery"),
|
||||
intra_flight_radio=radio_config.intra_flight,
|
||||
channel_allocator=radio_config.channel_allocator,
|
||||
channel_namer=radio_config.channel_namer,
|
||||
kneeboard_units=units,
|
||||
utc_kneeboard=data.get("utc_kneeboard", False),
|
||||
unit_class=unit_class,
|
||||
cabin_size=data.get("cabin_size", 10 if aircraft.helicopter else 0),
|
||||
can_carry_crates=data.get("can_carry_crates", aircraft.helicopter),
|
||||
task_priorities=task_priorities,
|
||||
has_built_in_target_pod=data.get("has_built_in_target_pod", False),
|
||||
laser_code_configs=[
|
||||
LaserCodeConfig.from_yaml(d) for d in data.get("laser_codes", [])
|
||||
],
|
||||
use_f15e_waypoint_names=data.get("use_f15e_waypoint_names", False),
|
||||
)
|
||||
|
||||
for variant in data.get("variants", [aircraft.id]):
|
||||
yield AircraftType(
|
||||
dcs_unit_type=aircraft,
|
||||
name=variant,
|
||||
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."),
|
||||
role=data.get("role", "No data."),
|
||||
price=price,
|
||||
carrier_capable=data.get("carrier_capable", False),
|
||||
lha_capable=data.get("lha_capable", False),
|
||||
always_keeps_gun=data.get("always_keeps_gun", False),
|
||||
gunfighter=data.get("gunfighter", False),
|
||||
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,
|
||||
default_livery=data.get("default_livery"),
|
||||
intra_flight_radio=radio_config.intra_flight,
|
||||
channel_allocator=radio_config.channel_allocator,
|
||||
channel_namer=radio_config.channel_namer,
|
||||
kneeboard_units=units,
|
||||
utc_kneeboard=data.get("utc_kneeboard", False),
|
||||
unit_class=unit_class,
|
||||
cabin_size=data.get("cabin_size", 10 if aircraft.helicopter else 0),
|
||||
can_carry_crates=data.get("can_carry_crates", aircraft.helicopter),
|
||||
has_built_in_target_pod=data.get("has_built_in_target_pod", False),
|
||||
task_priorities=task_priorities,
|
||||
)
|
||||
@staticmethod
|
||||
def _custom_weapon_injections(
|
||||
aircraft: Type[FlyingType], data: Dict[str, Any]
|
||||
) -> None:
|
||||
if (wpn_injection := data.get("weapon_injections")) is not None:
|
||||
pylons = [
|
||||
v
|
||||
for v in aircraft.__dict__.values()
|
||||
if inspect.isclass(v) and v.__name__.startswith(f"Pylon")
|
||||
]
|
||||
pylons.sort(key=lambda x: int(x.__name__.replace("Pylon", "")))
|
||||
for pylon_number, weapons in wpn_injection.items():
|
||||
for w in weapons:
|
||||
weapon = weapon_ids[w]
|
||||
pylon = [
|
||||
pylon
|
||||
for pylon in pylons
|
||||
if int(pylon.__name__.replace("Pylon", "")) == pylon_number
|
||||
][0]
|
||||
setattr(pylon, w, (pylon_number, weapon))
|
||||
|
||||
@staticmethod
|
||||
def _user_weapon_injections(aircraft: Type[FlyingType]) -> None:
|
||||
data_path = user_custom_weapon_injections_dir() / f"{aircraft.id}.yaml"
|
||||
if not data_path.exists():
|
||||
return
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
AircraftType._custom_weapon_injections(aircraft, data)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.name)
|
||||
return hash(self.variant_id)
|
||||
|
||||
@@ -6,7 +6,6 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, Iterator, Optional, Type
|
||||
|
||||
import yaml
|
||||
from dcs.unittype import VehicleType
|
||||
from dcs.vehicles import vehicle_map
|
||||
|
||||
@@ -65,9 +64,29 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
|
||||
dict[type[VehicleType], list[GroundUnitType]]
|
||||
] = defaultdict(list)
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# Save compat: the `name` field has been renamed `variant_id`.
|
||||
if "name" in state:
|
||||
state["variant_id"] = state.pop("name")
|
||||
|
||||
# iron-dome migration to IDF assets
|
||||
if state["variant_id"] in [
|
||||
"(IDF Mods Project) BM-21 Grad 122mm",
|
||||
"(IDF Mods Project) Urgan BM-27 220mm",
|
||||
"(IDF Mods Project) 9A52 Smerch CM 300mm",
|
||||
]:
|
||||
state["variant_id"] = "M109A6 Paladin"
|
||||
elif state["variant_id"] == "Iron Dome ELM-2048 MMR":
|
||||
state["variant_id"] = "ELM-2084MMR AD Rotating Mode"
|
||||
|
||||
# Update any existing models with new data on load.
|
||||
updated = GroundUnitType.named(state["variant_id"])
|
||||
state.update(updated.__dict__)
|
||||
self.__dict__.update(state)
|
||||
|
||||
@classmethod
|
||||
def register(cls, unit_type: GroundUnitType) -> None:
|
||||
cls._by_name[unit_type.name] = unit_type
|
||||
cls._by_name[unit_type.variant_id] = unit_type
|
||||
cls._by_unit_type[unit_type.dcs_unit_type].append(unit_type)
|
||||
|
||||
@classmethod
|
||||
@@ -87,15 +106,13 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
|
||||
yield from vehicle_map.values()
|
||||
|
||||
@classmethod
|
||||
def _each_variant_of(cls, vehicle: Type[VehicleType]) -> Iterator[GroundUnitType]:
|
||||
data_path = Path("resources/units/ground_units") / f"{vehicle.id}.yaml"
|
||||
if not data_path.exists():
|
||||
logging.warning(f"No data for {vehicle.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
def _data_directory(cls) -> Path:
|
||||
return Path("resources/units/ground_units")
|
||||
|
||||
@classmethod
|
||||
def _variant_from_dict(
|
||||
cls, vehicle: Type[VehicleType], variant_id: str, data: dict[str, Any]
|
||||
) -> GroundUnitType:
|
||||
try:
|
||||
introduction = data["introduced"]
|
||||
if introduction is None:
|
||||
@@ -110,23 +127,24 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
|
||||
else:
|
||||
unit_class = UnitClass(class_name)
|
||||
|
||||
for variant in data.get("variants", [vehicle.id]):
|
||||
yield GroundUnitType(
|
||||
dcs_unit_type=vehicle,
|
||||
unit_class=unit_class,
|
||||
spawn_weight=data.get("spawn_weight", 0),
|
||||
name=variant,
|
||||
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."),
|
||||
role=data.get("role", "No data."),
|
||||
price=data.get("price", 1),
|
||||
skynet_properties=SkynetProperties.from_data(
|
||||
data.get("skynet_properties", {})
|
||||
),
|
||||
reversed_heading=data.get("reversed_heading", False),
|
||||
)
|
||||
display_name = data.get("display_name", variant_id)
|
||||
return GroundUnitType(
|
||||
dcs_unit_type=vehicle,
|
||||
unit_class=unit_class,
|
||||
spawn_weight=data.get("spawn_weight", 0),
|
||||
variant_id=variant_id,
|
||||
display_name=display_name,
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{display_name.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {display_name}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=data.get("price", 1),
|
||||
skynet_properties=SkynetProperties.from_data(
|
||||
data.get("skynet_properties", {})
|
||||
),
|
||||
reversed_heading=data.get("reversed_heading", False),
|
||||
)
|
||||
|
||||
51
game/dcs/lasercodeconfig.py
Normal file
51
game/dcs/lasercodeconfig.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LaserCodeConfig(ABC):
|
||||
@staticmethod
|
||||
def from_yaml(data: dict[str, Any]) -> LaserCodeConfig:
|
||||
if (property_def := data.get("property")) is not None:
|
||||
return SinglePropertyLaserCodeConfig(
|
||||
property_def["id"], int(property_def["digits"])
|
||||
)
|
||||
return MultiplePropertyLaserCodeConfig(
|
||||
[(d["id"], d["digit"]) for d in data["properties"]]
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def iter_prop_ids(self) -> Iterator[str]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def property_dict_for_code(self, code: int) -> dict[str, int]:
|
||||
...
|
||||
|
||||
|
||||
class SinglePropertyLaserCodeConfig(LaserCodeConfig):
|
||||
def __init__(self, property_id: str, digits: int) -> None:
|
||||
self.property_id = property_id
|
||||
self.digits = digits
|
||||
|
||||
def iter_prop_ids(self) -> Iterator[str]:
|
||||
yield self.property_id
|
||||
|
||||
def property_dict_for_code(self, code: int) -> dict[str, int]:
|
||||
return {self.property_id: code % 10**self.digits}
|
||||
|
||||
|
||||
class MultiplePropertyLaserCodeConfig(LaserCodeConfig):
|
||||
def __init__(self, property_digit_mappings: list[tuple[str, int]]) -> None:
|
||||
self.property_digit_mappings = property_digit_mappings
|
||||
|
||||
def iter_prop_ids(self) -> Iterator[str]:
|
||||
yield from (i for i, p in self.property_digit_mappings)
|
||||
|
||||
def property_dict_for_code(self, code: int) -> dict[str, int]:
|
||||
d = {}
|
||||
for prop_id, idx in self.property_digit_mappings:
|
||||
d[prop_id] = code // 10**idx % 10
|
||||
return d
|
||||
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Iterator, Type
|
||||
from typing import ClassVar, Iterator, Type, Any
|
||||
|
||||
import yaml
|
||||
from dcs.ships import ship_map
|
||||
from dcs.unittype import ShipType
|
||||
|
||||
@@ -21,9 +19,19 @@ class ShipUnitType(UnitType[Type[ShipType]]):
|
||||
list
|
||||
)
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# Save compat: the `name` field has been renamed `variant_id`.
|
||||
if "name" in state:
|
||||
state["variant_id"] = state.pop("name")
|
||||
|
||||
# Update any existing models with new data on load.
|
||||
updated = ShipUnitType.named(state["variant_id"])
|
||||
state.update(updated.__dict__)
|
||||
self.__dict__.update(state)
|
||||
|
||||
@classmethod
|
||||
def register(cls, unit_type: ShipUnitType) -> None:
|
||||
cls._by_name[unit_type.name] = unit_type
|
||||
cls._by_name[unit_type.variant_id] = unit_type
|
||||
cls._by_unit_type[unit_type.dcs_unit_type].append(unit_type)
|
||||
|
||||
@classmethod
|
||||
@@ -43,15 +51,13 @@ class ShipUnitType(UnitType[Type[ShipType]]):
|
||||
yield from ship_map.values()
|
||||
|
||||
@classmethod
|
||||
def _each_variant_of(cls, ship: Type[ShipType]) -> Iterator[ShipUnitType]:
|
||||
data_path = Path("resources/units/ships") / f"{ship.id}.yaml"
|
||||
if not data_path.exists():
|
||||
logging.warning(f"No data for {ship.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
def _data_directory(cls) -> Path:
|
||||
return Path("resources/units/ships")
|
||||
|
||||
@classmethod
|
||||
def _variant_from_dict(
|
||||
cls, ship: Type[ShipType], variant_id: str, data: dict[str, Any]
|
||||
) -> ShipUnitType:
|
||||
try:
|
||||
introduction = data["introduced"]
|
||||
if introduction is None:
|
||||
@@ -62,18 +68,19 @@ class ShipUnitType(UnitType[Type[ShipType]]):
|
||||
class_name = data.get("class")
|
||||
unit_class = UnitClass(class_name)
|
||||
|
||||
for variant in data.get("variants", [ship.id]):
|
||||
yield ShipUnitType(
|
||||
dcs_unit_type=ship,
|
||||
unit_class=unit_class,
|
||||
name=variant,
|
||||
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."),
|
||||
role=data.get("role", "No data."),
|
||||
price=data.get("price"),
|
||||
)
|
||||
display_name = data.get("display_name", variant_id)
|
||||
return ShipUnitType(
|
||||
dcs_unit_type=ship,
|
||||
unit_class=unit_class,
|
||||
variant_id=variant_id,
|
||||
display_name=data.get("display_name", variant_id),
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{display_name.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {display_name}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=data["price"],
|
||||
)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import ClassVar, Generic, Iterator, Type, TypeVar
|
||||
from typing_extensions import Self
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Generic, Iterator, Type, TypeVar, Any
|
||||
|
||||
import yaml
|
||||
from dcs.unittype import UnitType as DcsUnitType
|
||||
from typing_extensions import Self
|
||||
|
||||
from game.data.units import UnitClass
|
||||
|
||||
@@ -16,7 +19,8 @@ DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType])
|
||||
@dataclass(frozen=True)
|
||||
class UnitType(ABC, Generic[DcsUnitTypeT]):
|
||||
dcs_unit_type: DcsUnitTypeT
|
||||
name: str
|
||||
variant_id: str
|
||||
display_name: str
|
||||
description: str
|
||||
year_introduced: str
|
||||
country_of_origin: str
|
||||
@@ -28,7 +32,7 @@ class UnitType(ABC, Generic[DcsUnitTypeT]):
|
||||
_loaded: ClassVar[bool] = False
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
return self.display_name
|
||||
|
||||
@property
|
||||
def dcs_id(self) -> str:
|
||||
@@ -50,8 +54,29 @@ class UnitType(ABC, Generic[DcsUnitTypeT]):
|
||||
def each_dcs_type() -> Iterator[DcsUnitTypeT]:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _data_directory(cls) -> Path:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _each_variant_of(cls, unit: DcsUnitTypeT) -> Iterator[Self]:
|
||||
data_path = cls._data_directory() / f"{unit.id}.yaml"
|
||||
if not data_path.exists():
|
||||
logging.warning(f"No data for {unit.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
for variant_id, variant_data in data.get("variants", {unit.id: {}}).items():
|
||||
if variant_data is None:
|
||||
variant_data = {}
|
||||
yield cls._variant_from_dict(unit, variant_id, data | variant_data)
|
||||
|
||||
@classmethod
|
||||
def _variant_from_dict(
|
||||
cls, dcs_unit_type: DcsUnitTypeT, variant_id: str, data: dict[str, Any]
|
||||
) -> Self:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -45,6 +45,9 @@ class Faction:
|
||||
#: choose the default locale.
|
||||
locales: Optional[List[str]]
|
||||
|
||||
# The unit type to spawn for cargo shipping.
|
||||
cargo_ship: ShipUnitType
|
||||
|
||||
# Country used by this faction
|
||||
country: Country
|
||||
|
||||
@@ -166,7 +169,7 @@ class Faction:
|
||||
def air_defenses(self) -> list[str]:
|
||||
"""Returns the Air Defense types"""
|
||||
# This is used for the faction overview in NewGameWizard
|
||||
air_defenses = [a.name for a in self.air_defense_units]
|
||||
air_defenses = [a.variant_id for a in self.air_defense_units]
|
||||
air_defenses.extend(
|
||||
[
|
||||
pg.name
|
||||
@@ -177,7 +180,7 @@ class Faction:
|
||||
return sorted(air_defenses)
|
||||
|
||||
@cached_property
|
||||
def aircrafts(self) -> list[UnitType[Any]]:
|
||||
def all_aircrafts(self) -> list[UnitType[Any]]:
|
||||
# Migrator can't cope with this, so we need to do it here...
|
||||
self.aircraft = set(self.aircraft)
|
||||
self.awacs = set(self.awacs)
|
||||
@@ -194,7 +197,11 @@ class Faction:
|
||||
"country ID"
|
||||
) from ex
|
||||
|
||||
faction = Faction(locales=json.get("locales"), country=country)
|
||||
faction = Faction(
|
||||
locales=json.get("locales"),
|
||||
country=country,
|
||||
cargo_ship=ShipUnitType.named(json.get("cargo_ship", "Bulker Handy Wind")),
|
||||
)
|
||||
|
||||
faction.name = json.get("name", "")
|
||||
if not faction.name:
|
||||
@@ -328,9 +335,13 @@ class Faction:
|
||||
if not mod_settings.uh_60l:
|
||||
self.remove_aircraft("UH-60L")
|
||||
self.remove_aircraft("KC130J")
|
||||
if not mod_settings.fa18ef_tanker:
|
||||
self.remove_aircraft("Superbug_AITanker")
|
||||
if not mod_settings.f4bc_phantom:
|
||||
self.remove_aircraft("VSN_F4B")
|
||||
self.remove_aircraft("VSN_F4C")
|
||||
if not mod_settings.f9f_panther:
|
||||
self.remove_aircraft("VSN_F9F")
|
||||
if not mod_settings.f15d_baz:
|
||||
self.remove_aircraft("F-15D")
|
||||
if not mod_settings.f_15_idf:
|
||||
@@ -372,6 +383,8 @@ class Faction:
|
||||
self.remove_aircraft("JAS39Gripen")
|
||||
self.remove_aircraft("JAS39Gripen_BVR")
|
||||
self.remove_aircraft("JAS39Gripen_AG")
|
||||
if not mod_settings.super_etendard:
|
||||
self.remove_aircraft("VSN_SEM")
|
||||
if not mod_settings.su30_flanker_h:
|
||||
self.remove_aircraft("Su-30MKA")
|
||||
self.remove_aircraft("Su-30MKI")
|
||||
@@ -449,13 +462,16 @@ class Faction:
|
||||
self.remove_ship("L02")
|
||||
self.remove_ship("DDG39")
|
||||
if not mod_settings.irondome:
|
||||
self.remove_vehicle("I9K51_GRAD")
|
||||
self.remove_vehicle("I9K57_URAGAN")
|
||||
self.remove_vehicle("I9K58_SMERCH")
|
||||
self.remove_vehicle("IRON_DOME_CP")
|
||||
self.remove_vehicle("Iron_Dome_David_Sling_CP")
|
||||
self.remove_vehicle("IRON_DOME_LN")
|
||||
self.remove_vehicle("ELM2048_MMR")
|
||||
self.remove_vehicle("DAVID_SLING_LN")
|
||||
self.remove_vehicle("ELM2084_MMR_AD_RT")
|
||||
self.remove_vehicle("ELM2084_MMR_AD_SC")
|
||||
self.remove_vehicle("ELM2084_MMR_WLR")
|
||||
self.remove_preset("Iron Dome")
|
||||
self.remove_preset("Iron Dome (Semicircle)")
|
||||
self.remove_preset("David's Sling")
|
||||
self.remove_preset("David's Sling (Semicircle)")
|
||||
# swedish military assets pack
|
||||
if not mod_settings.swedishmilitaryassetspack:
|
||||
self.remove_vehicle("BV410_RBS70")
|
||||
@@ -495,6 +511,10 @@ class Faction:
|
||||
self.remove_ship("HSwMS_Visby")
|
||||
self.remove_ship("Strb90")
|
||||
self.remove_aircraft("HKP15B")
|
||||
self.remove_preset("LvS-103 Rb103A")
|
||||
self.remove_preset("LvS-103 Rb103A Mobile")
|
||||
self.remove_preset("LvS-103 Rb103B")
|
||||
self.remove_preset("LvS-103 Rb103B Mobile")
|
||||
# SWPack
|
||||
if not mod_settings.SWPack:
|
||||
self.remove_aircraft("AWINGA")
|
||||
@@ -528,14 +548,16 @@ class Faction:
|
||||
self.remove_ship("Destroyer_carrier")
|
||||
|
||||
def remove_aircraft(self, name: str) -> None:
|
||||
for i in list(self.aircrafts):
|
||||
if i.dcs_unit_type.id == name:
|
||||
self.aircrafts.remove(i)
|
||||
for aircraft_set in [self.aircraft, self.awacs, self.tankers]:
|
||||
for i in list(aircraft_set):
|
||||
if i.dcs_unit_type.id == name:
|
||||
aircraft_set.remove(i)
|
||||
|
||||
def remove_aircraft_by_name(self, name: str) -> None:
|
||||
for i in list(self.aircrafts):
|
||||
if i.name == name:
|
||||
self.aircrafts.remove(i)
|
||||
for aircraft_set in [self.aircraft, self.awacs, self.tankers]:
|
||||
for i in list(aircraft_set):
|
||||
if i.display_name == name:
|
||||
aircraft_set.remove(i)
|
||||
|
||||
def remove_preset(self, name: str) -> None:
|
||||
for pg in self.preset_groups:
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
from .holdzonegeometry import HoldZoneGeometry
|
||||
from .ipzonegeometry import IpZoneGeometry
|
||||
from .joinzonegeometry import JoinZoneGeometry
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user