mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
Compare commits
574 Commits
develop-4.
...
develop-5.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb10f60fb | ||
|
|
a7a28205a9 | ||
|
|
a22452baca | ||
|
|
8422823921 | ||
|
|
30fe1ca6af | ||
|
|
faf70debce | ||
|
|
42929b08d8 | ||
|
|
91bb5acd04 | ||
|
|
d69841f261 | ||
|
|
dba8c26887 | ||
|
|
bef27ffb20 | ||
|
|
d9eac37766 | ||
|
|
39a5d4e7c4 | ||
|
|
ce4d388e27 | ||
|
|
3a318fbbdf | ||
|
|
526d082020 | ||
|
|
3d68eabcaa | ||
|
|
409a099339 | ||
|
|
0ba86af4fd | ||
|
|
99d4cb7ad5 | ||
|
|
815849ae64 | ||
|
|
31efac53f4 | ||
|
|
3ede2b5755 | ||
|
|
de43c0d621 | ||
|
|
7a6ec30368 | ||
|
|
08c3da9502 | ||
|
|
6d389f7612 | ||
|
|
bc3b6c2a26 | ||
|
|
041cd0057a | ||
|
|
57ee611d06 | ||
|
|
f63fae2d4f | ||
|
|
cd120047cb | ||
|
|
7bb41d07b3 | ||
|
|
8aca036bf0 | ||
|
|
878695b2c7 | ||
|
|
f57bd2f253 | ||
|
|
cf7330e938 | ||
|
|
e56abbe768 | ||
|
|
b7a92a2ebf | ||
|
|
a17b8dac7f | ||
|
|
dbe9691d9e | ||
|
|
d2eb98bcc5 | ||
|
|
475cb4851a | ||
|
|
24e904134e | ||
|
|
365eaa98f7 | ||
|
|
e070d5bf0d | ||
|
|
ffd8152b36 | ||
|
|
11812f07fc | ||
|
|
ba4de3dc27 | ||
|
|
208382f3c4 | ||
|
|
aadb2dd54d | ||
|
|
1b2eb1236f | ||
|
|
00bdbf65ce | ||
|
|
290646c8ae | ||
|
|
8f58832527 | ||
|
|
f426a16e9d | ||
|
|
6f4e436305 | ||
|
|
08288c9da9 | ||
|
|
9bfac347db | ||
|
|
22a35b0d2e | ||
|
|
e56d8de800 | ||
|
|
c806980ab8 | ||
|
|
4750fff114 | ||
|
|
b405b8e7ee | ||
|
|
074ae328ee | ||
|
|
5fd6ccb81d | ||
|
|
64d273b433 | ||
|
|
d7f833ecfb | ||
|
|
5d89b9ac25 | ||
|
|
056851765f | ||
|
|
54bf9357bf | ||
|
|
dfcc458479 | ||
|
|
f3f0e23eff | ||
|
|
5ce6de6645 | ||
|
|
50b4d420fe | ||
|
|
224c78ac11 | ||
|
|
227b054279 | ||
|
|
0093fa0fe1 | ||
|
|
3cdfd9d7d2 | ||
|
|
5e2e07da80 | ||
|
|
5f3f6f8c9f | ||
|
|
0077b04698 | ||
|
|
bb1e314260 | ||
|
|
d710c2631a | ||
|
|
7278878266 | ||
|
|
ec8391bbfb | ||
|
|
74504173c7 | ||
|
|
792c7c5091 | ||
|
|
b7e9a4a243 | ||
|
|
bdbb338e83 | ||
|
|
98fa70c73d | ||
|
|
7991e0157d | ||
|
|
21643c500f | ||
|
|
b4ddfb9dfd | ||
|
|
d1e50a5bbe | ||
|
|
a188f7b7e5 | ||
|
|
4f76b73de2 | ||
|
|
a4b09bc973 | ||
|
|
a792c73cae | ||
|
|
113380661c | ||
|
|
3ab9b25b08 | ||
|
|
29d4ca38f9 | ||
|
|
6a3ff8d6ac | ||
|
|
c1f194b3d5 | ||
|
|
a6809e0103 | ||
|
|
a559aa8646 | ||
|
|
c766322960 | ||
|
|
c7581568c2 | ||
|
|
f0827a429e | ||
|
|
c2ee44d8bb | ||
|
|
af362be3a2 | ||
|
|
80c8563d67 | ||
|
|
f44654f66e | ||
|
|
ca7f61c938 | ||
|
|
10ccada17a | ||
|
|
46bf952562 | ||
|
|
822d737f65 | ||
|
|
d656ec3220 | ||
|
|
a2140b915f | ||
|
|
6dae5b98d5 | ||
|
|
626740366b | ||
|
|
a618f00662 | ||
|
|
7bec4c62f7 | ||
|
|
39fae9effc | ||
|
|
3c145cf2ff | ||
|
|
ad6f3ef8cc | ||
|
|
79839f83a0 | ||
|
|
17011820de | ||
|
|
551ea728fc | ||
|
|
d4f77f6588 | ||
|
|
8fe7551176 | ||
|
|
e47276ce2e | ||
|
|
fd1463eb4c | ||
|
|
d5eaa4d091 | ||
|
|
b174e668f4 | ||
|
|
fd49d213c2 | ||
|
|
bbeb80fc48 | ||
|
|
702e29b54b | ||
|
|
0fd911feb1 | ||
|
|
39234adff7 | ||
|
|
e45505b406 | ||
|
|
b1eb876572 | ||
|
|
7e66aa16f7 | ||
|
|
35b30a01ed | ||
|
|
76b3ff5f6e | ||
|
|
5e5f249bd2 | ||
|
|
ce57acb9d6 | ||
|
|
2a79e4a4e5 | ||
|
|
7f948465a4 | ||
|
|
a24ab63fc7 | ||
|
|
643718be23 | ||
|
|
d4d6ee3d26 | ||
|
|
4399e10cef | ||
|
|
519f0542dd | ||
|
|
9456fd77d1 | ||
|
|
37874d82f2 | ||
|
|
9f2a9bf458 | ||
|
|
43a8897c28 | ||
|
|
6980f96697 | ||
|
|
4ffb294d65 | ||
|
|
ae46631b2b | ||
|
|
c42bdd256f | ||
|
|
8990e0c1ff | ||
|
|
f26452c07d | ||
|
|
89789f16d2 | ||
|
|
3bb4c9c29a | ||
|
|
3b8e392395 | ||
|
|
3e6d63e8f7 | ||
|
|
90ca619839 | ||
|
|
8c2aa78b9f | ||
|
|
e544b2d1ba | ||
|
|
82f5287282 | ||
|
|
589a353f02 | ||
|
|
e84e36fd22 | ||
|
|
a4b03c5cfe | ||
|
|
6aee4c2ec4 | ||
|
|
65abef7979 | ||
|
|
45b52f4dea | ||
|
|
18336f58d3 | ||
|
|
12ad4fbf63 | ||
|
|
b1fee9fe56 | ||
|
|
2a6f250706 | ||
|
|
ab2bb6814e | ||
|
|
94fb0d8c66 | ||
|
|
a192e4c872 | ||
|
|
99acd52e89 | ||
|
|
a1ee9d7476 | ||
|
|
757363e372 | ||
|
|
7d0b3a096d | ||
|
|
24a0211d8c | ||
|
|
2c8f960696 | ||
|
|
16d397db1c | ||
|
|
9c3171f1ce | ||
|
|
8a60fa5c83 | ||
|
|
15ce48e712 | ||
|
|
c252fd6a77 | ||
|
|
90a8bb63dc | ||
|
|
1a4be911c0 | ||
|
|
f9f0b429b6 | ||
|
|
a404792bd2 | ||
|
|
e0047b1bbc | ||
|
|
18eb661e84 | ||
|
|
c2e5cba061 | ||
|
|
cd15de6d42 | ||
|
|
60a5ee42fe | ||
|
|
46e2d8c1f9 | ||
|
|
824745c11d | ||
|
|
33709b0558 | ||
|
|
aac91c15d9 | ||
|
|
67405e4af5 | ||
|
|
41aa743947 | ||
|
|
7aca108ef5 | ||
|
|
380d6bf47a | ||
|
|
8fea8e7b47 | ||
|
|
469b1e5efe | ||
|
|
5fae178081 | ||
|
|
4715773bba | ||
|
|
74577752e0 | ||
|
|
056e6b28da | ||
|
|
0cb10e4224 | ||
|
|
34ff5fbc6a | ||
|
|
f63a35b1fa | ||
|
|
57e78d5c55 | ||
|
|
2ee604d2a4 | ||
|
|
a7c3a0f7fd | ||
|
|
5445c41f81 | ||
|
|
e1e1e471a1 | ||
|
|
2e3b43b28b | ||
|
|
fe118d81db | ||
|
|
d3b2a751e2 | ||
|
|
b856a84adc | ||
|
|
707d13a65c | ||
|
|
7417429fdb | ||
|
|
8f5b6f58d1 | ||
|
|
08365bcbda | ||
|
|
4423288a53 | ||
|
|
99274133ff | ||
|
|
55c6728c42 | ||
|
|
357487b767 | ||
|
|
9768fb3493 | ||
|
|
90ad1f4a61 | ||
|
|
51e056a765 | ||
|
|
8e1b33bc51 | ||
|
|
d2e22ef8bf | ||
|
|
103675e5bb | ||
|
|
b5b0d82a1a | ||
|
|
c80d0e5378 | ||
|
|
adeebbc422 | ||
|
|
ee8e8d4a9a | ||
|
|
bc5ffdec8e | ||
|
|
88d52003b3 | ||
|
|
6c7b62b8b1 | ||
|
|
37491ceffb | ||
|
|
b3dedbdf75 | ||
|
|
42d09292b7 | ||
|
|
c80293d9e0 | ||
|
|
b31c09c4ff | ||
|
|
91daabc9d2 | ||
|
|
def5454e5f | ||
|
|
74e6226d13 | ||
|
|
72d83e2fe4 | ||
|
|
85ccbf34c7 | ||
|
|
5e715daded | ||
|
|
5412487178 | ||
|
|
c4937e95e9 | ||
|
|
4f53e2beea | ||
|
|
9ea1edf9db | ||
|
|
ce1c416b20 | ||
|
|
fdbc3c55c7 | ||
|
|
71559154a8 | ||
|
|
07f8a203ea | ||
|
|
b67fd16081 | ||
|
|
9792c17c69 | ||
|
|
8488a5ec1a | ||
|
|
ff571db494 | ||
|
|
aaa932f725 | ||
|
|
c58ecd96f0 | ||
|
|
8608b73009 | ||
|
|
30801dff9f | ||
|
|
912311ad55 | ||
|
|
14615f9976 | ||
|
|
9121cf7ecb | ||
|
|
a831800a05 | ||
|
|
399c739fd7 | ||
|
|
63f687a20e | ||
|
|
fbd0198771 | ||
|
|
00e85280fd | ||
|
|
5b37698d36 | ||
|
|
483640b0c6 | ||
|
|
1e96aad484 | ||
|
|
b88e0e8a52 | ||
|
|
6028009aac | ||
|
|
9aa9b72557 | ||
|
|
8c023c5727 | ||
|
|
d9edaede89 | ||
|
|
0220b37c2d | ||
|
|
f2d2d1cc8d | ||
|
|
437eeef9a0 | ||
|
|
8e4f291389 | ||
|
|
8f27222b07 | ||
|
|
9e05991908 | ||
|
|
1c76bf93a2 | ||
|
|
d5cedee6c5 | ||
|
|
eb1b7176a6 | ||
|
|
71143536bf | ||
|
|
f2608cecd5 | ||
|
|
c95d5464d8 | ||
|
|
0ac7466a81 | ||
|
|
77e62d5a54 | ||
|
|
bef015eb57 | ||
|
|
d99d95217f | ||
|
|
5cbf8db272 | ||
|
|
6ee0c7600b | ||
|
|
6621421a6f | ||
|
|
edf95ea9fb | ||
|
|
a3e3e9046f | ||
|
|
04cdb6fbfc | ||
|
|
8c7e56a2bd | ||
|
|
3e08574fbe | ||
|
|
73ba7933da | ||
|
|
fc45c3b98c | ||
|
|
0d6f420f97 | ||
|
|
bef85963a6 | ||
|
|
3be57efa97 | ||
|
|
981d8510c2 | ||
|
|
5d8f655243 | ||
|
|
0cb41469ab | ||
|
|
971d7e730a | ||
|
|
06f8b9b817 | ||
|
|
db51384b63 | ||
|
|
ac088ea692 | ||
|
|
2a5793e8ce | ||
|
|
6c60ff88a3 | ||
|
|
8d68c10905 | ||
|
|
119d4b9514 | ||
|
|
0370aa8df5 | ||
|
|
6034c899d3 | ||
|
|
d2fe11ba6f | ||
|
|
58c96e1329 | ||
|
|
4c51b4b822 | ||
|
|
f5dea4935c | ||
|
|
0117ab8aa4 | ||
|
|
a5ade0c41a | ||
|
|
4df12ae675 | ||
|
|
274a41f052 | ||
|
|
3670c8f879 | ||
|
|
e88bb442f3 | ||
|
|
a0d1bf4b5c | ||
|
|
32f05dccd9 | ||
|
|
4aac2d2b7b | ||
|
|
e5a40bfb69 | ||
|
|
741ae36d4c | ||
|
|
67fa4a8910 | ||
|
|
80bf3c97b2 | ||
|
|
9f23cb35a9 | ||
|
|
458de17b8f | ||
|
|
dd50ee92a9 | ||
|
|
1094085872 | ||
|
|
edbd3de4a4 | ||
|
|
91d430085e | ||
|
|
fab550157a | ||
|
|
5e2ed04d72 | ||
|
|
e87aa83666 | ||
|
|
c9b6b5d4a8 | ||
|
|
ce01ad2083 | ||
|
|
0eb8ec70d9 | ||
|
|
270f87f193 | ||
|
|
c2951e5e41 | ||
|
|
e22e8669e1 | ||
|
|
2580fe6b79 | ||
|
|
c11c6f40d5 | ||
|
|
3c90a92641 | ||
|
|
4c0a97e62f | ||
|
|
0a57bb5029 | ||
|
|
c65ac5a7cf | ||
|
|
04a8040292 | ||
|
|
adab00bc0e | ||
|
|
9bb8e00c3d | ||
|
|
f2dc95b86d | ||
|
|
28f98aed88 | ||
|
|
d11174da21 | ||
|
|
8e977f994f | ||
|
|
11c2d4ab25 | ||
|
|
b733e6855b | ||
|
|
aa3d644f97 | ||
|
|
bb46d00f22 | ||
|
|
771c74ee75 | ||
|
|
04a346678c | ||
|
|
e5c0fc92ec | ||
|
|
1b640f40dc | ||
|
|
ee77516716 | ||
|
|
82cca0a602 | ||
|
|
d444d716f5 | ||
|
|
e03d710d53 | ||
|
|
b46d44c3a5 | ||
|
|
7a459fd5b8 | ||
|
|
2b696144e3 | ||
|
|
62036a273e | ||
|
|
d25befabdd | ||
|
|
56b17dfbcf | ||
|
|
72c181a399 | ||
|
|
b1b60f4286 | ||
|
|
7648716199 | ||
|
|
9177588220 | ||
|
|
9bbcee645e | ||
|
|
a7d49b986d | ||
|
|
7c3e08050f | ||
|
|
076df7cf66 | ||
|
|
415b8c6317 | ||
|
|
c1d3c93dbb | ||
|
|
8e59c99666 | ||
|
|
dfcd372d2d | ||
|
|
f7bbe0fa94 | ||
|
|
a1910f49a8 | ||
|
|
5f8be5fa91 | ||
|
|
587034ad03 | ||
|
|
9568bc7ea6 | ||
|
|
ccf6b6ef5f | ||
|
|
24f6aff8c8 | ||
|
|
17c19d453b | ||
|
|
4534758c21 | ||
|
|
c180eb466d | ||
|
|
0a416ab758 | ||
|
|
575aca5886 | ||
|
|
c0cc5657a7 | ||
|
|
78514b6c2e | ||
|
|
7e4390d743 | ||
|
|
cd558daf5a | ||
|
|
dda5955121 | ||
|
|
783ac18222 | ||
|
|
81c8052449 | ||
|
|
6ce02282e7 | ||
|
|
a19a0b6789 | ||
|
|
9de08dc83f | ||
|
|
96c7b87ac7 | ||
|
|
469dd49def | ||
|
|
53f6a0b32b | ||
|
|
fb9a0fe833 | ||
|
|
69c3d41a8a | ||
|
|
fc32b98341 | ||
|
|
299ed88f09 | ||
|
|
29753a6aa9 | ||
|
|
7983cd8d62 | ||
|
|
05fab1f79d | ||
|
|
7229b886e0 | ||
|
|
8b70d2674f | ||
|
|
8ba27cdaea | ||
|
|
1c2411a0fc | ||
|
|
ec88d07ef1 | ||
|
|
aa328d3ef7 | ||
|
|
727facfb90 | ||
|
|
4add853473 | ||
|
|
b2db27f9aa | ||
|
|
96be6c0efe | ||
|
|
3f42f1281d | ||
|
|
bab8384803 | ||
|
|
ceb77c990b | ||
|
|
3f65928e9d | ||
|
|
4e6659e7e8 | ||
|
|
9e22d4b5df | ||
|
|
357361de3d | ||
|
|
de443fa3f0 | ||
|
|
20839853b7 | ||
|
|
bc2539b566 | ||
|
|
c89416702d | ||
|
|
b2dd8c68e1 | ||
|
|
2ef2eafdd3 | ||
|
|
568655d503 | ||
|
|
9bd6f9ef47 | ||
|
|
c8e5cefd36 | ||
|
|
7ba4077f9f | ||
|
|
151f8bf329 | ||
|
|
e94d48c265 | ||
|
|
2a5c523afd | ||
|
|
f80696b724 | ||
|
|
5f5b5f69e3 | ||
|
|
d99f8fef09 | ||
|
|
97e59db5e6 | ||
|
|
1c813c0e0e | ||
|
|
e39f17b3de | ||
|
|
0b90b53e09 | ||
|
|
847d729ba4 | ||
|
|
aa86a6e53b | ||
|
|
34470336e4 | ||
|
|
5a2a89f19e | ||
|
|
7eb4df770e | ||
|
|
550bb5fd33 | ||
|
|
ffcae66f59 | ||
|
|
d2df795ba7 | ||
|
|
b930e13964 | ||
|
|
1b9da9cdd8 | ||
|
|
e6bf318cdf | ||
|
|
4cfed08247 | ||
|
|
4460b526cb | ||
|
|
01e6a87968 | ||
|
|
7667a4f8c0 | ||
|
|
6fbfb83e6c | ||
|
|
123d3e182a | ||
|
|
fd8d16035c | ||
|
|
1ff45b55d6 | ||
|
|
0ce02d7766 | ||
|
|
959a13a514 | ||
|
|
b601d713d2 | ||
|
|
dc96d8699a | ||
|
|
f38cdd8432 | ||
|
|
91655a3d5a | ||
|
|
7774a9b2ab | ||
|
|
80cf8f484d | ||
|
|
cb7c075a61 | ||
|
|
4d0fb67c53 | ||
|
|
380d1d4f18 | ||
|
|
71832859a5 | ||
|
|
a31432ad9e | ||
|
|
26743154d8 | ||
|
|
a50a6fa917 | ||
|
|
b43e5bac0b | ||
|
|
ddaef1fb64 | ||
|
|
6f264ff5de | ||
|
|
a06fc6d80f | ||
|
|
3ddfc47d3a | ||
|
|
905bd05ba8 | ||
|
|
aa19787654 | ||
|
|
3274f3ec35 | ||
|
|
c3b8c48ca2 | ||
|
|
d365094616 | ||
|
|
7c76684076 | ||
|
|
0ef27b038a | ||
|
|
610a27c0e4 | ||
|
|
752c91a721 | ||
|
|
d3d655da07 | ||
|
|
db36cf248e | ||
|
|
153d8e106e | ||
|
|
df8829b477 | ||
|
|
569bc297a8 | ||
|
|
099cbbdb64 | ||
|
|
ca7469b92e | ||
|
|
6db4145927 | ||
|
|
ca93f2baff | ||
|
|
84a0a3caeb | ||
|
|
7b327693e2 | ||
|
|
dba70dc6d5 | ||
|
|
bd1618e41d | ||
|
|
08b7aff0d8 | ||
|
|
a75688f89c | ||
|
|
30763b5401 | ||
|
|
814519248c | ||
|
|
8c71be5257 | ||
|
|
91763b233e | ||
|
|
ab51f5e69a | ||
|
|
d278d58f6c | ||
|
|
47e038c9fa | ||
|
|
e96210f48c | ||
|
|
aa3811ad02 | ||
|
|
963ab38b2e | ||
|
|
11069cc219 | ||
|
|
d074500109 | ||
|
|
63af28b016 | ||
|
|
82bb2fcf6a | ||
|
|
e56e765450 | ||
|
|
c70169b4a0 | ||
|
|
adad88681e | ||
|
|
1b9ac088e4 | ||
|
|
e00951e5b9 | ||
|
|
b7a0feba5b | ||
|
|
51fa0a0891 | ||
|
|
f4c54bb9e6 | ||
|
|
e00ca5d096 | ||
|
|
07b93167f0 | ||
|
|
29c0a8d054 | ||
|
|
73b1be36a2 | ||
|
|
4eb78810c6 | ||
|
|
f31861441b | ||
|
|
cc93c686d9 | ||
|
|
d5990e60c9 |
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
12
.github/ISSUE_TEMPLATE/mod_support.md
vendored
Normal file
12
.github/ISSUE_TEMPLATE/mod_support.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Mod support request
|
||||
about: Request Liberation support for new mods, or updates to existing mods
|
||||
title: Add/update <mod name>
|
||||
labels: mod support
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
* Mod name:
|
||||
* Mod URL:
|
||||
* Update or new mod?
|
||||
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
@@ -37,6 +37,11 @@ jobs:
|
||||
./venv/scripts/activate
|
||||
mypy gen
|
||||
|
||||
- name: mypy tests
|
||||
run: |
|
||||
./venv/scripts/activate
|
||||
mypy tests
|
||||
|
||||
- name: update build number
|
||||
run: |
|
||||
[IO.File]::WriteAllLines($pwd.path + "\resources\buildnumber", $env:GITHUB_RUN_NUMBER)
|
||||
|
||||
33
.github/workflows/test.yml
vendored
Normal file
33
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install environment
|
||||
run: |
|
||||
python -m venv ./venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
./venv/scripts/activate
|
||||
python -m pip install -r requirements.txt
|
||||
# For some reason the shiboken2.abi3.dll is not found properly, so I copy it instead
|
||||
Copy-Item .\venv\Lib\site-packages\shiboken2\shiboken2.abi3.dll .\venv\Lib\site-packages\PySide2\ -Force
|
||||
|
||||
- name: run tests
|
||||
run: |
|
||||
./venv/scripts/activate
|
||||
pytest tests
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,7 +18,7 @@ env/
|
||||
/liberation_preferences.json
|
||||
/state.json
|
||||
|
||||
logs/
|
||||
/logs/
|
||||
|
||||
qt_ui/logs/liberation.log
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||

|
||||
[](https://shdwp.github.io/ukraine/)
|
||||
|
||||
(Github Readme Banner and Splash screen Artwork by Andriy Dankovych, CC BY-SA 4.0)
|
||||
|
||||
[](https://patreon.com/khopa)
|
||||
|
||||
|
||||
166
changelog.md
166
changelog.md
@@ -1,30 +1,192 @@
|
||||
# 5.2.0
|
||||
|
||||
Saves from 5.1.0 are compatible with 5.2.0
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
* **[Modding]** Add UH-60L mod support
|
||||
* **[Campaign]** Vegas Nerve campaign update
|
||||
* **[Campaign]** Add 3 new campaigns from Sith1144
|
||||
## Fixes
|
||||
|
||||
* **[Mission Generation]** Fixed incorrect SA-5 and NASAMS threat range when TR destroyed. It will not count as threat anymore when the TR is dead.
|
||||
* **[Mission Generation]** Fixed "Max Threat Range" error
|
||||
* **[Mission Generation]** Fix unculled zones not updating when needed
|
||||
* **[Data]** Removed Fw 190 A-8 and D-9 from Germany 1940 and 1942 faction list for historical accuracy.
|
||||
* **[Data]** Updated Loadouts for Tornado GR4, F-15E and F-16C
|
||||
* **[Data]** Corrected some unit data
|
||||
* **[UI]** Fixed various UI issues (for example Scaling and HighDPI)
|
||||
* **[UI]** Typhoon GR4 and IDS images
|
||||
|
||||
|
||||
# 5.1.0
|
||||
|
||||
Saves from 5.0.0 are compatible with 5.1.0
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
* **[Engine]** Support for DCS 2.7.9.17830 and newer, including the HTS and ECM pod.
|
||||
* **[Campaign]** Add option to manually add and remove squadrons and different aircraft type in the new game wizard / air wing configuration dialog.
|
||||
* **[Mission Generation]** Add Option to enforce the Easy Communication setting for the mission
|
||||
* **[Mission Generation]** Add Option to select between only night missions, day missions or any time (default).
|
||||
* **[Modding]** Add F-104 mod support
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[Campaign]** Fixed some minor issues in campaigns which generated error messages in the log.
|
||||
* **[Campaign]** Changed the way how map object / scenery kills where tracked. This fixes issues with kill recognition after map updates from ED which change the object ids and therefore prevent correct kill recognition.
|
||||
* **[Mission Generation]** Fixed incorrect radio specification for the AN/ARC-222.
|
||||
* **[Mission Generation]** Fixed mission scripting error when using a dedicated server.
|
||||
* **[Mission Generation]** Fixed an issue where empty convoys lead to an index error when a point capture made a pending transfer of units not completable anymore.
|
||||
* **[Mission Generation]** Corrected Viggen FR22 & FR24 preset channels for the DCS 2.7.9 update
|
||||
* **[Mission Generation]** Fixed the SA-5 Generator to use the P-19 FlatFace SR as a Fallback radar if the faction does not have access to the TinShield SR.
|
||||
* **[UI]** Enable / Disable the settings, save and stats actions if no game is loaded to prevent an error as these functions can only be used on a valid game.
|
||||
* **[UI]** Added missing icons for Tornado GR4, and Tornado IDS.
|
||||
|
||||
# 5.0.0
|
||||
|
||||
Saves from 4.x are not compatible with 5.0.
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
* **[Campaign]** Weather! Theaters now experience weather that is more realistic for the region and its current season. For example, Persian Gulf will have very hot, sunny summers and Marianas will experience lots of rain during fall. These changes affect pressure, temperature, clouds and precipitation. Additionally, temperature will drop during the night, by an amount that is somewhat realistic for the region.
|
||||
* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated.
|
||||
* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts.
|
||||
* **[Campaign]** FOBs control point can have FARP/helipad slot and host helicopters. To enable this feature on a FOB, add "Invisible FARP" statics objects near the FOB location in the campaign definition file.
|
||||
* **[Campaign]** Squadrons now have a home base and will not operate out of other bases. See https://github.com/dcs-liberation/dcs_liberation/issues/1145 for status.
|
||||
* **[Campaign]** Aircraft now belong to squadrons rather than bases to support squadron location transfers.
|
||||
* **[Campaign]** Skipped turns are no longer counted as defeats on front lines.
|
||||
* **[Campaign AI]** Overhauled campaign AI target prioritization.
|
||||
* **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI.
|
||||
* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points.
|
||||
* **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken.
|
||||
* **[Campaign AI]** Transport aircraft will now be bought only if necessary at control points which can produce ground units and are capable to operate transport aircraft.
|
||||
* **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron.
|
||||
* **[Engine]** Support for DCS 2.7.7.14727 and newer, including support for F-16 CBU-105s, SA-5s, and the Forrestal.
|
||||
* **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet and Viper).
|
||||
* **[Kneeboard]** QNH (pressure MSL) and temperature have been added to the kneeboard.
|
||||
* **[Mission Generation]** EWRs are now also headed towards the center of the conflict
|
||||
* **[Mission Generation]** FACs can now use FC3 compatible laser codes. Note that this setting is global, not per FAC.
|
||||
* **[Modding]** Can now install custom campaigns to <DCS saved games>/Liberation/Campaigns instead of the Liberation install directory.
|
||||
* **[Modding]** Campaigns can now define a default start date.
|
||||
* **[Modding]** Campaigns now specify the squadrons that are present in the campaign, their roles, and their starting bases. Players can customize this at game start but the campaign will choose the defaults.
|
||||
* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable, relocate, or rename squadrons.
|
||||
* **[Plugins]** Updated SkynetIADS to 2.4.0 (adds SA-5 support).
|
||||
* **[UI]** Sell Button for aircraft will be disabled if there are no units available to be sold or all are already assigned to a mission
|
||||
* **[UI]** Enemy aircraft inventory now viewable in the air wing menu.
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning.
|
||||
* **[Campaign]** Units aboard sunk cargo ships will now have their losses tracked properly.
|
||||
* **[Mission Generation]** Mission results and other files will now be opened with enforced utf-8 encoding to prevent an issue where destroyed ground units were untracked because of special characters in their names.
|
||||
* **[Mission Generation]** Fixed generation of landing waypoints so that the AI obeys them.
|
||||
* **[Mission Generation]** AI carrier aircraft with a start time of T+0 will now start at T+1s to avoid traffic jams.
|
||||
* **[Mission Generation]** Fixed cases of unused aircraft not being spawned at airfields as soon as any airport filled up.
|
||||
* **[Mission Generation]** Fixed cases with multiple client flights of the same airframe all received the same preset channels.
|
||||
* **[Mission Generation]** F-14A is now generated with stored alignment.
|
||||
* **[Mission Generation]** Su-33s set to cold or warm start on the Kuznetsov will always be generated as runway starts to avoid the AI getting stuck.
|
||||
* **[Mission Generation]** Fixed AI not receiving anti-ship tasks against carriers and LHAs.
|
||||
* **[Mods]** Fixed broken A-4 support causing no weapons to be available.
|
||||
* **[UI]** Selling of Units is now visible again in the UI dialog and shows the correct amount of sold units
|
||||
* **[UI]** Fixed bug where an incompatible campaign could be generated if no action is taken on the campaign selection screen.
|
||||
|
||||
# 4.1.1
|
||||
|
||||
Saves from 4.1.0 are compatible with 4.1.1.
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[Campaign]** Fixed broken support for Mariana Islands map.
|
||||
* **[Mission Generation]** Fix SAM sites pointing towards the center of the conflict.
|
||||
* **[Flight Planning]** No longer using Su-34 for CAP missions.
|
||||
|
||||
# 4.1.0
|
||||
|
||||
Saves from 4.0.0 are compatible with 4.1.0.
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
* **[Campaign]** Air defense sites now generate a fixed number of launchers per type.
|
||||
* **[Campaign]** Added support for Mariana Islands map.
|
||||
* **[Campaign AI]** Adjustments to aircraft selection priorities for most mission types.
|
||||
* **[Engine]** Support for DCS 2.7.4.9632 and newer, including the Marianas map, F-16 JSOWs, NASAMS, and Tin Shield EWR.
|
||||
* **[Flight Planning]** CAP patrol altitudes are now set per-aircraft. By default the altitude will be set based on the aircraft's maximum speed.
|
||||
* **[Flight Planning]** CAP patrol speeds are now set per-aircraft to be more suitable/sensible. By default the speed will be set based on the aircraft's maximum speed.
|
||||
* **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR
|
||||
* **[Mission Generation]** SAM sites are now headed towards the center of the conflict
|
||||
* **[Mods]** Support for latest version of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts.
|
||||
* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI.
|
||||
* **[Plugins]** Updated SkynetIADS to 2.2.0 (adds NASAMS support).
|
||||
* **[UI]** Added ability to take notes and have those notes appear as a kneeboard page.
|
||||
* **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet).
|
||||
* **[UI]** Google search link added to unit information when there is no information provided.
|
||||
* **[UI]** Control point name displayed with ground object group name on map.
|
||||
* **[UI]** Buy or Replace will now show the correct price for generated ground objects like sams.
|
||||
* **[UI]** Improved logging for frontline movement to be more descriptive about what happened and why.
|
||||
* **[UI]** Brought ruler map module into source, which should fix file integrity issues with the module.
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[Campaign]** Fixed the Silkworm generator to include launchers and not all radars.
|
||||
* **[Data]** Fixed Introduction dates for targeting pods (ATFLIR and LITENING were both a few years too early).
|
||||
* **[Data]** Removed SA-10 from Syria 2011 faction.
|
||||
* **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money
|
||||
* **[Flight Planning]** Helicopters are now correctly identified, and will fly ingress/CAS/BAI/egress and similar at low altitude.
|
||||
* **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles.
|
||||
* **[Mission Generation]** The lua data for other plugins is now generated correctly
|
||||
* **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs
|
||||
* **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation.
|
||||
* **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured.
|
||||
* **[Mission Generation]** Planned transfers which will be impossible after a base capture will no longer prevent the mission result submit.
|
||||
* **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed.
|
||||
* **[Mission Generation]** Fix for AAA Flak generator using Opel Blitz preventing the mission from being generated because duplicate unit names were used.
|
||||
* **[Mission Generation]** Fixed a potential bug with laser code generation where it would generate invalid codes.
|
||||
* **[UI]** Statistics window tick marks are now always integers.
|
||||
* **[UI]** Statistics window now shows the correct info for the turn
|
||||
* **[UI]** Toggling custom loadout for an aircraft with no preset loadouts no longer breaks the flight.
|
||||
|
||||
# 4.0.0
|
||||
|
||||
Saves from 3.x are not compatible with 4.0.
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
* **[Campaign]** Squadrons now have a maximum size and killed pilots replenish at a limited rate.
|
||||
* **[Engine]** Support for DCS 2.7.2.7910.1 and newer, including Cyprus, F-16 JDAMs, and the Hind.
|
||||
* **[Campaign]** Squadrons now (optionally, off by default) have a maximum size and killed pilots replenish at a limited rate.
|
||||
* **[Campaign]** Added an option to disable levelling up of AI pilots.
|
||||
* **[Campaign]** Added Russian Intervention 2015 campaign on Syria, for a small and somewhat realistic Russian COIN scenario.
|
||||
* **[Campaign]** Added Operation Atilla campaign on Syria, for a reasonably large invasion of Cyprus scenario.
|
||||
* **[Campaign AI]** AI will plan Tanker flights.
|
||||
* **[Campaign AI]** Removed max distance for AEW&C auto planning.
|
||||
* **[Economy]** Adjusted prices for aircraft to balance out some price inconsistencies.
|
||||
* **[Factions]** Added more tankers to factions.
|
||||
* **[Flight Planner]** Added ability to plan Tankers.
|
||||
* **[Modding]** Campaign format version is now 7.0 to account for DCS map changes that made scenery strike targets incompatible with existing campaigns.
|
||||
* **[Mods]** Added support for the Gripen mod.
|
||||
* **[Mods]** Removes MB-339PAN support, as the mod is now deprecated and no longer works with DCS 2.7+.
|
||||
* **[Mission Generation]** Added support for "Neutral Dot" label options.
|
||||
* **[New Game Wizard]** Mods are now selected via checkboxes in the new game wizard, not as separate factions.
|
||||
* **[UI]** Ctrl click and shift click now buy or sell 5 or 10 units respectively.
|
||||
* **[UI]** Multiple waypoints can now be deleted simultaneously if multiple waypoints are selected.
|
||||
* **[UI]** Carriers and LHAs now match the colour of airfields, and their destination icons are translucent.
|
||||
* **[UI]** Updated intel box text for first turn.
|
||||
* **[UI]** Base Capture Cheat is now usable at all bases and can also be used to transfer player-owned bases to OPFOR.
|
||||
* **[UI]** Pass Turn button is relabled as "Begin Campaign" on Turn 0.
|
||||
* **[UI]** Added a ruler to the map.
|
||||
* **[UI]** Liberation now saves games to `<DCS user directory>/Liberation/Saves` by default to declutter the main directory.
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[Campaign AI]** Fix procurement for factions that lack some unit types.
|
||||
* **[Campaign AI]** Improved pruning of unplannable missions which should improve turn cycle time and prevent the auto-planner from quitting early.
|
||||
* **[Campaign AI]** Fix auto purchase of aircraft for factions that have no transport aircraft.
|
||||
* **[Campaign AI]** Fix refunding of pending aircraft purchases when a side has no factory available.
|
||||
* **[Mission Generation]** Fixed problem with mission load when control point name contained an apostrophe.
|
||||
* **[Mission Generation]** Fixed EWR group names so they contribute to Skynet again.
|
||||
* **[Mission Generation]** Fixed duplicate name error when generating convoys and cargo ships when creating manual transfers after loading a game.
|
||||
* **[Mission Generation]** Fixed empty convoys not being disbanded when all units are killed/removed.
|
||||
* **[Mission Generation]** Fixed player losing frontline progress when skipping from turn 0 to turn 1.
|
||||
* **[Mission Generation]** Fixed issue where frontline would only search to the right for valid locations.
|
||||
* **[UI]** Made non-interactive map elements less obstructive.
|
||||
* **[UI]** Added support for Neutral Dot difficulty label
|
||||
* **[UI]** Clear skies at night no longer described as "Sunny" by the weather widget.
|
||||
|
||||
80
doc/fuel-consumption-measurement.md
Normal file
80
doc/fuel-consumption-measurement.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Measuring estimated fuel consumption
|
||||
|
||||
To estimate fuel consumption numbers for an aircraft, create a mission with a
|
||||
typical heavy load for the aircraft. For example, to measure for the F/A-18C, a
|
||||
loadout with two bags, two GBU-31s, two sidewinders, an AMRAAM, and an ATFLIR.
|
||||
Do **not** drop bags or weapons during the test flight.
|
||||
|
||||
Start the aircraft on the ground at a large airport (for example, Akrotiri) at a
|
||||
parking space at the opposite end of the takeoff runway so you can estimate long
|
||||
taxi fuel consumption.
|
||||
|
||||
When you enter the jet, note the amount of fuel below, then taxi to the far end
|
||||
of the runway. Hold short and note the remaining fuel below.
|
||||
|
||||
Follow a typical takeoff pattern for the aircraft. For the F/A-18C, this might
|
||||
be AB takeoff, reduce to MIL at 350KIAS, and maintian 350KIAS/0.85 mach until
|
||||
cruise altitude (angles 25).
|
||||
|
||||
Once you reach angels 25, pause the game. Note your remaining fuel below and
|
||||
measure the distance traveled from takeoff. Mark your location on the map.
|
||||
|
||||
Level out and increase to cruise speed if needed. Liberation assumes 0.85 mach
|
||||
for supersonic aircraft, for subsonic aircraft it depends so pick something
|
||||
reasonable and note your descision in a comment in the file when done. Maintain
|
||||
speed, heading, and altitude for a long distance (the longer the distance, the
|
||||
more accurate the result, but be careful to leave enough fuel for the final
|
||||
section). Once complete, note the distance traveled and the remaining fuel.
|
||||
|
||||
Finally, increase speed as you would for an attack. At least MIL power,
|
||||
potentially use AB sparingly, etc. The goal is to measure fuel consumption per
|
||||
mile traveled during an attack run.
|
||||
|
||||
```
|
||||
start:
|
||||
taxi end:
|
||||
to 25k distance:
|
||||
at 25k fuel:
|
||||
cruise (.85 mach) distance:
|
||||
cruise (.85 mach) end fuel:
|
||||
combat distance:
|
||||
combat end fuel:
|
||||
```
|
||||
|
||||
Finally, fill out the data in the aircraft data. Below is an example for the
|
||||
F/A-18C:
|
||||
|
||||
```
|
||||
start: 15290
|
||||
taxi end: 15120
|
||||
climb distance: 40NM
|
||||
at 25k fuel: 13350
|
||||
cruise (.85 mach) distance: 100NM
|
||||
cruise (.85 mach) end fuel: 11140
|
||||
combat distance: 100NM
|
||||
combat end fuel: 8390
|
||||
|
||||
taxi = start - taxi end = 15290 - 15120 = 170
|
||||
climb fuel = taxi end - at 25k fuel = 15120 - 13350 = 1770
|
||||
climb ppm = climb fuel / climb distance = 1770 / 40 = 44.25
|
||||
cruise fuel = at 25k fuel - cruise end fuel = 13350 - 11140 = 2210
|
||||
cruise ppm = cruise fuel / cruise distance = 2210 / 100 = 22.1
|
||||
combat fuel = cruise end fuel - combat end fuel = 11140 - 8390 = 2750
|
||||
combat ppm = combat fuel / combat distance = 2750 / 100 = 27.5
|
||||
```
|
||||
|
||||
```yaml
|
||||
fuel:
|
||||
# Parking A1 to RWY 32 at Akrotiri.
|
||||
taxi: 170
|
||||
# AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft.
|
||||
climb_ppm: 44.25
|
||||
# 0.85 mach for 100NM.
|
||||
cruise_ppm: 22.1
|
||||
# ~0.9 mach for 100NM. Occasional AB use.
|
||||
combat_ppm: 27.5
|
||||
min_safe: 2000
|
||||
```
|
||||
|
||||
The last entry (`min_safe`) is the minimum amount of fuel that the aircraft
|
||||
should land with.
|
||||
2
game/campaignloader/__init__.py
Normal file
2
game/campaignloader/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .campaign import Campaign
|
||||
from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig
|
||||
183
game/campaignloader/campaign.py
Normal file
183
game/campaignloader/campaign.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
|
||||
from packaging.version import Version
|
||||
import yaml
|
||||
|
||||
from game.profiling import logged_duration
|
||||
from game.theater import (
|
||||
ConflictTheater,
|
||||
CaucasusTheater,
|
||||
NevadaTheater,
|
||||
PersianGulfTheater,
|
||||
NormandyTheater,
|
||||
TheChannelTheater,
|
||||
SyriaTheater,
|
||||
MarianaIslandsTheater,
|
||||
)
|
||||
from game.version import CAMPAIGN_FORMAT_VERSION
|
||||
from .campaignairwingconfig import CampaignAirWingConfig
|
||||
from .mizcampaignloader import MizCampaignLoader
|
||||
from .. import persistency
|
||||
|
||||
PERF_FRIENDLY = 0
|
||||
PERF_MEDIUM = 1
|
||||
PERF_HARD = 2
|
||||
PERF_NASA = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Campaign:
|
||||
name: str
|
||||
icon_name: str
|
||||
authors: str
|
||||
description: str
|
||||
|
||||
#: The revision of the campaign format the campaign was built for. We do not attempt
|
||||
#: to migrate old campaigns, but this is used to show a warning in the UI when
|
||||
#: selecting a campaign that is not up to date.
|
||||
version: Tuple[int, int]
|
||||
|
||||
recommended_player_faction: str
|
||||
recommended_enemy_faction: str
|
||||
recommended_start_date: Optional[datetime.date]
|
||||
performance: int
|
||||
data: Dict[str, Any]
|
||||
path: Path
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> Campaign:
|
||||
with path.open() as campaign_file:
|
||||
if path.suffix == ".yaml":
|
||||
data = yaml.safe_load(campaign_file)
|
||||
else:
|
||||
data = json.load(campaign_file)
|
||||
|
||||
sanitized_theater = data["theater"].replace(" ", "")
|
||||
version_field = data.get("version", "0")
|
||||
try:
|
||||
version = Version(version_field)
|
||||
except TypeError:
|
||||
logging.warning(
|
||||
f"Non-string campaign version in {path}. Parse may be incorrect."
|
||||
)
|
||||
version = Version(str(version_field))
|
||||
|
||||
start_date_raw = data.get("recommended_start_date")
|
||||
|
||||
# YAML automatically parses dates, but while we still support JSON campaigns we
|
||||
# need to be able to handle parsing dates from strings ourselves as well.
|
||||
start_date: Optional[datetime.date]
|
||||
if isinstance(start_date_raw, str):
|
||||
start_date = datetime.date.fromisoformat(start_date_raw)
|
||||
elif isinstance(start_date_raw, datetime.date):
|
||||
start_date = start_date_raw
|
||||
elif start_date_raw is None:
|
||||
start_date = None
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Invalid value for recommended_start_date in {path}: {start_date_raw}"
|
||||
)
|
||||
|
||||
return cls(
|
||||
data["name"],
|
||||
f"Terrain_{sanitized_theater}",
|
||||
data.get("authors", "???"),
|
||||
data.get("description", ""),
|
||||
(version.major, version.minor),
|
||||
data.get("recommended_player_faction", "USA 2005"),
|
||||
data.get("recommended_enemy_faction", "Russia 1990"),
|
||||
start_date,
|
||||
data.get("performance", 0),
|
||||
data,
|
||||
path,
|
||||
)
|
||||
|
||||
def load_theater(self) -> ConflictTheater:
|
||||
theaters = {
|
||||
"Caucasus": CaucasusTheater,
|
||||
"Nevada": NevadaTheater,
|
||||
"Persian Gulf": PersianGulfTheater,
|
||||
"Normandy": NormandyTheater,
|
||||
"The Channel": TheChannelTheater,
|
||||
"Syria": SyriaTheater,
|
||||
"MarianaIslands": MarianaIslandsTheater,
|
||||
}
|
||||
theater = theaters[self.data["theater"]]
|
||||
t = theater()
|
||||
|
||||
try:
|
||||
miz = self.data["miz"]
|
||||
except KeyError as ex:
|
||||
raise RuntimeError(
|
||||
"Old format (non-miz) campaigns are no longer supported."
|
||||
) from ex
|
||||
|
||||
with logged_duration("Importing miz data"):
|
||||
MizCampaignLoader(self.path.parent / miz, t).populate_theater()
|
||||
return t
|
||||
|
||||
def load_air_wing_config(self, theater: ConflictTheater) -> CampaignAirWingConfig:
|
||||
try:
|
||||
squadron_data = self.data["squadrons"]
|
||||
except KeyError:
|
||||
logging.warning(f"Campaign {self.name} does not define any squadrons")
|
||||
return CampaignAirWingConfig({})
|
||||
return CampaignAirWingConfig.from_campaign_data(squadron_data, theater)
|
||||
|
||||
@property
|
||||
def is_out_of_date(self) -> bool:
|
||||
"""Returns True if this campaign is not up to date with the latest format.
|
||||
|
||||
This is more permissive than is_from_future, which is sensitive to minor version
|
||||
bumps (the old game definitely doesn't support the minor features added in the
|
||||
new version, and the campaign may require them. However, the minor version only
|
||||
indicates *optional* new features, so we do not need to mark out of date
|
||||
campaigns as incompatible if they are within the same major version.
|
||||
"""
|
||||
return self.version[0] < CAMPAIGN_FORMAT_VERSION[0]
|
||||
|
||||
@property
|
||||
def is_from_future(self) -> bool:
|
||||
"""Returns True if this campaign is newer than the supported format."""
|
||||
return self.version > CAMPAIGN_FORMAT_VERSION
|
||||
|
||||
@property
|
||||
def is_compatible(self) -> bool:
|
||||
"""Returns True is this campaign was built for this version of the game."""
|
||||
if self.version == (0, 0):
|
||||
return False
|
||||
if self.is_out_of_date:
|
||||
return False
|
||||
if self.is_from_future:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def iter_campaigns_in_dir(path: Path) -> Iterator[Path]:
|
||||
yield from path.glob("*.yaml")
|
||||
yield from path.glob("*.json")
|
||||
|
||||
@classmethod
|
||||
def iter_campaign_defs(cls) -> Iterator[Path]:
|
||||
yield from cls.iter_campaigns_in_dir(
|
||||
Path(persistency.base_path()) / "Liberation/Campaigns"
|
||||
)
|
||||
yield from cls.iter_campaigns_in_dir(Path("resources/campaigns"))
|
||||
|
||||
@classmethod
|
||||
def load_each(cls) -> Iterator[Campaign]:
|
||||
for path in cls.iter_campaign_defs():
|
||||
try:
|
||||
logging.debug(f"Loading campaign from {path}...")
|
||||
campaign = Campaign.from_file(path)
|
||||
yield campaign
|
||||
except RuntimeError:
|
||||
logging.exception(f"Unable to load campaign from {path}")
|
||||
68
game/campaignloader/campaignairwingconfig.py
Normal file
68
game/campaignloader/campaignairwingconfig.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TYPE_CHECKING, Union
|
||||
|
||||
from gen.flights.flight import FlightType
|
||||
from game.theater.controlpoint import ControlPoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater import ConflictTheater
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SquadronConfig:
|
||||
primary: FlightType
|
||||
secondary: list[FlightType]
|
||||
aircraft: list[str]
|
||||
|
||||
@property
|
||||
def auto_assignable(self) -> set[FlightType]:
|
||||
return set(self.secondary) | {self.primary}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: dict[str, Any]) -> SquadronConfig:
|
||||
secondary_raw = data.get("secondary")
|
||||
if secondary_raw is None:
|
||||
secondary = []
|
||||
elif isinstance(secondary_raw, str):
|
||||
secondary = cls.expand_secondary_alias(secondary_raw)
|
||||
else:
|
||||
secondary = [FlightType(s) for s in secondary_raw]
|
||||
|
||||
return SquadronConfig(
|
||||
FlightType(data["primary"]), secondary, data.get("aircraft", [])
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def expand_secondary_alias(alias: str) -> list[FlightType]:
|
||||
if alias == "any":
|
||||
return list(FlightType)
|
||||
elif alias == "air-to-air":
|
||||
return [t for t in FlightType if t.is_air_to_air]
|
||||
elif alias == "air-to-ground":
|
||||
return [t for t in FlightType if t.is_air_to_ground]
|
||||
raise KeyError(f"Unknown secondary mission type: {alias}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CampaignAirWingConfig:
|
||||
by_location: dict[ControlPoint, list[SquadronConfig]]
|
||||
|
||||
@classmethod
|
||||
def from_campaign_data(
|
||||
cls, data: dict[Union[str, int], Any], theater: ConflictTheater
|
||||
) -> CampaignAirWingConfig:
|
||||
by_location: dict[ControlPoint, list[SquadronConfig]] = defaultdict(list)
|
||||
for base_id, squadron_configs in data.items():
|
||||
if isinstance(base_id, int):
|
||||
base = theater.find_control_point_by_id(base_id)
|
||||
else:
|
||||
base = theater.control_point_named(base_id)
|
||||
|
||||
for squadron_data in squadron_configs:
|
||||
by_location[base].append(SquadronConfig.from_data(squadron_data))
|
||||
|
||||
return CampaignAirWingConfig(by_location)
|
||||
144
game/campaignloader/defaultsquadronassigner.py
Normal file
144
game/campaignloader/defaultsquadronassigner.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from game.squadrons import Squadron
|
||||
from game.squadrons.squadrondef import SquadronDef
|
||||
from gen.flights.flight import FlightType
|
||||
from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig
|
||||
from ..dcs.aircrafttype import AircraftType
|
||||
from ..theater import ControlPoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class DefaultSquadronAssigner:
|
||||
def __init__(
|
||||
self, config: CampaignAirWingConfig, game: Game, coalition: Coalition
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.game = game
|
||||
self.coalition = coalition
|
||||
self.air_wing = coalition.air_wing
|
||||
|
||||
def assign(self) -> None:
|
||||
for control_point in self.game.theater.control_points_for(
|
||||
self.coalition.player
|
||||
):
|
||||
for squadron_config in self.config.by_location[control_point]:
|
||||
squadron_def = self.find_squadron_for(squadron_config, control_point)
|
||||
if squadron_def is None:
|
||||
logging.info(
|
||||
f"{self.coalition.faction.name} has no aircraft compatible "
|
||||
f"with {squadron_config.primary} at {control_point}"
|
||||
)
|
||||
continue
|
||||
|
||||
squadron = Squadron.create_from(
|
||||
squadron_def, control_point, self.coalition, self.game
|
||||
)
|
||||
squadron.set_auto_assignable_mission_types(
|
||||
squadron_config.auto_assignable
|
||||
)
|
||||
self.air_wing.add_squadron(squadron)
|
||||
|
||||
def find_squadron_for(
|
||||
self, config: SquadronConfig, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
for preferred_aircraft in config.aircraft:
|
||||
squadron_def = self.find_preferred_squadron(
|
||||
preferred_aircraft, config.primary, control_point
|
||||
)
|
||||
if squadron_def is not None:
|
||||
return squadron_def
|
||||
|
||||
# If we didn't find any of the preferred types we should use any squadron
|
||||
# compatible with the primary task.
|
||||
squadron_def = self.find_squadron_for_task(config.primary, control_point)
|
||||
if squadron_def is not None:
|
||||
return squadron_def
|
||||
|
||||
# If we can't find any squadron matching the requirement, we should
|
||||
# create one.
|
||||
return self.air_wing.squadron_def_generator.generate_for_task(
|
||||
config.primary, control_point
|
||||
)
|
||||
|
||||
def find_preferred_squadron(
|
||||
self, preferred_aircraft: str, task: FlightType, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
# Attempt to find a squadron with the name in the request.
|
||||
squadron_def = self.find_squadron_by_name(
|
||||
preferred_aircraft, task, control_point
|
||||
)
|
||||
if squadron_def is not None:
|
||||
return squadron_def
|
||||
|
||||
# If the name didn't match a squadron available to this coalition, try to find
|
||||
# an aircraft with the matching name that meets the requirements.
|
||||
try:
|
||||
aircraft = AircraftType.named(preferred_aircraft)
|
||||
except KeyError:
|
||||
# No aircraft with this name.
|
||||
return None
|
||||
|
||||
if aircraft not in self.coalition.faction.aircrafts:
|
||||
return None
|
||||
|
||||
squadron_def = self.find_squadron_for_airframe(aircraft, task, control_point)
|
||||
if squadron_def is not None:
|
||||
return squadron_def
|
||||
|
||||
# No premade squadron available for this aircraft that meets the requirements,
|
||||
# so generate one if possible.
|
||||
return self.air_wing.squadron_def_generator.generate_for_aircraft(aircraft)
|
||||
|
||||
@staticmethod
|
||||
def squadron_compatible_with(
|
||||
squadron: SquadronDef,
|
||||
task: FlightType,
|
||||
control_point: ControlPoint,
|
||||
ignore_base_preference: bool = False,
|
||||
) -> bool:
|
||||
if ignore_base_preference:
|
||||
return control_point.can_operate(squadron.aircraft)
|
||||
return squadron.operates_from(control_point) and task in squadron.mission_types
|
||||
|
||||
def find_squadron_for_airframe(
|
||||
self, aircraft: AircraftType, task: FlightType, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
for squadron in self.air_wing.squadron_defs[aircraft]:
|
||||
if not squadron.claimed and self.squadron_compatible_with(
|
||||
squadron, task, control_point
|
||||
):
|
||||
return squadron
|
||||
return None
|
||||
|
||||
def find_squadron_by_name(
|
||||
self, name: str, task: FlightType, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
for squadrons in self.air_wing.squadron_defs.values():
|
||||
for squadron in squadrons:
|
||||
if (
|
||||
not squadron.claimed
|
||||
and squadron.name == name
|
||||
and self.squadron_compatible_with(
|
||||
squadron, task, control_point, ignore_base_preference=True
|
||||
)
|
||||
):
|
||||
return squadron
|
||||
return None
|
||||
|
||||
def find_squadron_for_task(
|
||||
self, task: FlightType, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
for squadrons in self.air_wing.squadron_defs.values():
|
||||
for squadron in squadrons:
|
||||
if not squadron.claimed and self.squadron_compatible_with(
|
||||
squadron, task, control_point
|
||||
):
|
||||
return squadron
|
||||
return None
|
||||
473
game/campaignloader/mizcampaignloader.py
Normal file
473
game/campaignloader/mizcampaignloader.py
Normal file
@@ -0,0 +1,473 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import Iterator, List, Dict, Tuple, TYPE_CHECKING
|
||||
|
||||
from dcs import Mission
|
||||
from dcs.countries import CombinedJointTaskForcesBlue, CombinedJointTaskForcesRed
|
||||
from dcs.country import Country
|
||||
from dcs.planes import F_15C
|
||||
from dcs.ships import Stennis, LHA_Tarawa, HandyWind, USS_Arleigh_Burke_IIa
|
||||
from dcs.statics import Fortification, Warehouse
|
||||
from dcs.terrain import Airport
|
||||
from dcs.unitgroup import PlaneGroup, ShipGroup, VehicleGroup, StaticGroup
|
||||
from dcs.vehicles import Armor, Unarmed, MissilesSS, AirDefence
|
||||
|
||||
from game.point_with_heading import PointWithHeading
|
||||
from game.positioned import Positioned
|
||||
from game.profiling import logged_duration
|
||||
from game.scenery_group import SceneryGroup
|
||||
from game.utils import Distance, meters, Heading
|
||||
from game.theater.controlpoint import (
|
||||
Airfield,
|
||||
Carrier,
|
||||
ControlPoint,
|
||||
Fob,
|
||||
Lha,
|
||||
OffMapSpawn,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater.conflicttheater import ConflictTheater
|
||||
|
||||
|
||||
class MizCampaignLoader:
|
||||
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
|
||||
RED_COUNTRY = CombinedJointTaskForcesRed()
|
||||
|
||||
OFF_MAP_UNIT_TYPE = F_15C.id
|
||||
|
||||
CV_UNIT_TYPE = Stennis.id
|
||||
LHA_UNIT_TYPE = LHA_Tarawa.id
|
||||
FRONT_LINE_UNIT_TYPE = Armor.M_113.id
|
||||
SHIPPING_LANE_UNIT_TYPE = HandyWind.id
|
||||
|
||||
FOB_UNIT_TYPE = Unarmed.SKP_11.id
|
||||
FARP_HELIPADS_TYPE = ["Invisible FARP", "SINGLE_HELIPAD"]
|
||||
|
||||
OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id
|
||||
SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id
|
||||
MISSILE_SITE_UNIT_TYPE = MissilesSS.Scud_B.id
|
||||
COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.Hy_launcher.id
|
||||
|
||||
# Multiple options for air defenses so campaign designers can more accurately see
|
||||
# the coverage of their IADS for the expected type.
|
||||
LONG_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.Patriot_ln.id,
|
||||
AirDefence.S_300PS_5P85C_ln.id,
|
||||
AirDefence.S_300PS_5P85D_ln.id,
|
||||
}
|
||||
|
||||
MEDIUM_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.Hawk_ln.id,
|
||||
AirDefence.S_75M_Volhov.id,
|
||||
AirDefence._5p73_s_125_ln.id,
|
||||
}
|
||||
|
||||
SHORT_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.M1097_Avenger.id,
|
||||
AirDefence.Rapier_fsa_launcher.id,
|
||||
AirDefence._2S6_Tunguska.id,
|
||||
AirDefence.Strela_1_9P31.id,
|
||||
}
|
||||
|
||||
AAA_UNIT_TYPES = {
|
||||
AirDefence.Flak18.id,
|
||||
AirDefence.Vulcan.id,
|
||||
AirDefence.ZSU_23_4_Shilka.id,
|
||||
}
|
||||
|
||||
EWR_UNIT_TYPE = AirDefence._1L13_EWR.id
|
||||
|
||||
ARMOR_GROUP_UNIT_TYPE = Armor.M_1_Abrams.id
|
||||
|
||||
FACTORY_UNIT_TYPE = Fortification.Workshop_A.id
|
||||
|
||||
AMMUNITION_DEPOT_UNIT_TYPE = Warehouse._Ammunition_depot.id
|
||||
|
||||
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
|
||||
|
||||
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
|
||||
self.theater = theater
|
||||
self.mission = Mission()
|
||||
with logged_duration("Loading miz"):
|
||||
self.mission.load_file(str(miz))
|
||||
self.control_point_id = itertools.count(1000)
|
||||
|
||||
# If there are no red carriers there usually aren't red units. Make sure
|
||||
# both countries are initialized so we don't have to deal with None.
|
||||
if self.mission.country(self.BLUE_COUNTRY.name) is None:
|
||||
self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY)
|
||||
if self.mission.country(self.RED_COUNTRY.name) is None:
|
||||
self.mission.coalition["red"].add_country(self.RED_COUNTRY)
|
||||
|
||||
@staticmethod
|
||||
def control_point_from_airport(airport: Airport) -> ControlPoint:
|
||||
cp = Airfield(airport, starts_blue=airport.is_blue())
|
||||
|
||||
# Use the unlimited aircraft option to determine if an airfield should
|
||||
# be owned by the player when the campaign is "inverted".
|
||||
cp.captured_invert = airport.unlimited_aircrafts
|
||||
|
||||
return cp
|
||||
|
||||
def country(self, blue: bool) -> Country:
|
||||
country = self.mission.country(
|
||||
self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name
|
||||
)
|
||||
# Should be guaranteed because we initialized them.
|
||||
assert country
|
||||
return country
|
||||
|
||||
@property
|
||||
def blue(self) -> Country:
|
||||
return self.country(blue=True)
|
||||
|
||||
@property
|
||||
def red(self) -> Country:
|
||||
return self.country(blue=False)
|
||||
|
||||
def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]:
|
||||
for group in self.country(blue).plane_group:
|
||||
if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def carriers(self, blue: bool) -> Iterator[ShipGroup]:
|
||||
for group in self.country(blue).ship_group:
|
||||
if group.units[0].type == self.CV_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def lhas(self, blue: bool) -> Iterator[ShipGroup]:
|
||||
for group in self.country(blue).ship_group:
|
||||
if group.units[0].type == self.LHA_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def fobs(self, blue: bool) -> Iterator[VehicleGroup]:
|
||||
for group in self.country(blue).vehicle_group:
|
||||
if group.units[0].type == self.FOB_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ships(self) -> Iterator[ShipGroup]:
|
||||
for group in self.red.ship_group:
|
||||
if group.units[0].type == self.SHIP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def offshore_strike_targets(self) -> Iterator[StaticGroup]:
|
||||
for group in self.red.static_group:
|
||||
if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def missile_sites(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def coastal_defenses(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def long_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def medium_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def short_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def aaa(self) -> Iterator[VehicleGroup]:
|
||||
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
|
||||
if group.units[0].type in self.AAA_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ewrs(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.EWR_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def armor_groups(self) -> Iterator[VehicleGroup]:
|
||||
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
|
||||
if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def helipads(self) -> Iterator[StaticGroup]:
|
||||
for group in itertools.chain(self.blue.static_group, self.red.static_group):
|
||||
if group.units[0].type in self.FARP_HELIPADS_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def factories(self) -> Iterator[StaticGroup]:
|
||||
for group in self.blue.static_group:
|
||||
if group.units[0].type in self.FACTORY_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ammunition_depots(self) -> Iterator[StaticGroup]:
|
||||
for group in itertools.chain(self.blue.static_group, self.red.static_group):
|
||||
if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def strike_targets(self) -> Iterator[StaticGroup]:
|
||||
for group in itertools.chain(self.blue.static_group, self.red.static_group):
|
||||
if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def scenery(self) -> List[SceneryGroup]:
|
||||
return SceneryGroup.from_trigger_zones(self.mission.triggers._zones)
|
||||
|
||||
@cached_property
|
||||
def control_points(self) -> Dict[int, ControlPoint]:
|
||||
control_points = {}
|
||||
for airport in self.mission.terrain.airport_list():
|
||||
if airport.is_blue() or airport.is_red():
|
||||
control_point = self.control_point_from_airport(airport)
|
||||
control_points[control_point.id] = control_point
|
||||
|
||||
for blue in (False, True):
|
||||
for group in self.off_map_spawns(blue):
|
||||
control_point = OffMapSpawn(
|
||||
next(self.control_point_id),
|
||||
str(group.name),
|
||||
group.position,
|
||||
starts_blue=blue,
|
||||
)
|
||||
control_point.captured_invert = group.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for ship in self.carriers(blue):
|
||||
control_point = Carrier(
|
||||
ship.name,
|
||||
ship.position,
|
||||
next(self.control_point_id),
|
||||
starts_blue=blue,
|
||||
)
|
||||
control_point.captured_invert = ship.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for ship in self.lhas(blue):
|
||||
control_point = Lha(
|
||||
ship.name,
|
||||
ship.position,
|
||||
next(self.control_point_id),
|
||||
starts_blue=blue,
|
||||
)
|
||||
control_point.captured_invert = ship.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for fob in self.fobs(blue):
|
||||
control_point = Fob(
|
||||
str(fob.name),
|
||||
fob.position,
|
||||
next(self.control_point_id),
|
||||
starts_blue=blue,
|
||||
)
|
||||
control_point.captured_invert = fob.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
|
||||
return control_points
|
||||
|
||||
@property
|
||||
def front_line_path_groups(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.country(blue=True).vehicle_group:
|
||||
if group.units[0].type == self.FRONT_LINE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def shipping_lane_groups(self) -> Iterator[ShipGroup]:
|
||||
for group in self.country(blue=True).ship_group:
|
||||
if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def add_supply_routes(self) -> None:
|
||||
for group in self.front_line_path_groups:
|
||||
# The unit will have its first waypoint at the source CP and the final
|
||||
# waypoint at the destination CP. Each waypoint defines the path of the
|
||||
# cargo ship.
|
||||
waypoints = [p.position for p in group.points]
|
||||
origin = self.theater.closest_control_point(waypoints[0])
|
||||
if origin is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the first waypoint of {group.name}"
|
||||
)
|
||||
destination = self.theater.closest_control_point(waypoints[-1])
|
||||
if destination is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the final waypoint of {group.name}"
|
||||
)
|
||||
|
||||
self.control_points[origin.id].create_convoy_route(destination, waypoints)
|
||||
self.control_points[destination.id].create_convoy_route(
|
||||
origin, list(reversed(waypoints))
|
||||
)
|
||||
|
||||
def add_shipping_lanes(self) -> None:
|
||||
for group in self.shipping_lane_groups:
|
||||
# The unit will have its first waypoint at the source CP and the final
|
||||
# waypoint at the destination CP. Each waypoint defines the path of the
|
||||
# cargo ship.
|
||||
waypoints = [p.position for p in group.points]
|
||||
origin = self.theater.closest_control_point(waypoints[0])
|
||||
if origin is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the first waypoint of {group.name}"
|
||||
)
|
||||
destination = self.theater.closest_control_point(waypoints[-1])
|
||||
if destination is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the final waypoint of {group.name}"
|
||||
)
|
||||
|
||||
self.control_points[origin.id].create_shipping_lane(destination, waypoints)
|
||||
self.control_points[destination.id].create_shipping_lane(
|
||||
origin, list(reversed(waypoints))
|
||||
)
|
||||
|
||||
def objective_info(
|
||||
self, near: Positioned, allow_naval: bool = False
|
||||
) -> Tuple[ControlPoint, Distance]:
|
||||
closest = self.theater.closest_control_point(near.position, allow_naval)
|
||||
distance = meters(closest.position.distance_to_point(near.position))
|
||||
return closest, distance
|
||||
|
||||
def add_preset_locations(self) -> None:
|
||||
for static in self.offshore_strike_targets:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.offshore_strike_locations.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for ship in self.ships:
|
||||
closest, distance = self.objective_info(ship, allow_naval=True)
|
||||
closest.preset_locations.ships.append(
|
||||
PointWithHeading.from_point(
|
||||
ship.position, Heading.from_degrees(ship.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.missile_sites:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.missile_sites.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.coastal_defenses:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.coastal_defenses.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.long_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.long_range_sams.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.medium_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.medium_range_sams.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.short_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.short_range_sams.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.aaa:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.aaa.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.ewrs:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.ewrs.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for group in self.armor_groups:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.armor_groups.append(
|
||||
PointWithHeading.from_point(
|
||||
group.position, Heading.from_degrees(group.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.helipads:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.helipads.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.factories:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.factories.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.ammunition_depots:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.ammunition_depots.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for static in self.strike_targets:
|
||||
closest, distance = self.objective_info(static)
|
||||
closest.preset_locations.strike_locations.append(
|
||||
PointWithHeading.from_point(
|
||||
static.position, Heading.from_degrees(static.units[0].heading)
|
||||
)
|
||||
)
|
||||
|
||||
for scenery_group in self.scenery:
|
||||
closest, distance = self.objective_info(scenery_group)
|
||||
closest.preset_locations.scenery.append(scenery_group)
|
||||
|
||||
def populate_theater(self) -> None:
|
||||
for control_point in self.control_points.values():
|
||||
self.theater.add_controlpoint(control_point)
|
||||
self.add_preset_locations()
|
||||
self.add_supply_routes()
|
||||
self.add_shipping_lanes()
|
||||
325
game/campaignloader/squadrondefgenerator.py
Normal file
325
game/campaignloader/squadrondefgenerator.py
Normal file
@@ -0,0 +1,325 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import random
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.squadrons.operatingbases import OperatingBases
|
||||
from game.squadrons.squadrondef import SquadronDef
|
||||
from game.theater import ControlPoint
|
||||
from gen.flights.ai_flight_planner_db import aircraft_for_task, tasks_for_aircraft
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.factions.faction import Faction
|
||||
|
||||
|
||||
class SquadronDefGenerator:
|
||||
def __init__(self, faction: Faction) -> None:
|
||||
self.faction = faction
|
||||
self.count = itertools.count(1)
|
||||
self.used_nicknames: set[str] = set()
|
||||
|
||||
def generate_for_task(
|
||||
self, task: FlightType, control_point: ControlPoint
|
||||
) -> Optional[SquadronDef]:
|
||||
aircraft_choice: Optional[AircraftType] = None
|
||||
for aircraft in aircraft_for_task(task):
|
||||
if aircraft not in self.faction.aircrafts:
|
||||
continue
|
||||
if not control_point.can_operate(aircraft):
|
||||
continue
|
||||
aircraft_choice = aircraft
|
||||
# 50/50 chance to keep looking for an aircraft that isn't as far up the
|
||||
# priority list to maintain some unit variety.
|
||||
if random.choice([True, False]):
|
||||
break
|
||||
|
||||
if aircraft_choice is None:
|
||||
return None
|
||||
return self.generate_for_aircraft(aircraft_choice)
|
||||
|
||||
def generate_for_aircraft(self, aircraft: AircraftType) -> SquadronDef:
|
||||
return SquadronDef(
|
||||
name=f"Squadron {next(self.count):03}",
|
||||
nickname=self.random_nickname(),
|
||||
country=self.faction.country,
|
||||
role="Flying Squadron",
|
||||
aircraft=aircraft,
|
||||
livery=None,
|
||||
mission_types=tuple(tasks_for_aircraft(aircraft)),
|
||||
operating_bases=OperatingBases.default_for_aircraft(aircraft),
|
||||
pilot_pool=[],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_random_nickname() -> str:
|
||||
from gen.naming import ANIMALS
|
||||
|
||||
animal = random.choice(ANIMALS)
|
||||
adjective = random.choice(
|
||||
(
|
||||
None,
|
||||
"Aggressive",
|
||||
"Alpha",
|
||||
"Ancient",
|
||||
"Angelic",
|
||||
"Angry",
|
||||
"Apoplectic",
|
||||
"Aquamarine",
|
||||
"Astral",
|
||||
"Avenging",
|
||||
"Azure",
|
||||
"Badass",
|
||||
"Barbaric",
|
||||
"Battle",
|
||||
"Battling",
|
||||
"Bellicose",
|
||||
"Belligerent",
|
||||
"Big",
|
||||
"Bionic",
|
||||
"Black",
|
||||
"Bladed",
|
||||
"Blazoned",
|
||||
"Blood",
|
||||
"Bloody",
|
||||
"Blue",
|
||||
"Bold",
|
||||
"Boxing",
|
||||
"Brash",
|
||||
"Brass",
|
||||
"Brave",
|
||||
"Brazen",
|
||||
"Bronze",
|
||||
"Brown",
|
||||
"Brutal",
|
||||
"Burning",
|
||||
"Buzzing",
|
||||
"Celestial",
|
||||
"Clever",
|
||||
"Cloud",
|
||||
"Cobalt",
|
||||
"Copper",
|
||||
"Coral",
|
||||
"Crazy",
|
||||
"Crimson",
|
||||
"Crouching",
|
||||
"Cursed",
|
||||
"Cyan",
|
||||
"Danger",
|
||||
"Dangerous",
|
||||
"Dapper",
|
||||
"Daring",
|
||||
"Dark",
|
||||
"Dawn",
|
||||
"Day",
|
||||
"Deadly",
|
||||
"Death",
|
||||
"Defiant",
|
||||
"Demon",
|
||||
"Desert",
|
||||
"Devil",
|
||||
"Devil's",
|
||||
"Diabolical",
|
||||
"Diamond",
|
||||
"Dire",
|
||||
"Dirty",
|
||||
"Doom",
|
||||
"Doomed",
|
||||
"Double",
|
||||
"Drunken",
|
||||
"Dusk",
|
||||
"Dusty",
|
||||
"Eager",
|
||||
"Ebony",
|
||||
"Electric",
|
||||
"Emerald",
|
||||
"Eternal",
|
||||
"Evil",
|
||||
"Faithful",
|
||||
"Famous",
|
||||
"Fanged",
|
||||
"Fearless",
|
||||
"Feisty",
|
||||
"Ferocious",
|
||||
"Fierce",
|
||||
"Fiery",
|
||||
"Fighting",
|
||||
"Fire",
|
||||
"First",
|
||||
"Flame",
|
||||
"Flaming",
|
||||
"Flying",
|
||||
"Forest",
|
||||
"Frenzied",
|
||||
"Frosty",
|
||||
"Frozen",
|
||||
"Furious",
|
||||
"Gallant",
|
||||
"Ghost",
|
||||
"Giant",
|
||||
"Gigantic",
|
||||
"Glaring",
|
||||
"Global",
|
||||
"Gold",
|
||||
"Golden",
|
||||
"Green",
|
||||
"Grey",
|
||||
"Grim",
|
||||
"Grizzly",
|
||||
"Growling",
|
||||
"Grumpy",
|
||||
"Hammer",
|
||||
"Hard",
|
||||
"Hardy",
|
||||
"Heavy",
|
||||
"Hell",
|
||||
"Hell's",
|
||||
"Hidden",
|
||||
"Homicidal",
|
||||
"Hostile",
|
||||
"Howling",
|
||||
"Hyper",
|
||||
"Ice",
|
||||
"Icy",
|
||||
"Immortal",
|
||||
"Indignant",
|
||||
"Infamous",
|
||||
"Invincible",
|
||||
"Iron",
|
||||
"Jolly",
|
||||
"Laser",
|
||||
"Lava",
|
||||
"Lavender",
|
||||
"Lethal",
|
||||
"Light",
|
||||
"Lightning",
|
||||
"Livid",
|
||||
"Lucky",
|
||||
"Mad",
|
||||
"Magenta",
|
||||
"Magma",
|
||||
"Maroon",
|
||||
"Menacing",
|
||||
"Merciless",
|
||||
"Metal",
|
||||
"Midnight",
|
||||
"Mighty",
|
||||
"Mithril",
|
||||
"Mocking",
|
||||
"Moon",
|
||||
"Mountain",
|
||||
"Muddy",
|
||||
"Nasty",
|
||||
"Naughty",
|
||||
"Night",
|
||||
"Nova",
|
||||
"Nutty",
|
||||
"Obsidian",
|
||||
"Ocean",
|
||||
"Oddball",
|
||||
"Old",
|
||||
"Omega",
|
||||
"Onyx",
|
||||
"Orange",
|
||||
"Perky",
|
||||
"Pink",
|
||||
"Power",
|
||||
"Prickly",
|
||||
"Proud",
|
||||
"Puckered",
|
||||
"Pugnacious",
|
||||
"Puking",
|
||||
"Purple",
|
||||
"Ragged",
|
||||
"Raging",
|
||||
"Rainbow",
|
||||
"Rampant",
|
||||
"Razor",
|
||||
"Ready",
|
||||
"Reaper",
|
||||
"Reckless",
|
||||
"Red",
|
||||
"Roaring",
|
||||
"Rocky",
|
||||
"Rolling",
|
||||
"Royal",
|
||||
"Rusty",
|
||||
"Sable",
|
||||
"Salty",
|
||||
"Sand",
|
||||
"Sarcastic",
|
||||
"Saucy",
|
||||
"Scarlet",
|
||||
"Scarred",
|
||||
"Scary",
|
||||
"Screaming",
|
||||
"Scythed",
|
||||
"Shadow",
|
||||
"Shiny",
|
||||
"Shocking",
|
||||
"Silver",
|
||||
"Sky",
|
||||
"Smoke",
|
||||
"Smokin'",
|
||||
"Snapping",
|
||||
"Snappy",
|
||||
"Snarling",
|
||||
"Snow",
|
||||
"Soaring",
|
||||
"Space",
|
||||
"Spiky",
|
||||
"Spiny",
|
||||
"Star",
|
||||
"Steady",
|
||||
"Steel",
|
||||
"Stone",
|
||||
"Storm",
|
||||
"Striking",
|
||||
"Strong",
|
||||
"Stubborn",
|
||||
"Sun",
|
||||
"Super",
|
||||
"Terrible",
|
||||
"Thorny",
|
||||
"Thunder",
|
||||
"Top",
|
||||
"Tough",
|
||||
"Toxic",
|
||||
"Tricky",
|
||||
"Turquoise",
|
||||
"Typhoon",
|
||||
"Ultimate",
|
||||
"Ultra",
|
||||
"Ultramarine",
|
||||
"Vengeful",
|
||||
"Venom",
|
||||
"Vermillion",
|
||||
"Vicious",
|
||||
"Victorious",
|
||||
"Vigilant",
|
||||
"Violent",
|
||||
"Violet",
|
||||
"War",
|
||||
"Water",
|
||||
"Whistling",
|
||||
"White",
|
||||
"Wicked",
|
||||
"Wild",
|
||||
"Wizard",
|
||||
"Wrathful",
|
||||
"Yellow",
|
||||
"Young",
|
||||
)
|
||||
)
|
||||
if adjective is None:
|
||||
return animal.title()
|
||||
return f"{adjective} {animal}".title()
|
||||
|
||||
def random_nickname(self) -> str:
|
||||
while True:
|
||||
nickname = self._make_random_nickname()
|
||||
if nickname not in self.used_nicknames:
|
||||
self.used_nicknames.add(nickname)
|
||||
return nickname
|
||||
235
game/coalition.py
Normal file
235
game/coalition.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from dcs import Point
|
||||
from faker import Faker
|
||||
|
||||
from game.campaignloader import CampaignAirWingConfig
|
||||
from game.campaignloader.defaultsquadronassigner import DefaultSquadronAssigner
|
||||
from game.commander import TheaterCommander
|
||||
from game.commander.missionscheduler import MissionScheduler
|
||||
from game.income import Income
|
||||
from game.navmesh import NavMesh
|
||||
from game.orderedset import OrderedSet
|
||||
from game.profiling import logged_duration, MultiEventTracer
|
||||
from game.squadrons import AirWing
|
||||
from game.threatzones import ThreatZones
|
||||
from game.transfers import PendingTransfers
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.factions.faction import Faction
|
||||
from game.procurement import AircraftProcurementRequest, ProcurementAi
|
||||
from game.theater.bullseye import Bullseye
|
||||
from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
|
||||
from gen.ato import AirTaskingOrder
|
||||
|
||||
|
||||
class Coalition:
|
||||
def __init__(
|
||||
self, game: Game, faction: Faction, budget: float, player: bool
|
||||
) -> None:
|
||||
self.game = game
|
||||
self.player = player
|
||||
self.faction = faction
|
||||
self.budget = budget
|
||||
self.ato = AirTaskingOrder()
|
||||
self.transit_network = TransitNetwork()
|
||||
self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet()
|
||||
self.bullseye = Bullseye(Point(0, 0))
|
||||
self.faker = Faker(self.faction.locales)
|
||||
self.air_wing = AirWing(player, game, self.faction)
|
||||
self.transfers = PendingTransfers(game, player)
|
||||
|
||||
# Late initialized because the two coalitions in the game are mutually
|
||||
# dependent, so must be both constructed before this property can be set.
|
||||
self._opponent: Optional[Coalition] = None
|
||||
|
||||
# Volatile properties that are not persisted to the save file since they can be
|
||||
# recomputed on load. Keeping this data out of the save file makes save compat
|
||||
# breaks less frequent. Each of these properties has a non-underscore-prefixed
|
||||
# @property that should be used for non-Optional access.
|
||||
#
|
||||
# All of these are late-initialized (whether via on_load or called later), but
|
||||
# will be non-None after the game has finished loading.
|
||||
self._threat_zone: Optional[ThreatZones] = None
|
||||
self._navmesh: Optional[NavMesh] = None
|
||||
self.on_load()
|
||||
|
||||
@property
|
||||
def doctrine(self) -> Doctrine:
|
||||
return self.faction.doctrine
|
||||
|
||||
@property
|
||||
def coalition_id(self) -> int:
|
||||
if self.player:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
@property
|
||||
def country_name(self) -> str:
|
||||
return self.faction.country
|
||||
|
||||
@property
|
||||
def opponent(self) -> Coalition:
|
||||
assert self._opponent is not None
|
||||
return self._opponent
|
||||
|
||||
@property
|
||||
def threat_zone(self) -> ThreatZones:
|
||||
assert self._threat_zone is not None
|
||||
return self._threat_zone
|
||||
|
||||
@property
|
||||
def nav_mesh(self) -> NavMesh:
|
||||
assert self._navmesh is not None
|
||||
return self._navmesh
|
||||
|
||||
def __getstate__(self) -> dict[str, Any]:
|
||||
state = self.__dict__.copy()
|
||||
# Avoid persisting any volatile types that can be deterministically
|
||||
# recomputed on load for the sake of save compatibility.
|
||||
del state["_threat_zone"]
|
||||
del state["_navmesh"]
|
||||
del state["faker"]
|
||||
return state
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
self.__dict__.update(state)
|
||||
# Regenerate any state that was not persisted.
|
||||
self.on_load()
|
||||
|
||||
def on_load(self) -> None:
|
||||
self.faker = Faker(self.faction.locales)
|
||||
|
||||
def set_opponent(self, opponent: Coalition) -> None:
|
||||
if self._opponent is not None:
|
||||
raise RuntimeError("Double-initialization of Coalition.opponent")
|
||||
self._opponent = opponent
|
||||
|
||||
def configure_default_air_wing(
|
||||
self, air_wing_config: CampaignAirWingConfig
|
||||
) -> None:
|
||||
DefaultSquadronAssigner(air_wing_config, self.game, self).assign()
|
||||
|
||||
def adjust_budget(self, amount: float) -> None:
|
||||
self.budget += amount
|
||||
|
||||
def compute_threat_zones(self) -> None:
|
||||
self._threat_zone = ThreatZones.for_faction(self.game, self.player)
|
||||
|
||||
def compute_nav_meshes(self) -> None:
|
||||
self._navmesh = NavMesh.from_threat_zones(
|
||||
self.opponent.threat_zone, self.game.theater
|
||||
)
|
||||
|
||||
def update_transit_network(self) -> None:
|
||||
self.transit_network = TransitNetworkBuilder(
|
||||
self.game.theater, self.player
|
||||
).build()
|
||||
|
||||
def set_bullseye(self, bullseye: Bullseye) -> None:
|
||||
self.bullseye = bullseye
|
||||
|
||||
def end_turn(self) -> None:
|
||||
"""Processes coalition-specific turn finalization.
|
||||
|
||||
For more information on turn finalization in general, see the documentation for
|
||||
`Game.finish_turn`.
|
||||
"""
|
||||
self.air_wing.end_turn()
|
||||
self.budget += Income(self.game, self.player).total
|
||||
|
||||
# Need to recompute before transfers and deliveries to account for captures.
|
||||
# This happens in in initialize_turn as well, because cheating doesn't advance a
|
||||
# turn but can capture bases so we need to recompute there as well.
|
||||
self.update_transit_network()
|
||||
|
||||
# Must happen *before* unit deliveries are handled, or else new units will spawn
|
||||
# one hop ahead. ControlPoint.process_turn handles unit deliveries. The
|
||||
# coalition-specific turn-end happens before the theater-wide turn-end, so this
|
||||
# is handled correctly.
|
||||
self.transfers.perform_transfers()
|
||||
|
||||
def preinit_turn_0(self) -> None:
|
||||
"""Runs final Coalition initialization.
|
||||
|
||||
Final initialization occurs before Game.initialize_turn runs for turn 0.
|
||||
"""
|
||||
self.air_wing.populate_for_turn_0()
|
||||
|
||||
def initialize_turn(self) -> None:
|
||||
"""Processes coalition-specific turn initialization.
|
||||
|
||||
For more information on turn initialization in general, see the documentation
|
||||
for `Game.initialize_turn`.
|
||||
"""
|
||||
# Needs to happen *before* planning transfers so we don't cancel them.
|
||||
self.ato.clear()
|
||||
self.air_wing.reset()
|
||||
self.refund_outstanding_orders()
|
||||
self.procurement_requests.clear()
|
||||
|
||||
with logged_duration("Transit network identification"):
|
||||
self.update_transit_network()
|
||||
with logged_duration("Procurement of airlift assets"):
|
||||
self.transfers.order_airlift_assets()
|
||||
with logged_duration("Transport planning"):
|
||||
self.transfers.plan_transports()
|
||||
|
||||
self.plan_missions()
|
||||
self.plan_procurement()
|
||||
|
||||
def refund_outstanding_orders(self) -> None:
|
||||
# TODO: Split orders between air and ground units.
|
||||
# This isn't quite right. If the player has ground purchases automated we should
|
||||
# be refunding the ground units, and if they have air automated but not ground
|
||||
# we should be refunding air units.
|
||||
if self.player and not self.game.settings.automate_aircraft_reinforcements:
|
||||
return
|
||||
|
||||
for cp in self.game.theater.control_points_for(self.player):
|
||||
cp.ground_unit_orders.refund_all(self)
|
||||
for squadron in self.air_wing.iter_squadrons():
|
||||
squadron.refund_orders()
|
||||
|
||||
def plan_missions(self) -> None:
|
||||
color = "Blue" if self.player else "Red"
|
||||
with MultiEventTracer() as tracer:
|
||||
with tracer.trace(f"{color} mission planning"):
|
||||
with tracer.trace(f"{color} mission identification"):
|
||||
TheaterCommander(self.game, self.player).plan_missions(tracer)
|
||||
with tracer.trace(f"{color} mission scheduling"):
|
||||
MissionScheduler(
|
||||
self, self.game.settings.desired_player_mission_duration
|
||||
).schedule_missions()
|
||||
|
||||
def plan_procurement(self) -> None:
|
||||
# The first turn needs to buy a *lot* of aircraft to fill CAPs, so it gets much
|
||||
# more of the budget that turn. Otherwise budget (after repairs) is split evenly
|
||||
# between air and ground. For the default starting budget of 2000 this gives 600
|
||||
# to ground forces and 1400 to aircraft. After that the budget will be spent
|
||||
# proportionally based on how much is already invested.
|
||||
|
||||
if self.player:
|
||||
manage_runways = self.game.settings.automate_runway_repair
|
||||
manage_front_line = self.game.settings.automate_front_line_reinforcements
|
||||
manage_aircraft = self.game.settings.automate_aircraft_reinforcements
|
||||
else:
|
||||
manage_runways = True
|
||||
manage_front_line = True
|
||||
manage_aircraft = True
|
||||
|
||||
self.budget = ProcurementAi(
|
||||
self.game,
|
||||
self.player,
|
||||
self.faction,
|
||||
manage_runways,
|
||||
manage_front_line,
|
||||
manage_aircraft,
|
||||
).spend_budget(self.budget)
|
||||
|
||||
def add_procurement_request(self, request: AircraftProcurementRequest) -> None:
|
||||
self.procurement_requests.add(request)
|
||||
1
game/commander/__init__.py
Normal file
1
game/commander/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .theatercommander import TheaterCommander
|
||||
52
game/commander/garrisons.py
Normal file
52
game/commander/garrisons.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.theater import ControlPoint
|
||||
from game.theater.theatergroundobject import VehicleGroupGroundObject
|
||||
from game.utils import meters
|
||||
|
||||
|
||||
@dataclass
|
||||
class Garrisons:
|
||||
blocking_capture: list[VehicleGroupGroundObject]
|
||||
defending_front_line: list[VehicleGroupGroundObject]
|
||||
|
||||
@property
|
||||
def in_priority_order(self) -> Iterator[VehicleGroupGroundObject]:
|
||||
yield from self.blocking_capture
|
||||
yield from self.defending_front_line
|
||||
|
||||
def eliminate(self, garrison: VehicleGroupGroundObject) -> None:
|
||||
if garrison in self.blocking_capture:
|
||||
self.blocking_capture.remove(garrison)
|
||||
if garrison in self.defending_front_line:
|
||||
self.defending_front_line.remove(garrison)
|
||||
|
||||
def __contains__(self, item: VehicleGroupGroundObject) -> bool:
|
||||
return item in self.in_priority_order
|
||||
|
||||
@classmethod
|
||||
def for_control_point(cls, control_point: ControlPoint) -> Garrisons:
|
||||
"""Categorize garrison groups based on target priority.
|
||||
|
||||
Any garrisons blocking base capture are the highest priority.
|
||||
"""
|
||||
blocking = []
|
||||
defending = []
|
||||
garrisons = [
|
||||
tgo
|
||||
for tgo in control_point.ground_objects
|
||||
if isinstance(tgo, VehicleGroupGroundObject) and not tgo.is_dead
|
||||
]
|
||||
for garrison in garrisons:
|
||||
if (
|
||||
meters(garrison.distance_to(control_point))
|
||||
< ControlPoint.CAPTURE_DISTANCE
|
||||
):
|
||||
blocking.append(garrison)
|
||||
else:
|
||||
defending.append(garrison)
|
||||
|
||||
return Garrisons(blocking, defending)
|
||||
58
game/commander/missionproposals.py
Normal file
58
game/commander/missionproposals.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from dataclasses import field, dataclass
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
from game.theater import MissionTarget
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
class EscortType(Enum):
|
||||
AirToAir = auto()
|
||||
Sead = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProposedFlight:
|
||||
"""A flight outline proposed by the mission planner.
|
||||
|
||||
Proposed flights haven't been assigned specific aircraft yet. They have only
|
||||
a task, a required number of aircraft, and a maximum distance allowed
|
||||
between the objective and the departure airfield.
|
||||
"""
|
||||
|
||||
#: The flight's role.
|
||||
task: FlightType
|
||||
|
||||
#: The number of aircraft required.
|
||||
num_aircraft: int
|
||||
|
||||
#: The type of threat this flight defends against if it is an escort. Escort
|
||||
#: flights will be pruned if the rest of the package is not threatened by
|
||||
#: the threat they defend against. If this flight is not an escort, this
|
||||
#: field is None.
|
||||
escort_type: Optional[EscortType] = field(default=None)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.task} {self.num_aircraft} ship"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProposedMission:
|
||||
"""A mission outline proposed by the mission planner.
|
||||
|
||||
Proposed missions haven't been assigned aircraft yet. They have only an
|
||||
objective location and a list of proposed flights that are required for the
|
||||
mission.
|
||||
"""
|
||||
|
||||
#: The mission objective.
|
||||
location: MissionTarget
|
||||
|
||||
#: The proposed flights that are required for the mission.
|
||||
flights: list[ProposedFlight]
|
||||
|
||||
asap: bool = field(default=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
flights = ", ".join([str(f) for f in self.flights])
|
||||
return f"{self.location.name}: {flights}"
|
||||
76
game/commander/missionscheduler.py
Normal file
76
game/commander/missionscheduler.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from typing import Iterator, Dict, TYPE_CHECKING
|
||||
|
||||
from game.theater import MissionTarget
|
||||
from gen.flights.flight import FlightType
|
||||
from gen.flights.traveltime import TotEstimator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class MissionScheduler:
|
||||
def __init__(self, coalition: Coalition, desired_mission_length: timedelta) -> None:
|
||||
self.coalition = coalition
|
||||
self.desired_mission_length = desired_mission_length
|
||||
|
||||
def schedule_missions(self) -> None:
|
||||
"""Identifies and plans mission for the turn."""
|
||||
|
||||
def start_time_generator(
|
||||
count: int, earliest: int, latest: int, margin: int
|
||||
) -> Iterator[timedelta]:
|
||||
interval = (latest - earliest) // count
|
||||
for time in range(earliest, latest, interval):
|
||||
error = random.randint(-margin, margin)
|
||||
yield timedelta(seconds=max(0, time + error))
|
||||
|
||||
dca_types = {
|
||||
FlightType.BARCAP,
|
||||
FlightType.TARCAP,
|
||||
}
|
||||
|
||||
previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(timedelta)
|
||||
non_dca_packages = [
|
||||
p for p in self.coalition.ato.packages if p.primary_task not in dca_types
|
||||
]
|
||||
|
||||
start_time = start_time_generator(
|
||||
count=len(non_dca_packages),
|
||||
earliest=5 * 60,
|
||||
latest=int(self.desired_mission_length.total_seconds()),
|
||||
margin=5 * 60,
|
||||
)
|
||||
for package in self.coalition.ato.packages:
|
||||
tot = TotEstimator(package).earliest_tot()
|
||||
if package.primary_task in dca_types:
|
||||
previous_end_time = previous_cap_end_time[package.target]
|
||||
if tot > previous_end_time:
|
||||
# Can't get there exactly on time, so get there ASAP. This
|
||||
# will typically only happen for the first CAP at each
|
||||
# target.
|
||||
package.time_over_target = tot
|
||||
else:
|
||||
package.time_over_target = previous_end_time
|
||||
|
||||
departure_time = package.mission_departure_time
|
||||
# Should be impossible for CAPs
|
||||
if departure_time is None:
|
||||
logging.error(f"Could not determine mission end time for {package}")
|
||||
continue
|
||||
previous_cap_end_time[package.target] = departure_time
|
||||
elif package.auto_asap:
|
||||
package.set_tot_asap()
|
||||
else:
|
||||
# But other packages should be spread out a bit. Note that take
|
||||
# times are delayed, but all aircraft will become active at
|
||||
# mission start. This makes it more worthwhile to attack enemy
|
||||
# airfields to hit grounded aircraft, since they're more likely
|
||||
# to be present. Runway and air started aircraft will be
|
||||
# delayed until their takeoff time by AirConflictGenerator.
|
||||
package.time_over_target = next(start_time) + tot
|
||||
246
game/commander/objectivefinder.py
Normal file
246
game/commander/objectivefinder.py
Normal file
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import operator
|
||||
from collections import Iterator, Iterable
|
||||
from typing import TypeVar, TYPE_CHECKING
|
||||
|
||||
from game.theater import (
|
||||
ControlPoint,
|
||||
OffMapSpawn,
|
||||
MissionTarget,
|
||||
Fob,
|
||||
FrontLine,
|
||||
Airfield,
|
||||
)
|
||||
from game.theater.theatergroundobject import (
|
||||
BuildingGroundObject,
|
||||
IadsGroundObject,
|
||||
NavalGroundObject,
|
||||
)
|
||||
from game.utils import meters, nautical_miles
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache, ClosestAirfields
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.transfers import CargoShip, Convoy
|
||||
|
||||
MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget)
|
||||
|
||||
|
||||
class ObjectiveFinder:
|
||||
"""Identifies potential objectives for the mission planner."""
|
||||
|
||||
# TODO: Merge into doctrine.
|
||||
AIRFIELD_THREAT_RANGE = nautical_miles(150)
|
||||
SAM_THREAT_RANGE = nautical_miles(100)
|
||||
|
||||
def __init__(self, game: Game, is_player: bool) -> None:
|
||||
self.game = game
|
||||
self.is_player = is_player
|
||||
|
||||
def enemy_air_defenses(self) -> Iterator[IadsGroundObject]:
|
||||
"""Iterates over all enemy SAM sites."""
|
||||
for cp in self.enemy_control_points():
|
||||
for ground_object in cp.ground_objects:
|
||||
if ground_object.is_dead:
|
||||
continue
|
||||
|
||||
if isinstance(ground_object, IadsGroundObject):
|
||||
yield ground_object
|
||||
|
||||
def enemy_ships(self) -> Iterator[NavalGroundObject]:
|
||||
for cp in self.enemy_control_points():
|
||||
for ground_object in cp.ground_objects:
|
||||
if not isinstance(ground_object, NavalGroundObject):
|
||||
continue
|
||||
|
||||
if ground_object.is_dead:
|
||||
continue
|
||||
|
||||
yield ground_object
|
||||
|
||||
def threatening_ships(self) -> Iterator[NavalGroundObject]:
|
||||
"""Iterates over enemy ships near friendly control points.
|
||||
|
||||
Groups are sorted by their closest proximity to any friendly control
|
||||
point (airfield or fleet).
|
||||
"""
|
||||
return self._targets_by_range(self.enemy_ships())
|
||||
|
||||
def _targets_by_range(
|
||||
self, targets: Iterable[MissionTargetType]
|
||||
) -> Iterator[MissionTargetType]:
|
||||
target_ranges: list[tuple[MissionTargetType, float]] = []
|
||||
for target in targets:
|
||||
ranges: list[float] = []
|
||||
for cp in self.friendly_control_points():
|
||||
ranges.append(target.distance_to(cp))
|
||||
target_ranges.append((target, min(ranges)))
|
||||
|
||||
target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
|
||||
for target, _range in target_ranges:
|
||||
yield target
|
||||
|
||||
def strike_targets(self) -> Iterator[BuildingGroundObject]:
|
||||
"""Iterates over enemy strike targets.
|
||||
|
||||
Targets are sorted by their closest proximity to any friendly control
|
||||
point (airfield or fleet).
|
||||
"""
|
||||
targets: list[tuple[BuildingGroundObject, float]] = []
|
||||
# Building objectives are made of several individual TGOs (one per
|
||||
# building).
|
||||
found_targets: set[str] = set()
|
||||
for enemy_cp in self.enemy_control_points():
|
||||
for ground_object in enemy_cp.ground_objects:
|
||||
# TODO: Reuse ground_object.mission_types.
|
||||
# The mission types for ground objects are currently not
|
||||
# accurate because we include things like strike and BAI for all
|
||||
# targets since they have different planning behavior (waypoint
|
||||
# generation is better for players with strike when the targets
|
||||
# are stationary, AI behavior against weaker air defenses is
|
||||
# better with BAI), so that's not a useful filter. Once we have
|
||||
# better control over planning profiles and target dependent
|
||||
# loadouts we can clean this up.
|
||||
if not isinstance(ground_object, BuildingGroundObject):
|
||||
# Other group types (like ships, SAMs, garrisons, etc) have better
|
||||
# suited mission types like anti-ship, DEAD, and BAI.
|
||||
continue
|
||||
|
||||
if isinstance(enemy_cp, Fob) and ground_object.is_control_point:
|
||||
# This is the FOB structure itself. Can't be repaired or
|
||||
# targeted by the player, so shouldn't be targetable by the
|
||||
# AI.
|
||||
continue
|
||||
|
||||
if ground_object.is_dead:
|
||||
continue
|
||||
if ground_object.name in found_targets:
|
||||
continue
|
||||
ranges: list[float] = []
|
||||
for friendly_cp in self.friendly_control_points():
|
||||
ranges.append(ground_object.distance_to(friendly_cp))
|
||||
targets.append((ground_object, min(ranges)))
|
||||
found_targets.add(ground_object.name)
|
||||
targets = sorted(targets, key=operator.itemgetter(1))
|
||||
for target, _range in targets:
|
||||
yield target
|
||||
|
||||
def front_lines(self) -> Iterator[FrontLine]:
|
||||
"""Iterates over all active front lines in the theater."""
|
||||
yield from self.game.theater.conflicts()
|
||||
|
||||
def vulnerable_control_points(self) -> Iterator[ControlPoint]:
|
||||
"""Iterates over friendly CPs that are vulnerable to enemy CPs.
|
||||
|
||||
Vulnerability is defined as any enemy CP within threat range of of the
|
||||
CP.
|
||||
"""
|
||||
for cp in self.friendly_control_points():
|
||||
if isinstance(cp, OffMapSpawn):
|
||||
# Off-map spawn locations don't need protection.
|
||||
continue
|
||||
airfields_in_proximity = self.closest_airfields_to(cp)
|
||||
airfields_in_threat_range = (
|
||||
airfields_in_proximity.operational_airfields_within(
|
||||
self.AIRFIELD_THREAT_RANGE
|
||||
)
|
||||
)
|
||||
for airfield in airfields_in_threat_range:
|
||||
if not airfield.is_friendly(self.is_player):
|
||||
yield cp
|
||||
break
|
||||
|
||||
def oca_targets(self, min_aircraft: int) -> Iterator[ControlPoint]:
|
||||
airfields = []
|
||||
for control_point in self.enemy_control_points():
|
||||
if not isinstance(control_point, Airfield):
|
||||
continue
|
||||
if control_point.allocated_aircraft().total_present >= min_aircraft:
|
||||
airfields.append(control_point)
|
||||
return self._targets_by_range(airfields)
|
||||
|
||||
def convoys(self) -> Iterator[Convoy]:
|
||||
for front_line in self.front_lines():
|
||||
yield from self.game.coalition_for(
|
||||
self.is_player
|
||||
).transfers.convoys.travelling_to(
|
||||
front_line.control_point_hostile_to(self.is_player)
|
||||
)
|
||||
|
||||
def cargo_ships(self) -> Iterator[CargoShip]:
|
||||
for front_line in self.front_lines():
|
||||
yield from self.game.coalition_for(
|
||||
self.is_player
|
||||
).transfers.cargo_ships.travelling_to(
|
||||
front_line.control_point_hostile_to(self.is_player)
|
||||
)
|
||||
|
||||
def friendly_control_points(self) -> Iterator[ControlPoint]:
|
||||
"""Iterates over all friendly control points."""
|
||||
return (
|
||||
c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player)
|
||||
)
|
||||
|
||||
def farthest_friendly_control_point(self) -> ControlPoint:
|
||||
"""Finds the friendly control point that is farthest from any threats."""
|
||||
threat_zones = self.game.threat_zone_for(not self.is_player)
|
||||
|
||||
farthest = None
|
||||
max_distance = meters(0)
|
||||
for cp in self.friendly_control_points():
|
||||
if isinstance(cp, OffMapSpawn):
|
||||
continue
|
||||
distance = threat_zones.distance_to_threat(cp.position)
|
||||
if distance > max_distance:
|
||||
farthest = cp
|
||||
max_distance = distance
|
||||
|
||||
if farthest is None:
|
||||
raise RuntimeError("Found no friendly control points. You probably lost.")
|
||||
return farthest
|
||||
|
||||
def closest_friendly_control_point(self) -> ControlPoint:
|
||||
"""Finds the friendly control point that is closest to any threats."""
|
||||
threat_zones = self.game.threat_zone_for(not self.is_player)
|
||||
|
||||
closest = None
|
||||
min_distance = meters(math.inf)
|
||||
for cp in self.friendly_control_points():
|
||||
if isinstance(cp, OffMapSpawn):
|
||||
continue
|
||||
distance = threat_zones.distance_to_threat(cp.position)
|
||||
if distance < min_distance:
|
||||
closest = cp
|
||||
min_distance = distance
|
||||
|
||||
if closest is None:
|
||||
raise RuntimeError("Found no friendly control points. You probably lost.")
|
||||
return closest
|
||||
|
||||
def enemy_control_points(self) -> Iterator[ControlPoint]:
|
||||
"""Iterates over all enemy control points."""
|
||||
return (
|
||||
c
|
||||
for c in self.game.theater.controlpoints
|
||||
if not c.is_friendly(self.is_player)
|
||||
)
|
||||
|
||||
def prioritized_unisolated_points(self) -> list[ControlPoint]:
|
||||
prioritized = []
|
||||
capturable_later = []
|
||||
for cp in self.game.theater.control_points_for(not self.is_player):
|
||||
if cp.is_isolated:
|
||||
continue
|
||||
if cp.has_active_frontline:
|
||||
prioritized.append(cp)
|
||||
else:
|
||||
capturable_later.append(cp)
|
||||
prioritized.extend(self._targets_by_range(capturable_later))
|
||||
return prioritized
|
||||
|
||||
@staticmethod
|
||||
def closest_airfields_to(location: MissionTarget) -> ClosestAirfields:
|
||||
"""Returns the closest airfields to the given location."""
|
||||
return ObjectiveDistanceCache.get_closest_airfields(location)
|
||||
94
game/commander/packagebuilder.py
Normal file
94
game/commander/packagebuilder.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from game.utils import nautical_miles
|
||||
from gen.ato import Package
|
||||
from game.theater import MissionTarget, OffMapSpawn, ControlPoint
|
||||
from gen.flights.flight import Flight
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.squadrons.airwing import AirWing
|
||||
from gen.flights.closestairfields import ClosestAirfields
|
||||
from .missionproposals import ProposedFlight
|
||||
|
||||
|
||||
class PackageBuilder:
|
||||
"""Builds a Package for the flights it receives."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
location: MissionTarget,
|
||||
closest_airfields: ClosestAirfields,
|
||||
air_wing: AirWing,
|
||||
is_player: bool,
|
||||
package_country: str,
|
||||
start_type: str,
|
||||
asap: bool,
|
||||
) -> None:
|
||||
self.closest_airfields = closest_airfields
|
||||
self.is_player = is_player
|
||||
self.package_country = package_country
|
||||
self.package = Package(location, auto_asap=asap)
|
||||
self.air_wing = air_wing
|
||||
self.start_type = start_type
|
||||
|
||||
def plan_flight(self, plan: ProposedFlight) -> bool:
|
||||
"""Allocates aircraft for the given flight and adds them to the package.
|
||||
|
||||
If no suitable aircraft are available, False is returned. If the failed
|
||||
flight was critical and the rest of the mission will be scrubbed, the
|
||||
caller should return any previously planned flights to the inventory
|
||||
using release_planned_aircraft.
|
||||
"""
|
||||
squadron = self.air_wing.best_squadron_for(
|
||||
self.package.target, plan.task, plan.num_aircraft, this_turn=True
|
||||
)
|
||||
if squadron is None:
|
||||
return False
|
||||
start_type = squadron.location.required_aircraft_start_type
|
||||
if start_type is None:
|
||||
start_type = self.start_type
|
||||
|
||||
flight = Flight(
|
||||
self.package,
|
||||
self.package_country,
|
||||
squadron,
|
||||
plan.num_aircraft,
|
||||
plan.task,
|
||||
start_type,
|
||||
divert=self.find_divert_field(squadron.aircraft, squadron.location),
|
||||
)
|
||||
self.package.add_flight(flight)
|
||||
return True
|
||||
|
||||
def find_divert_field(
|
||||
self, aircraft: AircraftType, arrival: ControlPoint
|
||||
) -> Optional[ControlPoint]:
|
||||
divert_limit = nautical_miles(150)
|
||||
for airfield in self.closest_airfields.operational_airfields_within(
|
||||
divert_limit
|
||||
):
|
||||
if airfield.captured != self.is_player:
|
||||
continue
|
||||
if airfield == arrival:
|
||||
continue
|
||||
if not airfield.can_operate(aircraft):
|
||||
continue
|
||||
if isinstance(airfield, OffMapSpawn):
|
||||
continue
|
||||
return airfield
|
||||
return None
|
||||
|
||||
def build(self) -> Package:
|
||||
"""Returns the built package."""
|
||||
return self.package
|
||||
|
||||
def release_planned_aircraft(self) -> None:
|
||||
"""Returns any planned flights to the inventory."""
|
||||
flights = list(self.package.flights)
|
||||
for flight in flights:
|
||||
flight.return_pilots_and_aircraft()
|
||||
self.package.remove_flight(flight)
|
||||
221
game/commander/packagefulfiller.py
Normal file
221
game/commander/packagefulfiller.py
Normal file
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional
|
||||
|
||||
from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType
|
||||
from game.commander.packagebuilder import PackageBuilder
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.procurement import AircraftProcurementRequest
|
||||
from game.profiling import MultiEventTracer
|
||||
from game.settings import Settings
|
||||
from game.squadrons import AirWing
|
||||
from game.theater import ConflictTheater
|
||||
from game.threatzones import ThreatZones
|
||||
from gen.ato import AirTaskingOrder, Package
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import FlightType
|
||||
from gen.flights.flightplan import FlightPlanBuilder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class PackageFulfiller:
|
||||
"""Responsible for package aircraft allocation and flight plan layout."""
|
||||
|
||||
def __init__(
|
||||
self, coalition: Coalition, theater: ConflictTheater, settings: Settings
|
||||
) -> None:
|
||||
self.coalition = coalition
|
||||
self.theater = theater
|
||||
self.player_missions_asap = settings.auto_ato_player_missions_asap
|
||||
self.default_start_type = settings.default_start_type
|
||||
|
||||
@property
|
||||
def is_player(self) -> bool:
|
||||
return self.coalition.player
|
||||
|
||||
@property
|
||||
def ato(self) -> AirTaskingOrder:
|
||||
return self.coalition.ato
|
||||
|
||||
@property
|
||||
def air_wing(self) -> AirWing:
|
||||
return self.coalition.air_wing
|
||||
|
||||
@property
|
||||
def doctrine(self) -> Doctrine:
|
||||
return self.coalition.doctrine
|
||||
|
||||
@property
|
||||
def threat_zones(self) -> ThreatZones:
|
||||
return self.coalition.opponent.threat_zone
|
||||
|
||||
def add_procurement_request(self, request: AircraftProcurementRequest) -> None:
|
||||
self.coalition.add_procurement_request(request)
|
||||
|
||||
def air_wing_can_plan(self, mission_type: FlightType) -> bool:
|
||||
"""Returns True if it is possible for the air wing to plan this mission type.
|
||||
|
||||
Not all mission types can be fulfilled by all air wings. Many factions do not
|
||||
have AEW&C aircraft, so they will never be able to plan those missions. It's
|
||||
also possible for the player to exclude mission types from their squadron
|
||||
designs.
|
||||
"""
|
||||
return self.air_wing.can_auto_plan(mission_type)
|
||||
|
||||
def plan_flight(
|
||||
self,
|
||||
mission: ProposedMission,
|
||||
flight: ProposedFlight,
|
||||
builder: PackageBuilder,
|
||||
missing_types: Set[FlightType],
|
||||
purchase_multiplier: int,
|
||||
) -> None:
|
||||
if not builder.plan_flight(flight):
|
||||
missing_types.add(flight.task)
|
||||
purchase_order = AircraftProcurementRequest(
|
||||
near=mission.location,
|
||||
task_capability=flight.task,
|
||||
number=flight.num_aircraft * purchase_multiplier,
|
||||
)
|
||||
# Reserves are planned for critical missions, so prioritize those orders
|
||||
# over aircraft needed for non-critical missions.
|
||||
self.add_procurement_request(purchase_order)
|
||||
|
||||
def scrub_mission_missing_aircraft(
|
||||
self,
|
||||
mission: ProposedMission,
|
||||
builder: PackageBuilder,
|
||||
missing_types: Set[FlightType],
|
||||
not_attempted: Iterable[ProposedFlight],
|
||||
purchase_multiplier: int,
|
||||
) -> None:
|
||||
# Try to plan the rest of the mission just so we can count the missing
|
||||
# types to buy.
|
||||
for flight in not_attempted:
|
||||
self.plan_flight(
|
||||
mission, flight, builder, missing_types, purchase_multiplier
|
||||
)
|
||||
|
||||
missing_types_str = ", ".join(sorted([t.name for t in missing_types]))
|
||||
builder.release_planned_aircraft()
|
||||
color = "Blue" if self.is_player else "Red"
|
||||
logging.debug(
|
||||
f"{color}: not enough aircraft in range for {mission.location.name} "
|
||||
f"capable of: {missing_types_str}"
|
||||
)
|
||||
|
||||
def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]:
|
||||
threats = defaultdict(bool)
|
||||
for flight in builder.package.flights:
|
||||
if self.threat_zones.waypoints_threatened_by_aircraft(
|
||||
flight.flight_plan.escorted_waypoints()
|
||||
):
|
||||
threats[EscortType.AirToAir] = True
|
||||
if self.threat_zones.waypoints_threatened_by_radar_sam(
|
||||
list(flight.flight_plan.escorted_waypoints())
|
||||
):
|
||||
threats[EscortType.Sead] = True
|
||||
return threats
|
||||
|
||||
def plan_mission(
|
||||
self,
|
||||
mission: ProposedMission,
|
||||
purchase_multiplier: int,
|
||||
tracer: MultiEventTracer,
|
||||
) -> Optional[Package]:
|
||||
"""Allocates aircraft for a proposed mission and adds it to the ATO."""
|
||||
builder = PackageBuilder(
|
||||
mission.location,
|
||||
ObjectiveDistanceCache.get_closest_airfields(mission.location),
|
||||
self.air_wing,
|
||||
self.is_player,
|
||||
self.coalition.country_name,
|
||||
self.default_start_type,
|
||||
mission.asap,
|
||||
)
|
||||
|
||||
# Attempt to plan all the main elements of the mission first. Escorts
|
||||
# will be planned separately so we can prune escorts for packages that
|
||||
# are not expected to encounter that type of threat.
|
||||
missing_types: Set[FlightType] = set()
|
||||
escorts = []
|
||||
for proposed_flight in mission.flights:
|
||||
if not self.air_wing_can_plan(proposed_flight.task):
|
||||
# This air wing can never plan this mission type because they do not
|
||||
# have compatible aircraft or squadrons. Skip fulfillment so that we
|
||||
# don't place the purchase request.
|
||||
continue
|
||||
if proposed_flight.escort_type is not None:
|
||||
# Escorts are planned after the primary elements of the package.
|
||||
# If the package does not need escorts they may be pruned.
|
||||
escorts.append(proposed_flight)
|
||||
continue
|
||||
with tracer.trace("Flight planning"):
|
||||
self.plan_flight(
|
||||
mission,
|
||||
proposed_flight,
|
||||
builder,
|
||||
missing_types,
|
||||
purchase_multiplier,
|
||||
)
|
||||
|
||||
if missing_types:
|
||||
self.scrub_mission_missing_aircraft(
|
||||
mission, builder, missing_types, escorts, purchase_multiplier
|
||||
)
|
||||
return None
|
||||
|
||||
if not builder.package.flights:
|
||||
# The non-escort part of this mission is unplannable by this faction. Scrub
|
||||
# the mission and do not attempt planning escorts because there's no reason
|
||||
# to buy them because this mission will never be planned.
|
||||
return None
|
||||
|
||||
# Create flight plans for the main flights of the package so we can
|
||||
# determine threats. This is done *after* creating all of the flights
|
||||
# rather than as each flight is added because the flight plan for
|
||||
# flights that will rendezvous with their package will be affected by
|
||||
# the other flights in the package. Escorts will not be able to
|
||||
# contribute to this.
|
||||
flight_plan_builder = FlightPlanBuilder(
|
||||
builder.package, self.coalition, self.theater
|
||||
)
|
||||
for flight in builder.package.flights:
|
||||
with tracer.trace("Flight plan population"):
|
||||
flight_plan_builder.populate_flight_plan(flight)
|
||||
|
||||
needed_escorts = self.check_needed_escorts(builder)
|
||||
for escort in escorts:
|
||||
# This list was generated from the not None set, so this should be
|
||||
# impossible.
|
||||
assert escort.escort_type is not None
|
||||
if needed_escorts[escort.escort_type]:
|
||||
with tracer.trace("Flight planning"):
|
||||
self.plan_flight(
|
||||
mission, escort, builder, missing_types, purchase_multiplier
|
||||
)
|
||||
|
||||
# Check again for unavailable aircraft. If the escort was required and
|
||||
# none were found, scrub the mission.
|
||||
if missing_types:
|
||||
self.scrub_mission_missing_aircraft(
|
||||
mission, builder, missing_types, escorts, purchase_multiplier
|
||||
)
|
||||
return None
|
||||
|
||||
package = builder.build()
|
||||
# Add flight plans for escorts.
|
||||
for flight in package.flights:
|
||||
if not flight.flight_plan.waypoints:
|
||||
with tracer.trace("Flight plan population"):
|
||||
flight_plan_builder.populate_flight_plan(flight)
|
||||
|
||||
if package.has_players and self.player_missions_asap:
|
||||
package.auto_asap = True
|
||||
package.set_tot_asap()
|
||||
|
||||
return package
|
||||
11
game/commander/tasks/compound/aewcsupport.py
Normal file
11
game/commander/tasks/compound/aewcsupport.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from collections import Iterator
|
||||
|
||||
from game.commander.tasks.primitive.aewc import PlanAewc
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
class PlanAewcSupport(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for target in state.aewc_targets:
|
||||
yield [PlanAewc(target)]
|
||||
15
game/commander/tasks/compound/attackairinfrastructure.py
Normal file
15
game/commander/tasks/compound/attackairinfrastructure.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.primitive.oca import PlanOcaStrike
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AttackAirInfrastructure(CompoundTask[TheaterState]):
|
||||
aircraft_cold_start: bool
|
||||
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for garrison in state.oca_targets:
|
||||
yield [PlanOcaStrike(garrison, self.aircraft_cold_start)]
|
||||
15
game/commander/tasks/compound/attackbuildings.py
Normal file
15
game/commander/tasks/compound/attackbuildings.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from collections import Iterator
|
||||
|
||||
from game.commander.tasks.primitive.strike import PlanStrike
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
class AttackBuildings(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for building in state.strike_targets:
|
||||
# Ammo depots are targeted based on the needs of the front line by
|
||||
# ReduceEnemyFrontLineCapacity. No reason to target them before that front
|
||||
# line is active.
|
||||
if not building.is_ammo_depot:
|
||||
yield [PlanStrike(building)]
|
||||
12
game/commander/tasks/compound/attackgarrisons.py
Normal file
12
game/commander/tasks/compound/attackgarrisons.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from collections import Iterator
|
||||
|
||||
from game.commander.tasks.primitive.bai import PlanBai
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
class AttackGarrisons(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for garrisons in state.enemy_garrisons.values():
|
||||
for garrison in garrisons.in_priority_order:
|
||||
yield [PlanBai(garrison)]
|
||||
51
game/commander/tasks/compound/capturebase.py
Normal file
51
game/commander/tasks/compound/capturebase.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.compound.destroyenemygroundunits import (
|
||||
DestroyEnemyGroundUnits,
|
||||
)
|
||||
from game.commander.tasks.compound.reduceenemyfrontlinecapacity import (
|
||||
ReduceEnemyFrontLineCapacity,
|
||||
)
|
||||
from game.commander.tasks.primitive.breakthroughattack import BreakthroughAttack
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
from game.theater import FrontLine, ControlPoint
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptureBase(CompoundTask[TheaterState]):
|
||||
front_line: FrontLine
|
||||
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
yield [BreakthroughAttack(self.front_line, state.context.coalition.player)]
|
||||
yield [DestroyEnemyGroundUnits(self.front_line)]
|
||||
if self.worth_destroying_ammo_depots(state):
|
||||
yield [ReduceEnemyFrontLineCapacity(self.enemy_cp(state))]
|
||||
|
||||
def enemy_cp(self, state: TheaterState) -> ControlPoint:
|
||||
return self.front_line.control_point_hostile_to(state.context.coalition.player)
|
||||
|
||||
def units_deployable(self, state: TheaterState, player: bool) -> int:
|
||||
cp = self.front_line.control_point_friendly_to(player)
|
||||
ammo_depots = list(state.ammo_dumps_at(cp))
|
||||
return cp.deployable_front_line_units_with(len(ammo_depots))
|
||||
|
||||
def unit_cap(self, state: TheaterState, player: bool) -> int:
|
||||
cp = self.front_line.control_point_friendly_to(player)
|
||||
ammo_depots = list(state.ammo_dumps_at(cp))
|
||||
return cp.front_line_capacity_with(len(ammo_depots))
|
||||
|
||||
def enemy_has_ammo_dumps(self, state: TheaterState) -> bool:
|
||||
return bool(state.ammo_dumps_at(self.enemy_cp(state)))
|
||||
|
||||
def worth_destroying_ammo_depots(self, state: TheaterState) -> bool:
|
||||
if not self.enemy_has_ammo_dumps(state):
|
||||
return False
|
||||
|
||||
friendly_cap = self.unit_cap(state, state.context.coalition.player)
|
||||
enemy_deployable = self.units_deployable(state, state.context.coalition.player)
|
||||
|
||||
# If the enemy can currently deploy 50% more units than we possibly could, it's
|
||||
# worth killing an ammo depot.
|
||||
return enemy_deployable / friendly_cap > 1.5
|
||||
13
game/commander/tasks/compound/capturebases.py
Normal file
13
game/commander/tasks/compound/capturebases.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.compound.capturebase import CaptureBase
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptureBases(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for front in state.active_front_lines:
|
||||
yield [CaptureBase(front)]
|
||||
19
game/commander/tasks/compound/defendbase.py
Normal file
19
game/commander/tasks/compound/defendbase.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.primitive.cas import PlanCas
|
||||
from game.commander.tasks.primitive.defensivestance import DefensiveStance
|
||||
from game.commander.tasks.primitive.retreatstance import RetreatStance
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
from game.theater import FrontLine
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DefendBase(CompoundTask[TheaterState]):
|
||||
front_line: FrontLine
|
||||
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
yield [DefensiveStance(self.front_line, state.context.coalition.player)]
|
||||
yield [RetreatStance(self.front_line, state.context.coalition.player)]
|
||||
yield [PlanCas(self.front_line)]
|
||||
13
game/commander/tasks/compound/defendbases.py
Normal file
13
game/commander/tasks/compound/defendbases.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.compound.defendbase import DefendBase
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DefendBases(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for front in state.active_front_lines:
|
||||
yield [DefendBase(front)]
|
||||
24
game/commander/tasks/compound/degradeiads.py
Normal file
24
game/commander/tasks/compound/degradeiads.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from collections import Iterator
|
||||
from typing import Union
|
||||
|
||||
from game.commander.tasks.primitive.antiship import PlanAntiShip
|
||||
from game.commander.tasks.primitive.dead import PlanDead
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
|
||||
|
||||
|
||||
class DegradeIads(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for air_defense in state.threatening_air_defenses:
|
||||
yield [self.plan_against(air_defense)]
|
||||
for detector in state.detecting_air_defenses:
|
||||
yield [self.plan_against(detector)]
|
||||
|
||||
@staticmethod
|
||||
def plan_against(
|
||||
target: Union[IadsGroundObject, NavalGroundObject]
|
||||
) -> Union[PlanDead, PlanAntiShip]:
|
||||
if isinstance(target, IadsGroundObject):
|
||||
return PlanDead(target)
|
||||
return PlanAntiShip(target)
|
||||
19
game/commander/tasks/compound/destroyenemygroundunits.py
Normal file
19
game/commander/tasks/compound/destroyenemygroundunits.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.primitive.aggressiveattack import AggressiveAttack
|
||||
from game.commander.tasks.primitive.cas import PlanCas
|
||||
from game.commander.tasks.primitive.eliminationattack import EliminationAttack
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
from game.theater import FrontLine
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DestroyEnemyGroundUnits(CompoundTask[TheaterState]):
|
||||
front_line: FrontLine
|
||||
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
yield [EliminationAttack(self.front_line, state.context.coalition.player)]
|
||||
yield [AggressiveAttack(self.front_line, state.context.coalition.player)]
|
||||
yield [PlanCas(self.front_line)]
|
||||
11
game/commander/tasks/compound/frontlinedefense.py
Normal file
11
game/commander/tasks/compound/frontlinedefense.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from collections import Iterator
|
||||
|
||||
from game.commander.tasks.primitive.cas import PlanCas
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
class FrontLineDefense(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for front_line in state.vulnerable_front_lines:
|
||||
yield [PlanCas(front_line)]
|
||||
27
game/commander/tasks/compound/interdictreinforcements.py
Normal file
27
game/commander/tasks/compound/interdictreinforcements.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from collections import Iterator
|
||||
|
||||
from game.commander.tasks.primitive.antishipping import PlanAntiShipping
|
||||
from game.commander.tasks.primitive.convoyinterdiction import PlanConvoyInterdiction
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
class InterdictReinforcements(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
# These will only rarely get planned. When a convoy is travelling multiple legs,
|
||||
# they're targetable after the first leg. The reason for this is that
|
||||
# procurement happens *after* mission planning so that the missions that could
|
||||
# not be filled will guide the procurement process. Procurement is the stage
|
||||
# that convoys are created (because they're created to move ground units that
|
||||
# were just purchased), so we haven't created any yet. Any incomplete transfers
|
||||
# from the previous turn (multi-leg journeys) will still be present though so
|
||||
# they can be targeted.
|
||||
#
|
||||
# Even after this is fixed, the player's convoys that were created through the
|
||||
# UI will never be targeted on the first turn of their journey because the AI
|
||||
# stops planning after the start of the turn. We could potentially fix this by
|
||||
# moving opfor mission planning until the takeoff button is pushed.
|
||||
for convoy in state.enemy_convoys:
|
||||
yield [PlanConvoyInterdiction(convoy)]
|
||||
for ship in state.enemy_shipping:
|
||||
yield [PlanAntiShipping(ship)]
|
||||
34
game/commander/tasks/compound/nextaction.py
Normal file
34
game/commander/tasks/compound/nextaction.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.compound.attackairinfrastructure import (
|
||||
AttackAirInfrastructure,
|
||||
)
|
||||
from game.commander.tasks.compound.attackbuildings import AttackBuildings
|
||||
from game.commander.tasks.compound.attackgarrisons import AttackGarrisons
|
||||
from game.commander.tasks.compound.capturebases import CaptureBases
|
||||
from game.commander.tasks.compound.defendbases import DefendBases
|
||||
from game.commander.tasks.compound.degradeiads import DegradeIads
|
||||
from game.commander.tasks.compound.interdictreinforcements import (
|
||||
InterdictReinforcements,
|
||||
)
|
||||
from game.commander.tasks.compound.protectairspace import ProtectAirSpace
|
||||
from game.commander.tasks.compound.theatersupport import TheaterSupport
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanNextAction(CompoundTask[TheaterState]):
|
||||
aircraft_cold_start: bool
|
||||
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
yield [TheaterSupport()]
|
||||
yield [ProtectAirSpace()]
|
||||
yield [CaptureBases()]
|
||||
yield [DefendBases()]
|
||||
yield [InterdictReinforcements()]
|
||||
yield [AttackGarrisons()]
|
||||
yield [AttackAirInfrastructure(self.aircraft_cold_start)]
|
||||
yield [AttackBuildings()]
|
||||
yield [DegradeIads()]
|
||||
12
game/commander/tasks/compound/protectairspace.py
Normal file
12
game/commander/tasks/compound/protectairspace.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from collections import Iterator
|
||||
|
||||
from game.commander.tasks.primitive.barcap import PlanBarcap
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
class ProtectAirSpace(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for cp, needed in state.barcaps_needed.items():
|
||||
if needed > 0:
|
||||
yield [PlanBarcap(cp, needed)]
|
||||
@@ -0,0 +1,16 @@
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.primitive.strike import PlanStrike
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
from game.theater import ControlPoint
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReduceEnemyFrontLineCapacity(CompoundTask[TheaterState]):
|
||||
control_point: ControlPoint
|
||||
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for ammo_dump in state.ammo_dumps_at(self.control_point):
|
||||
yield [PlanStrike(ammo_dump)]
|
||||
11
game/commander/tasks/compound/refuelingsupport.py
Normal file
11
game/commander/tasks/compound/refuelingsupport.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from collections import Iterator
|
||||
|
||||
from game.commander.tasks.primitive.refueling import PlanRefueling
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
class PlanRefuelingSupport(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
for target in state.refueling_targets:
|
||||
yield [PlanRefueling(target)]
|
||||
14
game/commander/tasks/compound/theatersupport.py
Normal file
14
game/commander/tasks/compound/theatersupport.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.compound.aewcsupport import PlanAewcSupport
|
||||
from game.commander.tasks.compound.refuelingsupport import PlanRefuelingSupport
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import CompoundTask, Method
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TheaterSupport(CompoundTask[TheaterState]):
|
||||
def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]:
|
||||
yield [PlanAewcSupport()]
|
||||
yield [PlanRefuelingSupport()]
|
||||
77
game/commander/tasks/frontlinestancetask.py
Normal file
77
game/commander/tasks/frontlinestancetask.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater import FrontLine
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class FrontLineStanceTask(TheaterCommanderTask, ABC):
|
||||
def __init__(self, front_line: FrontLine, player: bool) -> None:
|
||||
self.front_line = front_line
|
||||
self.friendly_cp = self.front_line.control_point_friendly_to(player)
|
||||
self.enemy_cp = self.front_line.control_point_hostile_to(player)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def stance(self) -> CombatStance:
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def management_allowed(state: TheaterState) -> bool:
|
||||
return (
|
||||
not state.context.coalition.player
|
||||
or state.context.settings.automate_front_line_stance
|
||||
)
|
||||
|
||||
def better_stance_already_set(self, state: TheaterState) -> bool:
|
||||
current_stance = state.front_line_stances[self.front_line]
|
||||
if current_stance is None:
|
||||
return False
|
||||
preference = (
|
||||
CombatStance.RETREAT,
|
||||
CombatStance.DEFENSIVE,
|
||||
CombatStance.AMBUSH,
|
||||
CombatStance.AGGRESSIVE,
|
||||
CombatStance.ELIMINATION,
|
||||
CombatStance.BREAKTHROUGH,
|
||||
)
|
||||
current_rating = preference.index(current_stance)
|
||||
new_rating = preference.index(self.stance)
|
||||
return current_rating >= new_rating
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def have_sufficient_front_line_advantage(self) -> bool:
|
||||
...
|
||||
|
||||
@property
|
||||
def ground_force_balance(self) -> float:
|
||||
# TODO: Planned CAS missions should reduce the expected opposing force size.
|
||||
friendly_forces = self.friendly_cp.deployable_front_line_units
|
||||
enemy_forces = self.enemy_cp.deployable_front_line_units
|
||||
if enemy_forces == 0:
|
||||
return math.inf
|
||||
return friendly_forces / enemy_forces
|
||||
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if not self.management_allowed(state):
|
||||
return False
|
||||
if self.better_stance_already_set(state):
|
||||
return False
|
||||
if self.friendly_cp.deployable_front_line_units == 0:
|
||||
return False
|
||||
return self.have_sufficient_front_line_advantage
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.front_line_stances[self.front_line] = self.stance
|
||||
|
||||
def execute(self, coalition: Coalition) -> None:
|
||||
self.friendly_cp.stances[self.enemy_cp.id] = self.stance
|
||||
174
game/commander/tasks/packageplanningtask.py
Normal file
174
game/commander/tasks/packageplanningtask.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import operator
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import unique, IntEnum, auto
|
||||
from typing import TYPE_CHECKING, Optional, Generic, TypeVar, Iterator, Union
|
||||
|
||||
from game.commander.missionproposals import ProposedFlight, EscortType, ProposedMission
|
||||
from game.commander.packagefulfiller import PackageFulfiller
|
||||
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.settings import AutoAtoBehavior
|
||||
from game.theater import MissionTarget
|
||||
from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
|
||||
from game.utils import Distance, meters
|
||||
from gen.ato import Package
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
MissionTargetT = TypeVar("MissionTargetT", bound=MissionTarget)
|
||||
|
||||
|
||||
@unique
|
||||
class RangeType(IntEnum):
|
||||
Detection = auto()
|
||||
Threat = auto()
|
||||
|
||||
|
||||
# TODO: Refactor so that we don't need to call up to the mission planner.
|
||||
# Bypass type checker due to https://github.com/python/mypy/issues/5374
|
||||
@dataclass # type: ignore
|
||||
class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
|
||||
target: MissionTargetT
|
||||
flights: list[ProposedFlight] = field(init=False)
|
||||
package: Optional[Package] = field(init=False, default=None)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.flights = []
|
||||
self.package = Package(self.target)
|
||||
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if (
|
||||
state.context.coalition.player
|
||||
and state.context.settings.auto_ato_behavior is AutoAtoBehavior.Disabled
|
||||
):
|
||||
return False
|
||||
return self.fulfill_mission(state)
|
||||
|
||||
def execute(self, coalition: Coalition) -> None:
|
||||
if self.package is None:
|
||||
raise RuntimeError("Attempted to execute failed package planning task")
|
||||
coalition.ato.add_package(self.package)
|
||||
|
||||
@abstractmethod
|
||||
def propose_flights(self) -> None:
|
||||
...
|
||||
|
||||
def propose_flight(
|
||||
self,
|
||||
task: FlightType,
|
||||
num_aircraft: int,
|
||||
escort_type: Optional[EscortType] = None,
|
||||
) -> None:
|
||||
self.flights.append(ProposedFlight(task, num_aircraft, escort_type))
|
||||
|
||||
@property
|
||||
def asap(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def purchase_multiplier(self) -> int:
|
||||
"""The multiplier for aircraft quantity when missions could not be fulfilled.
|
||||
|
||||
For missions that do not schedule in rounds like BARCAPs do, this should be one
|
||||
to ensure that the we only purchase enough aircraft to plan the mission once.
|
||||
|
||||
For missions that repeat within the same turn, however, we may need to buy for
|
||||
the same mission more than once. If three rounds of BARCAP still need to be
|
||||
fulfilled, this would return 3, and we'd triplicate the purchase order.
|
||||
|
||||
There is a small misbehavior here that's not symptomatic for our current mission
|
||||
planning: multi-round, multi-flight packages will only purchase multiple sets of
|
||||
aircraft for whatever is unavailable for the *first* failed package. For
|
||||
example, if we extend this to CAS and have no CAS aircraft but enough TARCAP
|
||||
aircraft for one round, we'll order CAS for every round but will not order any
|
||||
TARCAP aircraft, since we can't know that TARCAP aircraft are needed until we
|
||||
attempt to plan the second mission *without returning the first round aircraft*.
|
||||
"""
|
||||
return 1
|
||||
|
||||
def fulfill_mission(self, state: TheaterState) -> bool:
|
||||
self.propose_flights()
|
||||
fulfiller = PackageFulfiller(
|
||||
state.context.coalition,
|
||||
state.context.theater,
|
||||
state.context.settings,
|
||||
)
|
||||
self.package = fulfiller.plan_mission(
|
||||
ProposedMission(self.target, self.flights),
|
||||
self.purchase_multiplier,
|
||||
state.context.tracer,
|
||||
)
|
||||
return self.package is not None
|
||||
|
||||
def propose_common_escorts(self) -> None:
|
||||
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
|
||||
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
|
||||
|
||||
def iter_iads_ranges(
|
||||
self, state: TheaterState, range_type: RangeType
|
||||
) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
|
||||
target_ranges: list[
|
||||
tuple[Union[IadsGroundObject, NavalGroundObject], Distance]
|
||||
] = []
|
||||
all_iads: Iterator[
|
||||
Union[IadsGroundObject, NavalGroundObject]
|
||||
] = itertools.chain(state.enemy_air_defenses, state.enemy_ships)
|
||||
for target in all_iads:
|
||||
distance = meters(target.distance_to(self.target))
|
||||
if range_type is RangeType.Detection:
|
||||
target_range = target.max_detection_range()
|
||||
elif range_type is RangeType.Threat:
|
||||
target_range = target.max_threat_range()
|
||||
else:
|
||||
raise ValueError(f"Unknown RangeType: {range_type}")
|
||||
if not target_range:
|
||||
continue
|
||||
|
||||
# IADS out of range of our target area will have a positive
|
||||
# distance_to_threat and should be pruned. The rest have a decreasing
|
||||
# distance_to_threat as overlap increases. The most negative distance has
|
||||
# the greatest coverage of the target and should be treated as the highest
|
||||
# priority threat.
|
||||
distance_to_threat = distance - target_range
|
||||
if distance_to_threat > meters(0):
|
||||
continue
|
||||
target_ranges.append((target, distance_to_threat))
|
||||
|
||||
# TODO: Prioritize IADS by vulnerability?
|
||||
target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
|
||||
for target, _range in target_ranges:
|
||||
yield target
|
||||
|
||||
def iter_detecting_iads(
|
||||
self, state: TheaterState
|
||||
) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
|
||||
return self.iter_iads_ranges(state, RangeType.Detection)
|
||||
|
||||
def iter_iads_threats(
|
||||
self, state: TheaterState
|
||||
) -> Iterator[Union[IadsGroundObject, NavalGroundObject]]:
|
||||
return self.iter_iads_ranges(state, RangeType.Threat)
|
||||
|
||||
def target_area_preconditions_met(
|
||||
self, state: TheaterState, ignore_iads: bool = False
|
||||
) -> bool:
|
||||
"""Checks if the target area has been cleared of threats."""
|
||||
threatened = False
|
||||
|
||||
# Non-blocking, but analyzed so we can pick detectors worth eliminating.
|
||||
for detector in self.iter_detecting_iads(state):
|
||||
if detector not in state.detecting_air_defenses:
|
||||
state.detecting_air_defenses.append(detector)
|
||||
|
||||
if not ignore_iads:
|
||||
for iads_threat in self.iter_iads_threats(state):
|
||||
threatened = True
|
||||
if iads_threat not in state.threatening_air_defenses:
|
||||
state.threatening_air_defenses.append(iads_threat)
|
||||
return not threatened
|
||||
27
game/commander/tasks/primitive/aewc.py
Normal file
27
game/commander/tasks/primitive/aewc.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater import MissionTarget
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanAewc(PackagePlanningTask[MissionTarget]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if not super().preconditions_met(state):
|
||||
return False
|
||||
return self.target in state.aewc_targets
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.aewc_targets.remove(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.AEWC, 1)
|
||||
|
||||
@property
|
||||
def asap(self) -> bool:
|
||||
# Supports all the early CAP flights, so should be in the air ASAP.
|
||||
return True
|
||||
14
game/commander/tasks/primitive/aggressiveattack.py
Normal file
14
game/commander/tasks/primitive/aggressiveattack.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
|
||||
|
||||
class AggressiveAttack(FrontLineStanceTask):
|
||||
@property
|
||||
def stance(self) -> CombatStance:
|
||||
return CombatStance.AGGRESSIVE
|
||||
|
||||
@property
|
||||
def have_sufficient_front_line_advantage(self) -> bool:
|
||||
return self.ground_force_balance >= 0.8
|
||||
26
game/commander/tasks/primitive/antiship.py
Normal file
26
game/commander/tasks/primitive/antiship.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.missionproposals import EscortType
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater.theatergroundobject import NavalGroundObject
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanAntiShip(PackagePlanningTask[NavalGroundObject]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if self.target not in state.threatening_air_defenses:
|
||||
return False
|
||||
if not self.target_area_preconditions_met(state, ignore_iads=True):
|
||||
return False
|
||||
return super().preconditions_met(state)
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.eliminate_ship(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.ANTISHIP, 2)
|
||||
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
|
||||
25
game/commander/tasks/primitive/antishipping.py
Normal file
25
game/commander/tasks/primitive/antishipping.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.transfers import CargoShip
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanAntiShipping(PackagePlanningTask[CargoShip]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if self.target not in state.enemy_shipping:
|
||||
return False
|
||||
if not self.target_area_preconditions_met(state):
|
||||
return False
|
||||
return super().preconditions_met(state)
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.enemy_shipping.remove(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.ANTISHIP, 2)
|
||||
self.propose_common_escorts()
|
||||
25
game/commander/tasks/primitive/bai.py
Normal file
25
game/commander/tasks/primitive/bai.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater.theatergroundobject import VehicleGroupGroundObject
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if not state.has_garrison(self.target):
|
||||
return False
|
||||
if not self.target_area_preconditions_met(state):
|
||||
return False
|
||||
return super().preconditions_met(state)
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.eliminate_garrison(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.BAI, 2)
|
||||
self.propose_common_escorts()
|
||||
28
game/commander/tasks/primitive/barcap.py
Normal file
28
game/commander/tasks/primitive/barcap.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater import ControlPoint
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanBarcap(PackagePlanningTask[ControlPoint]):
|
||||
max_orders: int
|
||||
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if not state.barcaps_needed[self.target]:
|
||||
return False
|
||||
return super().preconditions_met(state)
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.barcaps_needed[self.target] -= 1
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.BARCAP, 2)
|
||||
|
||||
@property
|
||||
def purchase_multiplier(self) -> int:
|
||||
return self.max_orders
|
||||
28
game/commander/tasks/primitive/breakthroughattack.py
Normal file
28
game/commander/tasks/primitive/breakthroughattack.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
|
||||
|
||||
class BreakthroughAttack(FrontLineStanceTask):
|
||||
@property
|
||||
def stance(self) -> CombatStance:
|
||||
return CombatStance.BREAKTHROUGH
|
||||
|
||||
@property
|
||||
def have_sufficient_front_line_advantage(self) -> bool:
|
||||
return self.ground_force_balance >= 2.0
|
||||
|
||||
def opposing_garrisons_eliminated(self, state: TheaterState) -> bool:
|
||||
garrisons = state.enemy_garrisons[self.enemy_cp]
|
||||
return not bool(garrisons.blocking_capture)
|
||||
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if not super().preconditions_met(state):
|
||||
return False
|
||||
return self.opposing_garrisons_eliminated(state)
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
super().apply_effects(state)
|
||||
state.active_front_lines.remove(self.front_line)
|
||||
33
game/commander/tasks/primitive/cas.py
Normal file
33
game/commander/tasks/primitive/cas.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater import FrontLine
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanCas(PackagePlanningTask[FrontLine]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if self.target not in state.vulnerable_front_lines:
|
||||
return False
|
||||
|
||||
# Do not bother planning CAS when there are no enemy ground units at the front.
|
||||
# An exception is made for turn zero since that's not being truly planned, but
|
||||
# just to determine what missions should be planned on turn 1 (when there *will*
|
||||
# be ground units) and what aircraft should be ordered.
|
||||
enemy_cp = self.target.control_point_friendly_to(
|
||||
player=not state.context.coalition.player
|
||||
)
|
||||
if enemy_cp.deployable_front_line_units == 0 and state.context.turn > 0:
|
||||
return False
|
||||
return super().preconditions_met(state)
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.vulnerable_front_lines.remove(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.CAS, 2)
|
||||
self.propose_flight(FlightType.TARCAP, 2)
|
||||
26
game/commander/tasks/primitive/convoyinterdiction.py
Normal file
26
game/commander/tasks/primitive/convoyinterdiction.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.transfers import Convoy
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanConvoyInterdiction(PackagePlanningTask[Convoy]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if self.target not in state.enemy_convoys:
|
||||
return False
|
||||
if not self.target_area_preconditions_met(state):
|
||||
return False
|
||||
return super().preconditions_met(state)
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.enemy_convoys.remove(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.BAI, 2)
|
||||
self.propose_common_escorts()
|
||||
46
game/commander/tasks/primitive/dead.py
Normal file
46
game/commander/tasks/primitive/dead.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.missionproposals import EscortType
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater.theatergroundobject import IadsGroundObject
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanDead(PackagePlanningTask[IadsGroundObject]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if (
|
||||
self.target not in state.threatening_air_defenses
|
||||
and self.target not in state.detecting_air_defenses
|
||||
):
|
||||
return False
|
||||
if not self.target_area_preconditions_met(state, ignore_iads=True):
|
||||
return False
|
||||
return super().preconditions_met(state)
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.eliminate_air_defense(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.DEAD, 2)
|
||||
|
||||
# Only include SEAD against SAMs that still have emitters. No need to
|
||||
# suppress an EWR, and SEAD isn't useful against a SAM that no longer has a
|
||||
# working track radar.
|
||||
#
|
||||
# For SAMs without track radars and EWRs, we still want a SEAD escort if
|
||||
# needed.
|
||||
#
|
||||
# Note that there is a quirk here: we should potentially be included a SEAD
|
||||
# escort *and* SEAD when the target is a radar SAM but the flight path is
|
||||
# also threatened by SAMs. We don't want to include a SEAD escort if the
|
||||
# package is *only* threatened by the target though. Could be improved, but
|
||||
# needs a decent refactor to the escort planning to do so.
|
||||
if self.target.has_live_radar_sam:
|
||||
self.propose_flight(FlightType.SEAD, 2)
|
||||
else:
|
||||
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
|
||||
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
|
||||
14
game/commander/tasks/primitive/defensivestance.py
Normal file
14
game/commander/tasks/primitive/defensivestance.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
|
||||
|
||||
class DefensiveStance(FrontLineStanceTask):
|
||||
@property
|
||||
def stance(self) -> CombatStance:
|
||||
return CombatStance.DEFENSIVE
|
||||
|
||||
@property
|
||||
def have_sufficient_front_line_advantage(self) -> bool:
|
||||
return self.ground_force_balance >= 0.5
|
||||
14
game/commander/tasks/primitive/eliminationattack.py
Normal file
14
game/commander/tasks/primitive/eliminationattack.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
|
||||
|
||||
class EliminationAttack(FrontLineStanceTask):
|
||||
@property
|
||||
def stance(self) -> CombatStance:
|
||||
return CombatStance.ELIMINATION
|
||||
|
||||
@property
|
||||
def have_sufficient_front_line_advantage(self) -> bool:
|
||||
return self.ground_force_balance >= 1.5
|
||||
29
game/commander/tasks/primitive/oca.py
Normal file
29
game/commander/tasks/primitive/oca.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater import ControlPoint
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanOcaStrike(PackagePlanningTask[ControlPoint]):
|
||||
aircraft_cold_start: bool
|
||||
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if self.target not in state.oca_targets:
|
||||
return False
|
||||
if not self.target_area_preconditions_met(state):
|
||||
return False
|
||||
return super().preconditions_met(state)
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.oca_targets.remove(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.OCA_RUNWAY, 2)
|
||||
if self.aircraft_cold_start:
|
||||
self.propose_flight(FlightType.OCA_AIRCRAFT, 2)
|
||||
self.propose_common_escorts()
|
||||
22
game/commander/tasks/primitive/refueling.py
Normal file
22
game/commander/tasks/primitive/refueling.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater import MissionTarget
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanRefueling(PackagePlanningTask[MissionTarget]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if not super().preconditions_met(state):
|
||||
return False
|
||||
return self.target in state.refueling_targets
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.refueling_targets.remove(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.REFUELING, 1)
|
||||
14
game/commander/tasks/primitive/retreatstance.py
Normal file
14
game/commander/tasks/primitive/retreatstance.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from game.commander.tasks.frontlinestancetask import FrontLineStanceTask
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
|
||||
|
||||
class RetreatStance(FrontLineStanceTask):
|
||||
@property
|
||||
def stance(self) -> CombatStance:
|
||||
return CombatStance.RETREAT
|
||||
|
||||
@property
|
||||
def have_sufficient_front_line_advantage(self) -> bool:
|
||||
return True
|
||||
26
game/commander/tasks/primitive/strike.py
Normal file
26
game/commander/tasks/primitive/strike.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from game.commander.tasks.packageplanningtask import PackagePlanningTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.theater.theatergroundobject import TheaterGroundObject
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]):
|
||||
def preconditions_met(self, state: TheaterState) -> bool:
|
||||
if self.target not in state.strike_targets:
|
||||
return False
|
||||
if not self.target_area_preconditions_met(state):
|
||||
return False
|
||||
return super().preconditions_met(state)
|
||||
|
||||
def apply_effects(self, state: TheaterState) -> None:
|
||||
state.strike_targets.remove(self.target)
|
||||
|
||||
def propose_flights(self) -> None:
|
||||
self.propose_flight(FlightType.STRIKE, 2)
|
||||
self.propose_common_escorts()
|
||||
16
game/commander/tasks/theatercommandertask.py
Normal file
16
game/commander/tasks/theatercommandertask.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import PrimitiveTask
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class TheaterCommanderTask(PrimitiveTask[TheaterState]):
|
||||
@abstractmethod
|
||||
def execute(self, coalition: Coalition) -> None:
|
||||
...
|
||||
88
game/commander/theatercommander.py
Normal file
88
game/commander/theatercommander.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""The Theater Commander is the highest level campaign AI.
|
||||
|
||||
Target selection is performed with a hierarchical-task-network (HTN, linked below).
|
||||
These work by giving the planner an initial "task" which decomposes into other tasks
|
||||
until a concrete set of actions is formed. For example, the "capture base" task may
|
||||
decompose in the following manner:
|
||||
|
||||
* Defend
|
||||
* Reinforce front line
|
||||
* Set front line stance to defend
|
||||
* Destroy enemy front line units
|
||||
* Set front line stance to elimination
|
||||
* Plan CAS at front line
|
||||
* Prepare
|
||||
* Destroy enemy IADS
|
||||
* Plan DEAD against SAM Armadillo
|
||||
* ...
|
||||
* Destroy enemy front line units
|
||||
* Set front line stance to elimination
|
||||
* Plan CAS at front line
|
||||
* Inhibit
|
||||
* Destroy enemy unit production infrastructure
|
||||
* Destroy factory at Palmyra
|
||||
* ...
|
||||
* Destroy enemy front line units
|
||||
* Set front line stance to elimination
|
||||
* Plan CAS at front line
|
||||
* Attack
|
||||
* Set front line stance to breakthrough
|
||||
* Destroy enemy front line units
|
||||
* Set front line stance to elimination
|
||||
* Plan CAS at front line
|
||||
|
||||
This is not a reflection of the actual task composition but illustrates the capability
|
||||
of the system. Each task has preconditions which are checked before the task is
|
||||
decomposed. If preconditions are not met the task is ignored and the next is considered.
|
||||
For example the task to destroy the factory at Palmyra might be excluded until the air
|
||||
defenses protecting it are eliminated; or defensive air operations might be excluded if
|
||||
the enemy does not have sufficient air forces, or if the protected target has sufficient
|
||||
SAM coverage.
|
||||
|
||||
Each action updates the world state, which causes each action to account for the result
|
||||
of the tasks executed before it. Above, the preconditions for attacking the factory at
|
||||
Palmyra may not have been met due to the IADS coverage, leading the planning to decide
|
||||
on an attack against the IADS in the area instead. When planning the next task in the
|
||||
same turn, the world state will have been updated to account for the (hopefully)
|
||||
destroyed SAM sites, allowing the planner to choose the mission to attack the factory.
|
||||
|
||||
Preconditions can be aware of previous actions as well. A precondition for "Plan CAS at
|
||||
front line" can be "No CAS missions planned at front line" to avoid over-planning CAS
|
||||
even though it is a primitive task used by many other tasks.
|
||||
|
||||
https://en.wikipedia.org/wiki/Hierarchical_task_network
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from game.commander.tasks.compound.nextaction import PlanNextAction
|
||||
from game.commander.tasks.theatercommandertask import TheaterCommanderTask
|
||||
from game.commander.theaterstate import TheaterState
|
||||
from game.htn import Planner
|
||||
from game.profiling import MultiEventTracer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
|
||||
|
||||
class TheaterCommander(Planner[TheaterState, TheaterCommanderTask]):
|
||||
def __init__(self, game: Game, player: bool) -> None:
|
||||
super().__init__(
|
||||
PlanNextAction(
|
||||
aircraft_cold_start=game.settings.default_start_type == "Cold"
|
||||
)
|
||||
)
|
||||
self.game = game
|
||||
self.player = player
|
||||
|
||||
def plan_missions(self, tracer: MultiEventTracer) -> None:
|
||||
state = TheaterState.from_game(self.game, self.player, tracer)
|
||||
while True:
|
||||
result = self.plan(state)
|
||||
if result is None:
|
||||
# Planned all viable tasks this turn.
|
||||
return
|
||||
for task in result.tasks:
|
||||
task.execute(self.game.coalition_for(self.player))
|
||||
state = result.end_state
|
||||
176
game/commander/theaterstate.py
Normal file
176
game/commander/theaterstate.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import itertools
|
||||
import math
|
||||
from collections import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Union, Optional
|
||||
|
||||
from game.commander.garrisons import Garrisons
|
||||
from game.commander.objectivefinder import ObjectiveFinder
|
||||
from game.htn import WorldState
|
||||
from game.profiling import MultiEventTracer
|
||||
from game.settings import Settings
|
||||
from game.squadrons import AirWing
|
||||
from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater
|
||||
from game.theater.theatergroundobject import (
|
||||
TheaterGroundObject,
|
||||
NavalGroundObject,
|
||||
IadsGroundObject,
|
||||
VehicleGroupGroundObject,
|
||||
BuildingGroundObject,
|
||||
)
|
||||
from game.threatzones import ThreatZones
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.coalition import Coalition
|
||||
from game.transfers import Convoy, CargoShip
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersistentContext:
|
||||
coalition: Coalition
|
||||
theater: ConflictTheater
|
||||
turn: int
|
||||
settings: Settings
|
||||
tracer: MultiEventTracer
|
||||
|
||||
|
||||
@dataclass
|
||||
class TheaterState(WorldState["TheaterState"]):
|
||||
context: PersistentContext
|
||||
barcaps_needed: dict[ControlPoint, int]
|
||||
active_front_lines: list[FrontLine]
|
||||
front_line_stances: dict[FrontLine, Optional[CombatStance]]
|
||||
vulnerable_front_lines: list[FrontLine]
|
||||
aewc_targets: list[MissionTarget]
|
||||
refueling_targets: list[MissionTarget]
|
||||
enemy_air_defenses: list[IadsGroundObject]
|
||||
threatening_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]]
|
||||
detecting_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]]
|
||||
enemy_convoys: list[Convoy]
|
||||
enemy_shipping: list[CargoShip]
|
||||
enemy_ships: list[NavalGroundObject]
|
||||
enemy_garrisons: dict[ControlPoint, Garrisons]
|
||||
oca_targets: list[ControlPoint]
|
||||
strike_targets: list[TheaterGroundObject[Any]]
|
||||
enemy_barcaps: list[ControlPoint]
|
||||
threat_zones: ThreatZones
|
||||
|
||||
def _rebuild_threat_zones(self) -> None:
|
||||
"""Recreates the theater's threat zones based on the current planned state."""
|
||||
self.threat_zones = ThreatZones.for_threats(
|
||||
self.context.coalition.opponent.doctrine,
|
||||
barcap_locations=self.enemy_barcaps,
|
||||
air_defenses=itertools.chain(self.enemy_air_defenses, self.enemy_ships),
|
||||
)
|
||||
|
||||
def eliminate_air_defense(self, target: IadsGroundObject) -> None:
|
||||
if target in self.threatening_air_defenses:
|
||||
self.threatening_air_defenses.remove(target)
|
||||
if target in self.detecting_air_defenses:
|
||||
self.detecting_air_defenses.remove(target)
|
||||
self.enemy_air_defenses.remove(target)
|
||||
self._rebuild_threat_zones()
|
||||
|
||||
def eliminate_ship(self, target: NavalGroundObject) -> None:
|
||||
if target in self.threatening_air_defenses:
|
||||
self.threatening_air_defenses.remove(target)
|
||||
if target in self.detecting_air_defenses:
|
||||
self.detecting_air_defenses.remove(target)
|
||||
self.enemy_ships.remove(target)
|
||||
self._rebuild_threat_zones()
|
||||
|
||||
def has_garrison(self, target: VehicleGroupGroundObject) -> bool:
|
||||
return target in self.enemy_garrisons[target.control_point]
|
||||
|
||||
def eliminate_garrison(self, target: VehicleGroupGroundObject) -> None:
|
||||
self.enemy_garrisons[target.control_point].eliminate(target)
|
||||
|
||||
def ammo_dumps_at(
|
||||
self, control_point: ControlPoint
|
||||
) -> Iterator[BuildingGroundObject]:
|
||||
for target in self.strike_targets:
|
||||
if target.control_point != control_point:
|
||||
continue
|
||||
if target.is_ammo_depot:
|
||||
assert isinstance(target, BuildingGroundObject)
|
||||
yield target
|
||||
|
||||
def clone(self) -> TheaterState:
|
||||
# Do not use copy.deepcopy. Copying every TGO, control point, etc is absurdly
|
||||
# expensive.
|
||||
return TheaterState(
|
||||
context=self.context,
|
||||
barcaps_needed=dict(self.barcaps_needed),
|
||||
active_front_lines=list(self.active_front_lines),
|
||||
front_line_stances=dict(self.front_line_stances),
|
||||
vulnerable_front_lines=list(self.vulnerable_front_lines),
|
||||
aewc_targets=list(self.aewc_targets),
|
||||
refueling_targets=list(self.refueling_targets),
|
||||
enemy_air_defenses=list(self.enemy_air_defenses),
|
||||
enemy_convoys=list(self.enemy_convoys),
|
||||
enemy_shipping=list(self.enemy_shipping),
|
||||
enemy_ships=list(self.enemy_ships),
|
||||
enemy_garrisons={
|
||||
cp: dataclasses.replace(g) for cp, g in self.enemy_garrisons.items()
|
||||
},
|
||||
oca_targets=list(self.oca_targets),
|
||||
strike_targets=list(self.strike_targets),
|
||||
enemy_barcaps=list(self.enemy_barcaps),
|
||||
threat_zones=self.threat_zones,
|
||||
# Persistent properties are not copied. These are a way for failed subtasks
|
||||
# to communicate requirements to other tasks. For example, the task to
|
||||
# attack enemy garrisons might fail because the target area has IADS
|
||||
# protection. In that case, the preconditions of PlanBai would fail, but
|
||||
# would add the IADS that prevented it from being planned to the list of
|
||||
# IADS threats so that DegradeIads will consider it a threat later.
|
||||
threatening_air_defenses=self.threatening_air_defenses,
|
||||
detecting_air_defenses=self.detecting_air_defenses,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_game(
|
||||
cls, game: Game, player: bool, tracer: MultiEventTracer
|
||||
) -> TheaterState:
|
||||
coalition = game.coalition_for(player)
|
||||
finder = ObjectiveFinder(game, player)
|
||||
ordered_capturable_points = finder.prioritized_unisolated_points()
|
||||
|
||||
context = PersistentContext(
|
||||
coalition, game.theater, game.turn, game.settings, tracer
|
||||
)
|
||||
|
||||
# Plan enough rounds of CAP that the target has coverage over the expected
|
||||
# mission duration.
|
||||
mission_duration = game.settings.desired_player_mission_duration.total_seconds()
|
||||
barcap_duration = coalition.doctrine.cap_duration.total_seconds()
|
||||
barcap_rounds = math.ceil(mission_duration / barcap_duration)
|
||||
|
||||
return TheaterState(
|
||||
context=context,
|
||||
barcaps_needed={
|
||||
cp: barcap_rounds for cp in finder.vulnerable_control_points()
|
||||
},
|
||||
active_front_lines=list(finder.front_lines()),
|
||||
front_line_stances={f: None for f in finder.front_lines()},
|
||||
vulnerable_front_lines=list(finder.front_lines()),
|
||||
aewc_targets=[finder.farthest_friendly_control_point()],
|
||||
refueling_targets=[finder.closest_friendly_control_point()],
|
||||
enemy_air_defenses=list(finder.enemy_air_defenses()),
|
||||
threatening_air_defenses=[],
|
||||
detecting_air_defenses=[],
|
||||
enemy_convoys=list(finder.convoys()),
|
||||
enemy_shipping=list(finder.cargo_ships()),
|
||||
enemy_ships=list(finder.enemy_ships()),
|
||||
enemy_garrisons={
|
||||
cp: Garrisons.for_control_point(cp) for cp in ordered_capturable_points
|
||||
},
|
||||
oca_targets=list(finder.oca_targets(min_aircraft=20)),
|
||||
strike_targets=list(finder.strike_targets()),
|
||||
enemy_barcaps=list(game.theater.control_points_for(not player)),
|
||||
threat_zones=game.threat_zone_for(not player),
|
||||
)
|
||||
@@ -25,6 +25,7 @@ class AlicCodes:
|
||||
AirDefence.SNR_75V.id: 126,
|
||||
AirDefence.HQ_7_LN_SP.id: 127,
|
||||
AirDefence.HQ_7_STR_SP.id: 128,
|
||||
AirDefence.RLS_19J6.id: 130,
|
||||
AirDefence.Roland_ADS.id: 201,
|
||||
AirDefence.Patriot_str.id: 202,
|
||||
AirDefence.Hawk_sr.id: 203,
|
||||
@@ -33,6 +34,7 @@ class AlicCodes:
|
||||
AirDefence.Hawk_cwar.id: 206,
|
||||
AirDefence.Gepard.id: 207,
|
||||
AirDefence.Vulcan.id: 208,
|
||||
AirDefence.NASAMS_Radar_MPQ64F1.id: 209,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
from dcs.planes import (
|
||||
Bf_109K_4,
|
||||
C_101CC,
|
||||
FW_190A8,
|
||||
FW_190D9,
|
||||
F_5E_3,
|
||||
F_86F_Sabre,
|
||||
I_16,
|
||||
L_39ZA,
|
||||
MiG_15bis,
|
||||
MiG_19P,
|
||||
MiG_21Bis,
|
||||
P_47D_30,
|
||||
P_47D_30bl1,
|
||||
P_47D_40,
|
||||
P_51D,
|
||||
P_51D_30_NA,
|
||||
SpitfireLFMkIX,
|
||||
SpitfireLFMkIXCW,
|
||||
)
|
||||
|
||||
from pydcs_extensions.a4ec.a4ec import A_4E_C
|
||||
|
||||
"""
|
||||
This list contains the aircraft that do not use the guns as the last resort weapons, but as a main weapon
|
||||
They'll RTB when they don't have gun ammo left
|
||||
"""
|
||||
GUNFIGHTERS = [
|
||||
# Cold War
|
||||
MiG_15bis,
|
||||
MiG_19P,
|
||||
MiG_21Bis,
|
||||
F_86F_Sabre,
|
||||
A_4E_C,
|
||||
F_5E_3,
|
||||
# Trainers
|
||||
C_101CC,
|
||||
L_39ZA,
|
||||
# WW2
|
||||
P_51D_30_NA,
|
||||
P_51D,
|
||||
P_47D_30,
|
||||
P_47D_30bl1,
|
||||
P_47D_40,
|
||||
SpitfireLFMkIXCW,
|
||||
SpitfireLFMkIX,
|
||||
Bf_109K_4,
|
||||
FW_190D9,
|
||||
FW_190A8,
|
||||
I_16,
|
||||
]
|
||||
@@ -1,9 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from dcs.task import Reconnaissance
|
||||
|
||||
from game.utils import Distance, feet, nautical_miles
|
||||
from game.data.groundunitclass import GroundUnitClass
|
||||
from game.utils import Distance, feet, nautical_miles
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -26,13 +25,26 @@ class Doctrine:
|
||||
antiship: bool
|
||||
|
||||
rendezvous_altitude: Distance
|
||||
|
||||
#: The minimum distance between the departure airfield and the hold point.
|
||||
hold_distance: Distance
|
||||
|
||||
#: The minimum distance between the hold point and the join point.
|
||||
push_distance: Distance
|
||||
|
||||
#: The distance between the join point and the ingress point. Only used for the
|
||||
#: fallback flight plan layout (when the departure airfield is near a threat zone).
|
||||
join_distance: Distance
|
||||
split_distance: Distance
|
||||
ingress_egress_distance: Distance
|
||||
|
||||
#: The maximum distance between the ingress point (beginning of the attack) and
|
||||
#: target.
|
||||
max_ingress_distance: Distance
|
||||
|
||||
#: The minimum distance between the ingress point (beginning of the attack) and
|
||||
#: target.
|
||||
min_ingress_distance: Distance
|
||||
|
||||
ingress_altitude: Distance
|
||||
egress_altitude: Distance
|
||||
|
||||
min_patrol_altitude: Distance
|
||||
max_patrol_altitude: Distance
|
||||
@@ -73,13 +85,12 @@ MODERN_DOCTRINE = Doctrine(
|
||||
strike=True,
|
||||
antiship=True,
|
||||
rendezvous_altitude=feet(25000),
|
||||
hold_distance=nautical_miles(15),
|
||||
hold_distance=nautical_miles(25),
|
||||
push_distance=nautical_miles(20),
|
||||
join_distance=nautical_miles(20),
|
||||
split_distance=nautical_miles(20),
|
||||
ingress_egress_distance=nautical_miles(45),
|
||||
max_ingress_distance=nautical_miles(45),
|
||||
min_ingress_distance=nautical_miles(10),
|
||||
ingress_altitude=feet(20000),
|
||||
egress_altitude=feet(20000),
|
||||
min_patrol_altitude=feet(15000),
|
||||
max_patrol_altitude=feet(33000),
|
||||
pattern_altitude=feet(5000),
|
||||
@@ -111,13 +122,12 @@ COLDWAR_DOCTRINE = Doctrine(
|
||||
strike=True,
|
||||
antiship=True,
|
||||
rendezvous_altitude=feet(22000),
|
||||
hold_distance=nautical_miles(10),
|
||||
hold_distance=nautical_miles(15),
|
||||
push_distance=nautical_miles(10),
|
||||
join_distance=nautical_miles(10),
|
||||
split_distance=nautical_miles(10),
|
||||
ingress_egress_distance=nautical_miles(30),
|
||||
max_ingress_distance=nautical_miles(30),
|
||||
min_ingress_distance=nautical_miles(10),
|
||||
ingress_altitude=feet(18000),
|
||||
egress_altitude=feet(18000),
|
||||
min_patrol_altitude=feet(10000),
|
||||
max_patrol_altitude=feet(24000),
|
||||
pattern_altitude=feet(5000),
|
||||
@@ -148,14 +158,13 @@ WWII_DOCTRINE = Doctrine(
|
||||
sead=False,
|
||||
strike=True,
|
||||
antiship=True,
|
||||
hold_distance=nautical_miles(5),
|
||||
hold_distance=nautical_miles(10),
|
||||
push_distance=nautical_miles(5),
|
||||
join_distance=nautical_miles(5),
|
||||
split_distance=nautical_miles(5),
|
||||
rendezvous_altitude=feet(10000),
|
||||
ingress_egress_distance=nautical_miles(7),
|
||||
max_ingress_distance=nautical_miles(7),
|
||||
min_ingress_distance=nautical_miles(5),
|
||||
ingress_altitude=feet(8000),
|
||||
egress_altitude=feet(8000),
|
||||
min_patrol_altitude=feet(4000),
|
||||
max_patrol_altitude=feet(15000),
|
||||
pattern_altitude=feet(5000),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from dcs.ships import (
|
||||
Forrestal,
|
||||
PIOTR,
|
||||
MOSCOW,
|
||||
VINSON,
|
||||
@@ -24,7 +25,7 @@ from dcs.vehicles import AirDefence
|
||||
|
||||
TELARS = {
|
||||
AirDefence._2S6_Tunguska,
|
||||
AirDefence.SA_11_Buk_SR_9S18M1,
|
||||
AirDefence.SA_11_Buk_LN_9A310M1,
|
||||
AirDefence.Osa_9A33_ln,
|
||||
AirDefence.Tor_9A331,
|
||||
AirDefence.Roland_ADS,
|
||||
@@ -37,8 +38,10 @@ TRACK_RADARS = {
|
||||
AirDefence.Hawk_tr,
|
||||
AirDefence.Patriot_str,
|
||||
AirDefence.SNR_75V,
|
||||
AirDefence.RPC_5N62V,
|
||||
AirDefence.Rapier_fsa_blindfire_radar,
|
||||
AirDefence.HQ_7_STR_SP,
|
||||
AirDefence.NASAMS_Radar_MPQ64F1,
|
||||
}
|
||||
|
||||
LAUNCHER_TRACKER_PAIRS = {
|
||||
@@ -51,6 +54,9 @@ LAUNCHER_TRACKER_PAIRS = {
|
||||
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,
|
||||
}
|
||||
|
||||
UNITS_WITH_RADAR = {
|
||||
@@ -79,30 +85,33 @@ UNITS_WITH_RADAR = {
|
||||
AirDefence.Roland_Radar,
|
||||
AirDefence.Snr_s_125_tr,
|
||||
AirDefence.SNR_75V,
|
||||
AirDefence.RLS_19J6,
|
||||
AirDefence.RPC_5N62V,
|
||||
AirDefence.Rapier_fsa_blindfire_radar,
|
||||
AirDefence.HQ_7_LN_SP,
|
||||
AirDefence.HQ_7_STR_SP,
|
||||
AirDefence.FuMG_401,
|
||||
AirDefence.FuSe_65,
|
||||
# Ships
|
||||
VINSON,
|
||||
PERRY,
|
||||
TICONDEROG,
|
||||
ALBATROS,
|
||||
KUZNECOW,
|
||||
MOLNIYA,
|
||||
MOSCOW,
|
||||
NEUSTRASH,
|
||||
PIOTR,
|
||||
REZKY,
|
||||
CV_1143_5,
|
||||
Stennis,
|
||||
CVN_71,
|
||||
CVN_72,
|
||||
CVN_73,
|
||||
USS_Arleigh_Burke_IIa,
|
||||
CV_1143_5,
|
||||
Forrestal,
|
||||
KUZNECOW,
|
||||
LHA_Tarawa,
|
||||
MOLNIYA,
|
||||
MOSCOW,
|
||||
NEUSTRASH,
|
||||
PERRY,
|
||||
PIOTR,
|
||||
REZKY,
|
||||
Stennis,
|
||||
TICONDEROG,
|
||||
Type_052B,
|
||||
Type_054A,
|
||||
Type_052C,
|
||||
Type_054A,
|
||||
USS_Arleigh_Burke_IIa,
|
||||
VINSON,
|
||||
}
|
||||
|
||||
1288
game/data/weapons.py
1288
game/data/weapons.py
File diff suppressed because it is too large
Load Diff
39
game/db.py
39
game/db.py
@@ -29,8 +29,9 @@ from dcs.ships import (
|
||||
CV_1143_5,
|
||||
)
|
||||
from dcs.terrain.terrain import Airport
|
||||
from dcs.unit import Ship
|
||||
from dcs.unitgroup import ShipGroup, StaticGroup
|
||||
from dcs.unittype import UnitType
|
||||
from dcs.unittype import UnitType, FlyingType, ShipType, VehicleType
|
||||
from dcs.vehicles import (
|
||||
vehicle_map,
|
||||
)
|
||||
@@ -41,19 +42,25 @@ import pydcs_extensions.highdigitsams.highdigitsams as highdigitsams
|
||||
# PATCH pydcs data with MODS
|
||||
from game.factions.faction_loader import FactionLoader
|
||||
from pydcs_extensions.a4ec.a4ec import A_4E_C
|
||||
from pydcs_extensions.f104.f104 import VSN_F104G, VSN_F104S, VSN_F104S_AG
|
||||
from pydcs_extensions.f22a.f22a import F_22A
|
||||
from pydcs_extensions.hercules.hercules import Hercules
|
||||
from pydcs_extensions.jas39.jas39 import JAS39Gripen, JAS39Gripen_AG
|
||||
from pydcs_extensions.mb339.mb339 import MB_339PAN
|
||||
from pydcs_extensions.su57.su57 import Su_57
|
||||
from pydcs_extensions.uh60l.uh60l import UH_60L, KC130J
|
||||
|
||||
plane_map["A-4E-C"] = A_4E_C
|
||||
plane_map["F-22A"] = F_22A
|
||||
plane_map["MB-339PAN"] = MB_339PAN
|
||||
plane_map["Su-57"] = Su_57
|
||||
plane_map["Hercules"] = Hercules
|
||||
plane_map["KC130J"] = KC130J
|
||||
plane_map["JAS39Gripen"] = JAS39Gripen
|
||||
plane_map["JAS39Gripen_AG"] = JAS39Gripen_AG
|
||||
plane_map["VSN_F104G"] = VSN_F104G
|
||||
plane_map["VSN_F104S"] = VSN_F104S
|
||||
plane_map["VSN_F104S_AG"] = VSN_F104S_AG
|
||||
|
||||
helicopter_map["UH-60L"] = UH_60L
|
||||
|
||||
vehicle_map["FieldHL"] = frenchpack._FIELD_HIDE
|
||||
vehicle_map["HARRIERH"] = frenchpack._FIELD_HIDE_SMALL
|
||||
@@ -89,6 +96,14 @@ vehicle_map["Toyota_bleu"] = frenchpack.DIM__TOYOTA_BLUE
|
||||
vehicle_map["Toyota_vert"] = frenchpack.DIM__TOYOTA_GREEN
|
||||
vehicle_map["Toyota_desert"] = frenchpack.DIM__TOYOTA_DESERT
|
||||
vehicle_map["Kamikaze"] = frenchpack.DIM__KAMIKAZE
|
||||
vehicle_map["AMX1375"] = frenchpack.AMX_13_75mm
|
||||
vehicle_map["AMX1390"] = frenchpack.AMX_13_90mm
|
||||
vehicle_map["VBCI"] = frenchpack.VBCI
|
||||
vehicle_map["T62"] = frenchpack.Char_T_62
|
||||
vehicle_map["T64BV"] = frenchpack.Char_T_64BV
|
||||
vehicle_map["T72M"] = frenchpack.Char_T_72A
|
||||
vehicle_map["KORNET"] = frenchpack.KORNET_ATGM
|
||||
|
||||
|
||||
vehicle_map[highdigitsams.AAA_SON_9_Fire_Can.id] = highdigitsams.AAA_SON_9_Fire_Can
|
||||
vehicle_map[highdigitsams.AAA_100mm_KS_19.id] = highdigitsams.AAA_100mm_KS_19
|
||||
@@ -249,7 +264,7 @@ Aircraft livery overrides. Syntax as follows:
|
||||
`Identifier` is aircraft identifier (as used troughout the file) and "LiveryName" (with double quotes)
|
||||
is livery name as found in mission editor.
|
||||
"""
|
||||
PLANE_LIVERY_OVERRIDES = {
|
||||
PLANE_LIVERY_OVERRIDES: dict[Type[FlyingType], str] = {
|
||||
FA_18C_hornet: "VFA-34", # default livery for the hornet is blue angels one
|
||||
}
|
||||
|
||||
@@ -311,6 +326,8 @@ REWARDS = {
|
||||
"comms": 10,
|
||||
"oil": 10,
|
||||
"derrick": 8,
|
||||
"village": 0.25,
|
||||
"allycamp": 0.5,
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -320,7 +337,7 @@ REWARDS = {
|
||||
StartingPosition = Union[ShipGroup, StaticGroup, Airport, Point]
|
||||
|
||||
|
||||
def upgrade_to_supercarrier(unit, name: str):
|
||||
def upgrade_to_supercarrier(unit: Type[ShipType], name: str) -> Type[ShipType]:
|
||||
if unit == Stennis:
|
||||
if name == "CVN-71 Theodore Roosevelt":
|
||||
return CVN_71
|
||||
@@ -353,7 +370,15 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]:
|
||||
return None
|
||||
|
||||
|
||||
def country_id_from_name(name):
|
||||
def vehicle_type_from_name(name: str) -> Type[VehicleType]:
|
||||
return vehicle_map[name]
|
||||
|
||||
|
||||
def ship_type_from_name(name: str) -> Type[ShipType]:
|
||||
return ship_map[name]
|
||||
|
||||
|
||||
def country_id_from_name(name: str) -> int:
|
||||
for k, v in country_dict.items():
|
||||
if v.name == name:
|
||||
return k
|
||||
@@ -366,7 +391,7 @@ class DefaultLiveries:
|
||||
|
||||
|
||||
OH_58D.Liveries = DefaultLiveries
|
||||
F_16C_50.Liveries = DefaultLiveries
|
||||
F_16C_50.Liveries = DefaultLiveries # type: ignore
|
||||
P_51D_30_NA.Liveries = DefaultLiveries
|
||||
Ju_88A4.Liveries = DefaultLiveries
|
||||
B_17G.Liveries = DefaultLiveries
|
||||
|
||||
@@ -29,12 +29,20 @@ from game.radio.channels import (
|
||||
ViggenRadioChannelAllocator,
|
||||
NoOpChannelAllocator,
|
||||
)
|
||||
from game.utils import Speed, kph
|
||||
from game.utils import (
|
||||
Distance,
|
||||
SPEED_OF_SOUND_AT_SEA_LEVEL,
|
||||
Speed,
|
||||
feet,
|
||||
kph,
|
||||
knots,
|
||||
nautical_miles,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen.aircraft import FlightData
|
||||
from gen import AirSupport, RadioFrequency, RadioRegistry
|
||||
from gen.radios import Radio
|
||||
from gen.airsupport import AirSupport
|
||||
from gen.radios import Radio, RadioFrequency, RadioRegistry
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -91,11 +99,70 @@ class RadioConfig:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AircraftType(UnitType[FlyingType]):
|
||||
class PatrolConfig:
|
||||
altitude: Optional[Distance]
|
||||
speed: Optional[Speed]
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: dict[str, Any]) -> PatrolConfig:
|
||||
altitude = data.get("altitude", None)
|
||||
speed = data.get("speed", None)
|
||||
return PatrolConfig(
|
||||
feet(altitude) if altitude is not None else None,
|
||||
knots(speed) if speed is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FuelConsumption:
|
||||
#: The estimated taxi fuel requirement, in pounds.
|
||||
taxi: int
|
||||
|
||||
#: The estimated fuel consumption for a takeoff climb, in pounds per nautical mile.
|
||||
climb: float
|
||||
|
||||
#: The estimated fuel consumption for cruising, in pounds per nautical mile.
|
||||
cruise: float
|
||||
|
||||
#: The estimated fuel consumption for combat speeds, in pounds per nautical mile.
|
||||
combat: float
|
||||
|
||||
#: The minimum amount of fuel that the aircraft should land with, in pounds. This is
|
||||
#: a reserve amount for landing delays or emergencies.
|
||||
min_safe: int
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: dict[str, Any]) -> FuelConsumption:
|
||||
return FuelConsumption(
|
||||
int(data["taxi"]),
|
||||
float(data["climb_ppm"]),
|
||||
float(data["cruise_ppm"]),
|
||||
float(data["combat_ppm"]),
|
||||
int(data["min_safe"]),
|
||||
)
|
||||
|
||||
|
||||
# TODO: Split into PlaneType and HelicopterType?
|
||||
@dataclass(frozen=True)
|
||||
class AircraftType(UnitType[Type[FlyingType]]):
|
||||
carrier_capable: bool
|
||||
lha_capable: bool
|
||||
always_keeps_gun: bool
|
||||
|
||||
# If true, the aircraft does not use the guns as the last resort weapons, but as a
|
||||
# main weapon. It'll RTB when it doesn't have gun ammo left.
|
||||
gunfighter: bool
|
||||
|
||||
max_group_size: int
|
||||
patrol_altitude: Optional[Distance]
|
||||
patrol_speed: Optional[Speed]
|
||||
|
||||
#: The maximum range between the origin airfield and the target for which the auto-
|
||||
#: planner will consider this aircraft usable for a mission.
|
||||
max_mission_range: Distance
|
||||
|
||||
fuel_consumption: Optional[FuelConsumption]
|
||||
|
||||
intra_flight_radio: Optional[Radio]
|
||||
channel_allocator: Optional[RadioChannelAllocator]
|
||||
channel_namer: Type[ChannelNamer]
|
||||
@@ -117,17 +184,94 @@ class AircraftType(UnitType[FlyingType]):
|
||||
def flyable(self) -> bool:
|
||||
return self.dcs_unit_type.flyable
|
||||
|
||||
@property
|
||||
def helicopter(self) -> bool:
|
||||
return self.dcs_unit_type.helicopter
|
||||
|
||||
@cached_property
|
||||
def max_speed(self) -> Speed:
|
||||
return kph(self.dcs_unit_type.max_speed)
|
||||
|
||||
@property
|
||||
def preferred_patrol_altitude(self) -> Distance:
|
||||
if self.patrol_altitude is not None:
|
||||
return self.patrol_altitude
|
||||
else:
|
||||
# Estimate based on max speed.
|
||||
# Aircaft with max speed 600 kph will prefer patrol at 10 000 ft
|
||||
# Aircraft with max speed 2800 kph will prefer pratrol at 33 000 ft
|
||||
altitude_for_lowest_speed = feet(10 * 1000)
|
||||
altitude_for_highest_speed = feet(33 * 1000)
|
||||
lowest_speed = kph(600)
|
||||
highest_speed = kph(2800)
|
||||
factor = (self.max_speed - lowest_speed).kph / (
|
||||
highest_speed - lowest_speed
|
||||
).kph
|
||||
altitude = (
|
||||
altitude_for_lowest_speed
|
||||
+ (altitude_for_highest_speed - altitude_for_lowest_speed) * factor
|
||||
)
|
||||
logging.debug(
|
||||
f"Preferred patrol altitude for {self.dcs_unit_type.id}: {altitude.feet}"
|
||||
)
|
||||
rounded_altitude = feet(round(1000 * round(altitude.feet / 1000)))
|
||||
return max(
|
||||
altitude_for_lowest_speed,
|
||||
min(altitude_for_highest_speed, rounded_altitude),
|
||||
)
|
||||
|
||||
def preferred_patrol_speed(self, altitude: Distance) -> Speed:
|
||||
"""Preferred true airspeed when patrolling"""
|
||||
if self.patrol_speed is not None:
|
||||
return self.patrol_speed
|
||||
else:
|
||||
# Estimate based on max speed.
|
||||
max_speed = self.max_speed
|
||||
if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.6:
|
||||
# Fast airplanes, should manage pretty high patrol speed
|
||||
return (
|
||||
Speed.from_mach(0.85, altitude)
|
||||
if altitude.feet > 20000
|
||||
else Speed.from_mach(0.7, altitude)
|
||||
)
|
||||
elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.2:
|
||||
# Medium-fast like F/A-18C
|
||||
return (
|
||||
Speed.from_mach(0.8, altitude)
|
||||
if altitude.feet > 20000
|
||||
else Speed.from_mach(0.65, altitude)
|
||||
)
|
||||
elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 0.7:
|
||||
# Semi-fast like airliners or similar
|
||||
return (
|
||||
Speed.from_mach(0.5, altitude)
|
||||
if altitude.feet > 20000
|
||||
else Speed.from_mach(0.4, altitude)
|
||||
)
|
||||
else:
|
||||
# Slow like warbirds or helicopters
|
||||
# Use whichever is slowest - mach 0.35 or 70% of max speed
|
||||
logging.debug(f"{self.name} max_speed * 0.7 is {max_speed * 0.7}")
|
||||
return min(Speed.from_mach(0.35, altitude), max_speed * 0.7)
|
||||
|
||||
def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
|
||||
from gen.radios import ChannelInUseError, MHz
|
||||
from gen.radios import ChannelInUseError, kHz
|
||||
|
||||
if self.intra_flight_radio is not None:
|
||||
return radio_registry.alloc_for_radio(self.intra_flight_radio)
|
||||
|
||||
freq = MHz(self.dcs_unit_type.radio_frequency)
|
||||
# The default radio frequency is set in megahertz. For some aircraft, it is a
|
||||
# floating point value. For all current aircraft, adjusting to kilohertz will be
|
||||
# sufficient to convert to an integer.
|
||||
in_khz = float(self.dcs_unit_type.radio_frequency) * 1000
|
||||
if not in_khz.is_integer():
|
||||
logging.warning(
|
||||
f"Found unexpected sub-kHz default radio for {self}: {in_khz} kHz. "
|
||||
"Truncating to integer. The truncated frequency may not be valid for "
|
||||
"the aircraft."
|
||||
)
|
||||
|
||||
freq = kHz(int(in_khz))
|
||||
try:
|
||||
radio_registry.reserve(freq)
|
||||
except ChannelInUseError:
|
||||
@@ -162,6 +306,8 @@ class AircraftType(UnitType[FlyingType]):
|
||||
|
||||
@classmethod
|
||||
def for_dcs_type(cls, dcs_unit_type: Type[FlyingType]) -> Iterator[AircraftType]:
|
||||
if not cls._loaded:
|
||||
cls._load_all()
|
||||
yield from cls._by_unit_type[dcs_unit_type]
|
||||
|
||||
@staticmethod
|
||||
@@ -183,7 +329,7 @@ class AircraftType(UnitType[FlyingType]):
|
||||
logging.warning(f"No data for {aircraft.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open() as data_file:
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
try:
|
||||
@@ -192,6 +338,26 @@ class AircraftType(UnitType[FlyingType]):
|
||||
raise KeyError(f"Missing required price field: {data_path}") from ex
|
||||
|
||||
radio_config = RadioConfig.from_data(data.get("radios", {}))
|
||||
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
|
||||
|
||||
try:
|
||||
mission_range = nautical_miles(int(data["max_range"]))
|
||||
except (KeyError, ValueError):
|
||||
mission_range = (
|
||||
nautical_miles(50) if aircraft.helicopter else nautical_miles(150)
|
||||
)
|
||||
logging.warning(
|
||||
f"{aircraft.id} does not specify a max_range. Defaulting to "
|
||||
f"{mission_range.nautical_miles}NM"
|
||||
)
|
||||
|
||||
fuel_data = data.get("fuel")
|
||||
if fuel_data is not None:
|
||||
fuel_consumption: Optional[FuelConsumption] = FuelConsumption.from_data(
|
||||
fuel_data
|
||||
)
|
||||
else:
|
||||
fuel_consumption = None
|
||||
|
||||
try:
|
||||
introduction = data["introduced"]
|
||||
@@ -204,7 +370,10 @@ class AircraftType(UnitType[FlyingType]):
|
||||
yield AircraftType(
|
||||
dcs_unit_type=aircraft,
|
||||
name=variant,
|
||||
description=data.get("description", "No data."),
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
@@ -213,7 +382,12 @@ class AircraftType(UnitType[FlyingType]):
|
||||
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,
|
||||
intra_flight_radio=radio_config.intra_flight,
|
||||
channel_allocator=radio_config.channel_allocator,
|
||||
channel_namer=radio_config.channel_namer,
|
||||
|
||||
@@ -15,7 +15,7 @@ from game.dcs.unittype import UnitType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroundUnitType(UnitType[VehicleType]):
|
||||
class GroundUnitType(UnitType[Type[VehicleType]]):
|
||||
unit_class: Optional[GroundUnitClass]
|
||||
spawn_weight: int
|
||||
|
||||
@@ -45,6 +45,8 @@ class GroundUnitType(UnitType[VehicleType]):
|
||||
|
||||
@classmethod
|
||||
def for_dcs_type(cls, dcs_unit_type: Type[VehicleType]) -> Iterator[GroundUnitType]:
|
||||
if not cls._loaded:
|
||||
cls._load_all()
|
||||
yield from cls._by_unit_type[dcs_unit_type]
|
||||
|
||||
@staticmethod
|
||||
@@ -65,7 +67,7 @@ class GroundUnitType(UnitType[VehicleType]):
|
||||
logging.warning(f"No data for {vehicle.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open() as data_file:
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
try:
|
||||
@@ -86,7 +88,10 @@ class GroundUnitType(UnitType[VehicleType]):
|
||||
unit_class=unit_class,
|
||||
spawn_weight=data.get("spawn_weight", 0),
|
||||
name=variant,
|
||||
description=data.get("description", "No data."),
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
|
||||
@@ -4,12 +4,12 @@ from typing import TypeVar, Generic, Type
|
||||
|
||||
from dcs.unittype import UnitType as DcsUnitType
|
||||
|
||||
DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=DcsUnitType)
|
||||
DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitType(Generic[DcsUnitTypeT]):
|
||||
dcs_unit_type: Type[DcsUnitTypeT]
|
||||
dcs_unit_type: DcsUnitTypeT
|
||||
name: str
|
||||
description: str
|
||||
year_introduced: str
|
||||
|
||||
@@ -15,9 +15,9 @@ from typing import (
|
||||
Iterator,
|
||||
List,
|
||||
TYPE_CHECKING,
|
||||
Union,
|
||||
)
|
||||
|
||||
from game import db
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.theater import Airfield, ControlPoint
|
||||
@@ -77,8 +77,8 @@ class GroundLosses:
|
||||
player_airlifts: List[AirliftUnits] = field(default_factory=list)
|
||||
enemy_airlifts: List[AirliftUnits] = field(default_factory=list)
|
||||
|
||||
player_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
|
||||
enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
|
||||
player_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
|
||||
enemy_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
|
||||
|
||||
player_buildings: List[Building] = field(default_factory=list)
|
||||
enemy_buildings: List[Building] = field(default_factory=list)
|
||||
@@ -101,11 +101,12 @@ class StateData:
|
||||
#: Names of aircraft units that were killed during the mission.
|
||||
killed_aircraft: List[str]
|
||||
|
||||
#: Names of vehicle (and ship) units that were killed during the mission.
|
||||
#: Names of vehicles, ships or buildings that were killed during the mission.
|
||||
killed_ground_units: List[str]
|
||||
|
||||
#: Names of static units that were destroyed during the mission.
|
||||
destroyed_statics: List[str]
|
||||
#: List of descriptions of destroyed statics. Format of each element is a mapping of
|
||||
#: the coordinate type ("x", "y", "z", "type", "orientation") to the value.
|
||||
destroyed_statics: List[dict[str, Union[float, str]]]
|
||||
|
||||
#: Mangled names of bases that were captured during the mission.
|
||||
base_capture_events: List[str]
|
||||
@@ -134,10 +135,8 @@ class Debriefing:
|
||||
self.game = game
|
||||
self.unit_map = unit_map
|
||||
|
||||
self.player_country = game.player_country
|
||||
self.enemy_country = game.enemy_country
|
||||
self.player_country_id = db.country_id_from_name(game.player_country)
|
||||
self.enemy_country_id = db.country_id_from_name(game.enemy_country)
|
||||
self.player_country = game.blue.country_name
|
||||
self.enemy_country = game.red.country_name
|
||||
|
||||
self.air_losses = self.dead_aircraft()
|
||||
self.ground_losses = self.dead_ground_units()
|
||||
@@ -164,7 +163,7 @@ class Debriefing:
|
||||
yield from self.ground_losses.enemy_airlifts
|
||||
|
||||
@property
|
||||
def ground_object_losses(self) -> Iterator[GroundObjectUnit]:
|
||||
def ground_object_losses(self) -> Iterator[GroundObjectUnit[Any]]:
|
||||
yield from self.ground_losses.player_ground_objects
|
||||
yield from self.ground_losses.enemy_ground_objects
|
||||
|
||||
@@ -370,32 +369,38 @@ class PollDebriefingFileThread(threading.Thread):
|
||||
self.game = game
|
||||
self.unit_map = unit_map
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
def stopped(self):
|
||||
def stopped(self) -> bool:
|
||||
return self._stop_event.is_set()
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
if os.path.isfile("state.json"):
|
||||
last_modified = os.path.getmtime("state.json")
|
||||
else:
|
||||
last_modified = 0
|
||||
while not self.stopped():
|
||||
if (
|
||||
os.path.isfile("state.json")
|
||||
and os.path.getmtime("state.json") > last_modified
|
||||
):
|
||||
with open("state.json", "r") as json_file:
|
||||
json_data = json.load(json_file)
|
||||
debriefing = Debriefing(json_data, self.game, self.unit_map)
|
||||
self.callback(debriefing)
|
||||
break
|
||||
try:
|
||||
if (
|
||||
os.path.isfile("state.json")
|
||||
and os.path.getmtime("state.json") > last_modified
|
||||
):
|
||||
with open("state.json", "r", encoding="utf-8") as json_file:
|
||||
json_data = json.load(json_file)
|
||||
debriefing = Debriefing(json_data, self.game, self.unit_map)
|
||||
self.callback(debriefing)
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
logging.exception(
|
||||
"Failed to decode state.json. Probably attempted read while DCS "
|
||||
"was still writing the file. Will retry in 5 seconds."
|
||||
)
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def wait_for_debriefing(
|
||||
callback: Callable[[Debriefing], None], game: Game, unit_map
|
||||
callback: Callable[[Debriefing], None], game: Game, unit_map: UnitMap
|
||||
) -> PollDebriefingFileThread:
|
||||
thread = PollDebriefingFileThread(callback, game, unit_map)
|
||||
thread.start()
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .event import Event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater import ConflictTheater
|
||||
|
||||
|
||||
class AirWarEvent(Event):
|
||||
"""Event handler for the air battle"""
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return "AirWar"
|
||||
|
||||
@@ -5,16 +5,13 @@ from typing import List, TYPE_CHECKING, Type
|
||||
|
||||
from dcs.mapping import Point
|
||||
from dcs.task import Task
|
||||
from dcs.unittype import VehicleType
|
||||
|
||||
from game import persistency
|
||||
from game.debriefing import AirLosses, Debriefing
|
||||
from game.infos.information import Information
|
||||
from game.debriefing import Debriefing
|
||||
from game.operation.operation import Operation
|
||||
from game.theater import ControlPoint
|
||||
from gen import AirTaskingOrder
|
||||
from gen.ato import AirTaskingOrder
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
from ..dcs.groundunittype import GroundUnitType
|
||||
from ..unitmap import UnitMap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -38,13 +35,13 @@ class Event:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
game,
|
||||
game: Game,
|
||||
from_cp: ControlPoint,
|
||||
target_cp: ControlPoint,
|
||||
location: Point,
|
||||
attacker_name: str,
|
||||
defender_name: str,
|
||||
):
|
||||
) -> None:
|
||||
self.game = game
|
||||
self.from_cp = from_cp
|
||||
self.to_cp = target_cp
|
||||
@@ -54,7 +51,7 @@ class Event:
|
||||
|
||||
@property
|
||||
def is_player_attacking(self) -> bool:
|
||||
return self.attacker_name == self.game.player_name
|
||||
return self.attacker_name == self.game.blue.faction.name
|
||||
|
||||
@property
|
||||
def tasks(self) -> List[Type[Task]]:
|
||||
@@ -68,59 +65,6 @@ class Event:
|
||||
)
|
||||
return unit_map
|
||||
|
||||
@staticmethod
|
||||
def _transfer_aircraft(
|
||||
ato: AirTaskingOrder, losses: AirLosses, for_player: bool
|
||||
) -> None:
|
||||
for package in ato.packages:
|
||||
for flight in package.flights:
|
||||
# No need to transfer to the same location.
|
||||
if flight.departure == flight.arrival:
|
||||
continue
|
||||
|
||||
# Don't transfer to bases that were captured. Note that if the
|
||||
# airfield was back-filling transfers it may overflow. We could
|
||||
# attempt to be smarter in the future by performing transfers in
|
||||
# order up a graph to prevent transfers to full airports and
|
||||
# send overflow off-map, but overflow is fine for now.
|
||||
if flight.arrival.captured != for_player:
|
||||
logging.info(
|
||||
f"Not transferring {flight} because {flight.arrival} "
|
||||
"was captured"
|
||||
)
|
||||
continue
|
||||
|
||||
transfer_count = losses.surviving_flight_members(flight)
|
||||
if transfer_count < 0:
|
||||
logging.error(
|
||||
f"{flight} had {flight.count} aircraft but "
|
||||
f"{transfer_count} losses were recorded."
|
||||
)
|
||||
continue
|
||||
|
||||
aircraft = flight.unit_type
|
||||
available = flight.departure.base.total_units_of_type(aircraft)
|
||||
if available < transfer_count:
|
||||
logging.error(
|
||||
f"Found killed {aircraft} from {flight.departure} but "
|
||||
f"that airbase has only {available} available."
|
||||
)
|
||||
continue
|
||||
|
||||
flight.departure.base.aircraft[aircraft] -= transfer_count
|
||||
if aircraft not in flight.arrival.base.aircraft:
|
||||
# TODO: Should use defaultdict.
|
||||
flight.arrival.base.aircraft[aircraft] = 0
|
||||
flight.arrival.base.aircraft[aircraft] += transfer_count
|
||||
|
||||
def complete_aircraft_transfers(self, debriefing: Debriefing) -> None:
|
||||
self._transfer_aircraft(
|
||||
self.game.blue_ato, debriefing.air_losses, for_player=True
|
||||
)
|
||||
self._transfer_aircraft(
|
||||
self.game.red_ato, debriefing.air_losses, for_player=False
|
||||
)
|
||||
|
||||
def commit_air_losses(self, debriefing: Debriefing) -> None:
|
||||
for loss in debriefing.air_losses.losses:
|
||||
if loss.pilot is not None and (
|
||||
@@ -128,18 +72,18 @@ class Event:
|
||||
or not self.game.settings.invulnerable_player_pilots
|
||||
):
|
||||
loss.pilot.kill()
|
||||
squadron = loss.flight.squadron
|
||||
aircraft = loss.flight.unit_type
|
||||
cp = loss.flight.departure
|
||||
available = cp.base.total_units_of_type(aircraft)
|
||||
available = squadron.owned_aircraft
|
||||
if available <= 0:
|
||||
logging.error(
|
||||
f"Found killed {aircraft} from {cp} but that airbase has "
|
||||
f"Found killed {aircraft} from {squadron} but that airbase has "
|
||||
"none available."
|
||||
)
|
||||
continue
|
||||
|
||||
logging.info(f"{aircraft} destroyed from {cp}")
|
||||
cp.base.aircraft[aircraft] -= 1
|
||||
logging.info(f"{aircraft} destroyed from {squadron}")
|
||||
squadron.owned_aircraft -= 1
|
||||
|
||||
@staticmethod
|
||||
def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
|
||||
@@ -155,8 +99,8 @@ class Event:
|
||||
pilot.record.missions_flown += 1
|
||||
|
||||
def commit_pilot_experience(self) -> None:
|
||||
self._commit_pilot_experience(self.game.blue_ato)
|
||||
self._commit_pilot_experience(self.game.red_ato)
|
||||
self._commit_pilot_experience(self.game.blue.ato)
|
||||
self._commit_pilot_experience(self.game.red.ato)
|
||||
|
||||
@staticmethod
|
||||
def commit_front_line_losses(debriefing: Debriefing) -> None:
|
||||
@@ -220,21 +164,18 @@ class Event:
|
||||
for loss in debriefing.ground_object_losses:
|
||||
# TODO: This should be stored in the TGO, not in the pydcs Group.
|
||||
if not hasattr(loss.group, "units_losts"):
|
||||
loss.group.units_losts = []
|
||||
loss.group.units_losts = [] # type: ignore
|
||||
|
||||
loss.group.units.remove(loss.unit)
|
||||
loss.group.units_losts.append(loss.unit)
|
||||
loss.group.units_losts.append(loss.unit) # type: ignore
|
||||
|
||||
def commit_building_losses(self, debriefing: Debriefing) -> None:
|
||||
for loss in debriefing.building_losses:
|
||||
loss.ground_object.kill()
|
||||
self.game.informations.append(
|
||||
Information(
|
||||
"Building destroyed",
|
||||
f"{loss.ground_object.dcs_identifier} has been destroyed at "
|
||||
f"location {loss.ground_object.obj_name}",
|
||||
self.game.turn,
|
||||
)
|
||||
self.game.message(
|
||||
"Building destroyed",
|
||||
f"{loss.ground_object.dcs_identifier} has been destroyed at "
|
||||
f"location {loss.ground_object.obj_name}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -246,38 +187,35 @@ class Event:
|
||||
for captured in debriefing.base_captures:
|
||||
try:
|
||||
if captured.captured_by_player:
|
||||
info = Information(
|
||||
self.game.message(
|
||||
f"{captured.control_point} captured!",
|
||||
f"We took control of {captured.control_point}.",
|
||||
self.game.turn,
|
||||
)
|
||||
else:
|
||||
info = Information(
|
||||
self.game.message(
|
||||
f"{captured.control_point} lost!",
|
||||
f"The enemy took control of {captured.control_point}.",
|
||||
self.game.turn,
|
||||
)
|
||||
|
||||
self.game.informations.append(info)
|
||||
captured.control_point.capture(self.game, captured.captured_by_player)
|
||||
logging.info(f"Will run redeploy for {captured.control_point}")
|
||||
self.redeploy_units(captured.control_point)
|
||||
except Exception:
|
||||
logging.exception(f"Could not process base capture {captured}")
|
||||
|
||||
def commit(self, debriefing: Debriefing):
|
||||
def commit(self, debriefing: Debriefing) -> None:
|
||||
logging.info("Committing mission results")
|
||||
|
||||
self.commit_air_losses(debriefing)
|
||||
self.commit_pilot_experience()
|
||||
self.commit_front_line_losses(debriefing)
|
||||
self.commit_convoy_losses(debriefing)
|
||||
self.commit_cargo_ship_losses(debriefing)
|
||||
self.commit_airlift_losses(debriefing)
|
||||
self.commit_ground_object_losses(debriefing)
|
||||
self.commit_building_losses(debriefing)
|
||||
self.commit_damaged_runways(debriefing)
|
||||
self.commit_captures(debriefing)
|
||||
self.complete_aircraft_transfers(debriefing)
|
||||
|
||||
# Destroyed units carcass
|
||||
# -------------------------
|
||||
@@ -298,15 +236,16 @@ class Event:
|
||||
|
||||
delta = 0.0
|
||||
player_won = True
|
||||
status_msg: str = ""
|
||||
ally_casualties = debriefing.casualty_count(cp)
|
||||
enemy_casualties = debriefing.casualty_count(enemy_cp)
|
||||
ally_units_alive = cp.base.total_armor
|
||||
enemy_units_alive = enemy_cp.base.total_armor
|
||||
|
||||
print(ally_units_alive)
|
||||
print(enemy_units_alive)
|
||||
print(ally_casualties)
|
||||
print(enemy_casualties)
|
||||
print(f"Remaining allied units: {ally_units_alive}")
|
||||
print(f"Remaining enemy units: {enemy_units_alive}")
|
||||
print(f"Allied casualties {ally_casualties}")
|
||||
print(f"Enemy casualties {enemy_casualties}")
|
||||
|
||||
ratio = (1.0 + enemy_casualties) / (1.0 + ally_casualties)
|
||||
|
||||
@@ -319,24 +258,31 @@ class Event:
|
||||
if ally_units_alive == 0:
|
||||
player_won = False
|
||||
delta = STRONG_DEFEAT_INFLUENCE
|
||||
status_msg = f"No allied units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces suffer a strong defeat."
|
||||
elif enemy_units_alive == 0:
|
||||
player_won = True
|
||||
delta = STRONG_DEFEAT_INFLUENCE
|
||||
status_msg = f"No enemy units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces win a strong victory."
|
||||
elif cp.stances[enemy_cp.id] == CombatStance.RETREAT:
|
||||
player_won = False
|
||||
delta = STRONG_DEFEAT_INFLUENCE
|
||||
status_msg = f"Allied forces are retreating along the {cp.name}-{enemy_cp.name} frontline, suffering a strong defeat."
|
||||
else:
|
||||
if enemy_casualties > ally_casualties:
|
||||
player_won = True
|
||||
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
|
||||
delta = STRONG_DEFEAT_INFLUENCE
|
||||
status_msg = f"Allied forces break through the {cp.name}-{enemy_cp.name} frontline, winning a strong victory"
|
||||
else:
|
||||
if ratio > 3:
|
||||
delta = STRONG_DEFEAT_INFLUENCE
|
||||
status_msg = f"Enemy casualties massively outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a strong victory."
|
||||
elif ratio < 1.5:
|
||||
delta = MINOR_DEFEAT_INFLUENCE
|
||||
status_msg = f"Enemy casualties minorly outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a minor victory."
|
||||
else:
|
||||
delta = DEFEAT_INFLUENCE
|
||||
status_msg = f"Enemy casualties outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces claim a victory."
|
||||
elif ally_casualties > enemy_casualties:
|
||||
|
||||
if (
|
||||
@@ -346,54 +292,60 @@ class Event:
|
||||
# Even with casualties if the enemy is overwhelmed, they are going to lose ground
|
||||
player_won = True
|
||||
delta = MINOR_DEFEAT_INFLUENCE
|
||||
status_msg = f"Despite suffering losses, allied forces still outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a minor victory."
|
||||
elif (
|
||||
ally_units_alive > 3 * enemy_units_alive
|
||||
and player_aggresive
|
||||
):
|
||||
player_won = True
|
||||
delta = STRONG_DEFEAT_INFLUENCE
|
||||
status_msg = f"Despite suffering losses, allied forces still heavily outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a major victory."
|
||||
else:
|
||||
# But is the enemy is not outnumbered, we lose
|
||||
# But if the enemy is not outnumbered, we lose
|
||||
player_won = False
|
||||
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
|
||||
delta = STRONG_DEFEAT_INFLUENCE
|
||||
status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces have overextended themselves, suffering a major defeat."
|
||||
else:
|
||||
delta = STRONG_DEFEAT_INFLUENCE
|
||||
delta = DEFEAT_INFLUENCE
|
||||
status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces suffer a defeat."
|
||||
|
||||
# No progress with defensive strategies
|
||||
if player_won and cp.stances[enemy_cp.id] in [
|
||||
CombatStance.DEFENSIVE,
|
||||
CombatStance.AMBUSH,
|
||||
]:
|
||||
print("Defensive stance, progress is limited")
|
||||
print(
|
||||
f"Allied forces have adopted a defensive stance along the {cp.name}-{enemy_cp.name} "
|
||||
f"frontline, making only limited progress."
|
||||
)
|
||||
delta = MINOR_DEFEAT_INFLUENCE
|
||||
|
||||
if player_won:
|
||||
print(cp.name + " won ! factor > " + str(delta))
|
||||
cp.base.affect_strength(delta)
|
||||
enemy_cp.base.affect_strength(-delta)
|
||||
info = Information(
|
||||
# Handle the case where there are no casualties at all on either side but both sides still have units
|
||||
if delta == 0.0:
|
||||
print(status_msg)
|
||||
self.game.message(
|
||||
"Frontline Report",
|
||||
"Our ground forces from "
|
||||
+ cp.name
|
||||
+ " are making progress toward "
|
||||
+ enemy_cp.name,
|
||||
self.game.turn,
|
||||
f"Our ground forces from {cp.name} reached a stalemate with enemy forces from {enemy_cp.name}.",
|
||||
)
|
||||
self.game.informations.append(info)
|
||||
else:
|
||||
print(cp.name + " lost ! factor > " + str(delta))
|
||||
enemy_cp.base.affect_strength(delta)
|
||||
cp.base.affect_strength(-delta)
|
||||
info = Information(
|
||||
"Frontline Report",
|
||||
"Our ground forces from "
|
||||
+ cp.name
|
||||
+ " are losing ground against the enemy forces from "
|
||||
+ enemy_cp.name,
|
||||
self.game.turn,
|
||||
)
|
||||
self.game.informations.append(info)
|
||||
if player_won:
|
||||
print(status_msg)
|
||||
cp.base.affect_strength(delta)
|
||||
enemy_cp.base.affect_strength(-delta)
|
||||
self.game.message(
|
||||
"Frontline Report",
|
||||
f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}",
|
||||
)
|
||||
else:
|
||||
print(status_msg)
|
||||
enemy_cp.base.affect_strength(delta)
|
||||
cp.base.affect_strength(-delta)
|
||||
self.game.message(
|
||||
"Frontline Report",
|
||||
f"Our ground forces from {cp.name} are losing ground against the enemy forces from "
|
||||
f"{enemy_cp.name}. {status_msg}",
|
||||
)
|
||||
|
||||
def redeploy_units(self, cp: ControlPoint) -> None:
|
||||
""" "
|
||||
@@ -439,22 +391,15 @@ class Event:
|
||||
source.base.commit_losses(moved_units)
|
||||
|
||||
# Also transfer pending deliveries.
|
||||
for unit_type, count in source.pending_unit_deliveries.units.items():
|
||||
if not isinstance(unit_type, GroundUnitType):
|
||||
continue
|
||||
if count <= 0:
|
||||
# Don't transfer *sales*...
|
||||
continue
|
||||
for unit_type, count in source.ground_unit_orders.units.items():
|
||||
move_count = int(count * move_factor)
|
||||
source.pending_unit_deliveries.sell({unit_type: move_count})
|
||||
destination.pending_unit_deliveries.order({unit_type: move_count})
|
||||
source.ground_unit_orders.sell({unit_type: move_count})
|
||||
destination.ground_unit_orders.order({unit_type: move_count})
|
||||
total_units_redeployed += move_count
|
||||
|
||||
if total_units_redeployed > 0:
|
||||
text = (
|
||||
self.game.message(
|
||||
"Units redeployed",
|
||||
f"{total_units_redeployed} units have been redeployed from "
|
||||
f"{source.name} to {destination.name}"
|
||||
f"{source.name} to {destination.name}",
|
||||
)
|
||||
info = Information("Units redeployed", text, self.game.turn)
|
||||
self.game.informations.append(info)
|
||||
logging.info(text)
|
||||
|
||||
@@ -8,5 +8,5 @@ class FrontlineAttackEvent(Event):
|
||||
future unique Event handling
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return "Frontline attack"
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import itertools
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Type, List, Any, Iterator
|
||||
from typing import Optional, Dict, Type, List, Any, Iterator, TYPE_CHECKING
|
||||
|
||||
import dcs
|
||||
from dcs.countries import country_dict
|
||||
@@ -25,6 +25,9 @@ from game.data.groundunitclass import GroundUnitClass
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater.start_generator import ModSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class Faction:
|
||||
@@ -81,10 +84,10 @@ class Faction:
|
||||
requirements: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# possible aircraft carrier units
|
||||
aircraft_carrier: List[Type[UnitType]] = field(default_factory=list)
|
||||
aircraft_carrier: List[Type[ShipType]] = field(default_factory=list)
|
||||
|
||||
# possible helicopter carrier units
|
||||
helicopter_carrier: List[Type[UnitType]] = field(default_factory=list)
|
||||
helicopter_carrier: List[Type[ShipType]] = field(default_factory=list)
|
||||
|
||||
# Possible carrier names
|
||||
carrier_names: List[str] = field(default_factory=list)
|
||||
@@ -257,6 +260,90 @@ class Faction:
|
||||
if unit.unit_class is unit_class:
|
||||
yield unit
|
||||
|
||||
def apply_mod_settings(self, mod_settings: ModSettings) -> Faction:
|
||||
# aircraft
|
||||
if not mod_settings.a4_skyhawk:
|
||||
self.remove_aircraft("A-4E-C")
|
||||
if not mod_settings.hercules:
|
||||
self.remove_aircraft("Hercules")
|
||||
if not mod_settings.uh_60l:
|
||||
self.remove_aircraft("UH-60L")
|
||||
self.remove_aircraft("KC130J")
|
||||
if not mod_settings.f22_raptor:
|
||||
self.remove_aircraft("F-22A")
|
||||
if not mod_settings.f104_starfighter:
|
||||
self.remove_aircraft("VSN_F104G")
|
||||
self.remove_aircraft("VSN_F104S")
|
||||
self.remove_aircraft("VSN_F104S_AG")
|
||||
if not mod_settings.jas39_gripen:
|
||||
self.remove_aircraft("JAS39Gripen")
|
||||
self.remove_aircraft("JAS39Gripen_AG")
|
||||
if not mod_settings.su57_felon:
|
||||
self.remove_aircraft("Su-57")
|
||||
# frenchpack
|
||||
if not mod_settings.frenchpack:
|
||||
self.remove_vehicle("AMX10RCR")
|
||||
self.remove_vehicle("SEPAR")
|
||||
self.remove_vehicle("ERC")
|
||||
self.remove_vehicle("M120")
|
||||
self.remove_vehicle("AA20")
|
||||
self.remove_vehicle("TRM2000")
|
||||
self.remove_vehicle("TRM2000_Citerne")
|
||||
self.remove_vehicle("TRM2000_AA20")
|
||||
self.remove_vehicle("TRMMISTRAL")
|
||||
self.remove_vehicle("VABH")
|
||||
self.remove_vehicle("VAB_RADIO")
|
||||
self.remove_vehicle("VAB_50")
|
||||
self.remove_vehicle("VIB_VBR")
|
||||
self.remove_vehicle("VAB_HOT")
|
||||
self.remove_vehicle("VAB_MORTIER")
|
||||
self.remove_vehicle("VBL50")
|
||||
self.remove_vehicle("VBLANF1")
|
||||
self.remove_vehicle("VBL-radio")
|
||||
self.remove_vehicle("VBAE")
|
||||
self.remove_vehicle("VBAE_MMP")
|
||||
self.remove_vehicle("AMX-30B2")
|
||||
self.remove_vehicle("Tracma")
|
||||
self.remove_vehicle("JTACFP")
|
||||
self.remove_vehicle("SHERIDAN")
|
||||
self.remove_vehicle("Leclerc_XXI")
|
||||
self.remove_vehicle("Toyota_bleu")
|
||||
self.remove_vehicle("Toyota_vert")
|
||||
self.remove_vehicle("Toyota_desert")
|
||||
self.remove_vehicle("Kamikaze")
|
||||
self.remove_vehicle("AMX1375")
|
||||
self.remove_vehicle("AMX1390")
|
||||
self.remove_vehicle("VBCI")
|
||||
self.remove_vehicle("T62")
|
||||
self.remove_vehicle("T64BV")
|
||||
self.remove_vehicle("T72M")
|
||||
self.remove_vehicle("KORNET")
|
||||
# high digit sams
|
||||
if not mod_settings.high_digit_sams:
|
||||
self.remove_air_defenses("SA10BGenerator")
|
||||
self.remove_air_defenses("SA12Generator")
|
||||
self.remove_air_defenses("SA20Generator")
|
||||
self.remove_air_defenses("SA20BGenerator")
|
||||
self.remove_air_defenses("SA23Generator")
|
||||
self.remove_air_defenses("SA17Generator")
|
||||
self.remove_air_defenses("KS19Generator")
|
||||
return self
|
||||
|
||||
def remove_aircraft(self, name: str) -> None:
|
||||
for i in self.aircrafts:
|
||||
if i.dcs_unit_type.id == name:
|
||||
self.aircrafts.remove(i)
|
||||
|
||||
def remove_air_defenses(self, name: str) -> None:
|
||||
for i in self.air_defenses:
|
||||
if i == name:
|
||||
self.air_defenses.remove(i)
|
||||
|
||||
def remove_vehicle(self, name: str) -> None:
|
||||
for i in self.frontline_units:
|
||||
if i.dcs_unit_type.id == name:
|
||||
self.frontline_units.remove(i)
|
||||
|
||||
|
||||
def load_ship(name: str) -> Optional[Type[ShipType]]:
|
||||
if (ship := getattr(dcs.ships, name, None)) is not None:
|
||||
@@ -265,7 +352,7 @@ def load_ship(name: str) -> Optional[Type[ShipType]]:
|
||||
return None
|
||||
|
||||
|
||||
def load_all_ships(data) -> List[Type[ShipType]]:
|
||||
def load_all_ships(data: list[str]) -> List[Type[ShipType]]:
|
||||
items = []
|
||||
for name in data:
|
||||
item = load_ship(name)
|
||||
|
||||
3
game/flightplan/__init__.py
Normal file
3
game/flightplan/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .holdzonegeometry import HoldZoneGeometry
|
||||
from .ipzonegeometry import IpZoneGeometry
|
||||
from .joinzonegeometry import JoinZoneGeometry
|
||||
108
game/flightplan/holdzonegeometry.py
Normal file
108
game/flightplan/holdzonegeometry.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import shapely.ops
|
||||
from dcs import Point
|
||||
from shapely.geometry import Point as ShapelyPoint, Polygon, MultiPolygon
|
||||
|
||||
from game.theater import ConflictTheater
|
||||
from game.utils import nautical_miles
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class HoldZoneGeometry:
|
||||
"""Defines the zones used for finding optimal hold point placement.
|
||||
|
||||
The zones themselves are stored in the class rather than just the resulting hold
|
||||
point so that the zones can be drawn in the map for debugging purposes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: Point,
|
||||
home: Point,
|
||||
ip: Point,
|
||||
join: Point,
|
||||
coalition: Coalition,
|
||||
theater: ConflictTheater,
|
||||
) -> None:
|
||||
# Hold points are placed one of two ways. Either approach guarantees:
|
||||
#
|
||||
# * Safe hold point.
|
||||
# * Minimum distance to the join point.
|
||||
# * Not closer to the target than the join point.
|
||||
#
|
||||
# 1. As near the join point as possible with a specific distance from the
|
||||
# departure airfield. This prevents loitering directly above the airfield but
|
||||
# also keeps the hold point close to the departure airfield.
|
||||
#
|
||||
# 2. Alternatively, if the entire home zone is excluded by the above criteria,
|
||||
# as neat the departure airfield as possible within a minimum distance from
|
||||
# the join point, with a restricted turn angle at the join point. This
|
||||
# handles the case where we need to backtrack from the departure airfield and
|
||||
# the join point to place the hold point, but the turn angle limit restricts
|
||||
# the maximum distance of the backtrack while maintaining the direction of
|
||||
# the flight plan.
|
||||
self.threat_zone = coalition.opponent.threat_zone.all
|
||||
self.home = ShapelyPoint(home.x, home.y)
|
||||
|
||||
self.join = ShapelyPoint(join.x, join.y)
|
||||
|
||||
self.join_bubble = self.join.buffer(coalition.doctrine.push_distance.meters)
|
||||
|
||||
join_to_target_distance = join.distance_to_point(target)
|
||||
self.target_bubble = ShapelyPoint(target.x, target.y).buffer(
|
||||
join_to_target_distance
|
||||
)
|
||||
|
||||
self.home_bubble = self.home.buffer(coalition.doctrine.hold_distance.meters)
|
||||
|
||||
excluded_zones = shapely.ops.unary_union(
|
||||
[self.join_bubble, self.target_bubble, self.threat_zone]
|
||||
)
|
||||
if not isinstance(excluded_zones, MultiPolygon):
|
||||
excluded_zones = MultiPolygon([excluded_zones])
|
||||
self.excluded_zones = excluded_zones
|
||||
|
||||
join_heading = ip.heading_between_point(join)
|
||||
|
||||
# Arbitrarily large since this is later constrained by the map boundary, and
|
||||
# we'll be picking a location close to the IP anyway. Just used to avoid real
|
||||
# distance calculations to project to the map edge.
|
||||
large_distance = nautical_miles(400).meters
|
||||
turn_limit = 40
|
||||
join_limit_ccw = join.point_from_heading(
|
||||
join_heading - turn_limit, large_distance
|
||||
)
|
||||
join_limit_cw = join.point_from_heading(
|
||||
join_heading + turn_limit, large_distance
|
||||
)
|
||||
|
||||
join_direction_limit_wedge = Polygon(
|
||||
[
|
||||
(join.x, join.y),
|
||||
(join_limit_ccw.x, join_limit_ccw.y),
|
||||
(join_limit_cw.x, join_limit_cw.y),
|
||||
]
|
||||
)
|
||||
|
||||
permissible_zones = (
|
||||
coalition.nav_mesh.map_bounds(theater)
|
||||
.intersection(join_direction_limit_wedge)
|
||||
.difference(self.excluded_zones)
|
||||
.difference(self.home_bubble)
|
||||
)
|
||||
if not isinstance(permissible_zones, MultiPolygon):
|
||||
permissible_zones = MultiPolygon([permissible_zones])
|
||||
self.permissible_zones = permissible_zones
|
||||
self.preferred_lines = self.home_bubble.boundary.difference(self.excluded_zones)
|
||||
|
||||
def find_best_hold_point(self) -> Point:
|
||||
if self.preferred_lines.is_empty:
|
||||
hold, _ = shapely.ops.nearest_points(self.permissible_zones, self.home)
|
||||
else:
|
||||
hold, _ = shapely.ops.nearest_points(self.preferred_lines, self.join)
|
||||
return Point(hold.x, hold.y)
|
||||
118
game/flightplan/ipzonegeometry.py
Normal file
118
game/flightplan/ipzonegeometry.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import shapely.ops
|
||||
from dcs import Point
|
||||
from shapely.geometry import Point as ShapelyPoint, MultiPolygon
|
||||
|
||||
from game.utils import nautical_miles, meters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class IpZoneGeometry:
|
||||
"""Defines the zones used for finding optimal IP placement.
|
||||
|
||||
The zones themselves are stored in the class rather than just the resulting IP so
|
||||
that the zones can be drawn in the map for debugging purposes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: Point,
|
||||
home: Point,
|
||||
coalition: Coalition,
|
||||
) -> None:
|
||||
self.threat_zone = coalition.opponent.threat_zone.all
|
||||
self.home = ShapelyPoint(home.x, home.y)
|
||||
|
||||
max_ip_distance = coalition.doctrine.max_ingress_distance
|
||||
min_ip_distance = coalition.doctrine.min_ingress_distance
|
||||
|
||||
# The minimum distance between the home location and the IP.
|
||||
min_distance_from_home = nautical_miles(5)
|
||||
|
||||
# The distance that is expected to be needed between the beginning of the attack
|
||||
# and weapon release. This buffers the threat zone to give a 5nm window between
|
||||
# the edge of the "safe" zone and the actual threat so that "safe" IPs are less
|
||||
# likely to end up with the attacker entering a threatened area.
|
||||
attack_distance_buffer = nautical_miles(5)
|
||||
|
||||
home_threatened = coalition.opponent.threat_zone.threatened(home)
|
||||
|
||||
shapely_target = ShapelyPoint(target.x, target.y)
|
||||
home_to_target_distance = meters(home.distance_to_point(target))
|
||||
|
||||
self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference(
|
||||
self.home.buffer(min_distance_from_home.meters)
|
||||
)
|
||||
|
||||
# If the home zone is not threatened and home is within LAR, constrain the max
|
||||
# range to the home-to-target distance to prevent excessive backtracking.
|
||||
#
|
||||
# If the home zone *is* threatened, we need to back out of the zone to
|
||||
# rendezvous anyway.
|
||||
if not home_threatened and (
|
||||
min_ip_distance < home_to_target_distance < max_ip_distance
|
||||
):
|
||||
max_ip_distance = home_to_target_distance
|
||||
max_ip_bubble = shapely_target.buffer(max_ip_distance.meters)
|
||||
min_ip_bubble = shapely_target.buffer(min_ip_distance.meters)
|
||||
self.ip_bubble = max_ip_bubble.difference(min_ip_bubble)
|
||||
|
||||
# The intersection of the home bubble and IP bubble will be all the points that
|
||||
# are within the valid IP range that are not farther from home than the target
|
||||
# is. However, if the origin airfield is threatened but there are safe
|
||||
# placements for the IP, we should not constrain to the home zone. In this case
|
||||
# we'll either end up with a safe zone outside the home zone and pick the
|
||||
# closest point in to to home (minimizing backtracking), or we'll have no safe
|
||||
# IP anywhere within range of the target, and we'll later pick the IP nearest
|
||||
# the edge of the threat zone.
|
||||
if home_threatened:
|
||||
self.permissible_zone = self.ip_bubble
|
||||
else:
|
||||
self.permissible_zone = self.ip_bubble.intersection(self.home_bubble)
|
||||
|
||||
if self.permissible_zone.is_empty:
|
||||
# If home is closer to the target than the min range, there will not be an
|
||||
# IP solution that's close enough to home, in which case we need to ignore
|
||||
# the home bubble.
|
||||
self.permissible_zone = self.ip_bubble
|
||||
|
||||
safe_zones = self.permissible_zone.difference(
|
||||
self.threat_zone.buffer(attack_distance_buffer.meters)
|
||||
)
|
||||
|
||||
if not isinstance(safe_zones, MultiPolygon):
|
||||
safe_zones = MultiPolygon([safe_zones])
|
||||
self.safe_zones = safe_zones
|
||||
|
||||
def _unsafe_ip(self) -> ShapelyPoint:
|
||||
unthreatened_home_zone = self.home_bubble.difference(self.threat_zone)
|
||||
if unthreatened_home_zone.is_empty:
|
||||
# Nowhere in our home zone is safe. The package will need to exit the
|
||||
# threatened area to hold and rendezvous. Pick the IP closest to the
|
||||
# edge of the threat zone.
|
||||
return shapely.ops.nearest_points(
|
||||
self.permissible_zone, self.threat_zone.boundary
|
||||
)[0]
|
||||
|
||||
# No safe point in the IP zone, but the home zone is safe. Pick the max-
|
||||
# distance IP that's closest to the untreatened home zone.
|
||||
return shapely.ops.nearest_points(
|
||||
self.permissible_zone, unthreatened_home_zone
|
||||
)[0]
|
||||
|
||||
def _safe_ip(self) -> ShapelyPoint:
|
||||
# We have a zone of possible IPs that are safe, close enough, and in range. Pick
|
||||
# the IP in the zone that's closest to the target.
|
||||
return shapely.ops.nearest_points(self.safe_zones, self.home)[0]
|
||||
|
||||
def find_best_ip(self) -> Point:
|
||||
if self.safe_zones.is_empty:
|
||||
ip = self._unsafe_ip()
|
||||
else:
|
||||
ip = self._safe_ip()
|
||||
return Point(ip.x, ip.y)
|
||||
103
game/flightplan/joinzonegeometry.py
Normal file
103
game/flightplan/joinzonegeometry.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import shapely.ops
|
||||
from dcs import Point
|
||||
from shapely.geometry import (
|
||||
Point as ShapelyPoint,
|
||||
Polygon,
|
||||
MultiPolygon,
|
||||
MultiLineString,
|
||||
)
|
||||
|
||||
from game.utils import nautical_miles
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class JoinZoneGeometry:
|
||||
"""Defines the zones used for finding optimal join point placement.
|
||||
|
||||
The zones themselves are stored in the class rather than just the resulting join
|
||||
point so that the zones can be drawn in the map for debugging purposes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, target: Point, home: Point, ip: Point, coalition: Coalition
|
||||
) -> None:
|
||||
# Normal join placement is based on the path from home to the IP. If no path is
|
||||
# found it means that the target is on a direct path. In that case we instead
|
||||
# want to enforce that the join point is:
|
||||
#
|
||||
# * Not closer to the target than the IP.
|
||||
# * Not too close to the home airfield.
|
||||
# * Not threatened.
|
||||
# * A minimum distance from the IP.
|
||||
# * Not too sharp a turn at the ingress point.
|
||||
self.ip = ShapelyPoint(ip.x, ip.y)
|
||||
self.threat_zone = coalition.opponent.threat_zone.all
|
||||
self.home = ShapelyPoint(home.x, home.y)
|
||||
|
||||
self.ip_bubble = self.ip.buffer(coalition.doctrine.join_distance.meters)
|
||||
|
||||
ip_distance = ip.distance_to_point(target)
|
||||
self.target_bubble = ShapelyPoint(target.x, target.y).buffer(ip_distance)
|
||||
|
||||
# The minimum distance between the home location and the IP.
|
||||
min_distance_from_home = nautical_miles(5)
|
||||
|
||||
self.home_bubble = self.home.buffer(min_distance_from_home.meters)
|
||||
|
||||
excluded_zones = shapely.ops.unary_union(
|
||||
[self.ip_bubble, self.target_bubble, self.threat_zone]
|
||||
)
|
||||
|
||||
if not isinstance(excluded_zones, MultiPolygon):
|
||||
excluded_zones = MultiPolygon([excluded_zones])
|
||||
self.excluded_zones = excluded_zones
|
||||
|
||||
ip_heading = target.heading_between_point(ip)
|
||||
|
||||
# Arbitrarily large since this is later constrained by the map boundary, and
|
||||
# we'll be picking a location close to the IP anyway. Just used to avoid real
|
||||
# distance calculations to project to the map edge.
|
||||
large_distance = nautical_miles(400).meters
|
||||
turn_limit = 40
|
||||
ip_limit_ccw = ip.point_from_heading(ip_heading - turn_limit, large_distance)
|
||||
ip_limit_cw = ip.point_from_heading(ip_heading + turn_limit, large_distance)
|
||||
|
||||
ip_direction_limit_wedge = Polygon(
|
||||
[
|
||||
(ip.x, ip.y),
|
||||
(ip_limit_ccw.x, ip_limit_ccw.y),
|
||||
(ip_limit_cw.x, ip_limit_cw.y),
|
||||
]
|
||||
)
|
||||
|
||||
permissible_zones = ip_direction_limit_wedge.difference(
|
||||
self.excluded_zones
|
||||
).difference(self.home_bubble)
|
||||
if permissible_zones.is_empty:
|
||||
permissible_zones = MultiPolygon([])
|
||||
if not isinstance(permissible_zones, MultiPolygon):
|
||||
permissible_zones = MultiPolygon([permissible_zones])
|
||||
self.permissible_zones = permissible_zones
|
||||
|
||||
preferred_lines = ip_direction_limit_wedge.intersection(
|
||||
self.excluded_zones.boundary
|
||||
).difference(self.home_bubble)
|
||||
|
||||
if preferred_lines.is_empty:
|
||||
preferred_lines = MultiLineString([])
|
||||
if not isinstance(preferred_lines, MultiLineString):
|
||||
preferred_lines = MultiLineString([preferred_lines])
|
||||
self.preferred_lines = preferred_lines
|
||||
|
||||
def find_best_join_point(self) -> Point:
|
||||
if self.preferred_lines.is_empty:
|
||||
join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip)
|
||||
else:
|
||||
join, _ = shapely.ops.nearest_points(self.preferred_lines, self.home)
|
||||
return Point(join.x, join.y)
|
||||
484
game/game.py
484
game/game.py
@@ -1,48 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
import math
|
||||
from collections import Iterator
|
||||
from datetime import date, datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, List, Type, Union, cast, TYPE_CHECKING
|
||||
|
||||
from dcs.action import Coalition
|
||||
from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors
|
||||
from dcs.country import Country
|
||||
from dcs.mapping import Point
|
||||
from dcs.task import CAP, CAS, PinpointStrike
|
||||
from dcs.vehicles import AirDefence
|
||||
from faker import Faker
|
||||
|
||||
from game import db
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
from game.models.game_stats import GameStats
|
||||
from game.plugins import LuaPluginManager
|
||||
from gen import naming
|
||||
from gen.ato import AirTaskingOrder
|
||||
from gen.conflictgen import Conflict
|
||||
from gen.flights.ai_flight_planner import CoalitionMissionPlanner
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import FlightType
|
||||
from gen.ground_forces.ai_ground_planner import GroundPlanner
|
||||
from . import persistency
|
||||
from .campaignloader import CampaignAirWingConfig
|
||||
from .coalition import Coalition
|
||||
from .debriefing import Debriefing
|
||||
from .event.event import Event
|
||||
from .event.frontlineattack import FrontlineAttackEvent
|
||||
from .factions.faction import Faction
|
||||
from .income import Income
|
||||
from .infos.information import Information
|
||||
from .navmesh import NavMesh
|
||||
from .procurement import AircraftProcurementRequest, ProcurementAi
|
||||
from .profiling import logged_duration
|
||||
from .settings import Settings, AutoAtoBehavior
|
||||
from .squadrons import AirWing
|
||||
from .theater import ConflictTheater
|
||||
from .settings import Settings
|
||||
from .theater import ConflictTheater, ControlPoint
|
||||
from .theater.bullseye import Bullseye
|
||||
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
|
||||
from .threatzones import ThreatZones
|
||||
from .transfers import PendingTransfers
|
||||
from .unitmap import UnitMap
|
||||
from .weather import Conditions, TimeOfDay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .squadrons import AirWing
|
||||
|
||||
COMMISION_UNIT_VARIETY = 4
|
||||
COMMISION_LIMITS_SCALE = 1.5
|
||||
COMMISION_LIMITS_FACTORS = {
|
||||
@@ -86,9 +87,10 @@ class TurnState(Enum):
|
||||
class Game:
|
||||
def __init__(
|
||||
self,
|
||||
player_name: str,
|
||||
enemy_name: str,
|
||||
player_faction: Faction,
|
||||
enemy_faction: Faction,
|
||||
theater: ConflictTheater,
|
||||
air_wing_config: CampaignAirWingConfig,
|
||||
start_date: datetime,
|
||||
settings: Settings,
|
||||
player_budget: float,
|
||||
@@ -97,163 +99,114 @@ class Game:
|
||||
self.settings = settings
|
||||
self.events: List[Event] = []
|
||||
self.theater = theater
|
||||
self.player_name = player_name
|
||||
self.player_country = db.FACTIONS[player_name].country
|
||||
self.enemy_name = enemy_name
|
||||
self.enemy_country = db.FACTIONS[enemy_name].country
|
||||
# pass_turn() will be called when initialization is complete which will
|
||||
# increment this to turn 0 before it reaches the player.
|
||||
self.turn = -1
|
||||
self.turn = 0
|
||||
# NB: This is the *start* date. It is never updated.
|
||||
self.date = date(start_date.year, start_date.month, start_date.day)
|
||||
self.game_stats = GameStats()
|
||||
self.game_stats.update(self)
|
||||
self.ground_planners: Dict[int, GroundPlanner] = {}
|
||||
self.informations = []
|
||||
self.informations.append(Information("Game Start", "-" * 40, 0))
|
||||
self.notes = ""
|
||||
self.ground_planners: dict[int, GroundPlanner] = {}
|
||||
self.informations: list[Information] = []
|
||||
self.message("Game Start", "-" * 40)
|
||||
# Culling Zones are for areas around points of interest that contain things we may not wish to cull.
|
||||
self.__culling_zones: List[Point] = []
|
||||
self.__destroyed_units: List[str] = []
|
||||
self.__destroyed_units: list[dict[str, Union[float, str]]] = []
|
||||
self.savepath = ""
|
||||
self.budget = player_budget
|
||||
self.enemy_budget = enemy_budget
|
||||
self.current_unit_id = 0
|
||||
self.current_group_id = 0
|
||||
self.name_generator = naming.namegen
|
||||
|
||||
self.conditions = self.generate_conditions()
|
||||
|
||||
self.blue_transit_network = TransitNetwork()
|
||||
self.red_transit_network = TransitNetwork()
|
||||
self.sanitize_sides(player_faction, enemy_faction)
|
||||
self.blue = Coalition(self, player_faction, player_budget, player=True)
|
||||
self.red = Coalition(self, enemy_faction, enemy_budget, player=False)
|
||||
self.blue.set_opponent(self.red)
|
||||
self.red.set_opponent(self.blue)
|
||||
|
||||
self.blue_procurement_requests: List[AircraftProcurementRequest] = []
|
||||
self.red_procurement_requests: List[AircraftProcurementRequest] = []
|
||||
for control_point in self.theater.controlpoints:
|
||||
control_point.finish_init(self)
|
||||
|
||||
self.blue_ato = AirTaskingOrder()
|
||||
self.red_ato = AirTaskingOrder()
|
||||
|
||||
self.blue_bullseye = Bullseye(Point(0, 0))
|
||||
self.red_bullseye = Bullseye(Point(0, 0))
|
||||
|
||||
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
|
||||
|
||||
self.transfers = PendingTransfers(self)
|
||||
|
||||
self.sanitize_sides()
|
||||
|
||||
self.blue_faker = Faker(self.player_faction.locales)
|
||||
self.red_faker = Faker(self.enemy_faction.locales)
|
||||
|
||||
self.blue_air_wing = AirWing(self, player=True)
|
||||
self.red_air_wing = AirWing(self, player=False)
|
||||
self.blue.configure_default_air_wing(air_wing_config)
|
||||
self.red.configure_default_air_wing(air_wing_config)
|
||||
|
||||
self.on_load(game_still_initializing=True)
|
||||
|
||||
def __getstate__(self) -> Dict[str, Any]:
|
||||
state = self.__dict__.copy()
|
||||
# Avoid persisting any volatile types that can be deterministically
|
||||
# recomputed on load for the sake of save compatibility.
|
||||
del state["blue_threat_zone"]
|
||||
del state["red_threat_zone"]
|
||||
del state["blue_navmesh"]
|
||||
del state["red_navmesh"]
|
||||
del state["blue_faker"]
|
||||
del state["red_faker"]
|
||||
return state
|
||||
|
||||
def __setstate__(self, state: Dict[str, Any]) -> None:
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
self.__dict__.update(state)
|
||||
# Regenerate any state that was not persisted.
|
||||
self.on_load()
|
||||
|
||||
def ato_for(self, player: bool) -> AirTaskingOrder:
|
||||
if player:
|
||||
return self.blue_ato
|
||||
return self.red_ato
|
||||
@property
|
||||
def coalitions(self) -> Iterator[Coalition]:
|
||||
yield self.blue
|
||||
yield self.red
|
||||
|
||||
def procurement_requests_for(
|
||||
self, player: bool
|
||||
) -> List[AircraftProcurementRequest]:
|
||||
if player:
|
||||
return self.blue_procurement_requests
|
||||
return self.red_procurement_requests
|
||||
def ato_for(self, player: bool) -> AirTaskingOrder:
|
||||
return self.coalition_for(player).ato
|
||||
|
||||
def transit_network_for(self, player: bool) -> TransitNetwork:
|
||||
if player:
|
||||
return self.blue_transit_network
|
||||
return self.red_transit_network
|
||||
return self.coalition_for(player).transit_network
|
||||
|
||||
def generate_conditions(self) -> Conditions:
|
||||
return Conditions.generate(
|
||||
self.theater, self.current_day, self.current_turn_time_of_day, self.settings
|
||||
)
|
||||
|
||||
def sanitize_sides(self):
|
||||
@staticmethod
|
||||
def sanitize_sides(player_faction: Faction, enemy_faction: Faction) -> None:
|
||||
"""
|
||||
Make sure the opposing factions are using different countries
|
||||
:return:
|
||||
"""
|
||||
if self.player_country == self.enemy_country:
|
||||
if self.player_country == "USA":
|
||||
self.enemy_country = "USAF Aggressors"
|
||||
elif self.player_country == "Russia":
|
||||
self.enemy_country = "USSR"
|
||||
if player_faction.country == enemy_faction.country:
|
||||
if player_faction.country == "USA":
|
||||
enemy_faction.country = "USAF Aggressors"
|
||||
elif player_faction.country == "Russia":
|
||||
enemy_faction.country = "USSR"
|
||||
else:
|
||||
self.enemy_country = "Russia"
|
||||
|
||||
@property
|
||||
def player_faction(self) -> Faction:
|
||||
return db.FACTIONS[self.player_name]
|
||||
|
||||
@property
|
||||
def enemy_faction(self) -> Faction:
|
||||
return db.FACTIONS[self.enemy_name]
|
||||
enemy_faction.country = "Russia"
|
||||
|
||||
def faction_for(self, player: bool) -> Faction:
|
||||
if player:
|
||||
return self.player_faction
|
||||
return self.enemy_faction
|
||||
return self.coalition_for(player).faction
|
||||
|
||||
def faker_for(self, player: bool) -> Faker:
|
||||
if player:
|
||||
return self.blue_faker
|
||||
return self.red_faker
|
||||
return self.coalition_for(player).faker
|
||||
|
||||
def air_wing_for(self, player: bool) -> AirWing:
|
||||
if player:
|
||||
return self.blue_air_wing
|
||||
return self.red_air_wing
|
||||
return self.coalition_for(player).air_wing
|
||||
|
||||
def country_for(self, player: bool) -> str:
|
||||
if player:
|
||||
return self.player_country
|
||||
return self.enemy_country
|
||||
return self.coalition_for(player).country_name
|
||||
|
||||
def bullseye_for(self, player: bool) -> Bullseye:
|
||||
if player:
|
||||
return self.blue_bullseye
|
||||
return self.red_bullseye
|
||||
return self.coalition_for(player).bullseye
|
||||
|
||||
def _roll(self, prob, mult):
|
||||
if self.settings.version == "dev":
|
||||
# always generate all events for dev
|
||||
return 100
|
||||
else:
|
||||
return random.randint(1, 100) <= prob * mult
|
||||
|
||||
def _generate_player_event(self, event_class, player_cp, enemy_cp):
|
||||
def _generate_player_event(
|
||||
self, event_class: Type[Event], player_cp: ControlPoint, enemy_cp: ControlPoint
|
||||
) -> None:
|
||||
self.events.append(
|
||||
event_class(
|
||||
self,
|
||||
player_cp,
|
||||
enemy_cp,
|
||||
enemy_cp.position,
|
||||
self.player_name,
|
||||
self.enemy_name,
|
||||
self.blue.faction.name,
|
||||
self.red.faction.name,
|
||||
)
|
||||
)
|
||||
|
||||
def _generate_events(self):
|
||||
@property
|
||||
def neutral_country(self) -> Type[Country]:
|
||||
"""Return the best fitting country that can be used as neutral faction in the generated mission"""
|
||||
countries_in_use = [self.red.country_name, self.blue.country_name]
|
||||
if UnitedNationsPeacekeepers not in countries_in_use:
|
||||
return UnitedNationsPeacekeepers
|
||||
elif Switzerland.name not in countries_in_use:
|
||||
return Switzerland
|
||||
else:
|
||||
return USAFAggressors
|
||||
|
||||
def _generate_events(self) -> None:
|
||||
for front_line in self.theater.conflicts():
|
||||
self._generate_player_event(
|
||||
FrontlineAttackEvent,
|
||||
@@ -261,27 +214,21 @@ class Game:
|
||||
front_line.red_cp,
|
||||
)
|
||||
|
||||
def adjust_budget(self, amount: float, player: bool) -> None:
|
||||
def coalition_for(self, player: bool) -> Coalition:
|
||||
if player:
|
||||
self.budget += amount
|
||||
else:
|
||||
self.enemy_budget += amount
|
||||
return self.blue
|
||||
return self.red
|
||||
|
||||
def process_player_income(self):
|
||||
self.budget += Income(self, player=True).total
|
||||
def adjust_budget(self, amount: float, player: bool) -> None:
|
||||
self.coalition_for(player).adjust_budget(amount)
|
||||
|
||||
def process_enemy_income(self):
|
||||
# TODO: Clean up save compat.
|
||||
if not hasattr(self, "enemy_budget"):
|
||||
self.enemy_budget = 0
|
||||
self.enemy_budget += Income(self, player=False).total
|
||||
|
||||
def initiate_event(self, event: Event) -> UnitMap:
|
||||
@staticmethod
|
||||
def initiate_event(event: Event) -> UnitMap:
|
||||
# assert event in self.events
|
||||
logging.info("Generating {} (regular)".format(event))
|
||||
return event.generate()
|
||||
|
||||
def finish_event(self, event: Event, debriefing: Debriefing):
|
||||
def finish_event(self, event: Event, debriefing: Debriefing) -> None:
|
||||
logging.info("Finishing event {}".format(event))
|
||||
event.commit(debriefing)
|
||||
|
||||
@@ -290,16 +237,6 @@ class Game:
|
||||
else:
|
||||
logging.info("finish_event: event not in the events!")
|
||||
|
||||
def is_player_attack(self, event):
|
||||
if isinstance(event, Event):
|
||||
return (
|
||||
event
|
||||
and event.attacker_name
|
||||
and event.attacker_name == self.player_name
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"{event} was passed when an Event type was expected")
|
||||
|
||||
def on_load(self, game_still_initializing: bool = False) -> None:
|
||||
if not hasattr(self, "name_generator"):
|
||||
self.name_generator = naming.namegen
|
||||
@@ -311,57 +248,71 @@ class Game:
|
||||
naming.namegen = self.name_generator
|
||||
LuaPluginManager.load_settings(self.settings)
|
||||
ObjectiveDistanceCache.set_theater(self.theater)
|
||||
self.compute_conflicts_position()
|
||||
self.compute_unculled_zones()
|
||||
if not game_still_initializing:
|
||||
self.compute_threat_zones()
|
||||
self.blue_faker = Faker(self.faction_for(player=True).locales)
|
||||
self.red_faker = Faker(self.faction_for(player=False).locales)
|
||||
|
||||
def reset_ato(self) -> None:
|
||||
self.blue_ato.clear()
|
||||
self.red_ato.clear()
|
||||
|
||||
def finish_turn(self, skipped: bool = False) -> None:
|
||||
self.informations.append(
|
||||
Information("End of turn #" + str(self.turn), "-" * 40, 0)
|
||||
)
|
||||
"""Finalizes the current turn and advances to the next turn.
|
||||
|
||||
This handles the turn-end portion of passing a turn. Initialization of the next
|
||||
turn is handled by `initialize_turn`. These are separate processes because while
|
||||
turns may be initialized more than once under some circumstances (see the
|
||||
documentation for `initialize_turn`), `finish_turn` performs the work that
|
||||
should be guaranteed to happen only once per turn:
|
||||
|
||||
* Turn counter increment.
|
||||
* Delivering units ordered the previous turn.
|
||||
* Transfer progress.
|
||||
* Squadron replenishment.
|
||||
* Income distribution.
|
||||
* Base strength (front line position) adjustment.
|
||||
* Weather/time-of-day generation.
|
||||
|
||||
Some actions (like transit network assembly) will happen both here and in
|
||||
`initialize_turn`. We need the network to be up to date so we can account for
|
||||
base captures when processing the transfers that occurred last turn, but we also
|
||||
need it to be up to date in the case of a re-initialization in `initialize_turn`
|
||||
(such as to account for a cheat base capture) so that orders are only placed
|
||||
where a supply route exists to the destination. This is a relatively cheap
|
||||
operation so duplicating the effort is not a problem.
|
||||
|
||||
Args:
|
||||
skipped: True if the turn was skipped.
|
||||
"""
|
||||
self.message("End of turn #" + str(self.turn), "-" * 40)
|
||||
self.turn += 1
|
||||
|
||||
# Need to recompute before transfers and deliveries to account for captures.
|
||||
# This happens in in initialize_turn as well, because cheating doesn't advance a
|
||||
# turn but can capture bases so we need to recompute there as well.
|
||||
self.compute_transit_networks()
|
||||
# The coalition-specific turn finalization *must* happen before unit deliveries,
|
||||
# since the coalition-specific finalization handles transit network updates and
|
||||
# transfer processing. If in the other order, units may be delivered to captured
|
||||
# bases, and freshly delivered units will spawn one leg through their journey.
|
||||
self.blue.end_turn()
|
||||
self.red.end_turn()
|
||||
|
||||
# Must happen *before* unit deliveries are handled, or else new units will spawn
|
||||
# one hop ahead. ControlPoint.process_turn handles unit deliveries.
|
||||
self.transfers.perform_transfers()
|
||||
|
||||
# Needs to happen *before* planning transfers so we don't cancel them.
|
||||
self.reset_ato()
|
||||
for control_point in self.theater.controlpoints:
|
||||
control_point.process_turn(self)
|
||||
|
||||
self.blue_air_wing.replenish()
|
||||
self.red_air_wing.replenish()
|
||||
|
||||
if not skipped and self.turn > 1:
|
||||
if not skipped:
|
||||
for cp in self.theater.player_points():
|
||||
cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY)
|
||||
else:
|
||||
for cp in self.theater.player_points():
|
||||
if not cp.is_carrier and not cp.is_lha:
|
||||
cp.base.affect_strength(-PLAYER_BASE_STRENGTH_RECOVERY)
|
||||
|
||||
self.conditions = self.generate_conditions()
|
||||
|
||||
self.process_enemy_income()
|
||||
self.process_player_income()
|
||||
|
||||
def begin_turn_0(self) -> None:
|
||||
self.turn = 0
|
||||
"""Initialization for the first turn of the game."""
|
||||
self.blue.preinit_turn_0()
|
||||
self.red.preinit_turn_0()
|
||||
self.initialize_turn()
|
||||
|
||||
def pass_turn(self, no_action: bool = False) -> None:
|
||||
"""Ends the current turn and initializes the new turn.
|
||||
|
||||
Called both when skipping a turn or by ending the turn as the result of combat.
|
||||
|
||||
Args:
|
||||
no_action: True if the turn was skipped.
|
||||
"""
|
||||
logging.info("Pass turn")
|
||||
with logged_duration("Turn finalization"):
|
||||
self.finish_turn(no_action)
|
||||
@@ -371,7 +322,7 @@ class Game:
|
||||
# Autosave progress
|
||||
persistency.autosave(self)
|
||||
|
||||
def check_win_loss(self):
|
||||
def check_win_loss(self) -> TurnState:
|
||||
player_airbases = {
|
||||
cp for cp in self.theater.player_points() if cp.runway_is_operational()
|
||||
}
|
||||
@@ -388,90 +339,79 @@ class Game:
|
||||
|
||||
def set_bullseye(self) -> None:
|
||||
player_cp, enemy_cp = self.theater.closest_opposing_control_points()
|
||||
self.blue_bullseye = Bullseye(enemy_cp.position)
|
||||
self.red_bullseye = Bullseye(player_cp.position)
|
||||
self.blue.bullseye = Bullseye(enemy_cp.position)
|
||||
self.red.bullseye = Bullseye(player_cp.position)
|
||||
|
||||
def initialize_turn(self) -> None:
|
||||
def initialize_turn(self, for_red: bool = True, for_blue: bool = True) -> None:
|
||||
"""Performs turn initialization for the specified players.
|
||||
|
||||
Turn initialization performs all of the beginning-of-turn actions. *End-of-turn*
|
||||
processing happens in `pass_turn` (despite the name, it's called both for
|
||||
skipping the turn and ending the turn after combat).
|
||||
|
||||
Special care needs to be taken here because initialization can occur more than
|
||||
once per turn. A number of events can require re-initializing a turn:
|
||||
|
||||
* Cheat capture. Bases changing hands invalidates many missions in both ATOs,
|
||||
purchase orders, threat zones, transit networks, etc. Practically speaking,
|
||||
after a base capture the turn needs to be treated as fully new. The game might
|
||||
even be over after a capture.
|
||||
* Cheat front line position. CAS missions are no longer in the correct location,
|
||||
and the ground planner may also need changes.
|
||||
* Selling/buying units at TGOs. Selling a TGO might leave missions in the ATO
|
||||
with invalid targets. Buying a new SAM (or even replacing some units in a SAM)
|
||||
potentially changes the threat zone and may alter mission priorities and
|
||||
flight planning.
|
||||
|
||||
Most of the work is delegated to initialize_turn_for, which handles the
|
||||
coalition-specific turn initialization. In some cases only one coalition will be
|
||||
(re-) initialized. This is the case when buying or selling TGO units, since we
|
||||
don't want to force the player to redo all their planning just because they
|
||||
repaired a SAM, but should replan opfor when that happens. On the other hand,
|
||||
base captures are significant enough (and likely enough to be the first thing
|
||||
the player does in a turn) that we replan blue as well. Front lines are less
|
||||
impactful but also likely to be early, so they also cause a blue replan.
|
||||
|
||||
Args:
|
||||
for_red: True if opfor should be re-initialized.
|
||||
for_blue: True if the player coalition should be re-initialized.
|
||||
"""
|
||||
self.events = []
|
||||
self._generate_events()
|
||||
|
||||
self.set_bullseye()
|
||||
|
||||
# Update statistics
|
||||
self.game_stats.update(self)
|
||||
|
||||
self.blue_air_wing.reset()
|
||||
self.red_air_wing.reset()
|
||||
self.aircraft_inventory.reset()
|
||||
for cp in self.theater.controlpoints:
|
||||
self.aircraft_inventory.set_from_control_point(cp)
|
||||
|
||||
# Check for win or loss condition
|
||||
turn_state = self.check_win_loss()
|
||||
if turn_state in (TurnState.LOSS, TurnState.WIN):
|
||||
return self.process_win_loss(turn_state)
|
||||
|
||||
# Plan flights & combat for next turn
|
||||
with logged_duration("Computing conflict positions"):
|
||||
self.compute_conflicts_position()
|
||||
with logged_duration("Threat zone computation"):
|
||||
self.compute_threat_zones()
|
||||
with logged_duration("Transit network identification"):
|
||||
self.compute_transit_networks()
|
||||
|
||||
# Plan Coalition specific turn
|
||||
if for_blue:
|
||||
self.blue.initialize_turn()
|
||||
if for_red:
|
||||
self.red.initialize_turn()
|
||||
|
||||
# Plan GroundWar
|
||||
self.ground_planners = {}
|
||||
|
||||
self.blue_procurement_requests.clear()
|
||||
self.red_procurement_requests.clear()
|
||||
|
||||
with logged_duration("Procurement of airlift assets"):
|
||||
self.transfers.order_airlift_assets()
|
||||
with logged_duration("Transport planning"):
|
||||
self.transfers.plan_transports()
|
||||
|
||||
with logged_duration("Blue mission planning"):
|
||||
if self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled:
|
||||
blue_planner = CoalitionMissionPlanner(self, is_player=True)
|
||||
blue_planner.plan_missions()
|
||||
|
||||
with logged_duration("Red mission planning"):
|
||||
red_planner = CoalitionMissionPlanner(self, is_player=False)
|
||||
red_planner.plan_missions()
|
||||
|
||||
for cp in self.theater.controlpoints:
|
||||
if cp.has_frontline:
|
||||
gplanner = GroundPlanner(cp, self)
|
||||
gplanner.plan_groundwar()
|
||||
self.ground_planners[cp.id] = gplanner
|
||||
|
||||
self.plan_procurement()
|
||||
# Update cull zones
|
||||
with logged_duration("Computing culling positions"):
|
||||
self.compute_unculled_zones()
|
||||
|
||||
def plan_procurement(self) -> None:
|
||||
# The first turn needs to buy a *lot* of aircraft to fill CAPs, so it
|
||||
# gets much more of the budget that turn. Otherwise budget (after
|
||||
# repairs) is split evenly between air and ground. For the default
|
||||
# starting budget of 2000 this gives 600 to ground forces and 1400 to
|
||||
# aircraft. After that the budget will be spend proportionally based on how much is already invested
|
||||
|
||||
self.budget = ProcurementAi(
|
||||
self,
|
||||
for_player=True,
|
||||
faction=self.player_faction,
|
||||
manage_runways=self.settings.automate_runway_repair,
|
||||
manage_front_line=self.settings.automate_front_line_reinforcements,
|
||||
manage_aircraft=self.settings.automate_aircraft_reinforcements,
|
||||
).spend_budget(self.budget)
|
||||
|
||||
self.enemy_budget = ProcurementAi(
|
||||
self,
|
||||
for_player=False,
|
||||
faction=self.enemy_faction,
|
||||
manage_runways=True,
|
||||
manage_front_line=True,
|
||||
manage_aircraft=True,
|
||||
).spend_budget(self.enemy_budget)
|
||||
|
||||
def message(self, text: str) -> None:
|
||||
self.informations.append(Information(text, turn=self.turn))
|
||||
def message(self, title: str, text: str = "") -> None:
|
||||
self.informations.append(Information(title, text, turn=self.turn))
|
||||
|
||||
@property
|
||||
def current_turn_time_of_day(self) -> TimeOfDay:
|
||||
@@ -481,51 +421,38 @@ class Game:
|
||||
def current_day(self) -> date:
|
||||
return self.date + timedelta(days=self.turn // 4)
|
||||
|
||||
def next_unit_id(self):
|
||||
def next_unit_id(self) -> int:
|
||||
"""
|
||||
Next unit id for pre-generated units
|
||||
"""
|
||||
self.current_unit_id += 1
|
||||
return self.current_unit_id
|
||||
|
||||
def next_group_id(self):
|
||||
def next_group_id(self) -> int:
|
||||
"""
|
||||
Next unit id for pre-generated units
|
||||
"""
|
||||
self.current_group_id += 1
|
||||
return self.current_group_id
|
||||
|
||||
def compute_transit_networks(self) -> None:
|
||||
self.blue_transit_network = self.compute_transit_network_for(player=True)
|
||||
self.red_transit_network = self.compute_transit_network_for(player=False)
|
||||
|
||||
def compute_transit_network_for(self, player: bool) -> TransitNetwork:
|
||||
return TransitNetworkBuilder(self.theater, player).build()
|
||||
|
||||
def compute_threat_zones(self) -> None:
|
||||
self.blue_threat_zone = ThreatZones.for_faction(self, player=True)
|
||||
self.red_threat_zone = ThreatZones.for_faction(self, player=False)
|
||||
self.blue_navmesh = NavMesh.from_threat_zones(
|
||||
self.red_threat_zone, self.theater
|
||||
)
|
||||
self.red_navmesh = NavMesh.from_threat_zones(
|
||||
self.blue_threat_zone, self.theater
|
||||
)
|
||||
self.blue.compute_threat_zones()
|
||||
self.red.compute_threat_zones()
|
||||
self.blue.compute_nav_meshes()
|
||||
self.red.compute_nav_meshes()
|
||||
|
||||
def threat_zone_for(self, player: bool) -> ThreatZones:
|
||||
if player:
|
||||
return self.blue_threat_zone
|
||||
return self.red_threat_zone
|
||||
return self.coalition_for(player).threat_zone
|
||||
|
||||
def navmesh_for(self, player: bool) -> NavMesh:
|
||||
if player:
|
||||
return self.blue_navmesh
|
||||
return self.red_navmesh
|
||||
return self.coalition_for(player).nav_mesh
|
||||
|
||||
def compute_conflicts_position(self):
|
||||
def compute_unculled_zones(self) -> None:
|
||||
"""
|
||||
Compute the current conflict center position(s), mainly used for culling calculation
|
||||
:return: List of points of interests
|
||||
Compute the current conflict position(s) used for culling calculation
|
||||
"""
|
||||
zones = []
|
||||
|
||||
@@ -545,7 +472,7 @@ class Game:
|
||||
# If there is no conflict take the center point between the two nearest opposing bases
|
||||
if len(zones) == 0:
|
||||
cpoint = None
|
||||
min_distance = sys.maxsize
|
||||
min_distance = math.inf
|
||||
for cp in self.theater.player_points():
|
||||
for cp2 in self.theater.enemy_points():
|
||||
d = cp.position.distance_to_point(cp2.position)
|
||||
@@ -563,7 +490,7 @@ class Game:
|
||||
if cpoint is not None:
|
||||
zones.append(cpoint)
|
||||
|
||||
packages = itertools.chain(self.blue_ato.packages, self.red_ato.packages)
|
||||
packages = itertools.chain(self.blue.ato.packages, self.red.ato.packages)
|
||||
for package in packages:
|
||||
if package.primary_task is FlightType.BARCAP:
|
||||
# BARCAPs will be planned at most locations on smaller theaters,
|
||||
@@ -581,15 +508,15 @@ class Game:
|
||||
|
||||
self.__culling_zones = zones
|
||||
|
||||
def add_destroyed_units(self, data):
|
||||
pos = Point(data["x"], data["z"])
|
||||
def add_destroyed_units(self, data: dict[str, Union[float, str]]) -> None:
|
||||
pos = Point(cast(float, data["x"]), cast(float, data["z"]))
|
||||
if self.theater.is_on_land(pos):
|
||||
self.__destroyed_units.append(data)
|
||||
|
||||
def get_destroyed_units(self):
|
||||
def get_destroyed_units(self) -> list[dict[str, Union[float, str]]]:
|
||||
return self.__destroyed_units
|
||||
|
||||
def position_culled(self, pos):
|
||||
def position_culled(self, pos: Point) -> bool:
|
||||
"""
|
||||
Check if unit can be generated at given position depending on culling performance settings
|
||||
:param pos: Position you are tryng to spawn stuff at
|
||||
@@ -602,38 +529,17 @@ class Game:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_culling_zones(self):
|
||||
def get_culling_zones(self) -> list[Point]:
|
||||
"""
|
||||
Check culling points
|
||||
:return: List of culling zones
|
||||
"""
|
||||
return self.__culling_zones
|
||||
|
||||
# 1 = red, 2 = blue
|
||||
def get_player_coalition_id(self):
|
||||
return 2
|
||||
|
||||
def get_enemy_coalition_id(self):
|
||||
return 1
|
||||
|
||||
def get_player_coalition(self):
|
||||
return Coalition.Blue
|
||||
|
||||
def get_enemy_coalition(self):
|
||||
return Coalition.Red
|
||||
|
||||
def get_player_color(self):
|
||||
return "blue"
|
||||
|
||||
def get_enemy_color(self):
|
||||
return "red"
|
||||
|
||||
def process_win_loss(self, turn_state: TurnState):
|
||||
def process_win_loss(self, turn_state: TurnState) -> None:
|
||||
if turn_state is TurnState.WIN:
|
||||
return self.message(
|
||||
"Congratulations, you are victorious! Start a new campaign to continue."
|
||||
self.message(
|
||||
"Congratulations, you are victorious! Start a new campaign to continue."
|
||||
)
|
||||
elif turn_state is TurnState.LOSS:
|
||||
return self.message(
|
||||
"Game Over, you lose. Start a new campaign to continue."
|
||||
)
|
||||
self.message("Game Over, you lose. Start a new campaign to continue.")
|
||||
|
||||
@@ -2,12 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, TYPE_CHECKING, Any
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from game.theater import ControlPoint
|
||||
from .coalition import Coalition
|
||||
from .dcs.groundunittype import GroundUnitType
|
||||
from .dcs.unittype import UnitType
|
||||
from .theater.transitnetwork import (
|
||||
NoPathError,
|
||||
TransitNetwork,
|
||||
@@ -18,97 +17,93 @@ if TYPE_CHECKING:
|
||||
from .game import Game
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroundUnitSource:
|
||||
control_point: ControlPoint
|
||||
|
||||
|
||||
class PendingUnitDeliveries:
|
||||
class GroundUnitOrders:
|
||||
def __init__(self, destination: ControlPoint) -> None:
|
||||
self.destination = destination
|
||||
|
||||
# Maps unit type to order quantity.
|
||||
self.units: Dict[UnitType, int] = defaultdict(int)
|
||||
self.units: dict[GroundUnitType, int] = defaultdict(int)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Pending delivery to {self.destination}"
|
||||
return f"Pending ground unit delivery to {self.destination}"
|
||||
|
||||
def order(self, units: Dict[UnitType, int]) -> None:
|
||||
def order(self, units: dict[GroundUnitType, int]) -> None:
|
||||
for k, v in units.items():
|
||||
self.units[k] += v
|
||||
|
||||
def sell(self, units: Dict[UnitType, int]) -> None:
|
||||
def sell(self, units: dict[GroundUnitType, int]) -> None:
|
||||
for k, v in units.items():
|
||||
self.units[k] -= v
|
||||
if self.units[k] == 0:
|
||||
del self.units[k]
|
||||
|
||||
def refund_all(self, game: Game) -> None:
|
||||
self.refund(game, self.units)
|
||||
def refund_all(self, coalition: Coalition) -> None:
|
||||
self._refund(coalition, self.units)
|
||||
self.units = defaultdict(int)
|
||||
|
||||
def refund(self, game: Game, units: Dict[UnitType, int]) -> None:
|
||||
def _refund(self, coalition: Coalition, units: dict[GroundUnitType, int]) -> None:
|
||||
for unit_type, count in units.items():
|
||||
logging.info(f"Refunding {count} {unit_type} at {self.destination.name}")
|
||||
game.adjust_budget(
|
||||
unit_type.price * count, player=self.destination.captured
|
||||
)
|
||||
coalition.adjust_budget(unit_type.price * count)
|
||||
|
||||
def pending_orders(self, unit_type: UnitType) -> int:
|
||||
def pending_orders(self, unit_type: GroundUnitType) -> int:
|
||||
pending_units = self.units.get(unit_type)
|
||||
if pending_units is None:
|
||||
pending_units = 0
|
||||
return pending_units
|
||||
|
||||
def available_next_turn(self, unit_type: UnitType) -> int:
|
||||
current_units = self.destination.base.total_units_of_type(unit_type)
|
||||
return self.pending_orders(unit_type) + current_units
|
||||
|
||||
def process(self, game: Game) -> None:
|
||||
coalition = game.coalition_for(self.destination.captured)
|
||||
ground_unit_source = self.find_ground_unit_source(game)
|
||||
if ground_unit_source is None:
|
||||
game.message(
|
||||
f"{self.destination.name} lost its source for ground unit "
|
||||
"reinforcements. Refunding purchase price."
|
||||
)
|
||||
self.refund_all(game)
|
||||
return
|
||||
self.refund_all(coalition)
|
||||
|
||||
bought_units: Dict[UnitType, int] = {}
|
||||
units_needing_transfer: Dict[GroundUnitType, int] = {}
|
||||
sold_units: Dict[UnitType, int] = {}
|
||||
bought_units: dict[GroundUnitType, int] = {}
|
||||
units_needing_transfer: dict[GroundUnitType, int] = {}
|
||||
for unit_type, count in self.units.items():
|
||||
coalition = "Ally" if self.destination.captured else "Enemy"
|
||||
d: dict[Any, int]
|
||||
if (
|
||||
isinstance(unit_type, GroundUnitType)
|
||||
and self.destination != ground_unit_source
|
||||
):
|
||||
allegiance = "Ally" if self.destination.captured else "Enemy"
|
||||
d: dict[GroundUnitType, int]
|
||||
if self.destination != ground_unit_source:
|
||||
source = ground_unit_source
|
||||
d = units_needing_transfer
|
||||
else:
|
||||
source = self.destination
|
||||
d = bought_units
|
||||
|
||||
if count >= 0:
|
||||
if count < 0:
|
||||
logging.error(
|
||||
f"Attempted sale of {unit_type} at {self.destination} but ground "
|
||||
"units cannot be sold"
|
||||
)
|
||||
elif count > 0:
|
||||
d[unit_type] = count
|
||||
game.message(
|
||||
f"{coalition} reinforcements: {unit_type} x {count} at {source}"
|
||||
f"{allegiance} reinforcements: {unit_type} x {count} at {source}"
|
||||
)
|
||||
else:
|
||||
sold_units[unit_type] = -count
|
||||
game.message(f"{coalition} sold: {unit_type} x {-count} at {source}")
|
||||
|
||||
self.units = defaultdict(int)
|
||||
self.destination.base.commission_units(bought_units)
|
||||
self.destination.base.commit_losses(sold_units)
|
||||
|
||||
if units_needing_transfer:
|
||||
if ground_unit_source is None:
|
||||
raise RuntimeError(
|
||||
f"Ground unit source could not be found for {self.destination} but "
|
||||
"still tried to transfer units to there"
|
||||
)
|
||||
ground_unit_source.base.commission_units(units_needing_transfer)
|
||||
self.create_transfer(game, ground_unit_source, units_needing_transfer)
|
||||
self.create_transfer(coalition, ground_unit_source, units_needing_transfer)
|
||||
|
||||
def create_transfer(
|
||||
self, game: Game, source: ControlPoint, units: Dict[GroundUnitType, int]
|
||||
self,
|
||||
coalition: Coalition,
|
||||
source: ControlPoint,
|
||||
units: dict[GroundUnitType, int],
|
||||
) -> None:
|
||||
game.transfers.new_transfer(TransferOrder(source, self.destination, units))
|
||||
coalition.transfers.new_transfer(TransferOrder(source, self.destination, units))
|
||||
|
||||
def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]:
|
||||
# This is running *after* the turn counter has been incremented, so this is the
|
||||
127
game/htn.py
Normal file
127
game/htn.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import Iterator, deque, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, Optional, TypeVar
|
||||
|
||||
WorldStateT = TypeVar("WorldStateT", bound="WorldState[Any]")
|
||||
|
||||
|
||||
class WorldState(ABC, Generic[WorldStateT]):
|
||||
@abstractmethod
|
||||
def clone(self) -> WorldStateT:
|
||||
...
|
||||
|
||||
|
||||
class Task(Generic[WorldStateT]):
|
||||
pass
|
||||
|
||||
|
||||
Method = Sequence[Task[WorldStateT]]
|
||||
|
||||
|
||||
class PrimitiveTask(Task[WorldStateT], Generic[WorldStateT], ABC):
|
||||
@abstractmethod
|
||||
def preconditions_met(self, state: WorldStateT) -> bool:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def apply_effects(self, state: WorldStateT) -> None:
|
||||
...
|
||||
|
||||
|
||||
class CompoundTask(Task[WorldStateT], Generic[WorldStateT], ABC):
|
||||
@abstractmethod
|
||||
def each_valid_method(self, state: WorldStateT) -> Iterator[Method[WorldStateT]]:
|
||||
...
|
||||
|
||||
|
||||
PrimitiveTaskT = TypeVar("PrimitiveTaskT", bound=PrimitiveTask[Any])
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanningState(Generic[WorldStateT, PrimitiveTaskT]):
|
||||
state: WorldStateT
|
||||
tasks_to_process: deque[Task[WorldStateT]]
|
||||
plan: list[PrimitiveTaskT]
|
||||
methods: Optional[Iterator[Method[WorldStateT]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanningResult(Generic[WorldStateT, PrimitiveTaskT]):
|
||||
tasks: list[PrimitiveTaskT]
|
||||
end_state: WorldStateT
|
||||
|
||||
|
||||
class PlanningHistory(Generic[WorldStateT, PrimitiveTaskT]):
|
||||
def __init__(self) -> None:
|
||||
self.states: list[PlanningState[WorldStateT, PrimitiveTaskT]] = []
|
||||
|
||||
def push(self, planning_state: PlanningState[WorldStateT, PrimitiveTaskT]) -> None:
|
||||
self.states.append(planning_state)
|
||||
|
||||
def pop(self) -> PlanningState[WorldStateT, PrimitiveTaskT]:
|
||||
return self.states.pop()
|
||||
|
||||
|
||||
class Planner(Generic[WorldStateT, PrimitiveTaskT]):
|
||||
def __init__(self, main_task: Task[WorldStateT]) -> None:
|
||||
self.main_task = main_task
|
||||
|
||||
def plan(
|
||||
self, initial_state: WorldStateT
|
||||
) -> Optional[PlanningResult[WorldStateT, PrimitiveTaskT]]:
|
||||
planning_state: PlanningState[WorldStateT, PrimitiveTaskT] = PlanningState(
|
||||
initial_state, deque([self.main_task]), [], None
|
||||
)
|
||||
history: PlanningHistory[WorldStateT, PrimitiveTaskT] = PlanningHistory()
|
||||
while planning_state.tasks_to_process:
|
||||
task = planning_state.tasks_to_process.popleft()
|
||||
if isinstance(task, PrimitiveTask):
|
||||
if task.preconditions_met(planning_state.state):
|
||||
task.apply_effects(planning_state.state)
|
||||
# Ignore type erasure. We've already verified that this is a Planner
|
||||
# with a WorldStateT and a PrimitiveTaskT, so we know that the task
|
||||
# list is a list of CompoundTask[WorldStateT] and PrimitiveTaskT. We
|
||||
# could scatter more unions throughout to be more explicit but
|
||||
# there's no way around the type erasure that mypy uses for
|
||||
# isinstance.
|
||||
planning_state.plan.append(task) # type: ignore
|
||||
else:
|
||||
planning_state = history.pop()
|
||||
else:
|
||||
assert isinstance(task, CompoundTask)
|
||||
# If the methods field of our current state is not None that means we're
|
||||
# resuming a prior attempt to execute this task after a subtask of the
|
||||
# previously selected method failed.
|
||||
#
|
||||
# Otherwise this is the first exectution of this task so we need to
|
||||
# create the generator.
|
||||
if planning_state.methods is None:
|
||||
methods = task.each_valid_method(planning_state.state)
|
||||
else:
|
||||
methods = planning_state.methods
|
||||
try:
|
||||
method = next(methods)
|
||||
# Push the current node back onto the stack so that we resume
|
||||
# handling this task when we pop back to this state.
|
||||
resume_tasks: deque[Task[WorldStateT]] = deque([task])
|
||||
resume_tasks.extend(planning_state.tasks_to_process)
|
||||
history.push(
|
||||
PlanningState(
|
||||
planning_state.state.clone(),
|
||||
resume_tasks,
|
||||
planning_state.plan,
|
||||
methods,
|
||||
)
|
||||
)
|
||||
planning_state.methods = None
|
||||
planning_state.tasks_to_process.extendleft(reversed(method))
|
||||
except StopIteration:
|
||||
try:
|
||||
planning_state = history.pop()
|
||||
except IndexError:
|
||||
# No valid plan was found.
|
||||
return None
|
||||
return PlanningResult(planning_state.plan, planning_state.state)
|
||||
@@ -14,10 +14,10 @@ class BuildingIncome:
|
||||
name: str
|
||||
category: str
|
||||
number: int
|
||||
income_per_building: int
|
||||
income_per_building: float
|
||||
|
||||
@property
|
||||
def income(self) -> int:
|
||||
def income(self) -> float:
|
||||
return self.number * self.income_per_building
|
||||
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ import datetime
|
||||
|
||||
|
||||
class Information:
|
||||
def __init__(self, title="", text="", turn=0):
|
||||
def __init__(self, title: str = "", text: str = "", turn: int = 0) -> None:
|
||||
self.title = title
|
||||
self.text = text
|
||||
self.turn = turn
|
||||
self.timestamp = datetime.datetime.now()
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return "[{}][{}] {} {}".format(
|
||||
self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if self.timestamp is not None
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Inventory management APIs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING, Type
|
||||
|
||||
from dcs.unittype import FlyingType
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from gen.flights.flight import Flight
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater import ControlPoint
|
||||
|
||||
|
||||
class ControlPointAircraftInventory:
|
||||
"""Aircraft inventory for a single control point."""
|
||||
|
||||
def __init__(self, control_point: ControlPoint) -> None:
|
||||
self.control_point = control_point
|
||||
self.inventory: Dict[AircraftType, int] = defaultdict(int)
|
||||
|
||||
def add_aircraft(self, aircraft: AircraftType, count: int) -> None:
|
||||
"""Adds aircraft to the inventory.
|
||||
|
||||
Args:
|
||||
aircraft: The type of aircraft to add.
|
||||
count: The number of aircraft to add.
|
||||
"""
|
||||
self.inventory[aircraft] += count
|
||||
|
||||
def remove_aircraft(self, aircraft: AircraftType, count: int) -> None:
|
||||
"""Removes aircraft from the inventory.
|
||||
|
||||
Args:
|
||||
aircraft: The type of aircraft to remove.
|
||||
count: The number of aircraft to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: The control point cannot fulfill the requested number of
|
||||
aircraft.
|
||||
"""
|
||||
available = self.inventory[aircraft]
|
||||
if available < count:
|
||||
raise ValueError(
|
||||
f"Cannot remove {count} {aircraft} from "
|
||||
f"{self.control_point.name}. Only have {available}."
|
||||
)
|
||||
self.inventory[aircraft] -= count
|
||||
|
||||
def available(self, aircraft: AircraftType) -> int:
|
||||
"""Returns the number of available aircraft of the given type.
|
||||
|
||||
Args:
|
||||
aircraft: The type of aircraft to query.
|
||||
"""
|
||||
try:
|
||||
return self.inventory[aircraft]
|
||||
except KeyError:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def types_available(self) -> Iterator[AircraftType]:
|
||||
"""Iterates over all available aircraft types."""
|
||||
for aircraft, count in self.inventory.items():
|
||||
if count > 0:
|
||||
yield aircraft
|
||||
|
||||
@property
|
||||
def all_aircraft(self) -> Iterator[Tuple[AircraftType, int]]:
|
||||
"""Iterates over all available aircraft types, including amounts."""
|
||||
for aircraft, count in self.inventory.items():
|
||||
if count > 0:
|
||||
yield aircraft, count
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clears all aircraft from the inventory."""
|
||||
self.inventory.clear()
|
||||
|
||||
|
||||
class GlobalAircraftInventory:
|
||||
"""Game-wide aircraft inventory."""
|
||||
|
||||
def __init__(self, control_points: Iterable[ControlPoint]) -> None:
|
||||
self.inventories: Dict[ControlPoint, ControlPointAircraftInventory] = {
|
||||
cp: ControlPointAircraftInventory(cp) for cp in control_points
|
||||
}
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clears all control points and their inventories."""
|
||||
for inventory in self.inventories.values():
|
||||
inventory.clear()
|
||||
|
||||
def set_from_control_point(self, control_point: ControlPoint) -> None:
|
||||
"""Set the control point's aircraft inventory.
|
||||
|
||||
If the inventory for the given control point has already been set for
|
||||
the turn, it will be overwritten.
|
||||
"""
|
||||
inventory = self.inventories[control_point]
|
||||
for aircraft, count in control_point.base.aircraft.items():
|
||||
inventory.add_aircraft(aircraft, count)
|
||||
|
||||
def for_control_point(
|
||||
self, control_point: ControlPoint
|
||||
) -> ControlPointAircraftInventory:
|
||||
"""Returns the inventory specific to the given control point."""
|
||||
return self.inventories[control_point]
|
||||
|
||||
@property
|
||||
def available_types_for_player(self) -> Iterator[AircraftType]:
|
||||
"""Iterates over all aircraft types available to the player."""
|
||||
seen: Set[AircraftType] = set()
|
||||
for control_point, inventory in self.inventories.items():
|
||||
if control_point.captured:
|
||||
for aircraft in inventory.types_available:
|
||||
if not control_point.can_operate(aircraft):
|
||||
continue
|
||||
if aircraft not in seen:
|
||||
seen.add(aircraft)
|
||||
yield aircraft
|
||||
|
||||
def claim_for_flight(self, flight: Flight) -> None:
|
||||
"""Removes aircraft from the inventory for the given flight."""
|
||||
inventory = self.for_control_point(flight.from_cp)
|
||||
inventory.remove_aircraft(flight.unit_type, flight.count)
|
||||
|
||||
def return_from_flight(self, flight: Flight) -> None:
|
||||
"""Returns a flight's aircraft to the inventory."""
|
||||
inventory = self.for_control_point(flight.from_cp)
|
||||
inventory.add_aircraft(flight.unit_type, flight.count)
|
||||
@@ -1,13 +0,0 @@
|
||||
class DestroyedUnit:
|
||||
"""
|
||||
Store info about a destroyed unit
|
||||
"""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
name: str
|
||||
|
||||
def __init__(self, x, y, name):
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.name = name
|
||||
@@ -1,4 +1,9 @@
|
||||
from typing import List
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
|
||||
|
||||
class FactionTurnMetadata:
|
||||
@@ -10,7 +15,7 @@ class FactionTurnMetadata:
|
||||
vehicles_count: int = 0
|
||||
sam_count: int = 0
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.aircraft_count = 0
|
||||
self.vehicles_count = 0
|
||||
self.sam_count = 0
|
||||
@@ -24,7 +29,7 @@ class GameTurnMetadata:
|
||||
allied_units: FactionTurnMetadata
|
||||
enemy_units: FactionTurnMetadata
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.allied_units = FactionTurnMetadata()
|
||||
self.enemy_units = FactionTurnMetadata()
|
||||
|
||||
@@ -34,23 +39,29 @@ class GameStats:
|
||||
Store statistics for the current game
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.data_per_turn: List[GameTurnMetadata] = []
|
||||
|
||||
def update(self, game):
|
||||
def update(self, game: Game) -> None:
|
||||
"""
|
||||
Save data for current turn
|
||||
:param game: Game we want to save the data about
|
||||
"""
|
||||
|
||||
# Remove the current turn if its just an update for this turn
|
||||
if 0 < game.turn < len(self.data_per_turn):
|
||||
del self.data_per_turn[-1]
|
||||
|
||||
turn_data = GameTurnMetadata()
|
||||
|
||||
for cp in game.theater.controlpoints:
|
||||
if cp.captured:
|
||||
turn_data.allied_units.aircraft_count += sum(cp.base.aircraft.values())
|
||||
for squadron in cp.squadrons:
|
||||
turn_data.allied_units.aircraft_count += squadron.owned_aircraft
|
||||
turn_data.allied_units.vehicles_count += sum(cp.base.armor.values())
|
||||
else:
|
||||
turn_data.enemy_units.aircraft_count += sum(cp.base.aircraft.values())
|
||||
for squadron in cp.squadrons:
|
||||
turn_data.enemy_units.aircraft_count += squadron.owned_aircraft
|
||||
turn_data.enemy_units.vehicles_count += sum(cp.base.armor.values())
|
||||
|
||||
self.data_per_turn.append(turn_data)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Set, TYPE_CHECKING
|
||||
from typing import List, Set, TYPE_CHECKING, cast
|
||||
|
||||
from dcs import Mission
|
||||
from dcs.action import DoScript, DoScriptFile
|
||||
@@ -16,25 +16,30 @@ from dcs.triggers import TriggerStart
|
||||
|
||||
from game.plugins import LuaPluginManager
|
||||
from game.theater.theatergroundobject import TheaterGroundObject
|
||||
from gen import Conflict, FlightType, VisualGenerator
|
||||
from gen.aircraft import AircraftConflictGenerator, FlightData
|
||||
from gen.airfields import AIRFIELD_DATA
|
||||
from gen.airsupportgen import AirSupport, AirSupportConflictGenerator
|
||||
from gen.armor import GroundConflictGenerator, JtacInfo
|
||||
from gen.airsupport import AirSupport
|
||||
from gen.airsupportgen import AirSupportConflictGenerator
|
||||
from gen.armor import GroundConflictGenerator
|
||||
from gen.beacons import load_beacons_for_terrain
|
||||
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
|
||||
from gen.cargoshipgen import CargoShipGenerator
|
||||
from gen.conflictgen import Conflict
|
||||
from gen.convoygen import ConvoyGenerator
|
||||
from gen.environmentgen import EnvironmentGenerator
|
||||
from gen.flights.flight import FlightType
|
||||
from gen.forcedoptionsgen import ForcedOptionsGenerator
|
||||
from gen.groundobjectsgen import GroundObjectsGenerator
|
||||
from gen.kneeboard import KneeboardGenerator
|
||||
from gen.lasercoderegistry import LaserCodeRegistry
|
||||
from gen.naming import namegen
|
||||
from gen.radios import RadioFrequency, RadioRegistry
|
||||
from gen.tacan import TacanRegistry
|
||||
from gen.tacan import TacanRegistry, TacanUsage
|
||||
from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator
|
||||
from gen.visualgen import VisualGenerator
|
||||
from .. import db
|
||||
from ..theater import Airfield, FrontLine
|
||||
from ..theater.bullseye import Bullseye
|
||||
from ..unitmap import UnitMap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -50,6 +55,7 @@ class Operation:
|
||||
groundobjectgen: GroundObjectsGenerator
|
||||
radio_registry: RadioRegistry
|
||||
tacan_registry: TacanRegistry
|
||||
laser_code_registry: LaserCodeRegistry
|
||||
game: Game
|
||||
trigger_radius = TRIGGER_RADIUS_MEDIUM
|
||||
is_quick = None
|
||||
@@ -58,32 +64,18 @@ class Operation:
|
||||
enemy_awacs_enabled = True
|
||||
ca_slots = 1
|
||||
unit_map: UnitMap
|
||||
jtacs: List[JtacInfo] = []
|
||||
plugin_scripts: List[str] = []
|
||||
air_support = AirSupport()
|
||||
|
||||
@classmethod
|
||||
def prepare(cls, game: Game):
|
||||
with open("resources/default_options.lua", "r") as f:
|
||||
def prepare(cls, game: Game) -> None:
|
||||
with open("resources/default_options.lua", "r", encoding="utf-8") as f:
|
||||
options_dict = loads(f.read())["options"]
|
||||
cls._set_mission(Mission(game.theater.terrain))
|
||||
cls.game = game
|
||||
cls._setup_mission_coalitions()
|
||||
cls.current_mission.options.load_from_dict(options_dict)
|
||||
|
||||
@classmethod
|
||||
def conflicts(cls) -> Iterable[Conflict]:
|
||||
assert cls.game
|
||||
for frontline in cls.game.theater.conflicts():
|
||||
yield Conflict(
|
||||
cls.game.theater,
|
||||
frontline,
|
||||
cls.game.player_name,
|
||||
cls.game.enemy_name,
|
||||
cls.game.player_country,
|
||||
cls.game.enemy_country,
|
||||
frontline.position,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def air_conflict(cls) -> Conflict:
|
||||
assert cls.game
|
||||
@@ -95,10 +87,10 @@ class Operation:
|
||||
return Conflict(
|
||||
cls.game.theater,
|
||||
FrontLine(player_cp, enemy_cp),
|
||||
cls.game.player_name,
|
||||
cls.game.enemy_name,
|
||||
cls.game.player_country,
|
||||
cls.game.enemy_country,
|
||||
cls.game.blue.faction.name,
|
||||
cls.game.red.faction.name,
|
||||
cls.current_mission.country(cls.game.blue.country_name),
|
||||
cls.current_mission.country(cls.game.red.country_name),
|
||||
mid_point,
|
||||
)
|
||||
|
||||
@@ -107,16 +99,19 @@ class Operation:
|
||||
cls.current_mission = mission
|
||||
|
||||
@classmethod
|
||||
def _setup_mission_coalitions(cls):
|
||||
def _setup_mission_coalitions(cls) -> None:
|
||||
cls.current_mission.coalition["blue"] = Coalition(
|
||||
"blue", bullseye=cls.game.blue_bullseye.to_pydcs()
|
||||
"blue", bullseye=cls.game.blue.bullseye.to_pydcs()
|
||||
)
|
||||
cls.current_mission.coalition["red"] = Coalition(
|
||||
"red", bullseye=cls.game.red_bullseye.to_pydcs()
|
||||
"red", bullseye=cls.game.red.bullseye.to_pydcs()
|
||||
)
|
||||
cls.current_mission.coalition["neutrals"] = Coalition(
|
||||
"neutrals", bullseye=Bullseye(Point(0, 0)).to_pydcs()
|
||||
)
|
||||
|
||||
p_country = cls.game.player_country
|
||||
e_country = cls.game.enemy_country
|
||||
p_country = cls.game.blue.country_name
|
||||
e_country = cls.game.red.country_name
|
||||
cls.current_mission.coalition["blue"].add_country(
|
||||
country_dict[db.country_id_from_name(p_country)]()
|
||||
)
|
||||
@@ -124,6 +119,16 @@ class Operation:
|
||||
country_dict[db.country_id_from_name(e_country)]()
|
||||
)
|
||||
|
||||
belligerents = [
|
||||
db.country_id_from_name(p_country),
|
||||
db.country_id_from_name(e_country),
|
||||
]
|
||||
for country in country_dict.keys():
|
||||
if country not in belligerents:
|
||||
cls.current_mission.coalition["neutrals"].add_country(
|
||||
country_dict[country]()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def inject_lua_trigger(cls, contents: str, comment: str) -> None:
|
||||
trigger = TriggerStart(comment=comment)
|
||||
@@ -160,10 +165,9 @@ class Operation:
|
||||
def notify_info_generators(
|
||||
cls,
|
||||
groundobjectgen: GroundObjectsGenerator,
|
||||
airsupportgen: AirSupportConflictGenerator,
|
||||
jtacs: List[JtacInfo],
|
||||
air_support: AirSupport,
|
||||
airgen: AircraftConflictGenerator,
|
||||
):
|
||||
) -> None:
|
||||
"""Generates subscribed MissionInfoGenerator objects (currently kneeboards and briefings)"""
|
||||
|
||||
gens: List[MissionInfoGenerator] = [
|
||||
@@ -174,15 +178,15 @@ class Operation:
|
||||
for dynamic_runway in groundobjectgen.runways.values():
|
||||
gen.add_dynamic_runway(dynamic_runway)
|
||||
|
||||
for tanker in airsupportgen.air_support.tankers:
|
||||
for tanker in air_support.tankers:
|
||||
if tanker.blue:
|
||||
gen.add_tanker(tanker)
|
||||
|
||||
for aewc in airsupportgen.air_support.awacs:
|
||||
for aewc in air_support.awacs:
|
||||
if aewc.blue:
|
||||
gen.add_awacs(aewc)
|
||||
|
||||
for jtac in jtacs:
|
||||
for jtac in air_support.jtacs:
|
||||
if jtac.blue:
|
||||
gen.add_jtac(jtac)
|
||||
|
||||
@@ -207,6 +211,10 @@ class Operation:
|
||||
for frequency in unique_map_frequencies:
|
||||
cls.radio_registry.reserve(frequency)
|
||||
|
||||
@classmethod
|
||||
def create_laser_code_registry(cls) -> None:
|
||||
cls.laser_code_registry = LaserCodeRegistry()
|
||||
|
||||
@classmethod
|
||||
def assign_channels_to_flights(
|
||||
cls, flights: List[FlightData], air_support: AirSupport
|
||||
@@ -234,7 +242,7 @@ class Operation:
|
||||
if beacon.channel is None:
|
||||
logging.error(f"TACAN beacon has no channel: {beacon.callsign}")
|
||||
else:
|
||||
cls.tacan_registry.reserve(beacon.tacan_channel)
|
||||
cls.tacan_registry.mark_unavailable(beacon.tacan_channel)
|
||||
|
||||
@classmethod
|
||||
def _create_radio_registry(
|
||||
@@ -251,7 +259,7 @@ class Operation:
|
||||
# beacon list.
|
||||
|
||||
@classmethod
|
||||
def _generate_ground_units(cls):
|
||||
def _generate_ground_units(cls) -> None:
|
||||
cls.groundobjectgen = GroundObjectsGenerator(
|
||||
cls.current_mission,
|
||||
cls.game,
|
||||
@@ -266,18 +274,23 @@ class Operation:
|
||||
"""Add destroyed units to the Mission"""
|
||||
for d in cls.game.get_destroyed_units():
|
||||
try:
|
||||
utype = db.unit_type_from_name(d["type"])
|
||||
type_name = d["type"]
|
||||
if not isinstance(type_name, str):
|
||||
raise TypeError(
|
||||
"Expected the type of the destroyed static to be a string"
|
||||
)
|
||||
utype = db.unit_type_from_name(type_name)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
pos = Point(d["x"], d["z"])
|
||||
pos = Point(cast(float, d["x"]), cast(float, d["z"]))
|
||||
if (
|
||||
utype is not None
|
||||
and not cls.game.position_culled(pos)
|
||||
and cls.game.settings.perf_destroyed_units
|
||||
):
|
||||
cls.current_mission.static_group(
|
||||
country=cls.current_mission.country(cls.game.player_country),
|
||||
country=cls.current_mission.country(cls.game.blue.country_name),
|
||||
name="",
|
||||
_type=utype,
|
||||
hidden=True,
|
||||
@@ -289,18 +302,22 @@ class Operation:
|
||||
@classmethod
|
||||
def generate(cls) -> UnitMap:
|
||||
"""Build the final Mission to be exported"""
|
||||
cls.air_support = AirSupport()
|
||||
cls.create_unit_map()
|
||||
cls.create_radio_registries()
|
||||
cls.create_laser_code_registry()
|
||||
# Set mission time and weather conditions.
|
||||
EnvironmentGenerator(cls.current_mission, cls.game.conditions).generate()
|
||||
cls._generate_ground_units()
|
||||
cls._generate_transports()
|
||||
cls._generate_destroyed_units()
|
||||
# Generate ground conflicts first so the JTACs get the first laser code (1688)
|
||||
# rather than the first player flight with a TGP.
|
||||
cls._generate_ground_conflicts()
|
||||
cls._generate_air_units()
|
||||
cls.assign_channels_to_flights(
|
||||
cls.airgen.flights, cls.airsupportgen.air_support
|
||||
)
|
||||
cls._generate_ground_conflicts()
|
||||
|
||||
# Triggers
|
||||
triggersgen = TriggersGenerator(cls.current_mission, cls.game)
|
||||
@@ -320,7 +337,7 @@ class Operation:
|
||||
if cls.game.settings.perf_smoke_gen:
|
||||
visualgen.generate()
|
||||
|
||||
cls.generate_lua(cls.airgen, cls.airsupportgen, cls.jtacs)
|
||||
cls.generate_lua(cls.airgen, cls.air_support)
|
||||
|
||||
# Inject Plugins Lua Scripts and data
|
||||
cls.plugin_scripts.clear()
|
||||
@@ -332,9 +349,7 @@ class Operation:
|
||||
cls.assign_channels_to_flights(
|
||||
cls.airgen.flights, cls.airsupportgen.air_support
|
||||
)
|
||||
cls.notify_info_generators(
|
||||
cls.groundobjectgen, cls.airsupportgen, cls.jtacs, cls.airgen
|
||||
)
|
||||
cls.notify_info_generators(cls.groundobjectgen, cls.air_support, cls.airgen)
|
||||
cls.reset_naming_ids()
|
||||
return cls.unit_map
|
||||
|
||||
@@ -350,6 +365,7 @@ class Operation:
|
||||
cls.game,
|
||||
cls.radio_registry,
|
||||
cls.tacan_registry,
|
||||
cls.air_support,
|
||||
)
|
||||
cls.airsupportgen.generate()
|
||||
|
||||
@@ -360,39 +376,40 @@ class Operation:
|
||||
cls.game,
|
||||
cls.radio_registry,
|
||||
cls.tacan_registry,
|
||||
cls.laser_code_registry,
|
||||
cls.unit_map,
|
||||
air_support=cls.airsupportgen.air_support,
|
||||
helipads=cls.groundobjectgen.helipads,
|
||||
)
|
||||
|
||||
cls.airgen.clear_parking_slots()
|
||||
|
||||
cls.airgen.generate_flights(
|
||||
cls.current_mission.country(cls.game.player_country),
|
||||
cls.game.blue_ato,
|
||||
cls.current_mission.country(cls.game.blue.country_name),
|
||||
cls.game.blue.ato,
|
||||
cls.groundobjectgen.runways,
|
||||
)
|
||||
cls.airgen.generate_flights(
|
||||
cls.current_mission.country(cls.game.enemy_country),
|
||||
cls.game.red_ato,
|
||||
cls.current_mission.country(cls.game.red.country_name),
|
||||
cls.game.red.ato,
|
||||
cls.groundobjectgen.runways,
|
||||
)
|
||||
cls.airgen.spawn_unused_aircraft(
|
||||
cls.current_mission.country(cls.game.player_country),
|
||||
cls.current_mission.country(cls.game.enemy_country),
|
||||
cls.current_mission.country(cls.game.blue.country_name),
|
||||
cls.current_mission.country(cls.game.red.country_name),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _generate_ground_conflicts(cls) -> None:
|
||||
"""For each frontline in the Operation, generate the ground conflicts and JTACs"""
|
||||
cls.jtacs = []
|
||||
for front_line in cls.game.theater.conflicts():
|
||||
player_cp = front_line.blue_cp
|
||||
enemy_cp = front_line.red_cp
|
||||
conflict = Conflict.frontline_cas_conflict(
|
||||
cls.game.player_name,
|
||||
cls.game.enemy_name,
|
||||
cls.current_mission.country(cls.game.player_country),
|
||||
cls.current_mission.country(cls.game.enemy_country),
|
||||
cls.game.blue.faction.name,
|
||||
cls.game.red.faction.name,
|
||||
cls.current_mission.country(cls.game.blue.country_name),
|
||||
cls.current_mission.country(cls.game.red.country_name),
|
||||
front_line,
|
||||
cls.game.theater,
|
||||
)
|
||||
@@ -406,10 +423,13 @@ class Operation:
|
||||
player_gp,
|
||||
enemy_gp,
|
||||
player_cp.stances[enemy_cp.id],
|
||||
enemy_cp.stances[player_cp.id],
|
||||
cls.unit_map,
|
||||
cls.radio_registry,
|
||||
cls.air_support,
|
||||
cls.laser_code_registry,
|
||||
)
|
||||
ground_conflict_gen.generate()
|
||||
cls.jtacs.extend(ground_conflict_gen.jtacs)
|
||||
|
||||
@classmethod
|
||||
def _generate_transports(cls) -> None:
|
||||
@@ -418,15 +438,12 @@ class Operation:
|
||||
CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate()
|
||||
|
||||
@classmethod
|
||||
def reset_naming_ids(cls):
|
||||
def reset_naming_ids(cls) -> None:
|
||||
namegen.reset_numbers()
|
||||
|
||||
@classmethod
|
||||
def generate_lua(
|
||||
cls,
|
||||
airgen: AircraftConflictGenerator,
|
||||
airsupportgen: AirSupportConflictGenerator,
|
||||
jtacs: List[JtacInfo],
|
||||
cls, airgen: AircraftConflictGenerator, air_support: AirSupport
|
||||
) -> None:
|
||||
# TODO: Refactor this
|
||||
luaData = {
|
||||
@@ -439,8 +456,8 @@ class Operation:
|
||||
"BlueAA": {},
|
||||
} # type: ignore
|
||||
|
||||
for tanker in airsupportgen.air_support.tankers:
|
||||
luaData["Tankers"][tanker.callsign] = {
|
||||
for i, tanker in enumerate(air_support.tankers):
|
||||
luaData["Tankers"][i] = {
|
||||
"dcsGroupName": tanker.group_name,
|
||||
"callsign": tanker.callsign,
|
||||
"variant": tanker.variant,
|
||||
@@ -448,23 +465,23 @@ class Operation:
|
||||
"tacan": str(tanker.tacan.number) + tanker.tacan.band.name,
|
||||
}
|
||||
|
||||
if airsupportgen.air_support.awacs:
|
||||
for awacs in airsupportgen.air_support.awacs:
|
||||
luaData["AWACs"][awacs.callsign] = {
|
||||
"dcsGroupName": awacs.group_name,
|
||||
"callsign": awacs.callsign,
|
||||
"radio": awacs.freq.mhz,
|
||||
}
|
||||
for i, awacs in enumerate(air_support.awacs):
|
||||
luaData["AWACs"][i] = {
|
||||
"dcsGroupName": awacs.group_name,
|
||||
"callsign": awacs.callsign,
|
||||
"radio": awacs.freq.mhz,
|
||||
}
|
||||
|
||||
for jtac in jtacs:
|
||||
luaData["JTACs"][jtac.callsign] = {
|
||||
for i, jtac in enumerate(air_support.jtacs):
|
||||
luaData["JTACs"][i] = {
|
||||
"dcsGroupName": jtac.group_name,
|
||||
"callsign": jtac.callsign,
|
||||
"zone": jtac.region,
|
||||
"dcsUnit": jtac.unit_name,
|
||||
"laserCode": jtac.code,
|
||||
"radio": jtac.freq.mhz,
|
||||
}
|
||||
|
||||
flight_count = 0
|
||||
for flight in airgen.flights:
|
||||
if flight.friendly and flight.flight_type in [
|
||||
FlightType.ANTISHIP,
|
||||
@@ -485,7 +502,7 @@ class Operation:
|
||||
elif hasattr(flightTarget, "name"):
|
||||
flightTargetName = flightTarget.name
|
||||
flightTargetType = flightType + " TGT (Airbase)"
|
||||
luaData["TargetPoints"][flightTargetName] = {
|
||||
luaData["TargetPoints"][flight_count] = {
|
||||
"name": flightTargetName,
|
||||
"type": flightTargetType,
|
||||
"position": {
|
||||
@@ -493,6 +510,7 @@ class Operation:
|
||||
"y": flightTarget.position.y,
|
||||
},
|
||||
}
|
||||
flight_count += 1
|
||||
|
||||
for cp in cls.game.theater.controlpoints:
|
||||
for ground_object in cp.ground_objects:
|
||||
@@ -578,7 +596,8 @@ class Operation:
|
||||
zone = data["zone"]
|
||||
laserCode = data["laserCode"]
|
||||
dcsUnit = data["dcsUnit"]
|
||||
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n"
|
||||
radio = data["radio"]
|
||||
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}', radio='{radio}' }}, \n"
|
||||
lua += "}"
|
||||
|
||||
# Process the Target Points
|
||||
|
||||
23
game/orderedset.py
Normal file
23
game/orderedset.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from collections import Iterator, Iterable
|
||||
from typing import Generic, TypeVar, Optional
|
||||
|
||||
ValueT = TypeVar("ValueT")
|
||||
|
||||
|
||||
class OrderedSet(Generic[ValueT]):
|
||||
def __init__(self, initial_data: Optional[Iterable[ValueT]] = None) -> None:
|
||||
if initial_data is None:
|
||||
initial_data = []
|
||||
self._data: dict[ValueT, None] = {v: None for v in initial_data}
|
||||
|
||||
def __iter__(self) -> Iterator[ValueT]:
|
||||
yield from self._data
|
||||
|
||||
def __contains__(self, item: ValueT) -> bool:
|
||||
return item in self._data
|
||||
|
||||
def add(self, item: ValueT) -> None:
|
||||
self._data[item] = None
|
||||
|
||||
def clear(self) -> None:
|
||||
self._data.clear()
|
||||
@@ -1,17 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import shutil
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
|
||||
_dcs_saved_game_folder: Optional[str] = None
|
||||
_file_abs_path = None
|
||||
|
||||
|
||||
def setup(user_folder: str):
|
||||
def setup(user_folder: str) -> None:
|
||||
global _dcs_saved_game_folder
|
||||
_dcs_saved_game_folder = user_folder
|
||||
_file_abs_path = os.path.join(base_path(), "default.liberation")
|
||||
if not save_dir().exists():
|
||||
save_dir().mkdir(parents=True)
|
||||
|
||||
|
||||
def base_path() -> str:
|
||||
@@ -20,19 +26,23 @@ def base_path() -> str:
|
||||
return _dcs_saved_game_folder
|
||||
|
||||
|
||||
def save_dir() -> Path:
|
||||
return Path(base_path()) / "Liberation" / "Saves"
|
||||
|
||||
|
||||
def _temporary_save_file() -> str:
|
||||
return os.path.join(base_path(), "tmpsave.liberation")
|
||||
return str(save_dir() / "tmpsave.liberation")
|
||||
|
||||
|
||||
def _autosave_path() -> str:
|
||||
return os.path.join(base_path(), "autosave.liberation")
|
||||
return str(save_dir() / "autosave.liberation")
|
||||
|
||||
|
||||
def mission_path_for(name: str) -> str:
|
||||
return os.path.join(base_path(), "Missions", "{}".format(name))
|
||||
return os.path.join(base_path(), "Missions", name)
|
||||
|
||||
|
||||
def load_game(path):
|
||||
def load_game(path: str) -> Optional[Game]:
|
||||
with open(path, "rb") as f:
|
||||
try:
|
||||
save = pickle.load(f)
|
||||
@@ -43,7 +53,7 @@ def load_game(path):
|
||||
return None
|
||||
|
||||
|
||||
def save_game(game) -> bool:
|
||||
def save_game(game: Game) -> bool:
|
||||
try:
|
||||
with open(_temporary_save_file(), "wb") as f:
|
||||
pickle.dump(game, f)
|
||||
@@ -54,7 +64,7 @@ def save_game(game) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def autosave(game) -> bool:
|
||||
def autosave(game: Game) -> bool:
|
||||
"""
|
||||
Autosave to the autosave location
|
||||
:param game: Game to save
|
||||
|
||||
@@ -38,7 +38,7 @@ class PluginSettings:
|
||||
self.settings = Settings()
|
||||
self.initialize_settings()
|
||||
|
||||
def set_settings(self, settings: Settings):
|
||||
def set_settings(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
self.initialize_settings()
|
||||
|
||||
@@ -146,7 +146,7 @@ class LuaPlugin(PluginSettings):
|
||||
|
||||
return cls(definition)
|
||||
|
||||
def set_settings(self, settings: Settings):
|
||||
def set_settings(self, settings: Settings) -> None:
|
||||
super().set_settings(settings)
|
||||
for option in self.definition.options:
|
||||
option.set_settings(self.settings)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dcs import Point
|
||||
from game.utils import Heading
|
||||
|
||||
|
||||
class PointWithHeading(Point):
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
super(PointWithHeading, self).__init__(0, 0)
|
||||
self.heading = 0
|
||||
self.heading: Heading = Heading.from_degrees(0)
|
||||
|
||||
@staticmethod
|
||||
def from_point(point: Point, heading: int):
|
||||
def from_point(point: Point, heading: Heading) -> PointWithHeading:
|
||||
p = PointWithHeading()
|
||||
p.x = point.x
|
||||
p.y = point.y
|
||||
|
||||
9
game/positioned.py
Normal file
9
game/positioned.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from typing import Protocol
|
||||
|
||||
from dcs import Point
|
||||
|
||||
|
||||
class Positioned(Protocol):
|
||||
@property
|
||||
def position(self) -> Point:
|
||||
raise NotImplementedError
|
||||
@@ -7,11 +7,11 @@ from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
|
||||
|
||||
from game import db
|
||||
from game.data.groundunitclass import GroundUnitClass
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.factions.faction import Faction
|
||||
from game.squadrons import Squadron
|
||||
from game.theater import ControlPoint, MissionTarget
|
||||
from game.utils import Distance
|
||||
from game.utils import meters
|
||||
from gen.flights.ai_flight_planner_db import aircraft_for_task
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import FlightType
|
||||
@@ -25,15 +25,13 @@ FRONTLINE_RESERVES_FACTOR = 1.3
|
||||
@dataclass(frozen=True)
|
||||
class AircraftProcurementRequest:
|
||||
near: MissionTarget
|
||||
range: Distance
|
||||
task_capability: FlightType
|
||||
number: int
|
||||
|
||||
def __str__(self) -> str:
|
||||
task = self.task_capability.value
|
||||
distance = self.range.nautical_miles
|
||||
target = self.near.name
|
||||
return f"{self.number} ship {task} within {distance} nm of {target}"
|
||||
return f"{self.number} ship {task} near {target}"
|
||||
|
||||
|
||||
class ProcurementAi:
|
||||
@@ -72,9 +70,11 @@ class ProcurementAi:
|
||||
return 1
|
||||
|
||||
for cp in self.owned_points:
|
||||
cp_ground_units = cp.allocated_ground_units(self.game.transfers)
|
||||
cp_ground_units = cp.allocated_ground_units(
|
||||
self.game.coalition_for(self.is_player).transfers
|
||||
)
|
||||
armor_investment += cp_ground_units.total_value
|
||||
cp_aircraft = cp.allocated_aircraft(self.game)
|
||||
cp_aircraft = cp.allocated_aircraft()
|
||||
aircraft_investment += cp_aircraft.total_value
|
||||
|
||||
total_investment = aircraft_investment + armor_investment
|
||||
@@ -97,37 +97,10 @@ class ProcurementAi:
|
||||
budget -= armor_budget
|
||||
budget += self.reinforce_front_line(armor_budget)
|
||||
|
||||
# Don't sell overstock aircraft until after we've bought runways and
|
||||
# front lines. Any budget we free up should be earmarked for aircraft.
|
||||
if not self.is_player:
|
||||
budget += self.sell_incomplete_squadrons()
|
||||
if self.manage_aircraft:
|
||||
budget = self.purchase_aircraft(budget)
|
||||
return budget
|
||||
|
||||
def sell_incomplete_squadrons(self) -> float:
|
||||
# Selling incomplete squadrons gives us more money to spend on the next
|
||||
# turn. This serves as a short term fix for
|
||||
# https://github.com/dcs-liberation/dcs_liberation/issues/41.
|
||||
#
|
||||
# Only incomplete squadrons which are unlikely to get used will be sold
|
||||
# rather than all unused aircraft because the unused aircraft are what
|
||||
# make OCA strikes worthwhile.
|
||||
#
|
||||
# This option is only used by the AI since players cannot cancel sales
|
||||
# (https://github.com/dcs-liberation/dcs_liberation/issues/365).
|
||||
total = 0.0
|
||||
for cp in self.game.theater.control_points_for(self.is_player):
|
||||
inventory = self.game.aircraft_inventory.for_control_point(cp)
|
||||
for aircraft, available in inventory.all_aircraft:
|
||||
# We only ever plan even groups, so the odd aircraft is unlikely
|
||||
# to get used.
|
||||
if available % 2 == 0:
|
||||
continue
|
||||
inventory.remove_aircraft(aircraft, 1)
|
||||
total += aircraft.price
|
||||
return total
|
||||
|
||||
def repair_runways(self, budget: float) -> float:
|
||||
for control_point in self.owned_points:
|
||||
if budget < db.RUNWAY_REPAIR_COST:
|
||||
@@ -180,7 +153,7 @@ class ProcurementAi:
|
||||
break
|
||||
|
||||
budget -= unit.price
|
||||
cp.pending_unit_deliveries.order({unit: 1})
|
||||
cp.ground_unit_orders.order({unit: 1})
|
||||
|
||||
return budget
|
||||
|
||||
@@ -209,67 +182,29 @@ class ProcurementAi:
|
||||
return GroundUnitClass.Tank
|
||||
return worst_balanced
|
||||
|
||||
def _affordable_aircraft_for_task(
|
||||
self,
|
||||
task: FlightType,
|
||||
airbase: ControlPoint,
|
||||
number: int,
|
||||
max_price: float,
|
||||
) -> Optional[AircraftType]:
|
||||
best_choice: Optional[AircraftType] = None
|
||||
for unit in aircraft_for_task(task):
|
||||
if unit not in self.faction.aircrafts:
|
||||
continue
|
||||
if unit.price * number > max_price:
|
||||
continue
|
||||
if not airbase.can_operate(unit):
|
||||
continue
|
||||
|
||||
for squadron in self.air_wing.squadrons_for(unit):
|
||||
if task in squadron.auto_assignable_mission_types:
|
||||
break
|
||||
else:
|
||||
continue
|
||||
|
||||
# Affordable, compatible, and we have a squadron capable of the task. To
|
||||
# keep some variety, skip with a 50/50 chance. Might be a good idea to have
|
||||
# the chance to skip based on the price compared to the rest of the choices.
|
||||
best_choice = unit
|
||||
if random.choice([True, False]):
|
||||
break
|
||||
return best_choice
|
||||
|
||||
def affordable_aircraft_for(
|
||||
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
|
||||
) -> Optional[AircraftType]:
|
||||
return self._affordable_aircraft_for_task(
|
||||
request.task_capability, airbase, request.number, budget
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def fulfill_aircraft_request(
|
||||
self, request: AircraftProcurementRequest, budget: float
|
||||
squadrons: list[Squadron], quantity: int, budget: float
|
||||
) -> Tuple[float, bool]:
|
||||
for airbase in self.best_airbases_for(request):
|
||||
unit = self.affordable_aircraft_for(request, airbase, budget)
|
||||
if unit is None:
|
||||
# Can't afford any aircraft capable of performing the
|
||||
# required mission that can operate from this airbase. We
|
||||
# might be able to afford aircraft at other airbases though,
|
||||
# in the case where the airbase we attempted to use is only
|
||||
# able to operate expensive aircraft.
|
||||
for squadron in squadrons:
|
||||
price = squadron.aircraft.price * quantity
|
||||
if price > budget:
|
||||
continue
|
||||
|
||||
budget -= unit.price * request.number
|
||||
airbase.pending_unit_deliveries.order({unit: request.number})
|
||||
squadron.pending_deliveries += quantity
|
||||
budget -= price
|
||||
return budget, True
|
||||
return budget, False
|
||||
|
||||
def purchase_aircraft(self, budget: float) -> float:
|
||||
for request in self.game.procurement_requests_for(self.is_player):
|
||||
if not list(self.best_airbases_for(request)):
|
||||
for request in self.game.coalition_for(self.is_player).procurement_requests:
|
||||
squadrons = list(self.best_squadrons_for(request))
|
||||
if not squadrons:
|
||||
# No airbases in range of this request. Skip it.
|
||||
continue
|
||||
budget, fulfilled = self.fulfill_aircraft_request(request, budget)
|
||||
budget, fulfilled = self.fulfill_aircraft_request(
|
||||
squadrons, request.number, budget
|
||||
)
|
||||
if not fulfilled:
|
||||
# The request was not fulfilled because we could not afford any suitable
|
||||
# aircraft. Rather than continuing, which could proceed to buy tons of
|
||||
@@ -286,19 +221,21 @@ class ProcurementAi:
|
||||
else:
|
||||
return self.game.theater.enemy_points()
|
||||
|
||||
def best_airbases_for(
|
||||
def best_squadrons_for(
|
||||
self, request: AircraftProcurementRequest
|
||||
) -> Iterator[ControlPoint]:
|
||||
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
|
||||
) -> Iterator[Squadron]:
|
||||
threatened = []
|
||||
for cp in distance_cache.operational_airfields_within(request.range):
|
||||
if not cp.is_friendly(self.is_player):
|
||||
for squadron in self.air_wing.best_squadrons_for(
|
||||
request.near, request.task_capability, request.number, this_turn=False
|
||||
):
|
||||
if not squadron.can_provide_pilots(request.number):
|
||||
continue
|
||||
if cp.unclaimed_parking(self.game) < request.number:
|
||||
if squadron.location.unclaimed_parking() < request.number:
|
||||
continue
|
||||
if self.threat_zones.threatened(cp.position):
|
||||
threatened.append(cp)
|
||||
yield cp
|
||||
if self.threat_zones.threatened(squadron.location.position):
|
||||
threatened.append(squadron)
|
||||
continue
|
||||
yield squadron
|
||||
yield from threatened
|
||||
|
||||
def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
|
||||
@@ -316,7 +253,9 @@ class ProcurementAi:
|
||||
continue
|
||||
|
||||
purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR
|
||||
allocated = cp.allocated_ground_units(self.game.transfers)
|
||||
allocated = cp.allocated_ground_units(
|
||||
self.game.coalition_for(self.is_player).transfers
|
||||
)
|
||||
if allocated.total >= purchase_target:
|
||||
# Control point is already sufficiently defended.
|
||||
continue
|
||||
@@ -343,7 +282,9 @@ class ProcurementAi:
|
||||
if not cp.can_recruit_ground_units(self.game):
|
||||
continue
|
||||
|
||||
allocated = cp.allocated_ground_units(self.game.transfers)
|
||||
allocated = cp.allocated_ground_units(
|
||||
self.game.coalition_for(self.is_player).transfers
|
||||
)
|
||||
if allocated.total >= self.game.settings.reserves_procurement_target:
|
||||
continue
|
||||
|
||||
@@ -356,7 +297,9 @@ class ProcurementAi:
|
||||
def cost_ratio_of_ground_unit(
|
||||
self, control_point: ControlPoint, unit_class: GroundUnitClass
|
||||
) -> float:
|
||||
allocations = control_point.allocated_ground_units(self.game.transfers)
|
||||
allocations = control_point.allocated_ground_units(
|
||||
self.game.coalition_for(self.is_player).transfers
|
||||
)
|
||||
class_cost = 0
|
||||
total_cost = 0
|
||||
for unit_type, count in allocations.all.items():
|
||||
|
||||
@@ -5,7 +5,8 @@ import timeit
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from typing import Iterator
|
||||
from types import TracebackType
|
||||
from typing import Iterator, Optional, Type
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -23,7 +24,12 @@ class MultiEventTracer:
|
||||
def __enter__(self) -> MultiEventTracer:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[Type[BaseException]],
|
||||
exc_val: Optional[BaseException],
|
||||
exc_tb: Optional[TracebackType],
|
||||
) -> None:
|
||||
for event, duration in self.events.items():
|
||||
logging.debug("%s took %s", event, duration)
|
||||
|
||||
|
||||
186
game/purchaseadapter.py
Normal file
186
game/purchaseadapter.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from abc import abstractmethod
|
||||
from typing import TypeVar, Generic, Any
|
||||
|
||||
from game import Game
|
||||
from game.coalition import Coalition
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.dcs.unittype import UnitType
|
||||
from game.squadrons import Squadron
|
||||
from game.theater import ControlPoint
|
||||
|
||||
ItemType = TypeVar("ItemType")
|
||||
|
||||
|
||||
class TransactionError(RuntimeError):
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class PurchaseAdapter(Generic[ItemType]):
|
||||
def __init__(self, coalition: Coalition) -> None:
|
||||
self.coalition = coalition
|
||||
|
||||
def buy(self, item: ItemType, quantity: int) -> None:
|
||||
for _ in range(quantity):
|
||||
if self.has_pending_sales(item):
|
||||
self.do_cancel_sale(item)
|
||||
elif self.can_buy(item):
|
||||
self.do_purchase(item)
|
||||
else:
|
||||
raise TransactionError(f"Cannot buy more {item}")
|
||||
self.coalition.adjust_budget(-self.price_of(item))
|
||||
|
||||
def sell(self, item: ItemType, quantity: int) -> None:
|
||||
for _ in range(quantity):
|
||||
if self.has_pending_orders(item):
|
||||
self.do_cancel_purchase(item)
|
||||
elif self.can_sell(item):
|
||||
self.do_sale(item)
|
||||
else:
|
||||
raise TransactionError(f"Cannot sell more {item}")
|
||||
self.coalition.adjust_budget(self.price_of(item))
|
||||
|
||||
def has_pending_orders(self, item: ItemType) -> bool:
|
||||
return self.pending_delivery_quantity(item) > 0
|
||||
|
||||
def has_pending_sales(self, item: ItemType) -> bool:
|
||||
return self.pending_delivery_quantity(item) < 0
|
||||
|
||||
@abstractmethod
|
||||
def current_quantity_of(self, item: ItemType) -> int:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def pending_delivery_quantity(self, item: ItemType) -> int:
|
||||
...
|
||||
|
||||
def expected_quantity_next_turn(self, item: ItemType) -> int:
|
||||
return self.current_quantity_of(item) + self.pending_delivery_quantity(item)
|
||||
|
||||
def can_buy(self, item: ItemType) -> bool:
|
||||
return self.coalition.budget >= self.price_of(item)
|
||||
|
||||
def can_sell_or_cancel(self, item: ItemType) -> bool:
|
||||
return self.can_sell(item) or self.has_pending_orders(item)
|
||||
|
||||
@abstractmethod
|
||||
def can_sell(self, item: ItemType) -> bool:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def do_purchase(self, item: ItemType) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def do_cancel_purchase(self, item: ItemType) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def do_sale(self, item: ItemType) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def do_cancel_sale(self, item: ItemType) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def price_of(self, item: ItemType) -> int:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def name_of(self, item: ItemType, multiline: bool = False) -> str:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def unit_type_of(self, item: ItemType) -> UnitType[Any]:
|
||||
...
|
||||
|
||||
|
||||
class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]):
|
||||
def __init__(self, control_point: ControlPoint) -> None:
|
||||
super().__init__(control_point.coalition)
|
||||
self.control_point = control_point
|
||||
|
||||
def pending_delivery_quantity(self, item: Squadron) -> int:
|
||||
return item.pending_deliveries
|
||||
|
||||
def current_quantity_of(self, item: Squadron) -> int:
|
||||
return item.owned_aircraft
|
||||
|
||||
def can_buy(self, item: Squadron) -> bool:
|
||||
return super().can_buy(item) and self.control_point.unclaimed_parking() > 0
|
||||
|
||||
def can_sell(self, item: Squadron) -> bool:
|
||||
return item.untasked_aircraft > 0
|
||||
|
||||
def do_purchase(self, item: Squadron) -> None:
|
||||
item.pending_deliveries += 1
|
||||
|
||||
def do_cancel_purchase(self, item: Squadron) -> None:
|
||||
item.pending_deliveries -= 1
|
||||
|
||||
def do_sale(self, item: Squadron) -> None:
|
||||
item.untasked_aircraft -= 1
|
||||
item.pending_deliveries -= 1
|
||||
|
||||
def do_cancel_sale(self, item: Squadron) -> None:
|
||||
item.untasked_aircraft += 1
|
||||
item.pending_deliveries += 1
|
||||
|
||||
def price_of(self, item: Squadron) -> int:
|
||||
return item.aircraft.price
|
||||
|
||||
def name_of(self, item: Squadron, multiline: bool = False) -> str:
|
||||
if multiline:
|
||||
separator = "<br />"
|
||||
else:
|
||||
separator = " "
|
||||
return separator.join([item.aircraft.name, str(item)])
|
||||
|
||||
def unit_type_of(self, item: Squadron) -> AircraftType:
|
||||
return item.aircraft
|
||||
|
||||
|
||||
class GroundUnitPurchaseAdapter(PurchaseAdapter[GroundUnitType]):
|
||||
def __init__(
|
||||
self, control_point: ControlPoint, coalition: Coalition, game: Game
|
||||
) -> None:
|
||||
super().__init__(coalition)
|
||||
self.control_point = control_point
|
||||
self.game = game
|
||||
|
||||
def pending_delivery_quantity(self, item: GroundUnitType) -> int:
|
||||
return self.control_point.ground_unit_orders.pending_orders(item)
|
||||
|
||||
def current_quantity_of(self, item: GroundUnitType) -> int:
|
||||
return self.control_point.base.total_units_of_type(item)
|
||||
|
||||
def can_buy(self, item: GroundUnitType) -> bool:
|
||||
return super().can_buy(item) and self.control_point.has_ground_unit_source(
|
||||
self.game
|
||||
)
|
||||
|
||||
def can_sell(self, item: GroundUnitType) -> bool:
|
||||
return False
|
||||
|
||||
def do_purchase(self, item: GroundUnitType) -> None:
|
||||
self.control_point.ground_unit_orders.order({item: 1})
|
||||
|
||||
def do_cancel_purchase(self, item: GroundUnitType) -> None:
|
||||
self.control_point.ground_unit_orders.sell({item: 1})
|
||||
|
||||
def do_sale(self, item: GroundUnitType) -> None:
|
||||
raise TransactionError("Sale of ground units not allowed")
|
||||
|
||||
def do_cancel_sale(self, item: GroundUnitType) -> None:
|
||||
raise TransactionError("Sale of ground units not allowed")
|
||||
|
||||
def price_of(self, item: GroundUnitType) -> int:
|
||||
return item.price
|
||||
|
||||
def name_of(self, item: GroundUnitType, multiline: bool = False) -> str:
|
||||
return f"{item}"
|
||||
|
||||
def unit_type_of(self, item: GroundUnitType) -> GroundUnitType:
|
||||
return item
|
||||
@@ -4,7 +4,8 @@ from dataclasses import dataclass
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen import FlightData, AirSupport
|
||||
from gen.aircraft import FlightData
|
||||
from gen.airsupport import AirSupport
|
||||
|
||||
|
||||
class RadioChannelAllocator:
|
||||
@@ -72,6 +73,9 @@ class CommonRadioChannelAllocator(RadioChannelAllocator):
|
||||
for awacs in air_support.awacs:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), awacs.freq)
|
||||
|
||||
for jtac in air_support.jtacs:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), jtac.freq)
|
||||
|
||||
if flight.arrival != flight.departure and flight.arrival.atc is not None:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc)
|
||||
|
||||
@@ -140,22 +144,35 @@ class ViggenRadioChannelAllocator(RadioChannelAllocator):
|
||||
self, flight: FlightData, air_support: AirSupport
|
||||
) -> None:
|
||||
# The Viggen's preset channels are handled differently from other
|
||||
# aircraft. The aircraft automatically configures channels for every
|
||||
# allied flight in the game (including AWACS) and for every airfield. As
|
||||
# such, we don't need to allocate any of those. There are seven presets
|
||||
# we can modify, however: three channels for the main radio intended for
|
||||
# communication with wingmen, and four emergency channels for the backup
|
||||
# radio. We'll set the first channel of the main radio to the
|
||||
# intra-flight channel, and the first three emergency channels to each
|
||||
# aircraft. Since 2.7.9 the group channels will not be generated automatically
|
||||
# anymore. So we have to set AWACS and JTAC manually. There are also seven
|
||||
# special channels we can modify. We'll set the first channel of the main radio
|
||||
# to the intra-flight channel, and the first three emergency channels to each
|
||||
# of the flight plan's airfields. The fourth emergency channel is always
|
||||
# the guard channel.
|
||||
radio_id = 1
|
||||
flight.assign_channel(radio_id, 1, flight.intra_flight_channel)
|
||||
|
||||
# Possible Group Channels (100-139)
|
||||
channel_alloc = iter(range(1, 40))
|
||||
|
||||
# Intra-Flight channel on Special 1 and Group 100 (required by module)
|
||||
flight.assign_channel(radio_id, 41, flight.intra_flight_channel) # Special 1
|
||||
flight.assign_channel(
|
||||
radio_id, next(channel_alloc), flight.intra_flight_channel
|
||||
)
|
||||
|
||||
for awacs in air_support.awacs:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), awacs.freq)
|
||||
|
||||
for jtac in air_support.jtacs:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), jtac.freq)
|
||||
|
||||
if flight.departure.atc is not None:
|
||||
flight.assign_channel(radio_id, 4, flight.departure.atc)
|
||||
flight.assign_channel(radio_id, 44, flight.departure.atc) # FR24 E
|
||||
if flight.arrival.atc is not None:
|
||||
flight.assign_channel(radio_id, 5, flight.arrival.atc)
|
||||
# TODO: Assign divert to 6 when we support divert airfields.
|
||||
flight.assign_channel(radio_id, 45, flight.arrival.atc) # FR24 F
|
||||
if flight.divert is not None and flight.divert.atc is not None:
|
||||
flight.assign_channel(radio_id, 46, flight.divert.atc) # FR24 G
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
@@ -259,10 +276,18 @@ class ViggenChannelNamer(ChannelNamer):
|
||||
|
||||
@staticmethod
|
||||
def channel_name(radio_id: int, channel_id: int) -> str:
|
||||
if channel_id >= 4:
|
||||
channel_letter = "EFGH"[channel_id - 4]
|
||||
return f"FR 24 {channel_letter}"
|
||||
return f"FR 22 Special {channel_id}"
|
||||
special_channels = [
|
||||
"FR 22 Special 1",
|
||||
"FR 22 Special 2",
|
||||
"FR 22 Special 3",
|
||||
"FR 24 E",
|
||||
"FR 24 F",
|
||||
"FR 24 G",
|
||||
"FR 24 H",
|
||||
]
|
||||
if channel_id >= 41: # Special channels are 41-47
|
||||
return special_channels[channel_id - 41]
|
||||
return f"FR 22 Group {99 + channel_id}"
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
|
||||
48
game/savecompat.py
Normal file
48
game/savecompat.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Tools for aiding in save compat removal after compatibility breaks."""
|
||||
from collections import Callable
|
||||
from typing import TypeVar
|
||||
|
||||
from game.version import MAJOR_VERSION
|
||||
|
||||
ReturnT = TypeVar("ReturnT")
|
||||
|
||||
|
||||
class DeprecatedSaveCompatError(RuntimeError):
|
||||
def __init__(self, function_name: str) -> None:
|
||||
super().__init__(
|
||||
f"{function_name} has save compat code for a different major version."
|
||||
)
|
||||
|
||||
|
||||
def has_save_compat_for(
|
||||
major: int,
|
||||
) -> Callable[[Callable[..., ReturnT]], Callable[..., ReturnT]]:
|
||||
"""Declares a function or method as having save compat code for a given version.
|
||||
|
||||
If the function has save compatibility for the current major version, there is no
|
||||
change in behavior.
|
||||
|
||||
If the function has save compatibility for a *different* (future or past) major
|
||||
version, DeprecatedSaveCompatError will be raised during startup. Since a break in
|
||||
save compatibility is the definition of a major version break, there's no need to
|
||||
keep around old save compat code; it only serves to mask initialization bugs.
|
||||
|
||||
Args:
|
||||
major: The major version for which the decorated function has save
|
||||
compatibility.
|
||||
|
||||
Returns:
|
||||
The decorated function or method.
|
||||
|
||||
Raises:
|
||||
DeprecatedSaveCompatError: The decorated function has save compat code for
|
||||
another version of liberation, and that code (and the decorator declaring it)
|
||||
should be removed from this branch.
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., ReturnT]) -> Callable[..., ReturnT]:
|
||||
if major != MAJOR_VERSION:
|
||||
raise DeprecatedSaveCompatError(func.__name__)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
112
game/settings.py
112
game/settings.py
@@ -1,112 +0,0 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from enum import Enum, unique
|
||||
from typing import Dict, Optional
|
||||
|
||||
from dcs.forcedoptions import ForcedOptions
|
||||
|
||||
|
||||
@unique
|
||||
class AutoAtoBehavior(Enum):
|
||||
Disabled = "Disabled"
|
||||
Never = "Never assign player pilots"
|
||||
Default = "No preference"
|
||||
Prefer = "Prefer player pilots"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
|
||||
# Difficulty settings
|
||||
player_skill: str = "Good"
|
||||
enemy_skill: str = "Average"
|
||||
ai_pilot_levelling: bool = True
|
||||
enemy_vehicle_skill: str = "Average"
|
||||
map_coalition_visibility: ForcedOptions.Views = ForcedOptions.Views.All
|
||||
labels: str = "Full"
|
||||
only_player_takeoff: bool = True # Legacy parameter do not use
|
||||
night_disabled: bool = False
|
||||
external_views_allowed: bool = True
|
||||
supercarrier: bool = False
|
||||
generate_marks: bool = True
|
||||
manpads: bool = True
|
||||
version: Optional[str] = None
|
||||
player_income_multiplier: float = 1.0
|
||||
enemy_income_multiplier: float = 1.0
|
||||
|
||||
#: The maximum number of pilots a squadron can have at one time. Changing this after
|
||||
#: the campaign has started will have no immediate effect; pilots already in the
|
||||
#: squadron will not be removed if the limit is lowered and pilots will not be
|
||||
#: immediately created if the limit is raised.
|
||||
squadron_pilot_limit: int = 24
|
||||
|
||||
#: The number of pilots a squadron can replace per turn.
|
||||
squadron_replenishment_rate: int = 4
|
||||
|
||||
default_start_type: str = "Cold"
|
||||
|
||||
# Mission specific
|
||||
desired_player_mission_duration: timedelta = timedelta(minutes=60)
|
||||
|
||||
# Campaign management
|
||||
automate_runway_repair: bool = False
|
||||
automate_front_line_reinforcements: bool = False
|
||||
automate_aircraft_reinforcements: bool = False
|
||||
restrict_weapons_by_date: bool = False
|
||||
disable_legacy_aewc: bool = True
|
||||
disable_legacy_tanker: bool = True
|
||||
generate_dark_kneeboard: bool = False
|
||||
invulnerable_player_pilots: bool = True
|
||||
auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default
|
||||
auto_ato_player_missions_asap: bool = True
|
||||
|
||||
# Performance oriented
|
||||
perf_red_alert_state: bool = True
|
||||
perf_smoke_gen: bool = True
|
||||
perf_smoke_spacing = 1600
|
||||
perf_artillery: bool = True
|
||||
perf_moving_units: bool = True
|
||||
perf_infantry: bool = True
|
||||
perf_destroyed_units: bool = True
|
||||
reserves_procurement_target: int = 10
|
||||
|
||||
# Performance culling
|
||||
perf_culling: bool = False
|
||||
perf_culling_distance: int = 100
|
||||
perf_do_not_cull_carrier = True
|
||||
|
||||
# LUA Plugins system
|
||||
plugins: Dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
# Cheating
|
||||
show_red_ato: bool = False
|
||||
enable_frontline_cheats: bool = False
|
||||
enable_base_capture_cheat: bool = False
|
||||
|
||||
never_delay_player_flights: bool = False
|
||||
|
||||
@staticmethod
|
||||
def plugin_settings_key(identifier: str) -> str:
|
||||
return f"plugins.{identifier}"
|
||||
|
||||
def initialize_plugin_option(self, identifier: str, default_value: bool) -> None:
|
||||
try:
|
||||
self.plugin_option(identifier)
|
||||
except KeyError:
|
||||
self.set_plugin_option(identifier, default_value)
|
||||
|
||||
def plugin_option(self, identifier: str) -> bool:
|
||||
return self.plugins[self.plugin_settings_key(identifier)]
|
||||
|
||||
def set_plugin_option(self, identifier: str, enabled: bool) -> None:
|
||||
self.plugins[self.plugin_settings_key(identifier)] = enabled
|
||||
|
||||
def __setstate__(self, state) -> None:
|
||||
# __setstate__ is called with the dict of the object being unpickled. We
|
||||
# can provide save compatibility for new settings options (which
|
||||
# normally would not be present in the unpickled object) by creating a
|
||||
# new settings object, updating it with the unpickled state, and
|
||||
# updating our dict with that.
|
||||
new_state = Settings().__dict__
|
||||
new_state.update(state)
|
||||
self.__dict__.update(new_state)
|
||||
7
game/settings/__init__.py
Normal file
7
game/settings/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .booleanoption import BooleanOption
|
||||
from .boundedfloatoption import BoundedFloatOption
|
||||
from .boundedintoption import BoundedIntOption
|
||||
from .choicesoption import ChoicesOption
|
||||
from .minutesoption import MinutesOption
|
||||
from .optiondescription import OptionDescription
|
||||
from .settings import AutoAtoBehavior, Settings
|
||||
37
game/settings/booleanoption.py
Normal file
37
game/settings/booleanoption.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BooleanOption(OptionDescription):
|
||||
invert: bool
|
||||
|
||||
|
||||
def boolean_option(
|
||||
text: str,
|
||||
page: str,
|
||||
section: str,
|
||||
default: bool,
|
||||
invert: bool = False,
|
||||
detail: Optional[str] = None,
|
||||
tooltip: Optional[str] = None,
|
||||
causes_expensive_game_update: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> bool:
|
||||
return field(
|
||||
metadata={
|
||||
SETTING_DESCRIPTION_KEY: BooleanOption(
|
||||
page,
|
||||
section,
|
||||
text,
|
||||
detail,
|
||||
tooltip,
|
||||
causes_expensive_game_update,
|
||||
invert,
|
||||
)
|
||||
},
|
||||
default=default,
|
||||
**kwargs,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user