mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
Compare commits
682 Commits
develop_2_
...
4.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e6e1469d7 | ||
|
|
6cc967742a | ||
|
|
17f2bcc9c9 | ||
|
|
9d499a1430 | ||
|
|
3b55dfad40 | ||
|
|
9d3c7a86b6 | ||
|
|
7f68846023 | ||
|
|
c1534cba9e | ||
|
|
eea31168c1 | ||
|
|
f2de1fdac6 | ||
|
|
8dd29d2319 | ||
|
|
b402dad801 | ||
|
|
7199fead00 | ||
|
|
4ff0f29fe0 | ||
|
|
1b8992eb04 | ||
|
|
ccbcf4f69a | ||
|
|
0747007f58 | ||
|
|
723588666f | ||
|
|
94861ca477 | ||
|
|
e8992c5bed | ||
|
|
e841358f74 | ||
|
|
3c135720a0 | ||
|
|
d7db290892 | ||
|
|
b7626c10da | ||
|
|
d79e8f46f3 | ||
|
|
278b9730cd | ||
|
|
d187c571ea | ||
|
|
b3705531d4 | ||
|
|
666b389821 | ||
|
|
ddc076b141 | ||
|
|
eee1791a79 | ||
|
|
fb5a6d3243 | ||
|
|
113c00ac05 | ||
|
|
85ca85ac6d | ||
|
|
da917a7dde | ||
|
|
b03d1599e1 | ||
|
|
2b3c56ad38 | ||
|
|
8dc35bec5a | ||
|
|
3f4f27612b | ||
|
|
17f9487fe0 | ||
|
|
e15b10ae7e | ||
|
|
17d56beeaa | ||
|
|
53c7912592 | ||
|
|
1f318aff3c | ||
|
|
2bb1c0b3f2 | ||
|
|
b057f027d5 | ||
|
|
cc079ad44e | ||
|
|
974c0069e6 | ||
|
|
9028109fe3 | ||
|
|
db27f3b0d9 | ||
|
|
cb542b6af4 | ||
|
|
fcea37c340 | ||
|
|
cf3d13f9d3 | ||
|
|
6789beb4b5 | ||
|
|
8f1ec4a519 | ||
|
|
b8bc9d87ec | ||
|
|
52aff8bc30 | ||
|
|
5c81ac06ac | ||
|
|
8364148305 | ||
|
|
2bcff5a5c2 | ||
|
|
c227923bdf | ||
|
|
4569b1b45a | ||
|
|
3a193d1dd4 | ||
|
|
9334cba564 | ||
|
|
4dc1daa100 | ||
|
|
0d99fc3d36 | ||
|
|
eee78288c9 | ||
|
|
c2f112e3a6 | ||
|
|
ef3f7125b3 | ||
|
|
4558088412 | ||
|
|
d2cc3f673e | ||
|
|
dc85644d71 | ||
|
|
0b5bdf8151 | ||
|
|
b27238a69a | ||
|
|
bb2bf78e8a | ||
|
|
7e17533cc6 | ||
|
|
7808da118a | ||
|
|
4259cf8764 | ||
|
|
994c55945e | ||
|
|
f20c145ece | ||
|
|
5b31026e1c | ||
|
|
39fe5951f7 | ||
|
|
9d767c3dd8 | ||
|
|
2a3f9bf81c | ||
|
|
3fd4359cb1 | ||
|
|
ca1be580df | ||
|
|
28820f2e64 | ||
|
|
6c3987ec86 | ||
|
|
089eb9e86b | ||
|
|
0793e9afc5 | ||
|
|
1e2522375b | ||
|
|
e09f53da8f | ||
|
|
29b4b62a44 | ||
|
|
b1a63db1fc | ||
|
|
9940dc8451 | ||
|
|
703c68eb66 | ||
|
|
3338df9836 | ||
|
|
dc4794b246 | ||
|
|
b130c9882a | ||
|
|
5f8b838652 | ||
|
|
4efd1b5d3e | ||
|
|
ad6ed21b6b | ||
|
|
2ffaa71bb5 | ||
|
|
1763f59320 | ||
|
|
08d32ffc77 | ||
|
|
7e3cebb96d | ||
|
|
930fb404af | ||
|
|
6cd711a1e2 | ||
|
|
1bcc332885 | ||
|
|
9bb986cff9 | ||
|
|
1247942bf1 | ||
|
|
95d3ff4cbe | ||
|
|
0a874a28ef | ||
|
|
2dee702060 | ||
|
|
4ea66477fe | ||
|
|
d3be732566 | ||
|
|
933517055e | ||
|
|
040a3d9b36 | ||
|
|
2c859bf280 | ||
|
|
fe227e02b8 | ||
|
|
c68e583c20 | ||
|
|
6620d56859 | ||
|
|
1ec72d3e94 | ||
|
|
5c3bb75786 | ||
|
|
a90cb0dad9 | ||
|
|
8854a491ab | ||
|
|
74e8073328 | ||
|
|
a6d62a7596 | ||
|
|
980a224d02 | ||
|
|
0c6b83fc35 | ||
|
|
6d2310f59d | ||
|
|
05107fab1c | ||
|
|
285bed65c6 | ||
|
|
b523c23e7a | ||
|
|
4c9a028a4e | ||
|
|
cea970f065 | ||
|
|
d8511fab1d | ||
|
|
b2d10e92e9 | ||
|
|
0582d5e2b6 | ||
|
|
da2b56b5b1 | ||
|
|
46c15f37c5 | ||
|
|
4ddc02d7fe | ||
|
|
4c3ac0af91 | ||
|
|
edd0b90576 | ||
|
|
11dca41945 | ||
|
|
75c4724200 | ||
|
|
09704b6f37 | ||
|
|
8a0824880e | ||
|
|
d84abf021e | ||
|
|
e7223da19f | ||
|
|
499d143199 | ||
|
|
fefeb3c006 | ||
|
|
6aeee933d2 | ||
|
|
34c0698c48 | ||
|
|
1cc1a00820 | ||
|
|
62f6b57948 | ||
|
|
5387acf533 | ||
|
|
077b3ef04d | ||
|
|
9c654254d3 | ||
|
|
4bb8bbbad8 | ||
|
|
39adafb1be | ||
|
|
e19bfcdd04 | ||
|
|
6fde92f5ac | ||
|
|
7170a7b302 | ||
|
|
24884e4a77 | ||
|
|
384be8ceae | ||
|
|
ee9a5e8482 | ||
|
|
34453fa3be | ||
|
|
f727712bfa | ||
|
|
3bb974b9e0 | ||
|
|
021445216e | ||
|
|
c13bf3ccd1 | ||
|
|
8d53f42421 | ||
|
|
ace42019fb | ||
|
|
54aa161da0 | ||
|
|
25c289deaa | ||
|
|
3c802e7d55 | ||
|
|
ba3cf4d2bd | ||
|
|
4aa905716b | ||
|
|
0fc1e8ec10 | ||
|
|
0875d35129 | ||
|
|
8c62a081fe | ||
|
|
f811ae6c61 | ||
|
|
4a3ef42e67 | ||
|
|
88abaef7f9 | ||
|
|
21fe746f2f | ||
|
|
c3c6915fa0 | ||
|
|
b2705c1a13 | ||
|
|
00ca7d0b4d | ||
|
|
64c426653c | ||
|
|
a8960c9bbe | ||
|
|
75e3b4cc84 | ||
|
|
72282845e8 | ||
|
|
78f5235eca | ||
|
|
e64aff4e91 | ||
|
|
78cd17e279 | ||
|
|
c51c8aae5c | ||
|
|
e192e54c90 | ||
|
|
40aa7734e1 | ||
|
|
39b0599b7b | ||
|
|
45b40e4aa3 | ||
|
|
9887a8ff83 | ||
|
|
0594e1148e | ||
|
|
9eacd1563f | ||
|
|
a53a648a63 | ||
|
|
a9dacf4a29 | ||
|
|
8d3556aa4b | ||
|
|
66f82b6ff9 | ||
|
|
a59c01bcfe | ||
|
|
fb72962f74 | ||
|
|
ed1dacfe7c | ||
|
|
0e68884493 | ||
|
|
f8d885fc9a | ||
|
|
794de0fcbb | ||
|
|
366190ee99 | ||
|
|
9d71b2e727 | ||
|
|
5b8f626651 | ||
|
|
42d56a324f | ||
|
|
7d1f1ea2f7 | ||
|
|
461f4b82a9 | ||
|
|
15653d0628 | ||
|
|
dffc631b87 | ||
|
|
30cab8e3a7 | ||
|
|
e0e2162c6d | ||
|
|
f1582fcc10 | ||
|
|
eb6206ea57 | ||
|
|
3ad51cafa8 | ||
|
|
17efb48b2e | ||
|
|
b8c14d69c3 | ||
|
|
7e85825d2b | ||
|
|
725b5083c7 | ||
|
|
798591b980 | ||
|
|
87dd6b19bf | ||
|
|
3188994261 | ||
|
|
4a52af298c | ||
|
|
fe886a754e | ||
|
|
e4c9d8799e | ||
|
|
0220fa4ff6 | ||
|
|
bc938db7f9 | ||
|
|
0a9dc49e7f | ||
|
|
07cdfc16d0 | ||
|
|
622a171ac4 | ||
|
|
fd85efbf55 | ||
|
|
ae2a818d8c | ||
|
|
6966c16dd2 | ||
|
|
27b5f24a0f | ||
|
|
ea15421308 | ||
|
|
ef35ad90b8 | ||
|
|
914691eaa7 | ||
|
|
37bb83dfa6 | ||
|
|
e7336d8608 | ||
|
|
d8881e2734 | ||
|
|
d77a174ac1 | ||
|
|
45869c428e | ||
|
|
40832bd3a1 | ||
|
|
126a8e8efb | ||
|
|
6348317893 | ||
|
|
a516cd2f80 | ||
|
|
1796c21f48 | ||
|
|
363d4af639 | ||
|
|
f1c881378c | ||
|
|
e1aa3e9d0e | ||
|
|
d316e13fa6 | ||
|
|
d1d1acf6e0 | ||
|
|
1ea98a6ed1 | ||
|
|
3d4415d5d2 | ||
|
|
3e43414d9c | ||
|
|
6d682d509f | ||
|
|
3a592aee8b | ||
|
|
b74f60fe0e | ||
|
|
34f3a50234 | ||
|
|
6094179a40 | ||
|
|
e3bc2688ba | ||
|
|
96cdea2a94 | ||
|
|
cb159e3341 | ||
|
|
136e776b03 | ||
|
|
a0833e8943 | ||
|
|
8bb1b1da7c | ||
|
|
558502d8ea | ||
|
|
8edb952800 | ||
|
|
f3d79e58db | ||
|
|
f26ff085e1 | ||
|
|
7ea550738e | ||
|
|
6b1048590f | ||
|
|
203f0d3851 | ||
|
|
d9c38a716c | ||
|
|
24709d01bd | ||
|
|
2dc2681f84 | ||
|
|
d53a39860e | ||
|
|
ad2f084112 | ||
|
|
d59c42ed3f | ||
|
|
e022ffee62 | ||
|
|
77ddd5ed78 | ||
|
|
8604faffe6 | ||
|
|
45919200c4 | ||
|
|
d498bb9cff | ||
|
|
389f60786a | ||
|
|
2d0929cd69 | ||
|
|
e94ebd6ed2 | ||
|
|
77373606fe | ||
|
|
284f2bc323 | ||
|
|
355e6e1d15 | ||
|
|
f6909d2f98 | ||
|
|
c42974f7b3 | ||
|
|
230d80a2a5 | ||
|
|
551038b295 | ||
|
|
4055b06e71 | ||
|
|
6616359baf | ||
|
|
a5336bbe56 | ||
|
|
871e7f7a50 | ||
|
|
d1c7146a47 | ||
|
|
30f6220c3e | ||
|
|
acd3e87996 | ||
|
|
8c8814d07e | ||
|
|
417fc3af5b | ||
|
|
2218733da4 | ||
|
|
9d1060975e | ||
|
|
82281e2477 | ||
|
|
d0976c45e9 | ||
|
|
a888397bef | ||
|
|
7b2bb4a128 | ||
|
|
d440dc00f1 | ||
|
|
d61382f4e2 | ||
|
|
d4fe893539 | ||
|
|
1af95955b6 | ||
|
|
a43e926dd2 | ||
|
|
ff49046bfa | ||
|
|
95b0b851a5 | ||
|
|
077ca19912 | ||
|
|
089cc23648 | ||
|
|
e6b9a73d03 | ||
|
|
cea264e871 | ||
|
|
d0bde7b016 | ||
|
|
5b271df66f | ||
|
|
bc7faee880 | ||
|
|
a2abdcf5d3 | ||
|
|
d4e843983d | ||
|
|
6e41c36a44 | ||
|
|
1fe3451120 | ||
|
|
bc4a95d0a5 | ||
|
|
14dc6d1604 | ||
|
|
1795ed7617 | ||
|
|
e8edb31be3 | ||
|
|
58fd30e6ad | ||
|
|
9a34ada258 | ||
|
|
748a752e29 | ||
|
|
37748ef3bd | ||
|
|
d41007de8e | ||
|
|
45befd440c | ||
|
|
a2c10f1c7a | ||
|
|
d7768f86d3 | ||
|
|
dae3835eb0 | ||
|
|
e9b5784d30 | ||
|
|
1521f0a9b1 | ||
|
|
9a9c351f47 | ||
|
|
4ec11ddea5 | ||
|
|
f619b6b9fc | ||
|
|
bcccb3206d | ||
|
|
11a8ff7f70 | ||
|
|
f6ab1aad77 | ||
|
|
5a732acf64 | ||
|
|
e4e06e0a6e | ||
|
|
28f20d47d3 | ||
|
|
82ce688a0d | ||
|
|
f36757b650 | ||
|
|
ac4a7441e9 | ||
|
|
9091afe682 | ||
|
|
e2034b19e7 | ||
|
|
1b7a225f9d | ||
|
|
a52043ef29 | ||
|
|
b38d271f10 | ||
|
|
e480519855 | ||
|
|
8b8d1e87e7 | ||
|
|
cd6de191d1 | ||
|
|
8b8e018521 | ||
|
|
5277beede3 | ||
|
|
57a2457050 | ||
|
|
2f8656d54f | ||
|
|
49102e510d | ||
|
|
e7b8548698 | ||
|
|
9c2bad85d5 | ||
|
|
4147d2f684 | ||
|
|
6b30f47588 | ||
|
|
e49da6afd6 | ||
|
|
6fa0a29249 | ||
|
|
c163e2c981 | ||
|
|
372bf9d97f | ||
|
|
619d5dd1b9 | ||
|
|
4939faf5fa | ||
|
|
205e4aa707 | ||
|
|
81ce7fbb62 | ||
|
|
de9651533f | ||
|
|
e6e31fd234 | ||
|
|
d242079a74 | ||
|
|
48f26cb181 | ||
|
|
c37a5b2405 | ||
|
|
d15bfaac76 | ||
|
|
e94657875f | ||
|
|
f2bd7300aa | ||
|
|
c255aee3b9 | ||
|
|
305d1f0523 | ||
|
|
970f2c25dd | ||
|
|
b7b3b35816 | ||
|
|
e8f326ebce | ||
|
|
62b743025a | ||
|
|
7934463a53 | ||
|
|
d15ef63182 | ||
|
|
c7edba5120 | ||
|
|
188f871bc8 | ||
|
|
31eba975fd | ||
|
|
2ea0bccd25 | ||
|
|
fa321c7ddc | ||
|
|
1d7b0c9b17 | ||
|
|
a4fbcd2d02 | ||
|
|
d788b286aa | ||
|
|
eedb5c26a9 | ||
|
|
ddd6e7d18f | ||
|
|
eae0d6be94 | ||
|
|
5e68dbe1ca | ||
|
|
98e0be6be9 | ||
|
|
7450a6b7eb | ||
|
|
c3802e5a37 | ||
|
|
43cd9bce67 | ||
|
|
2f6ab6d2b0 | ||
|
|
2df17c32cd | ||
|
|
16fff8d87a | ||
|
|
1087069277 | ||
|
|
1b624e7e6f | ||
|
|
7223ae327a | ||
|
|
bcdefda0db | ||
|
|
fc56642631 | ||
|
|
69299d395c | ||
|
|
a789f58068 | ||
|
|
f68935735d | ||
|
|
ba2157cc43 | ||
|
|
57fe5c04ec | ||
|
|
3a08944c99 | ||
|
|
b6154b273c | ||
|
|
e332bff362 | ||
|
|
59e03434e4 | ||
|
|
2ca0edf5fd | ||
|
|
90dca9072e | ||
|
|
c0ead4a484 | ||
|
|
f8cb9e2bd3 | ||
|
|
df4dabf68f | ||
|
|
40720f9949 | ||
|
|
7e7a1dce7b | ||
|
|
39b9a7f0ed | ||
|
|
43010779d4 | ||
|
|
a1a4fc8c7c | ||
|
|
621e4a513c | ||
|
|
6c821039b5 | ||
|
|
f80b948fb1 | ||
|
|
d4c27da892 | ||
|
|
11bf0ca868 | ||
|
|
664092c023 | ||
|
|
0cd2c4a90c | ||
|
|
2f6c04a86d | ||
|
|
a382e74a89 | ||
|
|
3c8c76f50d | ||
|
|
cbce379132 | ||
|
|
e795e96bfb | ||
|
|
e12e2c4b0b | ||
|
|
9a1b21a2fa | ||
|
|
79708f9ba6 | ||
|
|
102544877d | ||
|
|
1c32ae1227 | ||
|
|
55d7e444c7 | ||
|
|
9243fd499b | ||
|
|
844dc48d65 | ||
|
|
52d96b8518 | ||
|
|
8274e68846 | ||
|
|
f1adcd1836 | ||
|
|
2a77f57aa4 | ||
|
|
04ebe4c68a | ||
|
|
1c7e065c52 | ||
|
|
80f3857f44 | ||
|
|
3b62831401 | ||
|
|
a047e1d063 | ||
|
|
45985e1684 | ||
|
|
7dac886375 | ||
|
|
af3b8a9902 | ||
|
|
4e37666037 | ||
|
|
2769d32c81 | ||
|
|
4b004320a4 | ||
|
|
edfc879b41 | ||
|
|
0879d1da0d | ||
|
|
c5159f8a87 | ||
|
|
a0d9bf0f26 | ||
|
|
a3cce8ff72 | ||
|
|
242f00390d | ||
|
|
f4b64370bb | ||
|
|
ae57e4da83 | ||
|
|
cd391a360c | ||
|
|
dcbe12f1af | ||
|
|
5b61cfe922 | ||
|
|
739406614d | ||
|
|
8076206a90 | ||
|
|
f63d218aae | ||
|
|
f2e3ccd18c | ||
|
|
d41e69d770 | ||
|
|
0e3bc1ce43 | ||
|
|
6ca175345f | ||
|
|
c063a638cd | ||
|
|
2dfe1420bc | ||
|
|
7dd379c5c3 | ||
|
|
752eb6235d | ||
|
|
3f077727ae | ||
|
|
51d557524d | ||
|
|
5d9563304f | ||
|
|
53cb68f82c | ||
|
|
95b107ffad | ||
|
|
06dedf51aa | ||
|
|
ed7c8c11d9 | ||
|
|
643e5954f3 | ||
|
|
c7cc9d2a65 | ||
|
|
5050914d25 | ||
|
|
31fa2d866f | ||
|
|
4a096cb728 | ||
|
|
16b52f929c | ||
|
|
8ec133830f | ||
|
|
c144799a11 | ||
|
|
e56511a05a | ||
|
|
bdb959d986 | ||
|
|
dae9c368b7 | ||
|
|
2a401a302d | ||
|
|
ff3b8e5270 | ||
|
|
bb1a066ff7 | ||
|
|
9a9872812f | ||
|
|
969e4a2d65 | ||
|
|
77f0b87c54 | ||
|
|
eec56256e8 | ||
|
|
99dc91dcb4 | ||
|
|
5adcfbd7bd | ||
|
|
3c5f1f7c4b | ||
|
|
4415429661 | ||
|
|
92c404fbb6 | ||
|
|
956b9aaf95 | ||
|
|
c8348f1b44 | ||
|
|
d73ceb374c | ||
|
|
3e01953a3a | ||
|
|
1a65b1affb | ||
|
|
dd75078019 | ||
|
|
1ab205cb46 | ||
|
|
eb26d54ac1 | ||
|
|
d884645f37 | ||
|
|
45f0c3c85f | ||
|
|
4e498e6932 | ||
|
|
d9d68cd37c | ||
|
|
56abd0bb7f | ||
|
|
747683e9e8 | ||
|
|
5b191d72a6 | ||
|
|
b7619630cf | ||
|
|
de07f10e57 | ||
|
|
87e6080215 | ||
|
|
e721a234e1 | ||
|
|
67289bbba2 | ||
|
|
b0c24f6e51 | ||
|
|
12f474ecbe | ||
|
|
e2f20a7a65 | ||
|
|
58ffabe2d6 | ||
|
|
426f06045e | ||
|
|
2ca875192a | ||
|
|
36b2f24de9 | ||
|
|
2cf3b3be2b | ||
|
|
8320c6940b | ||
|
|
3c9d21e38d | ||
|
|
0d7f00aef6 | ||
|
|
1640763a7f | ||
|
|
0ec5346574 | ||
|
|
977845e2f4 | ||
|
|
4bb2ab73c1 | ||
|
|
af5584d244 | ||
|
|
b289e41a0d | ||
|
|
c0b4eef948 | ||
|
|
b8e6c2fe78 | ||
|
|
b10e86e484 | ||
|
|
1c31cffe4b | ||
|
|
b9822cd5d1 | ||
|
|
ef1c70123c | ||
|
|
a0e5a707fb | ||
|
|
c245531d65 | ||
|
|
522495fd11 | ||
|
|
b2a551dc63 | ||
|
|
840107c69e | ||
|
|
2a06a1ffdf | ||
|
|
8a01209ded | ||
|
|
2b8dfc9dbc | ||
|
|
e9f25eb562 | ||
|
|
475c7fd6db | ||
|
|
fa5d64022d | ||
|
|
5c0f6cf65e | ||
|
|
0f8d366e31 | ||
|
|
9e2e593825 | ||
|
|
028bfc11eb | ||
|
|
b6cf7a4534 | ||
|
|
0779679b99 | ||
|
|
a48ef69e41 | ||
|
|
64d7953e50 | ||
|
|
21c35b31d4 | ||
|
|
2d64acf299 | ||
|
|
e80819fc06 | ||
|
|
7e40d58d04 | ||
|
|
ba8fafcc95 | ||
|
|
42694d2004 | ||
|
|
5e67ce0ab2 | ||
|
|
97b73e1a01 | ||
|
|
4239257000 | ||
|
|
6bd94761d0 | ||
|
|
bc54e57fd4 | ||
|
|
17751e52fd | ||
|
|
6016ebd3b4 | ||
|
|
8a44fc19ee | ||
|
|
f69450e2ae | ||
|
|
3161ccced3 | ||
|
|
909aad22a6 | ||
|
|
c8b4fd1690 | ||
|
|
dac2271084 | ||
|
|
5320d20f71 | ||
|
|
20d8cc2b47 | ||
|
|
d3fdbdbca5 | ||
|
|
d80f7ebf3b | ||
|
|
d6c84e362f | ||
|
|
6aba07c33b | ||
|
|
f0558c4c1e | ||
|
|
45913b0add | ||
|
|
c258409a8d | ||
|
|
26cd2d3fef | ||
|
|
4069074f41 | ||
|
|
182422249f | ||
|
|
29b70b3247 | ||
|
|
e474748f4d | ||
|
|
132ba905c7 | ||
|
|
208d1b82b5 | ||
|
|
1fd7c95f1b | ||
|
|
481f195725 | ||
|
|
d6c1550a1d | ||
|
|
60b9ae0a70 | ||
|
|
bf71351e6d | ||
|
|
8e361a8776 | ||
|
|
de2e5f861b | ||
|
|
e39fd53727 | ||
|
|
76efcca64b | ||
|
|
35f49d9bc0 | ||
|
|
a0a55797a9 | ||
|
|
696a429e9e | ||
|
|
29cd55e795 | ||
|
|
c2ebf61fd3 | ||
|
|
489b4d6acf | ||
|
|
6cffc47f3c | ||
|
|
50d8e08a34 | ||
|
|
3f16c0378a | ||
|
|
2b06d8a096 | ||
|
|
2a5b37b9ad | ||
|
|
cabbd234af | ||
|
|
480039ca50 | ||
|
|
81d5cddac9 | ||
|
|
bb3e83548c | ||
|
|
a3ff58c42d | ||
|
|
30bf4542f0 | ||
|
|
d11c9a4615 | ||
|
|
d4679e0029 | ||
|
|
3c4d6eb8e4 | ||
|
|
df98e1f8ac | ||
|
|
56fc2986e9 | ||
|
|
627f18c42b | ||
|
|
67b341cbd6 | ||
|
|
eff5b94db7 | ||
|
|
707323ca12 | ||
|
|
cb2ba2f53a | ||
|
|
777cd310ef | ||
|
|
9a4ec5a899 | ||
|
|
c92e4e06cc | ||
|
|
39135f8c80 | ||
|
|
5e054cfc77 | ||
|
|
3b72c13f9d | ||
|
|
65ed110ab7 | ||
|
|
5dd7ea3060 | ||
|
|
bd9cbf5e3b | ||
|
|
65f6a4eddd | ||
|
|
e9ff554f39 | ||
|
|
b65d178cf1 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -9,6 +9,8 @@ assignees: ''
|
||||
|
||||
Before filing, please search the issue tracker to see if the issue has already been reported.
|
||||
|
||||
If reporting a DCS AI bug, check https://github.com/dcs-liberation/dcs_liberation#dcs-bugs.
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
|
||||
28
.github/ISSUE_TEMPLATE/campaign_update.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/campaign_update.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Campaign update submission
|
||||
about: Submit an update to a campaign you maintain.
|
||||
title: 'Update for <campaign name>'
|
||||
labels: campaign-update-submission
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
This form should only be used for submitted updated miz/json files for campaigns
|
||||
distributed with Liberation. If you are _requesting_ an update to a campaign, see
|
||||
https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance. If the
|
||||
campaign has an owner, it will be updated before release. If it does not, you can
|
||||
volunteer to own it.
|
||||
|
||||
If you are not the owner of the campaign listed on
|
||||
https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance, please start
|
||||
there.
|
||||
|
||||
Otherwise, delete everything above the line below and fill out the following form. Note:
|
||||
GitHub does not accept .miz files. You can either rename the file to .miz.txt or add the
|
||||
file to a .zip file.
|
||||
|
||||
---
|
||||
|
||||
* Campaign name:
|
||||
* Files:
|
||||
* Update summary (optional):
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -9,6 +9,8 @@ assignees: ''
|
||||
|
||||
Before filing, please search the issue tracker to see if this feature has already been requested.
|
||||
|
||||
If requesting a DCS AI feature, check If reporting a DCS AI bug, check https://github.com/dcs-liberation/dcs_liberation#dcs-bugs.
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.8
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install environment
|
||||
run: |
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python 3.8
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install environment
|
||||
run: |
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,6 +12,7 @@ resources/tools/a.miz
|
||||
# User-specific stuff
|
||||
.idea/
|
||||
.env
|
||||
env/
|
||||
|
||||
/kneeboards
|
||||
/liberation_preferences.json
|
||||
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[submodule "pydcs"]
|
||||
path = pydcs
|
||||
url = https://github.com/pydcs/dcs
|
||||
branch = master
|
||||
@@ -11,7 +11,7 @@ Note that you may need to remove the filter for open bugs if it's something we'v
|
||||
|
||||
## Making content for Liberation
|
||||
|
||||
You can create new campaigns : See [campaign creation wiki](https://github.com/Khopa/dcs_liberation/wiki/Custom-Campaigns).
|
||||
You can create new campaigns : See [campaign creation wiki](https://github.com/dcs-liberation/dcs_liberation/wiki/Custom-Campaigns).
|
||||
You can also improve existing campaigns.
|
||||
|
||||
You can then submit new campaigns on the "campaigns" channel on Discord, or by making a pull request if you are comfortable with git.
|
||||
|
||||
29
README.md
29
README.md
@@ -1,39 +1,46 @@
|
||||

|
||||
|
||||
[](https://www.paypal.com/paypalme/KhopaDCSL)
|
||||
[](https://patreon.com/khopa)
|
||||
|
||||
[](https://github.com/Khopa/dcs_liberation/releases)
|
||||
[](https://github.com/dcs-liberation/dcs_liberation/releases)
|
||||
|
||||
[](https://discord.gg/bKrtrkJ)
|
||||
|
||||
[](https://github.com/Khopa/dcs_liberation)
|
||||
[](https://github.com/Khopa/dcs_liberation/issues)
|
||||

|
||||
[](https://github.com/dcs-liberation/dcs_liberation)
|
||||
[](https://github.com/dcs-liberation/dcs_liberation/issues)
|
||||

|
||||
|
||||
## About DCS Liberation
|
||||
DCS Liberation is a [DCS World](https://www.digitalcombatsimulator.com/en/products/world/) turn based single-player or co-op dynamic campaign.
|
||||
It is an external program that generates full and complex DCS missions and manage a persistent combat environment.
|
||||
|
||||

|
||||

|
||||
|
||||
## Downloads
|
||||
|
||||
Latest release is available here : https://github.com/Khopa/dcs_liberation/releases
|
||||
Latest release is available here : https://github.com/dcs-liberation/dcs_liberation/releases
|
||||
|
||||
To download preview builds of the next version of DCS Liberation, see https://github.com/Khopa/dcs_liberation/wiki/Preview-builds.
|
||||
To download preview builds of the next version of DCS Liberation, see https://github.com/dcs-liberation/dcs_liberation/wiki/Preview-builds.
|
||||
|
||||
## DCS bugs
|
||||
|
||||
These DCS bugs prevent us from improving AI behavior. Please upvote them! (But please
|
||||
_don't_ spam them with comments):
|
||||
|
||||
* [A2A and SEAD escorts don't escort](https://forums.eagle.ru/topic/251798-options-for-alternate-ai-escort-behavior/?tab=comments#comment-4668033)
|
||||
* [DEAD can't use mixed loadouts effectively](https://forums.eagle.ru/topic/271941-ai-rtbs-after-firing-decoys-despite-full-load-of-bombs/)
|
||||
|
||||
## Bugs and feature requests
|
||||
|
||||
If you need to report a bug or want to suggest a new feature, you can do this on our [bug tracker](https://github.com/Khopa/dcs_liberation/issues). In either case, please use the search bar at the top of the page to see if it has already been reported. Note that you may need to remove the filter for open bugs if it's something we've recently fixed.
|
||||
If you need to report a bug or want to suggest a new feature, you can do this on our [bug tracker](https://github.com/dcs-liberation/dcs_liberation/issues). In either case, please use the search bar at the top of the page to see if it has already been reported. Note that you may need to remove the filter for open bugs if it's something we've recently fixed.
|
||||
|
||||
## Roadmap
|
||||
|
||||
Our plans for future releases can be found on our [Projects page](https://github.com/Khopa/dcs_liberation/projects). Each planned release has a Project, and the page for that project has columns for to do, in progress, and done. Items in the Done column are in the [preview build](https://github.com/Khopa/dcs_liberation/wiki/Preview-builds) for that release. Items in the To do column are planned to be added to that release.
|
||||
Our plans for future releases can be found on our [Projects page](https://github.com/dcs-liberation/dcs_liberation/projects). Each planned release has a Project, and the page for that project has columns for to do, in progress, and done. Items in the Done column are in the [preview build](https://github.com/dcs-liberation/dcs_liberation/wiki/Preview-builds) for that release. Items in the To do column are planned to be added to that release.
|
||||
|
||||
## Resources
|
||||
|
||||
Tutorials, contributors and developer's guides are available in the project's [Wiki](https://github.com/Khopa/dcs_liberation/wiki/)
|
||||
Tutorials, contributors and developer's guides are available in the project's [Wiki](https://github.com/dcs-liberation/dcs_liberation/wiki/)
|
||||
|
||||
## Special Thanks
|
||||
|
||||
|
||||
104
changelog.md
104
changelog.md
@@ -1,3 +1,107 @@
|
||||
# 4.0.0
|
||||
|
||||
Saves from 3.x are not compatible with 4.0.
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
* **[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]** 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.
|
||||
* **[UI]** Removed ability to buy (useless) ground units at carriers and LHAs.
|
||||
* **[UI]** Fixed enable/disable of buy/sell buttons.
|
||||
* **[UI]** EWRs now appear in the custom waypoint list.
|
||||
|
||||
# 3.0.0
|
||||
|
||||
Saves from 2.5 are not compatible with 3.0.
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
* **[Campaign]** Ground units can now be transferred by road, airlift, and cargo ship. See https://github.com/dcs-liberation/dcs_liberation/wiki/Unit-Transfers for more information.
|
||||
* **[Campaign]** Ground units can no longer be sold. To move units to a new location, transfer them.
|
||||
* **[Campaign]** Ground units must now be recruited at a base with a factory and transferred to their destination. When buying units in the UI, the purchase will automatically be fulfilled at the closest factory, and a transfer will be created on the next turn.
|
||||
* **[Campaign]** Non-control point FOBs will no longer spawn.
|
||||
* **[Campaign]** Added squadrons and pilots. See https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots for more information.
|
||||
* **[Campaign]** Capturing a base now depopulates all of its attached objectives with units: air defenses, EWRs, ships, armor groups, etc. Buildings are captured.
|
||||
* **[Campaign]** Ammunition Depots determine how many ground units can be deployed on the frontline by a control point.
|
||||
* **[Campaign AI]** AI now considers Ju-88s for CAS, strike, and DEAD missions.
|
||||
* **[Campaign AI]** AI planned AEW&C missions will now be scheduled ASAP.
|
||||
* **[Campaign AI]** AI now considers the range to the SAM's threat zone rather than the range to the SAM itself when determining target priorities.
|
||||
* **[Campaign AI]** Auto purchase of ground units will now maintain unit composition instead of buying randomly. The unit composition is predefined.
|
||||
* **[Campaign AI]** Auto purchase will aim to purchase enough ground units to support the frontline, plus 30% reserve units.
|
||||
* **[Campaign AI]** Auto purchase will now adjust its air/ground balance to favor whichever is under-funded.
|
||||
* **[Flight Planner]** Desired mission length is now configurable (defaults to 60 minutes). A BARCAP will be planned every 30 minutes. Other packages will simply have their takeoffs spread out or compressed such that the last flight will take off around the mission end time.
|
||||
* **[Flight Planner]** Flight plans now include bullseye waypoints.
|
||||
* **[Flight Planner]** Differentiated SEAD and SEAD escort. SEAD is tasked with suppressing the package target, SEAD escort is tasked with protecting the package from all SAMs along its route.
|
||||
* **[Flight Planner]** Planned airspeed increased to 0.85 mach for supersonic airframes and 85% of max speed for subsonic.
|
||||
* **[Flight Planner]** Taxi time estimation for airfields increased from 5 minutes to 8 minutes.
|
||||
* **[Flight Planner]** Reduce expected error margin for flight plans from 10% to 5%.
|
||||
* **[Flight Planner]** SEAD flights are scheduled one minute ahead of the package's TOT so that they can suppress the site ahead of the strike.
|
||||
* **[Flight Planner]** Automatic ATO generation for the player's coalition can now be disabled in the settings.
|
||||
* **[Payloads]** AI flights for most air to ground mission types (CAS excluded) will have their guns emptied to prevent strafing fully armed and operational battle stations. Gun-reliant airframes like A-10s and warbirds will keep their bullets.
|
||||
* **[Kneeboard]** ATC table overflow alleviated by wrapping long airfield names and splitting ATC frequency and channel into separate rows.
|
||||
* **[UI]** Overhauled the map implementation. Now uses satellite imagery instead of low res map images. Display options have moved from the toolbar to panels in the map.
|
||||
* **[UI]** Campaigns generated for an older or newer version of the game will now be marked as incompatible. They can still be played, but bugs may be present.
|
||||
* **[UI]** DCS loadouts are now selectable in the loadout setup menu.
|
||||
* **[UI]** Added global aircraft inventory view under Air Wing dialog.
|
||||
* **[UI]** Base menu now shows information about ground unit deployment limits.
|
||||
* **[Modding]** Campaigns now choose locations for factories to spawn.
|
||||
* **[Modding]** Campaigns now choose locations for ammunition depots to spawn.
|
||||
* **[Modding]** Campaigns now use map structures as strike targets.
|
||||
* **[Modding]** Campaigns may now set *any* objective type to be a required spawn rather than random chance. Support for random objective generation was removed.
|
||||
* **[Modding]** Campaigns may now place AAA objectives.
|
||||
* **[Modding]** Can now install custom factions to <DCS saved games>/Liberation/Factions instead of the Liberation install directory.
|
||||
* **[Performance Settings]** Added a settings to lower the number of smoke effects generated on frontlines. Lowered default settings for frontline smoke generators, so less smoke should be generated by default.
|
||||
* **[Configuration]** Liberation preferences (DCS install and save game location) are now saved to `%LOCALAPPDATA%/DCSLiberation` to prevent needing to reconfigure each new install.
|
||||
* **[Skynet]** Updated to 2.1.0.
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[Campaign AI]** Fix purchase of aircraft by priority (the faction's list was being used as the priority list rather than the game's).
|
||||
* **[Campaign AI]** Fixed bug causing AI to over-purchase cheap aircraft.
|
||||
* **[Campaign AI]** Auto planner will no longer attempt to plan missions for which the faction has no compatible aircraft.
|
||||
* **[Campaign AI]** Stop purchasing aircraft after the first unaffordable package to attempt to complete more packages rather than filling airfields with cheap escorts that will never be used.
|
||||
* **[Campaign]** Fixed bug where offshore strike locations were being used to spawn ship objectives.
|
||||
* **[Campaign]** EWR sites are now purchasable.
|
||||
* **[Flight Planner]** AI strike flight plans now include the correct target actions for building groups.
|
||||
* **[Flight Planner]** AI BAI/DEAD/SEAD flights now have tasks to attack all groups at the target location, not just the primary group (for multi-group SAM sites).
|
||||
* **[Flight Planner]** Fixed some contexts where damaged runways would be used. Destroying a carrier will no longer break the game.
|
||||
|
||||
# 2.5.1
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
0
game/data/__init__.py
Normal file
0
game/data/__init__.py
Normal file
@@ -1,22 +0,0 @@
|
||||
from dcs.vehicles import AirDefence
|
||||
|
||||
AAA_UNITS = [
|
||||
AirDefence.SPAAA_Gepard,
|
||||
AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish,
|
||||
AirDefence.SPAAA_Vulcan_M163,
|
||||
AirDefence.AAA_ZU_23_Closed_Emplacement,
|
||||
AirDefence.AAA_ZU_23_Emplacement,
|
||||
AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375,
|
||||
AirDefence.AAA_ZU_23_Closed_Emplacement_Insurgent,
|
||||
AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375,
|
||||
AirDefence.AAA_ZU_23_Insurgent,
|
||||
AirDefence.AAA_8_8cm_Flak_18,
|
||||
AirDefence.AAA_Flak_38_20mm,
|
||||
AirDefence.AAA_8_8cm_Flak_36,
|
||||
AirDefence.AAA_8_8cm_Flak_37,
|
||||
AirDefence.AAA_Flak_Vierling_38_Quad_20mm,
|
||||
AirDefence.AAA_SP_Kdo_G_40,
|
||||
AirDefence.AAA_8_8cm_Flak_41,
|
||||
AirDefence.AAA_40mm_Bofors,
|
||||
AirDefence.AAA_S_60_57mm,
|
||||
]
|
||||
40
game/data/alic.py
Normal file
40
game/data/alic.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from dcs.unit import Unit
|
||||
from dcs.vehicles import AirDefence
|
||||
|
||||
|
||||
class AlicCodes:
|
||||
CODES = {
|
||||
AirDefence._1L13_EWR.id: 101,
|
||||
AirDefence._55G6_EWR.id: 102,
|
||||
AirDefence.S_300PS_40B6MD_sr.id: 103,
|
||||
AirDefence.S_300PS_64H6E_sr.id: 104,
|
||||
AirDefence.SA_11_Buk_SR_9S18M1.id: 107,
|
||||
AirDefence.Kub_1S91_str.id: 108,
|
||||
AirDefence.Dog_Ear_radar.id: 109,
|
||||
AirDefence.S_300PS_40B6M_tr.id: 110,
|
||||
AirDefence.SA_11_Buk_LN_9A310M1.id: 115,
|
||||
AirDefence.Osa_9A33_ln.id: 117,
|
||||
AirDefence.Strela_10M3.id: 118,
|
||||
AirDefence.Tor_9A331.id: 119,
|
||||
AirDefence._2S6_Tunguska.id: 120,
|
||||
AirDefence.ZSU_23_4_Shilka.id: 121,
|
||||
AirDefence.P_19_s_125_sr.id: 122,
|
||||
AirDefence.Snr_s_125_tr.id: 123,
|
||||
AirDefence.Rapier_fsa_blindfire_radar.id: 124,
|
||||
AirDefence.Rapier_fsa_launcher.id: 125,
|
||||
AirDefence.SNR_75V.id: 126,
|
||||
AirDefence.HQ_7_LN_SP.id: 127,
|
||||
AirDefence.HQ_7_STR_SP.id: 128,
|
||||
AirDefence.Roland_ADS.id: 201,
|
||||
AirDefence.Patriot_str.id: 202,
|
||||
AirDefence.Hawk_sr.id: 203,
|
||||
AirDefence.Hawk_tr.id: 204,
|
||||
AirDefence.Roland_Radar.id: 205,
|
||||
AirDefence.Hawk_cwar.id: 206,
|
||||
AirDefence.Gepard.id: 207,
|
||||
AirDefence.Vulcan.id: 208,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def code_for(cls, unit: Unit) -> int:
|
||||
return cls.CODES[unit.type]
|
||||
@@ -3,37 +3,30 @@ import dcs
|
||||
|
||||
DEFAULT_AVAILABLE_BUILDINGS = [
|
||||
"fuel",
|
||||
"ammo",
|
||||
"comms",
|
||||
"oil",
|
||||
"ware",
|
||||
"farp",
|
||||
"fob",
|
||||
"power",
|
||||
"factory",
|
||||
"derrick",
|
||||
]
|
||||
|
||||
WW2_FREE = ["fuel", "factory", "ware", "fob"]
|
||||
WW2_FREE = ["fuel", "ware"]
|
||||
WW2_GERMANY_BUILDINGS = [
|
||||
"fuel",
|
||||
"factory",
|
||||
"ww2bunker",
|
||||
"ww2bunker",
|
||||
"ww2bunker",
|
||||
"allycamp",
|
||||
"allycamp",
|
||||
"fob",
|
||||
]
|
||||
WW2_ALLIES_BUILDINGS = [
|
||||
"fuel",
|
||||
"factory",
|
||||
"allycamp",
|
||||
"allycamp",
|
||||
"allycamp",
|
||||
"allycamp",
|
||||
"allycamp",
|
||||
"fob",
|
||||
]
|
||||
|
||||
FORTIFICATION_BUILDINGS = [
|
||||
|
||||
@@ -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,7 +1,20 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from dcs.task import Reconnaissance
|
||||
|
||||
from game.utils import Distance, feet, nautical_miles
|
||||
from game.data.groundunitclass import GroundUnitClass
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundUnitProcurementRatios:
|
||||
ratios: dict[GroundUnitClass, float]
|
||||
|
||||
def for_unit_class(self, unit_class: GroundUnitClass) -> float:
|
||||
try:
|
||||
return self.ratios[unit_class] / sum(self.ratios.values())
|
||||
except KeyError:
|
||||
return 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -50,6 +63,8 @@ class Doctrine:
|
||||
|
||||
sweep_distance: Distance
|
||||
|
||||
ground_unit_procurement_ratios: GroundUnitProcurementRatios
|
||||
|
||||
|
||||
MODERN_DOCTRINE = Doctrine(
|
||||
cap=True,
|
||||
@@ -76,6 +91,17 @@ MODERN_DOCTRINE = Doctrine(
|
||||
cap_engagement_range=nautical_miles(50),
|
||||
cas_duration=timedelta(minutes=30),
|
||||
sweep_distance=nautical_miles(60),
|
||||
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
|
||||
{
|
||||
GroundUnitClass.Tank: 3,
|
||||
GroundUnitClass.Atgm: 2,
|
||||
GroundUnitClass.Apc: 2,
|
||||
GroundUnitClass.Ifv: 3,
|
||||
GroundUnitClass.Artillery: 1,
|
||||
GroundUnitClass.Shorads: 2,
|
||||
GroundUnitClass.Recon: 1,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
COLDWAR_DOCTRINE = Doctrine(
|
||||
@@ -103,6 +129,17 @@ COLDWAR_DOCTRINE = Doctrine(
|
||||
cap_engagement_range=nautical_miles(35),
|
||||
cas_duration=timedelta(minutes=30),
|
||||
sweep_distance=nautical_miles(40),
|
||||
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
|
||||
{
|
||||
GroundUnitClass.Tank: 4,
|
||||
GroundUnitClass.Atgm: 2,
|
||||
GroundUnitClass.Apc: 3,
|
||||
GroundUnitClass.Ifv: 2,
|
||||
GroundUnitClass.Artillery: 1,
|
||||
GroundUnitClass.Shorads: 2,
|
||||
GroundUnitClass.Recon: 1,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
WWII_DOCTRINE = Doctrine(
|
||||
@@ -130,4 +167,14 @@ WWII_DOCTRINE = Doctrine(
|
||||
cap_engagement_range=nautical_miles(20),
|
||||
cas_duration=timedelta(minutes=30),
|
||||
sweep_distance=nautical_miles(10),
|
||||
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
|
||||
{
|
||||
GroundUnitClass.Tank: 3,
|
||||
GroundUnitClass.Atgm: 3,
|
||||
GroundUnitClass.Apc: 3,
|
||||
GroundUnitClass.Artillery: 1,
|
||||
GroundUnitClass.Shorads: 3,
|
||||
GroundUnitClass.Recon: 1,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
17
game/data/groundunitclass.py
Normal file
17
game/data/groundunitclass.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import unique, Enum
|
||||
|
||||
|
||||
@unique
|
||||
class GroundUnitClass(Enum):
|
||||
Tank = "Tank"
|
||||
Atgm = "ATGM"
|
||||
Ifv = "IFV"
|
||||
Apc = "APC"
|
||||
Artillery = "Artillery"
|
||||
Logistics = "Logistics"
|
||||
Recon = "Recon"
|
||||
Infantry = "Infantry"
|
||||
Shorads = "SHORADS"
|
||||
Manpads = "MANPADS"
|
||||
@@ -1,72 +1,108 @@
|
||||
from dcs.ships import (
|
||||
Battlecruiser_1144_2_Pyotr_Velikiy,
|
||||
Cruiser_1164_Moskva,
|
||||
CVN_70_Carl_Vinson,
|
||||
CVN_71_Theodore_Roosevelt,
|
||||
CVN_72_Abraham_Lincoln,
|
||||
CVN_73_George_Washington,
|
||||
CVN_74_John_C__Stennis,
|
||||
CV_1143_5_Admiral_Kuznetsov,
|
||||
CV_1143_5_Admiral_Kuznetsov_2017,
|
||||
Frigate_11540_Neustrashimy,
|
||||
Corvette_1124_4_Grisha,
|
||||
Frigate_1135M_Rezky,
|
||||
Corvette_1241_1_Molniya,
|
||||
LHA_1_Tarawa,
|
||||
FFG_Oliver_Hazzard_Perry,
|
||||
CG_Ticonderoga,
|
||||
Type_052B_Destroyer,
|
||||
Type_052C_Destroyer,
|
||||
Type_054A_Frigate,
|
||||
DDG_Arleigh_Burke_IIa,
|
||||
PIOTR,
|
||||
MOSCOW,
|
||||
VINSON,
|
||||
CVN_71,
|
||||
CVN_72,
|
||||
CVN_73,
|
||||
Stennis,
|
||||
KUZNECOW,
|
||||
CV_1143_5,
|
||||
NEUSTRASH,
|
||||
ALBATROS,
|
||||
REZKY,
|
||||
MOLNIYA,
|
||||
LHA_Tarawa,
|
||||
PERRY,
|
||||
TICONDEROG,
|
||||
Type_052B,
|
||||
Type_052C,
|
||||
Type_054A,
|
||||
USS_Arleigh_Burke_IIa,
|
||||
)
|
||||
from dcs.vehicles import AirDefence
|
||||
|
||||
UNITS_WITH_RADAR = [
|
||||
TELARS = {
|
||||
AirDefence._2S6_Tunguska,
|
||||
AirDefence.SA_11_Buk_SR_9S18M1,
|
||||
AirDefence.Osa_9A33_ln,
|
||||
AirDefence.Tor_9A331,
|
||||
AirDefence.Roland_ADS,
|
||||
}
|
||||
|
||||
TRACK_RADARS = {
|
||||
AirDefence.Kub_1S91_str,
|
||||
AirDefence.Snr_s_125_tr,
|
||||
AirDefence.S_300PS_40B6M_tr,
|
||||
AirDefence.Hawk_tr,
|
||||
AirDefence.Patriot_str,
|
||||
AirDefence.SNR_75V,
|
||||
AirDefence.Rapier_fsa_blindfire_radar,
|
||||
AirDefence.HQ_7_STR_SP,
|
||||
}
|
||||
|
||||
LAUNCHER_TRACKER_PAIRS = {
|
||||
AirDefence.Kub_2P25_ln: AirDefence.Kub_1S91_str,
|
||||
AirDefence._5p73_s_125_ln: AirDefence.Snr_s_125_tr,
|
||||
AirDefence.S_300PS_5P85C_ln: AirDefence.S_300PS_40B6M_tr,
|
||||
AirDefence.S_300PS_5P85D_ln: AirDefence.S_300PS_40B6M_tr,
|
||||
AirDefence.Hawk_ln: AirDefence.Hawk_tr,
|
||||
AirDefence.Patriot_ln: AirDefence.Patriot_str,
|
||||
AirDefence.S_75M_Volhov: AirDefence.SNR_75V,
|
||||
AirDefence.Rapier_fsa_launcher: AirDefence.Rapier_fsa_blindfire_radar,
|
||||
AirDefence.HQ_7_LN_SP: AirDefence.HQ_7_STR_SP,
|
||||
}
|
||||
|
||||
UNITS_WITH_RADAR = {
|
||||
# Radars
|
||||
AirDefence.SAM_SA_15_Tor_Gauntlet,
|
||||
AirDefence.SAM_SA_11_Buk_Gadfly_C2,
|
||||
AirDefence.SAM_Patriot_CR__AMG_AN_MRC_137,
|
||||
AirDefence.SAM_Patriot_ECS,
|
||||
AirDefence.SPAAA_Gepard,
|
||||
AirDefence.SPAAA_Vulcan_M163,
|
||||
AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish,
|
||||
AirDefence.EWR_1L13,
|
||||
AirDefence.SAM_SA_6_Kub_Long_Track_STR,
|
||||
AirDefence.SAM_SA_10_S_300_Grumble_Flap_Lid_TR,
|
||||
AirDefence.SAM_SA_10_S_300_Grumble_Clam_Shell_SR,
|
||||
AirDefence.EWR_55G6,
|
||||
AirDefence.SAM_SA_10_S_300_Grumble_Big_Bird_SR,
|
||||
AirDefence.SAM_SA_11_Buk_Gadfly_Snow_Drift_SR,
|
||||
AirDefence.MCC_SR_Sborka_Dog_Ear_SR,
|
||||
AirDefence.SAM_Hawk_TR__AN_MPQ_46,
|
||||
AirDefence.SAM_Hawk_SR__AN_MPQ_50,
|
||||
AirDefence.SAM_Patriot_STR,
|
||||
AirDefence.SAM_Hawk_CWAR_AN_MPQ_55,
|
||||
AirDefence.SAM_P19_Flat_Face_SR__SA_2_3,
|
||||
AirDefence.SAM_Roland_EWR,
|
||||
AirDefence.SAM_SA_3_S_125_Low_Blow_TR,
|
||||
AirDefence.SAM_SA_2_S_75_Fan_Song_TR,
|
||||
AirDefence.HQ_7_Self_Propelled_STR,
|
||||
AirDefence._2S6_Tunguska,
|
||||
AirDefence.SA_11_Buk_LN_9A310M1,
|
||||
AirDefence.Osa_9A33_ln,
|
||||
AirDefence.Tor_9A331,
|
||||
AirDefence.Gepard,
|
||||
AirDefence.Vulcan,
|
||||
AirDefence.Roland_ADS,
|
||||
AirDefence.ZSU_23_4_Shilka,
|
||||
AirDefence._1L13_EWR,
|
||||
AirDefence.Kub_1S91_str,
|
||||
AirDefence.S_300PS_40B6M_tr,
|
||||
AirDefence.S_300PS_40B6MD_sr,
|
||||
AirDefence._55G6_EWR,
|
||||
AirDefence.S_300PS_64H6E_sr,
|
||||
AirDefence.SA_11_Buk_SR_9S18M1,
|
||||
AirDefence.Dog_Ear_radar,
|
||||
AirDefence.Hawk_tr,
|
||||
AirDefence.Hawk_sr,
|
||||
AirDefence.Patriot_str,
|
||||
AirDefence.Hawk_cwar,
|
||||
AirDefence.P_19_s_125_sr,
|
||||
AirDefence.Roland_Radar,
|
||||
AirDefence.Snr_s_125_tr,
|
||||
AirDefence.SNR_75V,
|
||||
AirDefence.Rapier_fsa_blindfire_radar,
|
||||
AirDefence.HQ_7_LN_SP,
|
||||
AirDefence.HQ_7_STR_SP,
|
||||
AirDefence.FuMG_401,
|
||||
AirDefence.FuSe_65,
|
||||
# Ships
|
||||
CVN_70_Carl_Vinson,
|
||||
FFG_Oliver_Hazzard_Perry,
|
||||
CG_Ticonderoga,
|
||||
Corvette_1124_4_Grisha,
|
||||
CV_1143_5_Admiral_Kuznetsov,
|
||||
Corvette_1241_1_Molniya,
|
||||
Cruiser_1164_Moskva,
|
||||
Frigate_11540_Neustrashimy,
|
||||
Battlecruiser_1144_2_Pyotr_Velikiy,
|
||||
Frigate_1135M_Rezky,
|
||||
CV_1143_5_Admiral_Kuznetsov_2017,
|
||||
CVN_74_John_C__Stennis,
|
||||
CVN_71_Theodore_Roosevelt,
|
||||
CVN_72_Abraham_Lincoln,
|
||||
CVN_73_George_Washington,
|
||||
DDG_Arleigh_Burke_IIa,
|
||||
LHA_1_Tarawa,
|
||||
Type_052B_Destroyer,
|
||||
Type_054A_Frigate,
|
||||
Type_052C_Destroyer,
|
||||
]
|
||||
VINSON,
|
||||
PERRY,
|
||||
TICONDEROG,
|
||||
ALBATROS,
|
||||
KUZNECOW,
|
||||
MOLNIYA,
|
||||
MOSCOW,
|
||||
NEUSTRASH,
|
||||
PIOTR,
|
||||
REZKY,
|
||||
CV_1143_5,
|
||||
Stennis,
|
||||
CVN_71,
|
||||
CVN_72,
|
||||
CVN_73,
|
||||
USS_Arleigh_Burke_IIa,
|
||||
LHA_Tarawa,
|
||||
Type_052B,
|
||||
Type_054A,
|
||||
Type_052C,
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import datetime
|
||||
import inspect
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterator, Optional, Set, Tuple, Type, Union, cast
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Iterator, Optional, Set, Tuple, Union, cast
|
||||
|
||||
from dcs.unitgroup import FlyingGroup
|
||||
from dcs.unittype import FlyingType
|
||||
from dcs.weapons_data import Weapons, weapon_ids
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
|
||||
PydcsWeapon = Dict[str, Union[int, str]]
|
||||
PydcsWeaponAssignment = Tuple[int, PydcsWeapon]
|
||||
@@ -21,8 +21,8 @@ class Weapon:
|
||||
"""Wraps a pydcs weapon dict in a hashable type."""
|
||||
|
||||
cls_id: str
|
||||
name: str
|
||||
weight: int
|
||||
name: str = field(compare=False)
|
||||
weight: int = field(compare=False)
|
||||
|
||||
def available_on(self, date: datetime.date) -> bool:
|
||||
introduction_year = WEAPON_INTRODUCTION_YEARS.get(self)
|
||||
@@ -59,6 +59,9 @@ class Weapon:
|
||||
@classmethod
|
||||
def from_clsid(cls, clsid: str) -> Optional[Weapon]:
|
||||
data = weapon_ids.get(clsid)
|
||||
if clsid == "<CLEAN>":
|
||||
# Special case for a "weapon" that isn't exposed by pydcs.
|
||||
return Weapon(clsid, "Clean", 0)
|
||||
if data is None:
|
||||
return None
|
||||
return cls.from_pydcs(data)
|
||||
@@ -70,11 +73,19 @@ class Pylon:
|
||||
allowed: Set[Weapon]
|
||||
|
||||
def can_equip(self, weapon: Weapon) -> bool:
|
||||
return weapon in self.allowed
|
||||
# TODO: Fix pydcs to support the <CLEAN> "weapon".
|
||||
# <CLEAN> is a special case because pydcs doesn't know about that "weapon", so
|
||||
# it's not compatible with *any* pylon. Just trust the loadout and try to equip
|
||||
# it.
|
||||
#
|
||||
# A similar hack exists in QPylonEditor to forcibly add "Clean" to the list of
|
||||
# valid configurations for that pylon if a loadout has been seen with that
|
||||
# configuration.
|
||||
return weapon in self.allowed or weapon.cls_id == "<CLEAN>"
|
||||
|
||||
def equip(self, group: FlyingGroup, weapon: Weapon) -> None:
|
||||
if not self.can_equip(weapon):
|
||||
raise ValueError(f"Pylon {self.number} cannot equip {weapon.name}")
|
||||
logging.error(f"Pylon {self.number} cannot equip {weapon.name}")
|
||||
group.load_pylon(self.make_pydcs_assignment(weapon), self.number)
|
||||
|
||||
def make_pydcs_assignment(self, weapon: Weapon) -> PydcsWeaponAssignment:
|
||||
@@ -86,12 +97,12 @@ class Pylon:
|
||||
yield weapon
|
||||
|
||||
@classmethod
|
||||
def for_aircraft(cls, aircraft: Type[FlyingType], number: int) -> Pylon:
|
||||
def for_aircraft(cls, aircraft: AircraftType, number: int) -> Pylon:
|
||||
# In pydcs these are all arbitrary inner classes of the aircraft type.
|
||||
# The only way to identify them is by their name.
|
||||
pylons = [
|
||||
v
|
||||
for v in aircraft.__dict__.values()
|
||||
for v in aircraft.dcs_unit_type.__dict__.values()
|
||||
if inspect.isclass(v) and v.__name__.startswith("Pylon")
|
||||
]
|
||||
|
||||
@@ -110,8 +121,8 @@ class Pylon:
|
||||
return cls(number, allowed)
|
||||
|
||||
@classmethod
|
||||
def iter_pylons(cls, aircraft: Type[FlyingType]) -> Iterator[Pylon]:
|
||||
for pylon in sorted(list(aircraft.pylons)):
|
||||
def iter_pylons(cls, aircraft: AircraftType) -> Iterator[Pylon]:
|
||||
for pylon in sorted(list(aircraft.dcs_unit_type.pylons)):
|
||||
yield cls.for_aircraft(aircraft, pylon)
|
||||
|
||||
|
||||
@@ -134,20 +145,26 @@ _WEAPON_FALLBACKS = [
|
||||
Weapons.LAU_117_with_AGM_65E___Maverick_E__Laser_ASM___Lg_Whd_,
|
||||
), # internal pylons harrier
|
||||
# AGM-154 JSOW
|
||||
(Weapons.AGM_154A___JSOW_CEB__CBU_type_, Weapons.GBU_12),
|
||||
(
|
||||
Weapons.AGM_154A___JSOW_CEB__CBU_type_,
|
||||
Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
|
||||
),
|
||||
(
|
||||
Weapons.BRU_55_with_2_x_AGM_154A___JSOW_CEB__CBU_type_,
|
||||
Weapons.BRU_33_with_2_x_GBU_12___500lb_Laser_Guided_Bomb,
|
||||
Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
|
||||
),
|
||||
(
|
||||
Weapons.BRU_57_with_2_x_AGM_154A___JSOW_CEB__CBU_type_,
|
||||
None,
|
||||
Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
|
||||
), # doesn't exist on any aircraft yet
|
||||
(Weapons.AGM_154B___JSOW_Anti_Armour, Weapons.CBU_105___10_x_CEM__CBU_with_WCMD),
|
||||
(Weapons.AGM_154C___JSOW_Unitary_BROACH, Weapons.GBU_12),
|
||||
(Weapons.AGM_154B___JSOW_Anti_Armour, Weapons.CBU_105___10_x_SFW__CBU_with_WCMD),
|
||||
(
|
||||
Weapons.AGM_154C___JSOW_Unitary_BROACH,
|
||||
Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
|
||||
),
|
||||
(
|
||||
Weapons.BRU_55_with_2_x_AGM_154C___JSOW_Unitary_BROACH,
|
||||
Weapons.BRU_33_with_2_x_GBU_12___500lb_Laser_Guided_Bomb,
|
||||
Weapons.AGM_62_Walleye_II___Guided_Weapon_Mk_5__TV_Guided_,
|
||||
),
|
||||
# AGM-45 Shrike
|
||||
(Weapons.AGM_45A_Shrike_ARM, None),
|
||||
@@ -472,29 +489,29 @@ _WEAPON_FALLBACKS = [
|
||||
# CBU-87 CEM
|
||||
(Weapons.CBU_87___202_x_CEM_Cluster_Bomb, Weapons.Mk_82),
|
||||
(
|
||||
Weapons.TER_9A_with_2_x_CBU_87___202_x_Anti_Armor_Skeet_SFW_Cluster_Bomb,
|
||||
Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb,
|
||||
Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD,
|
||||
),
|
||||
(
|
||||
Weapons.TER_9A_with_2_x_CBU_87___202_x_Anti_Armor_Skeet_SFW_Cluster_Bomb_,
|
||||
Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb_,
|
||||
Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD,
|
||||
),
|
||||
(
|
||||
Weapons.TER_9A_with_3_x_CBU_87___202_x_Anti_Armor_Skeet_SFW_Cluster_Bomb,
|
||||
Weapons.TER_9A_with_3_x_CBU_87___202_x_CEM_Cluster_Bomb,
|
||||
Weapons.TER_9A_with_3_x_Mk_82___500lb_GP_Bomb_LD,
|
||||
),
|
||||
# CBU-97
|
||||
(Weapons.CBU_97___10_x_CEM_Cluster_Bomb, Weapons.Mk_82),
|
||||
(Weapons.CBU_97___10_x_SFW_Cluster_Bomb, Weapons.Mk_82),
|
||||
(
|
||||
Weapons.TER_9A_with_2_x_CBU_97___10_x_Anti_Armor_Skeet_SFW_Cluster_Bomb,
|
||||
Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb,
|
||||
Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD,
|
||||
),
|
||||
(
|
||||
Weapons.TER_9A_with_2_x_CBU_97___10_x_Anti_Armor_Skeet_SFW_Cluster_Bomb_,
|
||||
Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb_,
|
||||
Weapons.TER_9A_with_2_x_Mk_82___500lb_GP_Bomb_LD_,
|
||||
),
|
||||
(
|
||||
Weapons.TER_9A_with_3_x_CBU_97___10_x_Anti_Armor_Skeet_SFW_Cluster_Bomb,
|
||||
Weapons.TER_9A_with_3_x_CBU_97___10_x_SFW_Cluster_Bomb,
|
||||
Weapons.TER_9A_with_3_x_Mk_82___500lb_GP_Bomb_LD,
|
||||
),
|
||||
# CBU-99 (It's a bomb made in 1968, I'm not bothering right now with backups)
|
||||
@@ -504,7 +521,7 @@ _WEAPON_FALLBACKS = [
|
||||
Weapons.CBU_87___202_x_CEM_Cluster_Bomb,
|
||||
),
|
||||
# CBU-105
|
||||
(Weapons.CBU_105___10_x_CEM__CBU_with_WCMD, Weapons.CBU_97___10_x_CEM_Cluster_Bomb),
|
||||
(Weapons.CBU_105___10_x_SFW__CBU_with_WCMD, Weapons.CBU_97___10_x_SFW_Cluster_Bomb),
|
||||
(
|
||||
Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS,
|
||||
Weapons.LAU_131_pod___7_x_2_75_Hydra__UnGd_Rkts_M151__HE,
|
||||
@@ -830,6 +847,8 @@ WEAPON_INTRODUCTION_YEARS = {
|
||||
Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7F_Sparrow_Semi_Active_Radar): 1976,
|
||||
Weapon.from_pydcs(Weapons.LAU_115C_with_AIM_7MH_Sparrow_Semi_Active_Radar): 1987,
|
||||
# AIM-9 Sidewinder
|
||||
Weapon.from_pydcs(Weapons.LAU_7_with_AIM_9B_Sidewinder_IR_AAM): 1956,
|
||||
Weapon.from_pydcs(Weapons.LAU_7_with_2_x_AIM_9B_Sidewinder_IR_AAM): 1956,
|
||||
Weapon.from_pydcs(Weapons.AIM_9L_Sidewinder_IR_AAM): 1977,
|
||||
Weapon.from_pydcs(Weapons.AIM_9M_Sidewinder_IR_AAM): 1982,
|
||||
Weapon.from_pydcs(Weapons.AIM_9P5_Sidewinder_IR_AAM): 1980,
|
||||
@@ -958,26 +977,14 @@ WEAPON_INTRODUCTION_YEARS = {
|
||||
Weapon.from_pydcs(Weapons.CBU_52B___220_x_HE_Frag_bomblets): 1970,
|
||||
# CBU-87 CEM
|
||||
Weapon.from_pydcs(Weapons.CBU_87___202_x_CEM_Cluster_Bomb): 1986,
|
||||
Weapon.from_pydcs(
|
||||
Weapons.TER_9A_with_2_x_CBU_87___202_x_Anti_Armor_Skeet_SFW_Cluster_Bomb
|
||||
): 1986,
|
||||
Weapon.from_pydcs(
|
||||
Weapons.TER_9A_with_2_x_CBU_87___202_x_Anti_Armor_Skeet_SFW_Cluster_Bomb_
|
||||
): 1986,
|
||||
Weapon.from_pydcs(
|
||||
Weapons.TER_9A_with_3_x_CBU_87___202_x_Anti_Armor_Skeet_SFW_Cluster_Bomb
|
||||
): 1986,
|
||||
Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb): 1986,
|
||||
Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_87___202_x_CEM_Cluster_Bomb_): 1986,
|
||||
Weapon.from_pydcs(Weapons.TER_9A_with_3_x_CBU_87___202_x_CEM_Cluster_Bomb): 1986,
|
||||
# CBU-97
|
||||
Weapon.from_pydcs(Weapons.CBU_97___10_x_CEM_Cluster_Bomb): 1992,
|
||||
Weapon.from_pydcs(
|
||||
Weapons.TER_9A_with_2_x_CBU_97___10_x_Anti_Armor_Skeet_SFW_Cluster_Bomb
|
||||
): 1992,
|
||||
Weapon.from_pydcs(
|
||||
Weapons.TER_9A_with_2_x_CBU_97___10_x_Anti_Armor_Skeet_SFW_Cluster_Bomb_
|
||||
): 1992,
|
||||
Weapon.from_pydcs(
|
||||
Weapons.TER_9A_with_3_x_CBU_97___10_x_Anti_Armor_Skeet_SFW_Cluster_Bomb
|
||||
): 1992,
|
||||
Weapon.from_pydcs(Weapons.CBU_97___10_x_SFW_Cluster_Bomb): 1992,
|
||||
Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb): 1992,
|
||||
Weapon.from_pydcs(Weapons.TER_9A_with_2_x_CBU_97___10_x_SFW_Cluster_Bomb_): 1992,
|
||||
Weapon.from_pydcs(Weapons.TER_9A_with_3_x_CBU_97___10_x_SFW_Cluster_Bomb): 1992,
|
||||
# CBU-99
|
||||
Weapon.from_pydcs(
|
||||
Weapons.BRU_33_with_2_x_CBU_99___490lbs__247_x_HEAT_Bomblets
|
||||
@@ -1019,11 +1026,11 @@ WEAPON_INTRODUCTION_YEARS = {
|
||||
Weapons.MER2_with_2_x_Mk_20_Rockeye___490lbs_CBUs__247_x_HEAT_Bomblets
|
||||
): 1968,
|
||||
# CBU-103
|
||||
Weapon.from_pydcs(Weapons.BRU_57_with_2_x_CBU_103): 2000,
|
||||
Weapon.from_pydcs(Weapons.BRU_57_with_2_x_CBU_103___202_x_CEM__CBU_with_WCMD): 2000,
|
||||
Weapon.from_pydcs(Weapons.CBU_103___202_x_CEM__CBU_with_WCMD): 2000,
|
||||
# CBU-105
|
||||
Weapon.from_pydcs(Weapons.BRU_57_with_2_x_CBU_105): 2000,
|
||||
Weapon.from_pydcs(Weapons.CBU_105___10_x_CEM__CBU_with_WCMD): 2000,
|
||||
Weapon.from_pydcs(Weapons.BRU_57_with_2_x_CBU_105___10_x_SFW__CBU_with_WCMD): 2000,
|
||||
Weapon.from_pydcs(Weapons.CBU_105___10_x_SFW__CBU_with_WCMD): 2000,
|
||||
# APKWS
|
||||
Weapon.from_pydcs(
|
||||
Weapons.LAU_131_pod___7_x_2_75_Hydra__Laser_Guided_Rkts_M151__HE_APKWS
|
||||
|
||||
1388
game/db.py
1388
game/db.py
File diff suppressed because it is too large
Load Diff
248
game/dcs/aircrafttype.py
Normal file
248
game/dcs/aircrafttype.py
Normal file
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Type, Iterator, TYPE_CHECKING, Optional, Any
|
||||
|
||||
import yaml
|
||||
from dcs.helicopters import helicopter_map
|
||||
from dcs.planes import plane_map
|
||||
from dcs.unittype import FlyingType
|
||||
|
||||
from game.dcs.unittype import UnitType
|
||||
from game.radio.channels import (
|
||||
ChannelNamer,
|
||||
RadioChannelAllocator,
|
||||
CommonRadioChannelAllocator,
|
||||
HueyChannelNamer,
|
||||
SCR522ChannelNamer,
|
||||
ViggenChannelNamer,
|
||||
ViperChannelNamer,
|
||||
TomcatChannelNamer,
|
||||
MirageChannelNamer,
|
||||
SingleRadioChannelNamer,
|
||||
FarmerRadioChannelAllocator,
|
||||
SCR522RadioChannelAllocator,
|
||||
ViggenRadioChannelAllocator,
|
||||
NoOpChannelAllocator,
|
||||
)
|
||||
from game.utils import Distance, Speed, feet, kph, knots
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen.aircraft import FlightData
|
||||
from gen import AirSupport, RadioFrequency, RadioRegistry
|
||||
from gen.radios import Radio
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RadioConfig:
|
||||
inter_flight: Optional[Radio]
|
||||
intra_flight: Optional[Radio]
|
||||
channel_allocator: Optional[RadioChannelAllocator]
|
||||
channel_namer: Type[ChannelNamer]
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: dict[str, Any]) -> RadioConfig:
|
||||
return RadioConfig(
|
||||
cls.make_radio(data.get("inter_flight", None)),
|
||||
cls.make_radio(data.get("intra_flight", None)),
|
||||
cls.make_allocator(data.get("channels", {})),
|
||||
cls.make_namer(data.get("channels", {})),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def make_radio(cls, name: Optional[str]) -> Optional[Radio]:
|
||||
from gen.radios import get_radio
|
||||
|
||||
if name is None:
|
||||
return None
|
||||
return get_radio(name)
|
||||
|
||||
@classmethod
|
||||
def make_allocator(cls, data: dict[str, Any]) -> Optional[RadioChannelAllocator]:
|
||||
try:
|
||||
alloc_type = data["type"]
|
||||
except KeyError:
|
||||
return None
|
||||
allocator_type: Type[RadioChannelAllocator] = {
|
||||
"SCR-522": SCR522RadioChannelAllocator,
|
||||
"common": CommonRadioChannelAllocator,
|
||||
"farmer": FarmerRadioChannelAllocator,
|
||||
"noop": NoOpChannelAllocator,
|
||||
"viggen": ViggenRadioChannelAllocator,
|
||||
}[alloc_type]
|
||||
return allocator_type.from_cfg(data)
|
||||
|
||||
@classmethod
|
||||
def make_namer(cls, config: dict[str, Any]) -> Type[ChannelNamer]:
|
||||
return {
|
||||
"SCR-522": SCR522ChannelNamer,
|
||||
"default": ChannelNamer,
|
||||
"huey": HueyChannelNamer,
|
||||
"mirage": MirageChannelNamer,
|
||||
"single": SingleRadioChannelNamer,
|
||||
"tomcat": TomcatChannelNamer,
|
||||
"viggen": ViggenChannelNamer,
|
||||
"viper": ViperChannelNamer,
|
||||
}[config.get("namer", "default")]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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("altitude", 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 AircraftType(UnitType[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]
|
||||
intra_flight_radio: Optional[Radio]
|
||||
channel_allocator: Optional[RadioChannelAllocator]
|
||||
channel_namer: Type[ChannelNamer]
|
||||
|
||||
_by_name: ClassVar[dict[str, AircraftType]] = {}
|
||||
_by_unit_type: ClassVar[dict[Type[FlyingType], list[AircraftType]]] = defaultdict(
|
||||
list
|
||||
)
|
||||
_loaded: ClassVar[bool] = False
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def dcs_id(self) -> str:
|
||||
return self.dcs_unit_type.id
|
||||
|
||||
@property
|
||||
def flyable(self) -> bool:
|
||||
return self.dcs_unit_type.flyable
|
||||
|
||||
@cached_property
|
||||
def max_speed(self) -> Speed:
|
||||
return kph(self.dcs_unit_type.max_speed)
|
||||
|
||||
def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
|
||||
from gen.radios import ChannelInUseError, MHz
|
||||
|
||||
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)
|
||||
try:
|
||||
radio_registry.reserve(freq)
|
||||
except ChannelInUseError:
|
||||
pass
|
||||
return freq
|
||||
|
||||
def assign_channels_for_flight(
|
||||
self, flight: FlightData, air_support: AirSupport
|
||||
) -> None:
|
||||
if self.channel_allocator is not None:
|
||||
self.channel_allocator.assign_channels_for_flight(flight, air_support)
|
||||
|
||||
def channel_name(self, radio_id: int, channel_id: int) -> str:
|
||||
return self.channel_namer.channel_name(radio_id, channel_id)
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# Update any existing models with new data on load.
|
||||
updated = AircraftType.named(state["name"])
|
||||
state.update(updated.__dict__)
|
||||
self.__dict__.update(state)
|
||||
|
||||
@classmethod
|
||||
def register(cls, aircraft_type: AircraftType) -> None:
|
||||
cls._by_name[aircraft_type.name] = aircraft_type
|
||||
cls._by_unit_type[aircraft_type.dcs_unit_type].append(aircraft_type)
|
||||
|
||||
@classmethod
|
||||
def named(cls, name: str) -> AircraftType:
|
||||
if not cls._loaded:
|
||||
cls._load_all()
|
||||
return cls._by_name[name]
|
||||
|
||||
@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
|
||||
def _each_unit_type() -> Iterator[Type[FlyingType]]:
|
||||
yield from helicopter_map.values()
|
||||
yield from plane_map.values()
|
||||
|
||||
@classmethod
|
||||
def _load_all(cls) -> None:
|
||||
for unit_type in cls._each_unit_type():
|
||||
for data in cls._each_variant_of(unit_type):
|
||||
cls.register(data)
|
||||
cls._loaded = True
|
||||
|
||||
@classmethod
|
||||
def _each_variant_of(cls, aircraft: Type[FlyingType]) -> Iterator[AircraftType]:
|
||||
data_path = Path("resources/units/aircraft") / f"{aircraft.id}.yaml"
|
||||
if not data_path.exists():
|
||||
logging.warning(f"No data for {aircraft.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open() as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
try:
|
||||
price = data["price"]
|
||||
except KeyError as ex:
|
||||
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:
|
||||
introduction = data["introduced"]
|
||||
if introduction is None:
|
||||
introduction = "N/A"
|
||||
except KeyError:
|
||||
introduction = "No data."
|
||||
|
||||
for variant in data.get("variants", [aircraft.id]):
|
||||
yield AircraftType(
|
||||
dcs_unit_type=aircraft,
|
||||
name=variant,
|
||||
description=data.get("description", "No data."),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=price,
|
||||
carrier_capable=data.get("carrier_capable", False),
|
||||
lha_capable=data.get("lha_capable", False),
|
||||
always_keeps_gun=data.get("always_keeps_gun", False),
|
||||
gunfighter=data.get("gunfighter", False),
|
||||
max_group_size=data.get("max_group_size", aircraft.group_size_max),
|
||||
patrol_altitude=patrol_config.altitude,
|
||||
patrol_speed=patrol_config.speed,
|
||||
intra_flight_radio=radio_config.intra_flight,
|
||||
channel_allocator=radio_config.channel_allocator,
|
||||
channel_namer=radio_config.channel_namer,
|
||||
)
|
||||
97
game/dcs/groundunittype.py
Normal file
97
game/dcs/groundunittype.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Type, Optional, ClassVar, Iterator
|
||||
|
||||
import yaml
|
||||
from dcs.unittype import VehicleType
|
||||
from dcs.vehicles import vehicle_map
|
||||
|
||||
from game.data.groundunitclass import GroundUnitClass
|
||||
from game.dcs.unittype import UnitType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroundUnitType(UnitType[VehicleType]):
|
||||
unit_class: Optional[GroundUnitClass]
|
||||
spawn_weight: int
|
||||
|
||||
_by_name: ClassVar[dict[str, GroundUnitType]] = {}
|
||||
_by_unit_type: ClassVar[
|
||||
dict[Type[VehicleType], list[GroundUnitType]]
|
||||
] = defaultdict(list)
|
||||
_loaded: ClassVar[bool] = False
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def dcs_id(self) -> str:
|
||||
return self.dcs_unit_type.id
|
||||
|
||||
@classmethod
|
||||
def register(cls, aircraft_type: GroundUnitType) -> None:
|
||||
cls._by_name[aircraft_type.name] = aircraft_type
|
||||
cls._by_unit_type[aircraft_type.dcs_unit_type].append(aircraft_type)
|
||||
|
||||
@classmethod
|
||||
def named(cls, name: str) -> GroundUnitType:
|
||||
if not cls._loaded:
|
||||
cls._load_all()
|
||||
return cls._by_name[name]
|
||||
|
||||
@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
|
||||
def _each_unit_type() -> Iterator[Type[VehicleType]]:
|
||||
yield from vehicle_map.values()
|
||||
|
||||
@classmethod
|
||||
def _load_all(cls) -> None:
|
||||
for unit_type in cls._each_unit_type():
|
||||
for data in cls._each_variant_of(unit_type):
|
||||
cls.register(data)
|
||||
cls._loaded = True
|
||||
|
||||
@classmethod
|
||||
def _each_variant_of(cls, vehicle: Type[VehicleType]) -> Iterator[GroundUnitType]:
|
||||
data_path = Path("resources/units/ground_units") / f"{vehicle.id}.yaml"
|
||||
if not data_path.exists():
|
||||
logging.warning(f"No data for {vehicle.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open() as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
try:
|
||||
introduction = data["introduced"]
|
||||
if introduction is None:
|
||||
introduction = "N/A"
|
||||
except KeyError:
|
||||
introduction = "No data."
|
||||
|
||||
class_name = data.get("class")
|
||||
unit_class: Optional[GroundUnitClass] = None
|
||||
if class_name is not None:
|
||||
unit_class = GroundUnitClass(class_name)
|
||||
|
||||
for variant in data.get("variants", [vehicle.id]):
|
||||
yield GroundUnitType(
|
||||
dcs_unit_type=vehicle,
|
||||
unit_class=unit_class,
|
||||
spawn_weight=data.get("spawn_weight", 0),
|
||||
name=variant,
|
||||
description=data.get("description", "No data."),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=data.get("price", 1),
|
||||
)
|
||||
26
game/dcs/unittype.py
Normal file
26
game/dcs/unittype.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import TypeVar, Generic, Type
|
||||
|
||||
from dcs.unittype import UnitType as DcsUnitType
|
||||
|
||||
DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=DcsUnitType)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitType(Generic[DcsUnitTypeT]):
|
||||
dcs_unit_type: Type[DcsUnitTypeT]
|
||||
name: str
|
||||
description: str
|
||||
year_introduced: str
|
||||
country_of_origin: str
|
||||
manufacturer: str
|
||||
role: str
|
||||
price: int
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@cached_property
|
||||
def eplrs_capable(self) -> bool:
|
||||
return getattr(self.dcs_unit_type, "eplrs", False)
|
||||
@@ -14,15 +14,23 @@ from typing import (
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Type,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from dcs.unittype import FlyingType, UnitType
|
||||
|
||||
from game import db
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.theater import Airfield, ControlPoint
|
||||
from game.unitmap import Building, FrontLineUnit, GroundObjectUnit, UnitMap
|
||||
from game.transfers import CargoShip
|
||||
from game.unitmap import (
|
||||
AirliftUnits,
|
||||
Building,
|
||||
ConvoyUnit,
|
||||
FrontLineUnit,
|
||||
GroundObjectUnit,
|
||||
UnitMap,
|
||||
FlyingUnit,
|
||||
)
|
||||
from gen.flights.flight import Flight
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -33,24 +41,24 @@ DEBRIEFING_LOG_EXTENSION = "log"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AirLosses:
|
||||
player: List[Flight]
|
||||
enemy: List[Flight]
|
||||
player: List[FlyingUnit]
|
||||
enemy: List[FlyingUnit]
|
||||
|
||||
@property
|
||||
def losses(self) -> Iterator[Flight]:
|
||||
def losses(self) -> Iterator[FlyingUnit]:
|
||||
return itertools.chain(self.player, self.enemy)
|
||||
|
||||
def by_type(self, player: bool) -> Dict[Type[FlyingType], int]:
|
||||
losses_by_type: Dict[Type[FlyingType], int] = defaultdict(int)
|
||||
def by_type(self, player: bool) -> Dict[AircraftType, int]:
|
||||
losses_by_type: Dict[AircraftType, int] = defaultdict(int)
|
||||
losses = self.player if player else self.enemy
|
||||
for loss in losses:
|
||||
losses_by_type[loss.unit_type] += 1
|
||||
losses_by_type[loss.flight.unit_type] += 1
|
||||
return losses_by_type
|
||||
|
||||
def surviving_flight_members(self, flight: Flight) -> int:
|
||||
losses = 0
|
||||
for loss in self.losses:
|
||||
if loss == flight:
|
||||
if loss.flight == flight:
|
||||
losses += 1
|
||||
return flight.count - losses
|
||||
|
||||
@@ -60,6 +68,15 @@ class GroundLosses:
|
||||
player_front_line: List[FrontLineUnit] = field(default_factory=list)
|
||||
enemy_front_line: List[FrontLineUnit] = field(default_factory=list)
|
||||
|
||||
player_convoy: List[ConvoyUnit] = field(default_factory=list)
|
||||
enemy_convoy: List[ConvoyUnit] = field(default_factory=list)
|
||||
|
||||
player_cargo_ships: List[CargoShip] = field(default_factory=list)
|
||||
enemy_cargo_ships: List[CargoShip] = field(default_factory=list)
|
||||
|
||||
player_airlifts: List[AirliftUnits] = field(default_factory=list)
|
||||
enemy_airlifts: List[AirliftUnits] = field(default_factory=list)
|
||||
|
||||
player_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
|
||||
enemy_ground_objects: List[GroundObjectUnit] = field(default_factory=list)
|
||||
|
||||
@@ -70,6 +87,12 @@ class GroundLosses:
|
||||
enemy_airfields: List[Airfield] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BaseCaptureEvent:
|
||||
control_point: ControlPoint
|
||||
captured_by_player: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateData:
|
||||
#: True if the mission ended. If False, the mission exited abnormally.
|
||||
@@ -94,7 +117,10 @@ class StateData:
|
||||
killed_aircraft=data["killed_aircrafts"],
|
||||
# Airfields emit a new "dead" event every time a bomb is dropped on
|
||||
# them when they've already dead. Dedup.
|
||||
killed_ground_units=list(set(data["killed_ground_units"])),
|
||||
#
|
||||
# Also normalize dead map objects (which are ints) to strings. The unit map
|
||||
# only stores strings.
|
||||
killed_ground_units=list({str(u) for u in data["killed_ground_units"]}),
|
||||
destroyed_statics=data["destroyed_objects_positions"],
|
||||
base_capture_events=data["base_capture_events"],
|
||||
)
|
||||
@@ -105,6 +131,7 @@ class Debriefing:
|
||||
self, state_data: Dict[str, Any], game: Game, unit_map: UnitMap
|
||||
) -> None:
|
||||
self.state_data = StateData.from_json(state_data)
|
||||
self.game = game
|
||||
self.unit_map = unit_map
|
||||
|
||||
self.player_country = game.player_country
|
||||
@@ -114,12 +141,28 @@ class Debriefing:
|
||||
|
||||
self.air_losses = self.dead_aircraft()
|
||||
self.ground_losses = self.dead_ground_units()
|
||||
self.base_captures = self.base_capture_events()
|
||||
|
||||
@property
|
||||
def front_line_losses(self) -> Iterator[FrontLineUnit]:
|
||||
yield from self.ground_losses.player_front_line
|
||||
yield from self.ground_losses.enemy_front_line
|
||||
|
||||
@property
|
||||
def convoy_losses(self) -> Iterator[ConvoyUnit]:
|
||||
yield from self.ground_losses.player_convoy
|
||||
yield from self.ground_losses.enemy_convoy
|
||||
|
||||
@property
|
||||
def cargo_ship_losses(self) -> Iterator[CargoShip]:
|
||||
yield from self.ground_losses.player_cargo_ships
|
||||
yield from self.ground_losses.enemy_cargo_ships
|
||||
|
||||
@property
|
||||
def airlift_losses(self) -> Iterator[AirliftUnits]:
|
||||
yield from self.ground_losses.player_airlifts
|
||||
yield from self.ground_losses.enemy_airlifts
|
||||
|
||||
@property
|
||||
def ground_object_losses(self) -> Iterator[GroundObjectUnit]:
|
||||
yield from self.ground_losses.player_ground_objects
|
||||
@@ -138,8 +181,8 @@ class Debriefing:
|
||||
def casualty_count(self, control_point: ControlPoint) -> int:
|
||||
return len([x for x in self.front_line_losses if x.origin == control_point])
|
||||
|
||||
def front_line_losses_by_type(self, player: bool) -> Dict[Type[UnitType], int]:
|
||||
losses_by_type: Dict[Type[UnitType], int] = defaultdict(int)
|
||||
def front_line_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
|
||||
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
|
||||
if player:
|
||||
losses = self.ground_losses.player_front_line
|
||||
else:
|
||||
@@ -148,6 +191,38 @@ class Debriefing:
|
||||
losses_by_type[loss.unit_type] += 1
|
||||
return losses_by_type
|
||||
|
||||
def convoy_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
|
||||
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
|
||||
if player:
|
||||
losses = self.ground_losses.player_convoy
|
||||
else:
|
||||
losses = self.ground_losses.enemy_convoy
|
||||
for loss in losses:
|
||||
losses_by_type[loss.unit_type] += 1
|
||||
return losses_by_type
|
||||
|
||||
def cargo_ship_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
|
||||
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
|
||||
if player:
|
||||
ships = self.ground_losses.player_cargo_ships
|
||||
else:
|
||||
ships = self.ground_losses.enemy_cargo_ships
|
||||
for ship in ships:
|
||||
for unit_type, count in ship.units.items():
|
||||
losses_by_type[unit_type] += count
|
||||
return losses_by_type
|
||||
|
||||
def airlift_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
|
||||
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
|
||||
if player:
|
||||
losses = self.ground_losses.player_airlifts
|
||||
else:
|
||||
losses = self.ground_losses.enemy_airlifts
|
||||
for loss in losses:
|
||||
for unit_type in loss.cargo:
|
||||
losses_by_type[unit_type] += 1
|
||||
return losses_by_type
|
||||
|
||||
def building_losses_by_type(self, player: bool) -> Dict[str, int]:
|
||||
losses_by_type: Dict[str, int] = defaultdict(int)
|
||||
if player:
|
||||
@@ -165,14 +240,14 @@ class Debriefing:
|
||||
player_losses = []
|
||||
enemy_losses = []
|
||||
for unit_name in self.state_data.killed_aircraft:
|
||||
flight = self.unit_map.flight(unit_name)
|
||||
if flight is None:
|
||||
aircraft = self.unit_map.flight(unit_name)
|
||||
if aircraft is None:
|
||||
logging.error(f"Could not find Flight matching {unit_name}")
|
||||
continue
|
||||
if flight.departure.captured:
|
||||
player_losses.append(flight)
|
||||
if aircraft.flight.departure.captured:
|
||||
player_losses.append(aircraft)
|
||||
else:
|
||||
enemy_losses.append(flight)
|
||||
enemy_losses.append(aircraft)
|
||||
return AirLosses(player_losses, enemy_losses)
|
||||
|
||||
def dead_ground_units(self) -> GroundLosses:
|
||||
@@ -186,6 +261,22 @@ class Debriefing:
|
||||
losses.enemy_front_line.append(front_line_unit)
|
||||
continue
|
||||
|
||||
convoy_unit = self.unit_map.convoy_unit(unit_name)
|
||||
if convoy_unit is not None:
|
||||
if convoy_unit.convoy.player_owned:
|
||||
losses.player_convoy.append(convoy_unit)
|
||||
else:
|
||||
losses.enemy_convoy.append(convoy_unit)
|
||||
continue
|
||||
|
||||
cargo_ship = self.unit_map.cargo_ship(unit_name)
|
||||
if cargo_ship is not None:
|
||||
if cargo_ship.player_owned:
|
||||
losses.player_cargo_ships.append(cargo_ship)
|
||||
else:
|
||||
losses.enemy_cargo_ships.append(cargo_ship)
|
||||
continue
|
||||
|
||||
ground_object_unit = self.unit_map.ground_object_unit(unit_name)
|
||||
if ground_object_unit is not None:
|
||||
if ground_object_unit.ground_object.control_point.captured:
|
||||
@@ -224,17 +315,46 @@ class Debriefing:
|
||||
"have no effect. This may be normal behavior."
|
||||
)
|
||||
|
||||
for unit_name in self.state_data.killed_aircraft:
|
||||
airlift_unit = self.unit_map.airlift_unit(unit_name)
|
||||
if airlift_unit is not None:
|
||||
if airlift_unit.transfer.player:
|
||||
losses.player_airlifts.append(airlift_unit)
|
||||
else:
|
||||
losses.enemy_airlifts.append(airlift_unit)
|
||||
continue
|
||||
|
||||
return losses
|
||||
|
||||
@property
|
||||
def base_capture_events(self):
|
||||
def base_capture_events(self) -> List[BaseCaptureEvent]:
|
||||
"""Keeps only the last instance of a base capture event for each base ID."""
|
||||
reversed_captures = list(reversed(self.state_data.base_capture_events))
|
||||
last_base_cap_indexes = []
|
||||
for idx, base in enumerate(i.split("||")[0] for i in reversed_captures):
|
||||
if base not in [x[1] for x in last_base_cap_indexes]:
|
||||
last_base_cap_indexes.append((idx, base))
|
||||
return [reversed_captures[idx[0]] for idx in last_base_cap_indexes]
|
||||
blue_coalition_id = 2
|
||||
seen = set()
|
||||
captures = []
|
||||
for capture in reversed(self.state_data.base_capture_events):
|
||||
cp_id_str, new_owner_id_str, _name = capture.split("||")
|
||||
cp_id = int(cp_id_str)
|
||||
|
||||
# Only the most recent capture event matters.
|
||||
if cp_id in seen:
|
||||
continue
|
||||
seen.add(cp_id)
|
||||
|
||||
try:
|
||||
control_point = self.game.theater.find_control_point_by_id(cp_id)
|
||||
except KeyError:
|
||||
# Captured base is not a part of the campaign. This happens when neutral
|
||||
# bases are near the conflict. Nothing to do.
|
||||
continue
|
||||
|
||||
captured_by_player = int(new_owner_id_str) == blue_coalition_id
|
||||
if control_point.is_friendly(to_player=captured_by_player):
|
||||
# Base is currently friendly to the new owner. Was captured and
|
||||
# recaptured in the same mission. Nothing to do.
|
||||
continue
|
||||
|
||||
captures.append(BaseCaptureEvent(control_point, captured_by_player))
|
||||
return captures
|
||||
|
||||
|
||||
class PollDebriefingFileThread(threading.Thread):
|
||||
@@ -262,15 +382,21 @@ class PollDebriefingFileThread(threading.Thread):
|
||||
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") 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)
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Dict, Iterator, List, TYPE_CHECKING, Tuple, Type
|
||||
from typing import List, TYPE_CHECKING, Type
|
||||
|
||||
from dcs.mapping import Point
|
||||
from dcs.task import Task
|
||||
from dcs.unittype import UnitType, VehicleType
|
||||
from dcs.unittype import VehicleType
|
||||
|
||||
from game import persistency
|
||||
from game.debriefing import AirLosses, Debriefing
|
||||
@@ -15,7 +14,7 @@ from game.operation.operation import Operation
|
||||
from game.theater import ControlPoint
|
||||
from gen import AirTaskingOrder
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
from ..db import PRICES
|
||||
from ..dcs.groundunittype import GroundUnitType
|
||||
from ..unitmap import UnitMap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -55,7 +54,7 @@ class Event:
|
||||
|
||||
@property
|
||||
def is_player_attacking(self) -> bool:
|
||||
return self.attacker_name == self.game.player_name
|
||||
return self.attacker_name == self.game.player_faction.name
|
||||
|
||||
@property
|
||||
def tasks(self) -> List[Type[Task]]:
|
||||
@@ -122,11 +121,15 @@ class Event:
|
||||
self.game.red_ato, debriefing.air_losses, for_player=False
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def commit_air_losses(debriefing: Debriefing) -> None:
|
||||
def commit_air_losses(self, debriefing: Debriefing) -> None:
|
||||
for loss in debriefing.air_losses.losses:
|
||||
aircraft = loss.unit_type
|
||||
cp = loss.departure
|
||||
if loss.pilot is not None and (
|
||||
not loss.pilot.player
|
||||
or not self.game.settings.invulnerable_player_pilots
|
||||
):
|
||||
loss.pilot.kill()
|
||||
aircraft = loss.flight.unit_type
|
||||
cp = loss.flight.departure
|
||||
available = cp.base.total_units_of_type(aircraft)
|
||||
if available <= 0:
|
||||
logging.error(
|
||||
@@ -138,6 +141,23 @@ class Event:
|
||||
logging.info(f"{aircraft} destroyed from {cp}")
|
||||
cp.base.aircraft[aircraft] -= 1
|
||||
|
||||
@staticmethod
|
||||
def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
|
||||
for package in ato.packages:
|
||||
for flight in package.flights:
|
||||
for idx, pilot in enumerate(flight.roster.pilots):
|
||||
if pilot is None:
|
||||
logging.error(
|
||||
f"Cannot award experience to pilot #{idx} of {flight} "
|
||||
"because no pilot is assigned"
|
||||
)
|
||||
continue
|
||||
pilot.record.missions_flown += 1
|
||||
|
||||
def commit_pilot_experience(self) -> None:
|
||||
self._commit_pilot_experience(self.game.blue_ato)
|
||||
self._commit_pilot_experience(self.game.red_ato)
|
||||
|
||||
@staticmethod
|
||||
def commit_front_line_losses(debriefing: Debriefing) -> None:
|
||||
for loss in debriefing.front_line_losses:
|
||||
@@ -154,6 +174,47 @@ class Event:
|
||||
logging.info(f"{unit_type} destroyed from {control_point}")
|
||||
control_point.base.armor[unit_type] -= 1
|
||||
|
||||
@staticmethod
|
||||
def commit_convoy_losses(debriefing: Debriefing) -> None:
|
||||
for loss in debriefing.convoy_losses:
|
||||
unit_type = loss.unit_type
|
||||
convoy = loss.convoy
|
||||
available = loss.convoy.units.get(unit_type, 0)
|
||||
convoy_name = f"convoy from {convoy.origin} to {convoy.destination}"
|
||||
if available <= 0:
|
||||
logging.error(
|
||||
f"Found killed {unit_type} in {convoy_name} but that convoy has "
|
||||
"none available."
|
||||
)
|
||||
continue
|
||||
|
||||
logging.info(f"{unit_type} destroyed in {convoy_name}")
|
||||
convoy.kill_unit(unit_type)
|
||||
|
||||
@staticmethod
|
||||
def commit_cargo_ship_losses(debriefing: Debriefing) -> None:
|
||||
for ship in debriefing.cargo_ship_losses:
|
||||
logging.info(
|
||||
f"All units destroyed in cargo ship from {ship.origin} to "
|
||||
f"{ship.destination}."
|
||||
)
|
||||
ship.kill_all()
|
||||
|
||||
@staticmethod
|
||||
def commit_airlift_losses(debriefing: Debriefing) -> None:
|
||||
for loss in debriefing.airlift_losses:
|
||||
transfer = loss.transfer
|
||||
airlift_name = f"airlift from {transfer.origin} to {transfer.destination}"
|
||||
for unit_type in loss.cargo:
|
||||
try:
|
||||
transfer.kill_unit(unit_type)
|
||||
logging.info(f"{unit_type} destroyed in {airlift_name}")
|
||||
except KeyError:
|
||||
logging.exception(
|
||||
f"Found killed {unit_type} in {airlift_name} but that airlift "
|
||||
"has none available."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def commit_ground_object_losses(debriefing: Debriefing) -> None:
|
||||
for loss in debriefing.ground_object_losses:
|
||||
@@ -181,62 +242,41 @@ class Event:
|
||||
for damaged_runway in debriefing.damaged_runways:
|
||||
damaged_runway.damage_runway()
|
||||
|
||||
def commit_captures(self, debriefing: Debriefing) -> None:
|
||||
for captured in debriefing.base_captures:
|
||||
try:
|
||||
if captured.captured_by_player:
|
||||
info = Information(
|
||||
f"{captured.control_point} captured!",
|
||||
f"We took control of {captured.control_point}.",
|
||||
self.game.turn,
|
||||
)
|
||||
else:
|
||||
info = Information(
|
||||
f"{captured.control_point} lost!",
|
||||
f"The enemy took control of {captured.control_point}.",
|
||||
self.game.turn,
|
||||
)
|
||||
|
||||
self.game.informations.append(info)
|
||||
captured.control_point.capture(self.game, captured.captured_by_player)
|
||||
logging.info(f"Will run redeploy for {captured.control_point}")
|
||||
self.redeploy_units(captured.control_point)
|
||||
except Exception:
|
||||
logging.exception(f"Could not process base capture {captured}")
|
||||
|
||||
def commit(self, debriefing: Debriefing):
|
||||
logging.info("Committing mission results")
|
||||
|
||||
self.commit_air_losses(debriefing)
|
||||
self.commit_pilot_experience()
|
||||
self.commit_front_line_losses(debriefing)
|
||||
self.commit_convoy_losses(debriefing)
|
||||
self.commit_airlift_losses(debriefing)
|
||||
self.commit_ground_object_losses(debriefing)
|
||||
self.commit_building_losses(debriefing)
|
||||
self.commit_damaged_runways(debriefing)
|
||||
|
||||
# ------------------------------
|
||||
# Captured bases
|
||||
# if self.game.player_country in db.BLUEFOR_FACTIONS:
|
||||
coalition = 2 # Value in DCS mission event for BLUE
|
||||
# else:
|
||||
# coalition = 1 # Value in DCS mission event for RED
|
||||
|
||||
for captured in debriefing.base_capture_events:
|
||||
try:
|
||||
id = int(captured.split("||")[0])
|
||||
new_owner_coalition = int(captured.split("||")[1])
|
||||
|
||||
captured_cps = []
|
||||
for cp in self.game.theater.controlpoints:
|
||||
if cp.id == id:
|
||||
|
||||
if cp.captured and new_owner_coalition != coalition:
|
||||
for_player = False
|
||||
info = Information(
|
||||
cp.name + " lost !",
|
||||
"The ennemy took control of "
|
||||
+ cp.name
|
||||
+ "\nShame on us !",
|
||||
self.game.turn,
|
||||
)
|
||||
self.game.informations.append(info)
|
||||
captured_cps.append(cp)
|
||||
elif not (cp.captured) and new_owner_coalition == coalition:
|
||||
for_player = True
|
||||
info = Information(
|
||||
cp.name + " captured !",
|
||||
"We took control of " + cp.name + "! Great job !",
|
||||
self.game.turn,
|
||||
)
|
||||
self.game.informations.append(info)
|
||||
captured_cps.append(cp)
|
||||
else:
|
||||
continue
|
||||
|
||||
cp.capture(self.game, for_player)
|
||||
|
||||
for cp in captured_cps:
|
||||
logging.info("Will run redeploy for " + cp.name)
|
||||
self.redeploy_units(cp)
|
||||
except Exception:
|
||||
logging.exception(f"Could not process base capture {captured}")
|
||||
|
||||
self.commit_captures(debriefing)
|
||||
self.complete_aircraft_transfers(debriefing)
|
||||
|
||||
# Destroyed units carcass
|
||||
@@ -395,12 +435,12 @@ class Event:
|
||||
moved_units[frontline_unit] = int(count * move_factor)
|
||||
total_units_redeployed = total_units_redeployed + int(count * move_factor)
|
||||
|
||||
destination.base.commision_units(moved_units)
|
||||
destination.base.commission_units(moved_units)
|
||||
source.base.commit_losses(moved_units)
|
||||
|
||||
# Also transfer pending deliveries.
|
||||
for unit_type, count in source.pending_unit_deliveries.units.items():
|
||||
if not issubclass(unit_type, VehicleType):
|
||||
if not isinstance(unit_type, GroundUnitType):
|
||||
continue
|
||||
if count <= 0:
|
||||
# Don't transfer *sales*...
|
||||
@@ -418,63 +458,3 @@ class Event:
|
||||
info = Information("Units redeployed", text, self.game.turn)
|
||||
self.game.informations.append(info)
|
||||
logging.info(text)
|
||||
|
||||
|
||||
class UnitsDeliveryEvent:
|
||||
def __init__(self, control_point: ControlPoint) -> None:
|
||||
self.to_cp = control_point
|
||||
self.units: Dict[Type[UnitType], int] = {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Pending delivery to {}".format(self.to_cp)
|
||||
|
||||
def order(self, units: Dict[Type[UnitType], int]) -> None:
|
||||
for k, v in units.items():
|
||||
self.units[k] = self.units.get(k, 0) + v
|
||||
|
||||
def sell(self, units: Dict[Type[UnitType], int]) -> None:
|
||||
for k, v in units.items():
|
||||
self.units[k] = self.units.get(k, 0) - v
|
||||
|
||||
def consume_each_order(self) -> Iterator[Tuple[Type[UnitType], int]]:
|
||||
while self.units:
|
||||
yield self.units.popitem()
|
||||
|
||||
def refund_all(self, game: Game) -> None:
|
||||
for unit_type, count in self.consume_each_order():
|
||||
try:
|
||||
price = PRICES[unit_type]
|
||||
except KeyError:
|
||||
logging.error(f"Could not refund {unit_type.id}, price unknown")
|
||||
continue
|
||||
|
||||
logging.info(f"Refunding {count} {unit_type.id} at {self.to_cp.name}")
|
||||
game.adjust_budget(price * count, player=self.to_cp.captured)
|
||||
|
||||
def available_next_turn(self, unit_type: Type[UnitType]) -> int:
|
||||
pending_units = self.units.get(unit_type)
|
||||
if pending_units is None:
|
||||
pending_units = 0
|
||||
current_units = self.to_cp.base.total_units_of_type(unit_type)
|
||||
return pending_units + current_units
|
||||
|
||||
def process(self, game: Game) -> None:
|
||||
bought_units: Dict[Type[UnitType], int] = {}
|
||||
sold_units: Dict[Type[UnitType], int] = {}
|
||||
for unit_type, count in self.units.items():
|
||||
coalition = "Ally" if self.to_cp.captured else "Enemy"
|
||||
aircraft = unit_type.id
|
||||
name = self.to_cp.name
|
||||
if count >= 0:
|
||||
bought_units[unit_type] = count
|
||||
game.message(
|
||||
f"{coalition} reinforcements: {aircraft} x {count} at {name}"
|
||||
)
|
||||
else:
|
||||
sold_units[unit_type] = -count
|
||||
game.message(f"{coalition} sold: {aircraft} x {-count} at {name}")
|
||||
self.to_cp.base.commision_units(bought_units)
|
||||
self.to_cp.base.commit_losses(sold_units)
|
||||
self.units = {}
|
||||
bought_units = {}
|
||||
sold_units = {}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Type, List, Any, cast
|
||||
from typing import Optional, Dict, Type, List, Any, Iterator
|
||||
|
||||
import dcs
|
||||
from dcs.countries import country_dict
|
||||
from dcs.planes import plane_map
|
||||
from dcs.unittype import FlyingType, ShipType, VehicleType, UnitType
|
||||
from dcs.vehicles import Armor, Unarmed, Infantry, Artillery, AirDefence
|
||||
from dcs.unittype import ShipType, UnitType
|
||||
|
||||
from game.data.building_data import (
|
||||
WW2_ALLIES_BUILDINGS,
|
||||
@@ -22,11 +21,16 @@ from game.data.doctrine import (
|
||||
COLDWAR_DOCTRINE,
|
||||
WWII_DOCTRINE,
|
||||
)
|
||||
from pydcs_extensions.mod_units import MODDED_VEHICLES, MODDED_AIRPLANES
|
||||
from game.data.groundunitclass import GroundUnitClass
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
|
||||
|
||||
@dataclass
|
||||
class Faction:
|
||||
#: List of locales to use when generating random names. If not set, Faker will
|
||||
#: choose the default locale.
|
||||
locales: Optional[List[str]]
|
||||
|
||||
# Country used by this faction
|
||||
country: str = field(default="")
|
||||
@@ -41,25 +45,25 @@ class Faction:
|
||||
description: str = field(default="")
|
||||
|
||||
# Available aircraft
|
||||
aircrafts: List[Type[FlyingType]] = field(default_factory=list)
|
||||
aircrafts: List[AircraftType] = field(default_factory=list)
|
||||
|
||||
# Available awacs aircraft
|
||||
awacs: List[Type[FlyingType]] = field(default_factory=list)
|
||||
awacs: List[AircraftType] = field(default_factory=list)
|
||||
|
||||
# Available tanker aircraft
|
||||
tankers: List[Type[FlyingType]] = field(default_factory=list)
|
||||
tankers: List[AircraftType] = field(default_factory=list)
|
||||
|
||||
# Available frontline units
|
||||
frontline_units: List[Type[VehicleType]] = field(default_factory=list)
|
||||
frontline_units: List[GroundUnitType] = field(default_factory=list)
|
||||
|
||||
# Available artillery units
|
||||
artillery_units: List[Type[VehicleType]] = field(default_factory=list)
|
||||
artillery_units: List[GroundUnitType] = field(default_factory=list)
|
||||
|
||||
# Infantry units used
|
||||
infantry_units: List[Type[VehicleType]] = field(default_factory=list)
|
||||
infantry_units: List[GroundUnitType] = field(default_factory=list)
|
||||
|
||||
# Logistics units used
|
||||
logistics_units: List[Type[VehicleType]] = field(default_factory=list)
|
||||
logistics_units: List[GroundUnitType] = field(default_factory=list)
|
||||
|
||||
# Possible SAMS site generators for this faction
|
||||
air_defenses: List[str] = field(default_factory=list)
|
||||
@@ -110,7 +114,7 @@ class Faction:
|
||||
has_jtac: bool = field(default=False)
|
||||
|
||||
# Unit to use as JTAC for this faction
|
||||
jtac_unit: Optional[Type[FlyingType]] = field(default=None)
|
||||
jtac_unit: Optional[AircraftType] = field(default=None)
|
||||
|
||||
# doctrine
|
||||
doctrine: Doctrine = field(default=MODERN_DOCTRINE)
|
||||
@@ -119,7 +123,7 @@ class Faction:
|
||||
building_set: List[str] = field(default_factory=list)
|
||||
|
||||
# List of default livery overrides
|
||||
liveries_overrides: Dict[Type[UnitType], List[str]] = field(default_factory=dict)
|
||||
liveries_overrides: Dict[AircraftType, List[str]] = field(default_factory=dict)
|
||||
|
||||
#: Set to True if the faction should force the "Unrestricted satnav" option
|
||||
#: for the mission. This option enables GPS for capable aircraft regardless
|
||||
@@ -130,10 +134,15 @@ class Faction:
|
||||
#: both will use it.
|
||||
unrestricted_satnav: bool = False
|
||||
|
||||
def has_access_to_unittype(self, unit_class: GroundUnitClass) -> bool:
|
||||
for vehicle in itertools.chain(self.frontline_units, self.artillery_units):
|
||||
if vehicle.unit_class is unit_class:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def from_json(cls: Type[Faction], json: Dict[str, Any]) -> Faction:
|
||||
|
||||
faction = Faction()
|
||||
faction = Faction(locales=json.get("locales"))
|
||||
|
||||
faction.country = json.get("country", "/")
|
||||
if faction.country not in [c.name for c in country_dict.values()]:
|
||||
@@ -150,14 +159,26 @@ class Faction:
|
||||
faction.authors = json.get("authors", "")
|
||||
faction.description = json.get("description", "")
|
||||
|
||||
faction.aircrafts = load_all_aircraft(json.get("aircrafts", []))
|
||||
faction.awacs = load_all_aircraft(json.get("awacs", []))
|
||||
faction.tankers = load_all_aircraft(json.get("tankers", []))
|
||||
faction.aircrafts = [AircraftType.named(n) for n in json.get("aircrafts", [])]
|
||||
faction.awacs = [AircraftType.named(n) for n in json.get("awacs", [])]
|
||||
faction.tankers = [AircraftType.named(n) for n in json.get("tankers", [])]
|
||||
|
||||
faction.frontline_units = load_all_vehicles(json.get("frontline_units", []))
|
||||
faction.artillery_units = load_all_vehicles(json.get("artillery_units", []))
|
||||
faction.infantry_units = load_all_vehicles(json.get("infantry_units", []))
|
||||
faction.logistics_units = load_all_vehicles(json.get("logistics_units", []))
|
||||
faction.aircrafts = list(
|
||||
set(faction.aircrafts + faction.awacs + faction.tankers)
|
||||
)
|
||||
|
||||
faction.frontline_units = [
|
||||
GroundUnitType.named(n) for n in json.get("frontline_units", [])
|
||||
]
|
||||
faction.artillery_units = [
|
||||
GroundUnitType.named(n) for n in json.get("artillery_units", [])
|
||||
]
|
||||
faction.infantry_units = [
|
||||
GroundUnitType.named(n) for n in json.get("infantry_units", [])
|
||||
]
|
||||
faction.logistics_units = [
|
||||
GroundUnitType.named(n) for n in json.get("logistics_units", [])
|
||||
]
|
||||
|
||||
faction.ewrs = json.get("ewrs", [])
|
||||
|
||||
@@ -181,7 +202,7 @@ class Faction:
|
||||
faction.has_jtac = json.get("has_jtac", False)
|
||||
jtac_name = json.get("jtac_unit", None)
|
||||
if jtac_name is not None:
|
||||
faction.jtac_unit = load_aircraft(jtac_name)
|
||||
faction.jtac_unit = AircraftType.named(jtac_name)
|
||||
else:
|
||||
faction.jtac_unit = None
|
||||
faction.navy_group_count = int(json.get("navy_group_count", 1))
|
||||
@@ -215,87 +236,110 @@ class Faction:
|
||||
# Load liveries override
|
||||
faction.liveries_overrides = {}
|
||||
liveries_overrides = json.get("liveries_overrides", {})
|
||||
for k, v in liveries_overrides.items():
|
||||
k = load_aircraft(k)
|
||||
if k is not None:
|
||||
faction.liveries_overrides[k] = [s.lower() for s in v]
|
||||
for name, livery in liveries_overrides.items():
|
||||
aircraft = AircraftType.named(name)
|
||||
faction.liveries_overrides[aircraft] = [s.lower() for s in livery]
|
||||
|
||||
faction.unrestricted_satnav = json.get("unrestricted_satnav", False)
|
||||
|
||||
return faction
|
||||
|
||||
@property
|
||||
def units(self) -> List[Type[UnitType]]:
|
||||
return (
|
||||
self.infantry_units
|
||||
+ self.aircrafts
|
||||
+ self.awacs
|
||||
+ self.artillery_units
|
||||
+ self.frontline_units
|
||||
+ self.tankers
|
||||
+ self.logistics_units
|
||||
)
|
||||
def ground_units(self) -> Iterator[GroundUnitType]:
|
||||
yield from self.artillery_units
|
||||
yield from self.frontline_units
|
||||
yield from self.logistics_units
|
||||
|
||||
def infantry_with_class(
|
||||
self, unit_class: GroundUnitClass
|
||||
) -> Iterator[GroundUnitType]:
|
||||
for unit in self.infantry_units:
|
||||
if unit.unit_class is unit_class:
|
||||
yield unit
|
||||
|
||||
def unit_loader(unit: str, class_repository: List[Any]) -> Optional[Type[UnitType]]:
|
||||
"""
|
||||
Find unit by name
|
||||
:param unit: Unit name as string
|
||||
:param class_repository: Repository of classes (Either a module, a class, or a list of classes)
|
||||
:return: The unit as a PyDCS type
|
||||
"""
|
||||
if unit is None:
|
||||
return None
|
||||
elif unit in plane_map.keys():
|
||||
return plane_map[unit]
|
||||
else:
|
||||
for mother_class in class_repository:
|
||||
if getattr(mother_class, unit, None) is not None:
|
||||
return getattr(mother_class, unit)
|
||||
if type(mother_class) is list:
|
||||
for m in mother_class:
|
||||
if m.__name__ == unit:
|
||||
return m
|
||||
logging.error(f"FACTION ERROR : Unable to find {unit} in pydcs")
|
||||
return None
|
||||
def apply_mod_settings(self, mod_settings) -> 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.f22_raptor:
|
||||
self.remove_aircraft("F-22A")
|
||||
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):
|
||||
for i in self.aircrafts:
|
||||
if i.dcs_unit_type.id == name:
|
||||
self.aircrafts.remove(i)
|
||||
|
||||
def load_aircraft(name: str) -> Optional[Type[FlyingType]]:
|
||||
return cast(
|
||||
Optional[FlyingType],
|
||||
unit_loader(name, [dcs.planes, dcs.helicopters, MODDED_AIRPLANES]),
|
||||
)
|
||||
def remove_air_defenses(self, name):
|
||||
for i in self.air_defenses:
|
||||
if i == name:
|
||||
self.air_defenses.remove(i)
|
||||
|
||||
|
||||
def load_all_aircraft(data) -> List[Type[FlyingType]]:
|
||||
items = []
|
||||
for name in data:
|
||||
item = load_aircraft(name)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def load_vehicle(name: str) -> Optional[Type[VehicleType]]:
|
||||
return cast(
|
||||
Optional[FlyingType],
|
||||
unit_loader(
|
||||
name, [Infantry, Unarmed, Armor, AirDefence, Artillery, MODDED_VEHICLES]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def load_all_vehicles(data) -> List[Type[VehicleType]]:
|
||||
items = []
|
||||
for name in data:
|
||||
item = load_vehicle(name)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
return items
|
||||
def remove_vehicle(self, name):
|
||||
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]]:
|
||||
return cast(Optional[FlyingType], unit_loader(name, [dcs.ships]))
|
||||
if (ship := getattr(dcs.ships, name, None)) is not None:
|
||||
return ship
|
||||
logging.error(f"FACTION ERROR : Unable to find {name} in dcs.ships")
|
||||
return None
|
||||
|
||||
|
||||
def load_all_ships(data) -> List[Type[ShipType]]:
|
||||
|
||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterator, Optional, Type
|
||||
from typing import Dict, Iterator, List, Optional, Type
|
||||
|
||||
from game import persistency
|
||||
from game.factions.faction import Faction
|
||||
|
||||
FACTION_DIRECTORY = Path("./resources/factions/")
|
||||
@@ -23,9 +24,16 @@ class FactionLoader:
|
||||
if self._factions is None:
|
||||
self._factions = self.load_factions()
|
||||
|
||||
@staticmethod
|
||||
def find_faction_files_in(path: Path) -> List[Path]:
|
||||
return [f for f in path.glob("*.json") if f.is_file()]
|
||||
|
||||
@classmethod
|
||||
def load_factions(cls: Type[FactionLoader]) -> Dict[str, Faction]:
|
||||
files = [f for f in FACTION_DIRECTORY.glob("*.json") if f.is_file()]
|
||||
user_faction_path = Path(persistency.base_path()) / "Liberation/Factions"
|
||||
files = cls.find_faction_files_in(
|
||||
FACTION_DIRECTORY
|
||||
) + cls.find_faction_files_in(user_faction_path)
|
||||
factions = {}
|
||||
|
||||
for f in files:
|
||||
|
||||
286
game/game.py
286
game/game.py
@@ -1,20 +1,24 @@
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, List
|
||||
|
||||
from dcs.action import Coalition
|
||||
from dcs.mapping import Point
|
||||
from dcs.task import CAP, CAS, PinpointStrike
|
||||
from dcs.vehicles import AirDefence
|
||||
from pydcs_extensions.a4ec.a4ec import A_4E_C
|
||||
from faker import Faker
|
||||
|
||||
from game import db
|
||||
from game.inventory import GlobalAircraftInventory
|
||||
from game.models.game_stats import GameStats
|
||||
from game.plugins import LuaPluginManager
|
||||
from gen import aircraft, naming
|
||||
from gen.ato import AirTaskingOrder
|
||||
from gen.conflictgen import Conflict
|
||||
from gen.flights.ai_flight_planner import CoalitionMissionPlanner
|
||||
@@ -23,17 +27,21 @@ from gen.flights.flight import FlightType
|
||||
from gen.ground_forces.ai_ground_planner import GroundPlanner
|
||||
from . import persistency
|
||||
from .debriefing import Debriefing
|
||||
from .event.event import Event, UnitsDeliveryEvent
|
||||
from .event.event import Event
|
||||
from .event.frontlineattack import FrontlineAttackEvent
|
||||
from .factions.faction import Faction
|
||||
from .income import Income
|
||||
from .infos.information import Information
|
||||
from .navmesh import NavMesh
|
||||
from .procurement import ProcurementAi
|
||||
from .settings import Settings
|
||||
from .theater import ConflictTheater, ControlPoint, TheaterGroundObject
|
||||
from game.theater.theatergroundobject import MissileSiteGroundObject
|
||||
from .procurement import AircraftProcurementRequest, ProcurementAi
|
||||
from .profiling import logged_duration
|
||||
from .settings import Settings, AutoAtoBehavior
|
||||
from .squadrons import AirWing
|
||||
from .theater import ConflictTheater
|
||||
from .theater.bullseye import Bullseye
|
||||
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
|
||||
from .threatzones import ThreatZones
|
||||
from .transfers import PendingTransfers
|
||||
from .unitmap import UnitMap
|
||||
from .weather import Conditions, TimeOfDay
|
||||
|
||||
@@ -80,8 +88,8 @@ class TurnState(Enum):
|
||||
class Game:
|
||||
def __init__(
|
||||
self,
|
||||
player_name: str,
|
||||
enemy_name: str,
|
||||
player_faction: Faction,
|
||||
enemy_faction: Faction,
|
||||
theater: ConflictTheater,
|
||||
start_date: datetime,
|
||||
settings: Settings,
|
||||
@@ -91,52 +99,59 @@ 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
|
||||
self.turn = 0
|
||||
self.player_faction = player_faction
|
||||
self.player_country = player_faction.country
|
||||
self.enemy_faction = enemy_faction
|
||||
self.enemy_country = enemy_faction.country
|
||||
# pass_turn() will be called when initialization is complete which will
|
||||
# increment this to turn 0 before it reaches the player.
|
||||
self.turn = -1
|
||||
# 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.ground_planners: dict[int, GroundPlanner] = {}
|
||||
self.informations = []
|
||||
self.informations.append(Information("Game Start", "-" * 40, 0))
|
||||
# Culling Zones are for areas around points of interest that contain things we may not wish to cull.
|
||||
self.__culling_zones: List[Point] = []
|
||||
# Culling Points are for individual theater ground objects that we don't wish to cull.
|
||||
self.__culling_points: List[Point] = []
|
||||
self.__destroyed_units: List[str] = []
|
||||
self.savepath = ""
|
||||
self.budget = player_budget
|
||||
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.blue_procurement_requests: List[AircraftProcurementRequest] = []
|
||||
self.red_procurement_requests: List[AircraftProcurementRequest] = []
|
||||
|
||||
self.blue_ato = AirTaskingOrder()
|
||||
self.red_ato = AirTaskingOrder()
|
||||
|
||||
self.blue_bullseye = Bullseye(Point(0, 0))
|
||||
self.red_bullseye = Bullseye(Point(0, 0))
|
||||
|
||||
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
|
||||
|
||||
self.transfers = PendingTransfers(self)
|
||||
|
||||
self.sanitize_sides()
|
||||
|
||||
self.on_load()
|
||||
self.blue_faker = Faker(self.player_faction.locales)
|
||||
self.red_faker = Faker(self.enemy_faction.locales)
|
||||
|
||||
# Turn 0 procurement. We don't actually have any missions to plan, but
|
||||
# the planner will tell us what it would like to plan so we can use that
|
||||
# to drive purchase decisions.
|
||||
blue_planner = CoalitionMissionPlanner(self, is_player=True)
|
||||
blue_planner.plan_missions()
|
||||
self.blue_air_wing = AirWing(self, player=True)
|
||||
self.red_air_wing = AirWing(self, player=False)
|
||||
|
||||
red_planner = CoalitionMissionPlanner(self, is_player=False)
|
||||
red_planner.plan_missions()
|
||||
self.on_load(game_still_initializing=True)
|
||||
|
||||
self.plan_procurement(blue_planner, red_planner)
|
||||
|
||||
def __getstate__(self) -> Dict[str, Any]:
|
||||
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.
|
||||
@@ -144,13 +159,32 @@ class Game:
|
||||
del state["red_threat_zone"]
|
||||
del state["blue_navmesh"]
|
||||
del state["red_navmesh"]
|
||||
del state["blue_faker"]
|
||||
del state["red_faker"]
|
||||
return state
|
||||
|
||||
def __setstate__(self, state: Dict[str, Any]) -> None:
|
||||
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
|
||||
|
||||
def procurement_requests_for(
|
||||
self, player: bool
|
||||
) -> List[AircraftProcurementRequest]:
|
||||
if player:
|
||||
return self.blue_procurement_requests
|
||||
return self.red_procurement_requests
|
||||
|
||||
def transit_network_for(self, player: bool) -> TransitNetwork:
|
||||
if player:
|
||||
return self.blue_transit_network
|
||||
return self.red_transit_network
|
||||
|
||||
def generate_conditions(self) -> Conditions:
|
||||
return Conditions.generate(
|
||||
self.theater, self.current_day, self.current_turn_time_of_day, self.settings
|
||||
@@ -169,19 +203,31 @@ class Game:
|
||||
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]
|
||||
|
||||
def faction_for(self, player: bool) -> Faction:
|
||||
if player:
|
||||
return self.player_faction
|
||||
return self.enemy_faction
|
||||
|
||||
def faker_for(self, player: bool) -> Faker:
|
||||
if player:
|
||||
return self.blue_faker
|
||||
return self.red_faker
|
||||
|
||||
def air_wing_for(self, player: bool) -> AirWing:
|
||||
if player:
|
||||
return self.blue_air_wing
|
||||
return self.red_air_wing
|
||||
|
||||
def country_for(self, player: bool) -> str:
|
||||
if player:
|
||||
return self.player_country
|
||||
return self.enemy_country
|
||||
|
||||
def bullseye_for(self, player: bool) -> Bullseye:
|
||||
if player:
|
||||
return self.blue_bullseye
|
||||
return self.red_bullseye
|
||||
|
||||
def _roll(self, prob, mult):
|
||||
if self.settings.version == "dev":
|
||||
# always generate all events for dev
|
||||
@@ -196,17 +242,17 @@ class Game:
|
||||
player_cp,
|
||||
enemy_cp,
|
||||
enemy_cp.position,
|
||||
self.player_name,
|
||||
self.enemy_name,
|
||||
self.player_faction.name,
|
||||
self.enemy_faction.name,
|
||||
)
|
||||
)
|
||||
|
||||
def _generate_events(self):
|
||||
for front_line in self.theater.conflicts(True):
|
||||
for front_line in self.theater.conflicts():
|
||||
self._generate_player_event(
|
||||
FrontlineAttackEvent,
|
||||
front_line.control_point_a,
|
||||
front_line.control_point_b,
|
||||
front_line.blue_cp,
|
||||
front_line.red_cp,
|
||||
)
|
||||
|
||||
def adjust_budget(self, amount: float, player: bool) -> None:
|
||||
@@ -243,43 +289,79 @@ class Game:
|
||||
return (
|
||||
event
|
||||
and event.attacker_name
|
||||
and event.attacker_name == self.player_name
|
||||
and event.attacker_name == self.player_faction.name
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"{event} was passed when an Event type was expected")
|
||||
|
||||
def on_load(self) -> None:
|
||||
def on_load(self, game_still_initializing: bool = False) -> None:
|
||||
if not hasattr(self, "name_generator"):
|
||||
self.name_generator = naming.namegen
|
||||
# Hack: Replace the global name generator state with the state from the save
|
||||
# game.
|
||||
#
|
||||
# We need to persist this state so that names generated after game load don't
|
||||
# conflict with those generated before exit.
|
||||
naming.namegen = self.name_generator
|
||||
LuaPluginManager.load_settings(self.settings)
|
||||
ObjectiveDistanceCache.set_theater(self.theater)
|
||||
self.compute_conflicts_position()
|
||||
self.compute_threat_zones()
|
||||
if not game_still_initializing:
|
||||
self.compute_threat_zones()
|
||||
self.blue_faker = Faker(self.faction_for(player=True).locales)
|
||||
self.red_faker = Faker(self.faction_for(player=False).locales)
|
||||
|
||||
def pass_turn(self, no_action: bool = False) -> None:
|
||||
logging.info("Pass turn")
|
||||
def reset_ato(self) -> None:
|
||||
self.blue_ato.clear()
|
||||
self.red_ato.clear()
|
||||
|
||||
def finish_turn(self, skipped: bool = False) -> None:
|
||||
self.informations.append(
|
||||
Information("End of turn #" + str(self.turn), "-" * 40, 0)
|
||||
)
|
||||
self.turn += 1
|
||||
|
||||
# Need to recompute before transfers and deliveries to account for captures.
|
||||
# This happens in in initialize_turn as well, because cheating doesn't advance a
|
||||
# turn but can capture bases so we need to recompute there as well.
|
||||
self.compute_transit_networks()
|
||||
|
||||
# Must happen *before* unit deliveries are handled, or else new units will spawn
|
||||
# one hop ahead. ControlPoint.process_turn handles unit deliveries.
|
||||
self.transfers.perform_transfers()
|
||||
|
||||
# Needs to happen *before* planning transfers so we don't cancel them.
|
||||
self.reset_ato()
|
||||
for control_point in self.theater.controlpoints:
|
||||
control_point.process_turn(self)
|
||||
|
||||
self.process_enemy_income()
|
||||
self.blue_air_wing.replenish()
|
||||
self.red_air_wing.replenish()
|
||||
|
||||
self.process_player_income()
|
||||
|
||||
if not no_action and self.turn > 1:
|
||||
if not skipped:
|
||||
for cp in self.theater.player_points():
|
||||
cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY)
|
||||
else:
|
||||
elif self.turn > 1:
|
||||
for cp in self.theater.player_points():
|
||||
if not cp.is_carrier and not cp.is_lha:
|
||||
cp.base.affect_strength(-PLAYER_BASE_STRENGTH_RECOVERY)
|
||||
|
||||
self.conditions = self.generate_conditions()
|
||||
|
||||
self.process_enemy_income()
|
||||
self.process_player_income()
|
||||
|
||||
def begin_turn_0(self) -> None:
|
||||
self.turn = 0
|
||||
self.initialize_turn()
|
||||
|
||||
def pass_turn(self, no_action: bool = False) -> None:
|
||||
logging.info("Pass turn")
|
||||
with logged_duration("Turn finalization"):
|
||||
self.finish_turn(no_action)
|
||||
with logged_duration("Turn initialization"):
|
||||
self.initialize_turn()
|
||||
|
||||
# Autosave progress
|
||||
persistency.autosave(self)
|
||||
|
||||
@@ -298,13 +380,22 @@ class Game:
|
||||
|
||||
return TurnState.CONTINUE
|
||||
|
||||
def set_bullseye(self) -> None:
|
||||
player_cp, enemy_cp = self.theater.closest_opposing_control_points()
|
||||
self.blue_bullseye = Bullseye(enemy_cp.position)
|
||||
self.red_bullseye = Bullseye(player_cp.position)
|
||||
|
||||
def initialize_turn(self) -> None:
|
||||
self.events = []
|
||||
self._generate_events()
|
||||
|
||||
self.set_bullseye()
|
||||
|
||||
# Update statistics
|
||||
self.game_stats.update(self)
|
||||
|
||||
self.blue_air_wing.reset()
|
||||
self.red_air_wing.reset()
|
||||
self.aircraft_inventory.reset()
|
||||
for cp in self.theater.controlpoints:
|
||||
self.aircraft_inventory.set_from_control_point(cp)
|
||||
@@ -315,17 +406,30 @@ class Game:
|
||||
return self.process_win_loss(turn_state)
|
||||
|
||||
# Plan flights & combat for next turn
|
||||
self.compute_conflicts_position()
|
||||
self.compute_threat_zones()
|
||||
with logged_duration("Computing conflict positions"):
|
||||
self.compute_conflicts_position()
|
||||
with logged_duration("Threat zone computation"):
|
||||
self.compute_threat_zones()
|
||||
with logged_duration("Transit network identification"):
|
||||
self.compute_transit_networks()
|
||||
self.ground_planners = {}
|
||||
self.blue_ato.clear()
|
||||
self.red_ato.clear()
|
||||
|
||||
blue_planner = CoalitionMissionPlanner(self, is_player=True)
|
||||
blue_planner.plan_missions()
|
||||
self.blue_procurement_requests.clear()
|
||||
self.red_procurement_requests.clear()
|
||||
|
||||
red_planner = CoalitionMissionPlanner(self, is_player=False)
|
||||
red_planner.plan_missions()
|
||||
with logged_duration("Procurement of airlift assets"):
|
||||
self.transfers.order_airlift_assets()
|
||||
with logged_duration("Transport planning"):
|
||||
self.transfers.plan_transports()
|
||||
|
||||
with logged_duration("Blue mission planning"):
|
||||
if self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled:
|
||||
blue_planner = CoalitionMissionPlanner(self, is_player=True)
|
||||
blue_planner.plan_missions()
|
||||
|
||||
with logged_duration("Red mission planning"):
|
||||
red_planner = CoalitionMissionPlanner(self, is_player=False)
|
||||
red_planner.plan_missions()
|
||||
|
||||
for cp in self.theater.controlpoints:
|
||||
if cp.has_frontline:
|
||||
@@ -333,19 +437,15 @@ class Game:
|
||||
gplanner.plan_groundwar()
|
||||
self.ground_planners[cp.id] = gplanner
|
||||
|
||||
self.plan_procurement(blue_planner, red_planner)
|
||||
self.plan_procurement()
|
||||
|
||||
def plan_procurement(
|
||||
self,
|
||||
blue_planner: CoalitionMissionPlanner,
|
||||
red_planner: CoalitionMissionPlanner,
|
||||
) -> None:
|
||||
def plan_procurement(self) -> None:
|
||||
# The first turn needs to buy a *lot* of aircraft to fill CAPs, so it
|
||||
# gets much more of the budget that turn. Otherwise budget (after
|
||||
# repairs) is split evenly between air and ground. For the default
|
||||
# starting budget of 2000 this gives 600 to ground forces and 1400 to
|
||||
# aircraft.
|
||||
ground_portion = 0.3 if self.turn == 0 else 0.5
|
||||
# aircraft. After that the budget will be spend proportionally based on how much is already invested
|
||||
|
||||
self.budget = ProcurementAi(
|
||||
self,
|
||||
for_player=True,
|
||||
@@ -353,8 +453,7 @@ class Game:
|
||||
manage_runways=self.settings.automate_runway_repair,
|
||||
manage_front_line=self.settings.automate_front_line_reinforcements,
|
||||
manage_aircraft=self.settings.automate_aircraft_reinforcements,
|
||||
front_line_budget_share=ground_portion,
|
||||
).spend_budget(self.budget, blue_planner.procurement_requests)
|
||||
).spend_budget(self.budget)
|
||||
|
||||
self.enemy_budget = ProcurementAi(
|
||||
self,
|
||||
@@ -363,8 +462,7 @@ class Game:
|
||||
manage_runways=True,
|
||||
manage_front_line=True,
|
||||
manage_aircraft=True,
|
||||
front_line_budget_share=ground_portion,
|
||||
).spend_budget(self.enemy_budget, red_planner.procurement_requests)
|
||||
).spend_budget(self.enemy_budget)
|
||||
|
||||
def message(self, text: str) -> None:
|
||||
self.informations.append(Information(text, turn=self.turn))
|
||||
@@ -391,6 +489,13 @@ class Game:
|
||||
self.current_group_id += 1
|
||||
return self.current_group_id
|
||||
|
||||
def compute_transit_networks(self) -> None:
|
||||
self.blue_transit_network = self.compute_transit_network_for(player=True)
|
||||
self.red_transit_network = self.compute_transit_network_for(player=False)
|
||||
|
||||
def compute_transit_network_for(self, player: bool) -> TransitNetwork:
|
||||
return TransitNetworkBuilder(self.theater, player).build()
|
||||
|
||||
def compute_threat_zones(self) -> None:
|
||||
self.blue_threat_zone = ThreatZones.for_faction(self, player=True)
|
||||
self.red_threat_zone = ThreatZones.for_faction(self, player=False)
|
||||
@@ -417,23 +522,15 @@ class Game:
|
||||
:return: List of points of interests
|
||||
"""
|
||||
zones = []
|
||||
points = []
|
||||
|
||||
# By default, use the existing frontline conflict position
|
||||
for front_line in self.theater.conflicts():
|
||||
position = Conflict.frontline_position(
|
||||
front_line.control_point_a, front_line.control_point_b, self.theater
|
||||
)
|
||||
position = Conflict.frontline_position(front_line, self.theater)
|
||||
zones.append(position[0])
|
||||
zones.append(front_line.control_point_a.position)
|
||||
zones.append(front_line.control_point_b.position)
|
||||
zones.append(front_line.blue_cp.position)
|
||||
zones.append(front_line.red_cp.position)
|
||||
|
||||
for cp in self.theater.controlpoints:
|
||||
# Don't cull missile sites - their range is long enough to make them
|
||||
# easily culled despite being a threat.
|
||||
for tgo in cp.ground_objects:
|
||||
if isinstance(tgo, MissileSiteGroundObject):
|
||||
points.append(tgo.position)
|
||||
# If do_not_cull_carrier is enabled, add carriers as culling point
|
||||
if self.settings.perf_do_not_cull_carrier:
|
||||
if cp.is_carrier or cp.is_lha:
|
||||
@@ -477,7 +574,6 @@ class Game:
|
||||
zones.append(Point(0, 0))
|
||||
|
||||
self.__culling_zones = zones
|
||||
self.__culling_points = points
|
||||
|
||||
def add_destroyed_units(self, data):
|
||||
pos = Point(data["x"], data["z"])
|
||||
@@ -493,19 +589,12 @@ class Game:
|
||||
:param pos: Position you are tryng to spawn stuff at
|
||||
:return: True if units can not be added at given position
|
||||
"""
|
||||
if self.settings.perf_culling == False:
|
||||
if not self.settings.perf_culling:
|
||||
return False
|
||||
else:
|
||||
for z in self.__culling_zones:
|
||||
if (
|
||||
z.distance_to_point(pos)
|
||||
< self.settings.perf_culling_distance * 1000
|
||||
):
|
||||
return False
|
||||
for p in self.__culling_points:
|
||||
if p.distance_to_point(pos) < 2500:
|
||||
return False
|
||||
return True
|
||||
for z in self.__culling_zones:
|
||||
if z.distance_to_point(pos) < self.settings.perf_culling_distance * 1000:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_culling_zones(self):
|
||||
"""
|
||||
@@ -514,13 +603,6 @@ class Game:
|
||||
"""
|
||||
return self.__culling_zones
|
||||
|
||||
def get_culling_points(self):
|
||||
"""
|
||||
Check culling points
|
||||
:return: List of culling points
|
||||
"""
|
||||
return self.__culling_points
|
||||
|
||||
# 1 = red, 2 = blue
|
||||
def get_player_coalition_id(self):
|
||||
return 2
|
||||
|
||||
@@ -6,6 +6,7 @@ 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:
|
||||
@@ -17,9 +18,9 @@ class ControlPointAircraftInventory:
|
||||
|
||||
def __init__(self, control_point: ControlPoint) -> None:
|
||||
self.control_point = control_point
|
||||
self.inventory: Dict[Type[FlyingType], int] = defaultdict(int)
|
||||
self.inventory: Dict[AircraftType, int] = defaultdict(int)
|
||||
|
||||
def add_aircraft(self, aircraft: Type[FlyingType], count: int) -> None:
|
||||
def add_aircraft(self, aircraft: AircraftType, count: int) -> None:
|
||||
"""Adds aircraft to the inventory.
|
||||
|
||||
Args:
|
||||
@@ -28,7 +29,7 @@ class ControlPointAircraftInventory:
|
||||
"""
|
||||
self.inventory[aircraft] += count
|
||||
|
||||
def remove_aircraft(self, aircraft: Type[FlyingType], count: int) -> None:
|
||||
def remove_aircraft(self, aircraft: AircraftType, count: int) -> None:
|
||||
"""Removes aircraft from the inventory.
|
||||
|
||||
Args:
|
||||
@@ -42,12 +43,12 @@ class ControlPointAircraftInventory:
|
||||
available = self.inventory[aircraft]
|
||||
if available < count:
|
||||
raise ValueError(
|
||||
f"Cannot remove {count} {aircraft.id} from "
|
||||
f"Cannot remove {count} {aircraft} from "
|
||||
f"{self.control_point.name}. Only have {available}."
|
||||
)
|
||||
self.inventory[aircraft] -= count
|
||||
|
||||
def available(self, aircraft: Type[FlyingType]) -> int:
|
||||
def available(self, aircraft: AircraftType) -> int:
|
||||
"""Returns the number of available aircraft of the given type.
|
||||
|
||||
Args:
|
||||
@@ -59,14 +60,14 @@ class ControlPointAircraftInventory:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def types_available(self) -> Iterator[Type[FlyingType]]:
|
||||
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[Type[FlyingType], int]]:
|
||||
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:
|
||||
@@ -107,9 +108,9 @@ class GlobalAircraftInventory:
|
||||
return self.inventories[control_point]
|
||||
|
||||
@property
|
||||
def available_types_for_player(self) -> Iterator[Type[FlyingType]]:
|
||||
def available_types_for_player(self) -> Iterator[AircraftType]:
|
||||
"""Iterates over all aircraft types available to the player."""
|
||||
seen: Set[Type[FlyingType]] = set()
|
||||
seen: Set[AircraftType] = set()
|
||||
for control_point, inventory in self.inventories.items():
|
||||
if control_point.captured:
|
||||
for aircraft in inventory.types_available:
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
from game.theater import ControlPoint
|
||||
|
||||
|
||||
class FrontlineData:
|
||||
"""
|
||||
This Data structure will store information about an existing frontline
|
||||
"""
|
||||
|
||||
def __init__(self, from_cp: ControlPoint, to_cp: ControlPoint):
|
||||
self.to_cp = to_cp
|
||||
self.from_cp = from_cp
|
||||
self.enemy_units_position = []
|
||||
self.blue_units_position = []
|
||||
@@ -103,7 +103,7 @@ class NavMesh:
|
||||
# currently.
|
||||
p = ShapelyPoint(point.x, point.y)
|
||||
for navpoly in self.polys:
|
||||
if navpoly.poly.contains(p):
|
||||
if navpoly.poly.intersects(p):
|
||||
return navpoly
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from __future__ import annotations
|
||||
from game.theater.theatergroundobject import TheaterGroundObject
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Iterable, List, Optional, Set
|
||||
from typing import Iterable, List, Set, TYPE_CHECKING
|
||||
|
||||
from dcs import Mission
|
||||
from dcs.action import DoScript, DoScriptFile
|
||||
@@ -14,14 +13,18 @@ from dcs.lua.parse import loads
|
||||
from dcs.mapping import Point
|
||||
from dcs.translation import String
|
||||
from dcs.triggers import TriggerStart
|
||||
|
||||
from game.plugins import LuaPluginManager
|
||||
from game.theater.theatergroundobject import TheaterGroundObject
|
||||
from gen import Conflict, FlightType, VisualGenerator
|
||||
from gen.aircraft import AIRCRAFT_DATA, AircraftConflictGenerator, FlightData
|
||||
from gen.aircraft import AircraftConflictGenerator, FlightData
|
||||
from gen.airfields import AIRFIELD_DATA
|
||||
from gen.airsupportgen import AirSupport, AirSupportConflictGenerator
|
||||
from gen.armor import GroundConflictGenerator, JtacInfo
|
||||
from gen.beacons import load_beacons_for_terrain
|
||||
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
|
||||
from gen.cargoshipgen import CargoShipGenerator
|
||||
from gen.convoygen import ConvoyGenerator
|
||||
from gen.environmentgen import EnvironmentGenerator
|
||||
from gen.forcedoptionsgen import ForcedOptionsGenerator
|
||||
from gen.groundobjectsgen import GroundObjectsGenerator
|
||||
@@ -30,9 +33,8 @@ from gen.naming import namegen
|
||||
from gen.radios import RadioFrequency, RadioRegistry
|
||||
from gen.tacan import TacanRegistry
|
||||
from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator
|
||||
|
||||
from .. import db
|
||||
from ..theater import Airfield
|
||||
from ..theater import Airfield, FrontLine
|
||||
from ..unitmap import UnitMap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -42,18 +44,13 @@ if TYPE_CHECKING:
|
||||
class Operation:
|
||||
"""Static class for managing the final Mission generation"""
|
||||
|
||||
current_mission = None # type: Mission
|
||||
airgen = None # type: AircraftConflictGenerator
|
||||
triggersgen = None # type: TriggersGenerator
|
||||
airsupportgen = None # type: AirSupportConflictGenerator
|
||||
visualgen = None # type: VisualGenerator
|
||||
groundobjectgen = None # type: GroundObjectsGenerator
|
||||
briefinggen = None # type: BriefingGenerator
|
||||
forcedoptionsgen = None # type: ForcedOptionsGenerator
|
||||
radio_registry: Optional[RadioRegistry] = None
|
||||
tacan_registry: Optional[TacanRegistry] = None
|
||||
game = None # type: Game
|
||||
environment_settings = None
|
||||
current_mission: Mission
|
||||
airgen: AircraftConflictGenerator
|
||||
airsupportgen: AirSupportConflictGenerator
|
||||
groundobjectgen: GroundObjectsGenerator
|
||||
radio_registry: RadioRegistry
|
||||
tacan_registry: TacanRegistry
|
||||
game: Game
|
||||
trigger_radius = TRIGGER_RADIUS_MEDIUM
|
||||
is_quick = None
|
||||
player_awacs_enabled = True
|
||||
@@ -79,10 +76,9 @@ class Operation:
|
||||
for frontline in cls.game.theater.conflicts():
|
||||
yield Conflict(
|
||||
cls.game.theater,
|
||||
frontline.control_point_a,
|
||||
frontline.control_point_b,
|
||||
cls.game.player_name,
|
||||
cls.game.enemy_name,
|
||||
frontline,
|
||||
cls.game.player_faction.name,
|
||||
cls.game.enemy_faction.name,
|
||||
cls.game.player_country,
|
||||
cls.game.enemy_country,
|
||||
frontline.position,
|
||||
@@ -98,10 +94,9 @@ class Operation:
|
||||
)
|
||||
return Conflict(
|
||||
cls.game.theater,
|
||||
player_cp,
|
||||
enemy_cp,
|
||||
cls.game.player_name,
|
||||
cls.game.enemy_name,
|
||||
FrontLine(player_cp, enemy_cp),
|
||||
cls.game.player_faction.name,
|
||||
cls.game.enemy_faction.name,
|
||||
cls.game.player_country,
|
||||
cls.game.enemy_country,
|
||||
mid_point,
|
||||
@@ -113,8 +108,12 @@ class Operation:
|
||||
|
||||
@classmethod
|
||||
def _setup_mission_coalitions(cls):
|
||||
cls.current_mission.coalition["blue"] = Coalition("blue")
|
||||
cls.current_mission.coalition["red"] = Coalition("red")
|
||||
cls.current_mission.coalition["blue"] = Coalition(
|
||||
"blue", bullseye=cls.game.blue_bullseye.to_pydcs()
|
||||
)
|
||||
cls.current_mission.coalition["red"] = Coalition(
|
||||
"red", bullseye=cls.game.red_bullseye.to_pydcs()
|
||||
)
|
||||
|
||||
p_country = cls.game.player_country
|
||||
e_country = cls.game.enemy_country
|
||||
@@ -176,13 +175,16 @@ class Operation:
|
||||
gen.add_dynamic_runway(dynamic_runway)
|
||||
|
||||
for tanker in airsupportgen.air_support.tankers:
|
||||
gen.add_tanker(tanker)
|
||||
if tanker.blue:
|
||||
gen.add_tanker(tanker)
|
||||
|
||||
for aewc in airsupportgen.air_support.awacs:
|
||||
gen.add_awacs(aewc)
|
||||
if aewc.blue:
|
||||
gen.add_awacs(aewc)
|
||||
|
||||
for jtac in jtacs:
|
||||
gen.add_jtac(jtac)
|
||||
if jtac.blue:
|
||||
gen.add_jtac(jtac)
|
||||
|
||||
for flight in airgen.flights:
|
||||
gen.add_flight(flight)
|
||||
@@ -213,23 +215,7 @@ class Operation:
|
||||
for flight in flights:
|
||||
if not flight.client_units:
|
||||
continue
|
||||
cls.assign_channels_to_flight(flight, air_support)
|
||||
|
||||
@staticmethod
|
||||
def assign_channels_to_flight(flight: FlightData, air_support: AirSupport) -> None:
|
||||
"""Assigns preset radio channels for a client flight."""
|
||||
airframe = flight.aircraft_type
|
||||
|
||||
try:
|
||||
aircraft_data = AIRCRAFT_DATA[airframe.id]
|
||||
except KeyError:
|
||||
logging.warning(f"No aircraft data for {airframe.id}")
|
||||
return
|
||||
|
||||
if aircraft_data.channel_allocator is not None:
|
||||
aircraft_data.channel_allocator.assign_channels_for_flight(
|
||||
flight, air_support
|
||||
)
|
||||
flight.aircraft_type.assign_channels_for_flight(flight, air_support)
|
||||
|
||||
@classmethod
|
||||
def _create_tacan_registry(
|
||||
@@ -308,6 +294,7 @@ class Operation:
|
||||
# Set mission time and weather conditions.
|
||||
EnvironmentGenerator(cls.current_mission, cls.game.conditions).generate()
|
||||
cls._generate_ground_units()
|
||||
cls._generate_transports()
|
||||
cls._generate_destroyed_units()
|
||||
cls._generate_air_units()
|
||||
cls.assign_channels_to_flights(
|
||||
@@ -321,13 +308,8 @@ class Operation:
|
||||
|
||||
# Setup combined arms parameters
|
||||
cls.current_mission.groundControl.pilot_can_control_vehicles = cls.ca_slots > 0
|
||||
if cls.game.player_country in [
|
||||
country.name
|
||||
for country in cls.current_mission.coalition["blue"].countries.values()
|
||||
]:
|
||||
cls.current_mission.groundControl.blue_tactical_commander = cls.ca_slots
|
||||
else:
|
||||
cls.current_mission.groundControl.red_tactical_commander = cls.ca_slots
|
||||
cls.current_mission.groundControl.blue_tactical_commander = cls.ca_slots
|
||||
cls.current_mission.groundControl.blue_observer = 1
|
||||
|
||||
# Options
|
||||
forcedoptionsgen = ForcedOptionsGenerator(cls.current_mission, cls.game)
|
||||
@@ -377,6 +359,7 @@ class Operation:
|
||||
cls.game.settings,
|
||||
cls.game,
|
||||
cls.radio_registry,
|
||||
cls.tacan_registry,
|
||||
cls.unit_map,
|
||||
air_support=cls.airsupportgen.air_support,
|
||||
)
|
||||
@@ -401,16 +384,16 @@ class Operation:
|
||||
@classmethod
|
||||
def _generate_ground_conflicts(cls) -> None:
|
||||
"""For each frontline in the Operation, generate the ground conflicts and JTACs"""
|
||||
for front_line in cls.game.theater.conflicts(True):
|
||||
player_cp = front_line.control_point_a
|
||||
enemy_cp = front_line.control_point_b
|
||||
cls.jtacs = []
|
||||
for front_line in cls.game.theater.conflicts():
|
||||
player_cp = front_line.blue_cp
|
||||
enemy_cp = front_line.red_cp
|
||||
conflict = Conflict.frontline_cas_conflict(
|
||||
cls.game.player_name,
|
||||
cls.game.enemy_name,
|
||||
cls.game.player_faction.name,
|
||||
cls.game.enemy_faction.name,
|
||||
cls.current_mission.country(cls.game.player_country),
|
||||
cls.current_mission.country(cls.game.enemy_country),
|
||||
player_cp,
|
||||
enemy_cp,
|
||||
front_line,
|
||||
cls.game.theater,
|
||||
)
|
||||
# Generate frontline ops
|
||||
@@ -428,6 +411,12 @@ class Operation:
|
||||
ground_conflict_gen.generate()
|
||||
cls.jtacs.extend(ground_conflict_gen.jtacs)
|
||||
|
||||
@classmethod
|
||||
def _generate_transports(cls) -> None:
|
||||
"""Generates convoys for unit transfers by road."""
|
||||
ConvoyGenerator(cls.current_mission, cls.game, cls.unit_map).generate()
|
||||
CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate()
|
||||
|
||||
@classmethod
|
||||
def reset_naming_ids(cls):
|
||||
namegen.reset_numbers()
|
||||
@@ -452,7 +441,7 @@ class Operation:
|
||||
|
||||
for tanker in airsupportgen.air_support.tankers:
|
||||
luaData["Tankers"][tanker.callsign] = {
|
||||
"dcsGroupName": tanker.dcsGroupName,
|
||||
"dcsGroupName": tanker.group_name,
|
||||
"callsign": tanker.callsign,
|
||||
"variant": tanker.variant,
|
||||
"radio": tanker.freq.mhz,
|
||||
@@ -462,14 +451,14 @@ class Operation:
|
||||
if airsupportgen.air_support.awacs:
|
||||
for awacs in airsupportgen.air_support.awacs:
|
||||
luaData["AWACs"][awacs.callsign] = {
|
||||
"dcsGroupName": awacs.dcsGroupName,
|
||||
"dcsGroupName": awacs.group_name,
|
||||
"callsign": awacs.callsign,
|
||||
"radio": awacs.freq.mhz,
|
||||
}
|
||||
|
||||
for jtac in jtacs:
|
||||
luaData["JTACs"][jtac.callsign] = {
|
||||
"dcsGroupName": jtac.dcsGroupName,
|
||||
"dcsGroupName": jtac.group_name,
|
||||
"callsign": jtac.callsign,
|
||||
"zone": jtac.region,
|
||||
"dcsUnit": jtac.unit_name,
|
||||
@@ -589,8 +578,7 @@ class Operation:
|
||||
zone = data["zone"]
|
||||
laserCode = data["laserCode"]
|
||||
dcsUnit = data["dcsUnit"]
|
||||
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone='{zone}', laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n"
|
||||
# lua += f" {{name='{dcsGroupName}', description='JTAC {callsign} ', information='Laser:{laserCode}', jtac={laserCode} }}, \n"
|
||||
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n"
|
||||
lua += "}"
|
||||
|
||||
# Process the Target Points
|
||||
|
||||
@@ -2,16 +2,18 @@ import logging
|
||||
import os
|
||||
import pickle
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
_dcs_saved_game_folder: Optional[str] = None
|
||||
_file_abs_path = None
|
||||
|
||||
|
||||
def setup(user_folder: str):
|
||||
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,16 +22,20 @@ 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):
|
||||
|
||||
@@ -3,22 +3,24 @@ from __future__ import annotations
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator, List, Optional, TYPE_CHECKING, Type
|
||||
|
||||
from dcs.unittype import FlyingType, VehicleType
|
||||
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.theater import ControlPoint, MissionTarget
|
||||
from game.utils import Distance
|
||||
from gen.flights.ai_flight_planner_db import aircraft_for_task
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import FlightType
|
||||
from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
|
||||
FRONTLINE_RESERVES_FACTOR = 1.3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AircraftProcurementRequest:
|
||||
@@ -43,27 +45,55 @@ class ProcurementAi:
|
||||
manage_runways: bool,
|
||||
manage_front_line: bool,
|
||||
manage_aircraft: bool,
|
||||
front_line_budget_share: float,
|
||||
) -> None:
|
||||
if front_line_budget_share > 1.0:
|
||||
raise ValueError
|
||||
|
||||
self.game = game
|
||||
self.is_player = for_player
|
||||
self.air_wing = game.air_wing_for(for_player)
|
||||
self.faction = faction
|
||||
self.manage_runways = manage_runways
|
||||
self.manage_front_line = manage_front_line
|
||||
self.manage_aircraft = manage_aircraft
|
||||
self.front_line_budget_share = front_line_budget_share
|
||||
self.threat_zones = self.game.threat_zone_for(not self.is_player)
|
||||
|
||||
def spend_budget(
|
||||
self, budget: float, aircraft_requests: List[AircraftProcurementRequest]
|
||||
) -> float:
|
||||
def calculate_ground_unit_budget_share(self) -> float:
|
||||
armor_investment = 0
|
||||
aircraft_investment = 0
|
||||
|
||||
# faction has no ground units
|
||||
if (
|
||||
len(self.faction.artillery_units) == 0
|
||||
and len(self.faction.frontline_units) == 0
|
||||
):
|
||||
return 0
|
||||
|
||||
# faction has no planes
|
||||
if len(self.faction.aircrafts) == 0:
|
||||
return 1
|
||||
|
||||
for cp in self.owned_points:
|
||||
cp_ground_units = cp.allocated_ground_units(self.game.transfers)
|
||||
armor_investment += cp_ground_units.total_value
|
||||
cp_aircraft = cp.allocated_aircraft(self.game)
|
||||
aircraft_investment += cp_aircraft.total_value
|
||||
|
||||
total_investment = aircraft_investment + armor_investment
|
||||
if total_investment == 0:
|
||||
# Turn 0 or all units were destroyed. Either way, split 30/70.
|
||||
return 0.3
|
||||
|
||||
# the more planes we have, the more ground units we want and vice versa
|
||||
ground_unit_share = aircraft_investment / total_investment
|
||||
if ground_unit_share > 1.0:
|
||||
raise ValueError
|
||||
|
||||
return ground_unit_share
|
||||
|
||||
def spend_budget(self, budget: float) -> float:
|
||||
if self.manage_runways:
|
||||
budget = self.repair_runways(budget)
|
||||
if self.manage_front_line:
|
||||
armor_budget = math.ceil(budget * self.front_line_budget_share)
|
||||
armor_budget = budget * self.calculate_ground_unit_budget_share()
|
||||
budget -= armor_budget
|
||||
budget += self.reinforce_front_line(armor_budget)
|
||||
|
||||
@@ -72,20 +102,20 @@ class ProcurementAi:
|
||||
if not self.is_player:
|
||||
budget += self.sell_incomplete_squadrons()
|
||||
if self.manage_aircraft:
|
||||
budget = self.purchase_aircraft(budget, aircraft_requests)
|
||||
budget = self.purchase_aircraft(budget)
|
||||
return budget
|
||||
|
||||
def sell_incomplete_squadrons(self) -> float:
|
||||
# Selling incomplete squadrons gives us more money to spend on the next
|
||||
# turn. This serves as a short term fix for
|
||||
# https://github.com/Khopa/dcs_liberation/issues/41.
|
||||
# https://github.com/dcs-liberation/dcs_liberation/issues/41.
|
||||
#
|
||||
# Only incomplete squadrons which are unlikely to get used will be sold
|
||||
# rather than all unused aircraft because the unused aircraft are what
|
||||
# make OCA strikes worthwhile.
|
||||
#
|
||||
# This option is only used by the AI since players cannot cancel sales
|
||||
# (https://github.com/Khopa/dcs_liberation/issues/365).
|
||||
# (https://github.com/dcs-liberation/dcs_liberation/issues/365).
|
||||
total = 0.0
|
||||
for cp in self.game.theater.control_points_for(self.is_player):
|
||||
inventory = self.game.aircraft_inventory.for_control_point(cp)
|
||||
@@ -95,7 +125,7 @@ class ProcurementAi:
|
||||
if available % 2 == 0:
|
||||
continue
|
||||
inventory.remove_aircraft(aircraft, 1)
|
||||
total += db.PRICES[aircraft]
|
||||
total += aircraft.price
|
||||
return total
|
||||
|
||||
def repair_runways(self, budget: float) -> float:
|
||||
@@ -115,28 +145,19 @@ class ProcurementAi:
|
||||
)
|
||||
return budget
|
||||
|
||||
def random_affordable_ground_unit(
|
||||
self, budget: float, cp: ControlPoint
|
||||
) -> Optional[Type[VehicleType]]:
|
||||
affordable_units = [
|
||||
u
|
||||
for u in self.faction.frontline_units + self.faction.artillery_units
|
||||
if db.PRICES[u] <= budget
|
||||
]
|
||||
|
||||
total_number_aa = (
|
||||
cp.base.total_frontline_aa + cp.pending_frontline_aa_deliveries_count
|
||||
def affordable_ground_unit_of_class(
|
||||
self, budget: float, unit_class: GroundUnitClass
|
||||
) -> Optional[GroundUnitType]:
|
||||
faction_units = set(self.faction.frontline_units) | set(
|
||||
self.faction.artillery_units
|
||||
)
|
||||
total_non_aa = (
|
||||
cp.base.total_armor + cp.pending_deliveries_count - total_number_aa
|
||||
)
|
||||
max_aa = math.ceil(total_non_aa / 8)
|
||||
of_class = {u for u in faction_units if u.unit_class is unit_class}
|
||||
|
||||
# Limit the number of AA units the AI will buy
|
||||
if not total_number_aa < max_aa:
|
||||
for unit in [u for u in affordable_units if u in TYPE_SHORAD]:
|
||||
affordable_units.remove(unit)
|
||||
# faction has no access to needed unit type, take a random unit
|
||||
if not of_class:
|
||||
of_class = faction_units
|
||||
|
||||
affordable_units = [u for u in of_class if u.price <= budget]
|
||||
if not affordable_units:
|
||||
return None
|
||||
return random.choice(affordable_units)
|
||||
@@ -145,39 +166,74 @@ class ProcurementAi:
|
||||
if not self.faction.frontline_units and not self.faction.artillery_units:
|
||||
return budget
|
||||
|
||||
# TODO: Attempt to transfer from reserves.
|
||||
|
||||
while budget > 0:
|
||||
candidates = self.front_line_candidates()
|
||||
if not candidates:
|
||||
cp = self.ground_reinforcement_candidate()
|
||||
if cp is None:
|
||||
break
|
||||
|
||||
cp = random.choice(candidates)
|
||||
unit = self.random_affordable_ground_unit(budget, cp)
|
||||
most_needed_type = self.most_needed_unit_class(cp)
|
||||
unit = self.affordable_ground_unit_of_class(budget, most_needed_type)
|
||||
if unit is None:
|
||||
# Can't afford any more units.
|
||||
break
|
||||
|
||||
budget -= db.PRICES[unit]
|
||||
budget -= unit.price
|
||||
cp.pending_unit_deliveries.order({unit: 1})
|
||||
|
||||
return budget
|
||||
|
||||
def _affordable_aircraft_of_types(
|
||||
def most_needed_unit_class(self, cp: ControlPoint) -> GroundUnitClass:
|
||||
worst_balanced: Optional[GroundUnitClass] = None
|
||||
worst_fulfillment = math.inf
|
||||
for unit_class in GroundUnitClass:
|
||||
if not self.faction.has_access_to_unittype(unit_class):
|
||||
continue
|
||||
|
||||
current_ratio = self.cost_ratio_of_ground_unit(cp, unit_class)
|
||||
desired_ratio = (
|
||||
self.faction.doctrine.ground_unit_procurement_ratios.for_unit_class(
|
||||
unit_class
|
||||
)
|
||||
)
|
||||
if not desired_ratio:
|
||||
continue
|
||||
if current_ratio >= desired_ratio:
|
||||
continue
|
||||
fulfillment = current_ratio / desired_ratio
|
||||
if fulfillment < worst_fulfillment:
|
||||
worst_fulfillment = fulfillment
|
||||
worst_balanced = unit_class
|
||||
if worst_balanced is None:
|
||||
return GroundUnitClass.Tank
|
||||
return worst_balanced
|
||||
|
||||
def _affordable_aircraft_for_task(
|
||||
self,
|
||||
types: List[Type[FlyingType]],
|
||||
task: FlightType,
|
||||
airbase: ControlPoint,
|
||||
number: int,
|
||||
max_price: float,
|
||||
) -> Optional[Type[FlyingType]]:
|
||||
best_choice: Optional[Type[FlyingType]] = None
|
||||
for unit in [u for u in self.faction.aircrafts if u in types]:
|
||||
if db.PRICES[unit] * number > max_price:
|
||||
) -> 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
|
||||
|
||||
# Affordable and compatible. To keep some variety, skip with a 50/50
|
||||
# chance. Might be a good idea to have the chance to skip based on
|
||||
# the price compared to the rest of the choices.
|
||||
for squadron in self.air_wing.squadrons_for(unit):
|
||||
if task in squadron.auto_assignable_mission_types:
|
||||
break
|
||||
else:
|
||||
continue
|
||||
|
||||
# Affordable, compatible, and we have a squadron capable of the task. To
|
||||
# keep some variety, skip with a 50/50 chance. Might be a good idea to have
|
||||
# the chance to skip based on the price compared to the rest of the choices.
|
||||
best_choice = unit
|
||||
if random.choice([True, False]):
|
||||
break
|
||||
@@ -185,28 +241,42 @@ class ProcurementAi:
|
||||
|
||||
def affordable_aircraft_for(
|
||||
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
|
||||
) -> Optional[Type[FlyingType]]:
|
||||
return self._affordable_aircraft_of_types(
|
||||
aircraft_for_task(request.task_capability), airbase, request.number, budget
|
||||
) -> Optional[AircraftType]:
|
||||
return self._affordable_aircraft_for_task(
|
||||
request.task_capability, airbase, request.number, budget
|
||||
)
|
||||
|
||||
def purchase_aircraft(
|
||||
self, budget: float, aircraft_requests: List[AircraftProcurementRequest]
|
||||
) -> float:
|
||||
for request in aircraft_requests:
|
||||
for airbase in self.best_airbases_for(request):
|
||||
unit = self.affordable_aircraft_for(request, airbase, budget)
|
||||
if unit is None:
|
||||
# Can't afford any aircraft capable of performing the
|
||||
# required mission that can operate from this airbase. We
|
||||
# might be able to afford aircraft at other airbases though,
|
||||
# in the case where the airbase we attempted to use is only
|
||||
# able to operate expensive aircraft.
|
||||
continue
|
||||
def fulfill_aircraft_request(
|
||||
self, request: AircraftProcurementRequest, budget: float
|
||||
) -> Tuple[float, bool]:
|
||||
for airbase in self.best_airbases_for(request):
|
||||
unit = self.affordable_aircraft_for(request, airbase, budget)
|
||||
if unit is None:
|
||||
# Can't afford any aircraft capable of performing the
|
||||
# required mission that can operate from this airbase. We
|
||||
# might be able to afford aircraft at other airbases though,
|
||||
# in the case where the airbase we attempted to use is only
|
||||
# able to operate expensive aircraft.
|
||||
continue
|
||||
|
||||
budget -= db.PRICES[unit] * request.number
|
||||
airbase.pending_unit_deliveries.order({unit: request.number})
|
||||
budget -= unit.price * request.number
|
||||
airbase.pending_unit_deliveries.order({unit: request.number})
|
||||
return budget, True
|
||||
return budget, False
|
||||
|
||||
def purchase_aircraft(self, budget: float) -> float:
|
||||
for request in self.game.procurement_requests_for(self.is_player):
|
||||
if not list(self.best_airbases_for(request)):
|
||||
# No airbases in range of this request. Skip it.
|
||||
continue
|
||||
budget, fulfilled = self.fulfill_aircraft_request(request, budget)
|
||||
if not fulfilled:
|
||||
# The request was not fulfilled because we could not afford any suitable
|
||||
# aircraft. Rather than continuing, which could proceed to buy tons of
|
||||
# cheap escorts that will never allow us to plan a strike package, stop
|
||||
# buying so we can save the budget until a turn where we *can* afford to
|
||||
# fill the package.
|
||||
break
|
||||
return budget
|
||||
|
||||
@property
|
||||
@@ -221,11 +291,9 @@ class ProcurementAi:
|
||||
) -> Iterator[ControlPoint]:
|
||||
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
|
||||
threatened = []
|
||||
for cp in distance_cache.airfields_within(request.range):
|
||||
for cp in distance_cache.operational_airfields_within(request.range):
|
||||
if not cp.is_friendly(self.is_player):
|
||||
continue
|
||||
if not cp.runway_is_operational():
|
||||
continue
|
||||
if cp.unclaimed_parking(self.game) < request.number:
|
||||
continue
|
||||
if self.threat_zones.threatened(cp.position):
|
||||
@@ -233,37 +301,69 @@ class ProcurementAi:
|
||||
yield cp
|
||||
yield from threatened
|
||||
|
||||
def front_line_candidates(self) -> List[ControlPoint]:
|
||||
candidates = []
|
||||
def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
|
||||
worst_supply = math.inf
|
||||
understaffed: Optional[ControlPoint] = None
|
||||
|
||||
# Prefer to buy front line units at active front lines that are not
|
||||
# already overloaded.
|
||||
for cp in self.owned_points:
|
||||
if cp.expected_ground_units_next_turn.total >= 30:
|
||||
if not cp.has_active_frontline:
|
||||
continue
|
||||
|
||||
if not cp.has_ground_unit_source(self.game):
|
||||
# No source of ground units, so can't buy anything.
|
||||
continue
|
||||
|
||||
purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR
|
||||
allocated = cp.allocated_ground_units(self.game.transfers)
|
||||
if allocated.total >= purchase_target:
|
||||
# Control point is already sufficiently defended.
|
||||
continue
|
||||
for connected in cp.connected_points:
|
||||
if not connected.is_friendly(to_player=self.is_player):
|
||||
candidates.append(cp)
|
||||
if allocated.total < worst_supply:
|
||||
worst_supply = allocated.total
|
||||
understaffed = cp
|
||||
|
||||
if not candidates:
|
||||
# Otherwise buy reserves, but don't exceed 10 reserve units per CP.
|
||||
# These units do not exist in the world until the CP becomes
|
||||
# connected to an active front line, at which point all these units
|
||||
# will suddenly appear at the gates of the newly captured CP.
|
||||
#
|
||||
# To avoid sudden overwhelming numbers of units we avoid buying
|
||||
# many.
|
||||
#
|
||||
# Also, do not bother buying units at bases that will never connect
|
||||
# to a front line.
|
||||
for cp in self.owned_points:
|
||||
if not cp.can_deploy_ground_units:
|
||||
continue
|
||||
if cp.expected_ground_units_next_turn.total >= 10:
|
||||
continue
|
||||
if cp.is_global:
|
||||
continue
|
||||
candidates.append(cp)
|
||||
if understaffed is not None:
|
||||
return understaffed
|
||||
|
||||
return candidates
|
||||
# Otherwise buy reserves, but don't exceed the amount defined in the settings.
|
||||
# These units do not exist in the world until the CP becomes
|
||||
# connected to an active front line, at which point all these units
|
||||
# will suddenly appear at the gates of the newly captured CP.
|
||||
#
|
||||
# To avoid sudden overwhelming numbers of units we avoid buying
|
||||
# many.
|
||||
#
|
||||
# Also, do not bother buying units at bases that will never connect
|
||||
# to a front line.
|
||||
for cp in self.owned_points:
|
||||
if cp.is_global:
|
||||
continue
|
||||
if not cp.can_recruit_ground_units(self.game):
|
||||
continue
|
||||
|
||||
allocated = cp.allocated_ground_units(self.game.transfers)
|
||||
if allocated.total >= self.game.settings.reserves_procurement_target:
|
||||
continue
|
||||
|
||||
if allocated.total < worst_supply:
|
||||
worst_supply = allocated.total
|
||||
understaffed = cp
|
||||
|
||||
return understaffed
|
||||
|
||||
def cost_ratio_of_ground_unit(
|
||||
self, control_point: ControlPoint, unit_class: GroundUnitClass
|
||||
) -> float:
|
||||
allocations = control_point.allocated_ground_units(self.game.transfers)
|
||||
class_cost = 0
|
||||
total_cost = 0
|
||||
for unit_type, count in allocations.all.items():
|
||||
cost = unit_type.price * count
|
||||
total_cost += cost
|
||||
if unit_type.unit_class is unit_class:
|
||||
class_cost += cost
|
||||
if not total_cost:
|
||||
return 0
|
||||
return class_cost / total_cost
|
||||
|
||||
35
game/profiling.py
Normal file
35
game/profiling.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import timeit
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
@contextmanager
|
||||
def logged_duration(event: str) -> Iterator[None]:
|
||||
start = timeit.default_timer()
|
||||
yield
|
||||
end = timeit.default_timer()
|
||||
logging.debug("%s took %s", event, timedelta(seconds=end - start))
|
||||
|
||||
|
||||
class MultiEventTracer:
|
||||
def __init__(self) -> None:
|
||||
self.events: dict[str, timedelta] = defaultdict(timedelta)
|
||||
|
||||
def __enter__(self) -> MultiEventTracer:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
for event, duration in self.events.items():
|
||||
logging.debug("%s took %s", event, duration)
|
||||
|
||||
@contextmanager
|
||||
def trace(self, event: str) -> Iterator[None]:
|
||||
start = timeit.default_timer()
|
||||
yield
|
||||
end = timeit.default_timer()
|
||||
self.events[event] += timedelta(seconds=end - start)
|
||||
298
game/radio/channels.py
Normal file
298
game/radio/channels.py
Normal file
@@ -0,0 +1,298 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen import FlightData, AirSupport
|
||||
|
||||
|
||||
class RadioChannelAllocator:
|
||||
"""Base class for radio channel allocators."""
|
||||
|
||||
def assign_channels_for_flight(
|
||||
self, flight: FlightData, air_support: AirSupport
|
||||
) -> None:
|
||||
"""Assigns mission frequencies to preset channels for the flight."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def from_cfg(cls, cfg: dict[str, Any]) -> RadioChannelAllocator:
|
||||
return cls()
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommonRadioChannelAllocator(RadioChannelAllocator):
|
||||
"""Radio channel allocator suitable for most aircraft.
|
||||
|
||||
Most of the aircraft with preset channels available have one or more radios
|
||||
with 20 or more channels available (typically per-radio, but this is not the
|
||||
case for the JF-17).
|
||||
"""
|
||||
|
||||
#: Index of the radio used for intra-flight communications. Matches the
|
||||
#: index of the panel_radio field of the pydcs.dcs.planes object.
|
||||
inter_flight_radio_index: Optional[int]
|
||||
|
||||
#: Index of the radio used for intra-flight communications. Matches the
|
||||
#: index of the panel_radio field of the pydcs.dcs.planes object.
|
||||
intra_flight_radio_index: Optional[int]
|
||||
|
||||
def assign_channels_for_flight(
|
||||
self, flight: FlightData, air_support: AirSupport
|
||||
) -> None:
|
||||
if self.intra_flight_radio_index is not None:
|
||||
flight.assign_channel(
|
||||
self.intra_flight_radio_index, 1, flight.intra_flight_channel
|
||||
)
|
||||
|
||||
if self.inter_flight_radio_index is None:
|
||||
return
|
||||
|
||||
# For cases where the inter-flight and intra-flight radios share presets
|
||||
# (the JF-17 only has one set of channels, even though it can use two
|
||||
# channels simultaneously), start assigning inter-flight channels at 2.
|
||||
radio_id = self.inter_flight_radio_index
|
||||
if self.intra_flight_radio_index == radio_id:
|
||||
first_channel = 2
|
||||
else:
|
||||
first_channel = 1
|
||||
|
||||
last_channel = flight.num_radio_channels(radio_id)
|
||||
channel_alloc = iter(range(first_channel, last_channel + 1))
|
||||
|
||||
if flight.departure.atc is not None:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), flight.departure.atc)
|
||||
|
||||
# TODO: If there ever are multiple AWACS, limit to mission relevant.
|
||||
for awacs in air_support.awacs:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), awacs.freq)
|
||||
|
||||
if flight.arrival != flight.departure and flight.arrival.atc is not None:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc)
|
||||
|
||||
try:
|
||||
# TODO: Skip incompatible tankers.
|
||||
for tanker in air_support.tankers:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), tanker.freq)
|
||||
|
||||
if flight.divert is not None and flight.divert.atc is not None:
|
||||
flight.assign_channel(radio_id, next(channel_alloc), flight.divert.atc)
|
||||
except StopIteration:
|
||||
# Any remaining channels are nice-to-haves, but not necessary for
|
||||
# the few aircraft with a small number of channels available.
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def from_cfg(cls, cfg: dict[str, Any]) -> CommonRadioChannelAllocator:
|
||||
return CommonRadioChannelAllocator(
|
||||
inter_flight_radio_index=cfg["inter_flight_radio_index"],
|
||||
intra_flight_radio_index=cfg["intra_flight_radio_index"],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "common"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoOpChannelAllocator(RadioChannelAllocator):
|
||||
"""Channel allocator for aircraft that don't support preset channels."""
|
||||
|
||||
def assign_channels_for_flight(
|
||||
self, flight: FlightData, air_support: AirSupport
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "noop"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FarmerRadioChannelAllocator(RadioChannelAllocator):
|
||||
"""Preset channel allocator for the MiG-19P."""
|
||||
|
||||
def assign_channels_for_flight(
|
||||
self, flight: FlightData, air_support: AirSupport
|
||||
) -> None:
|
||||
# The Farmer only has 6 preset channels. It also only has a VHF radio,
|
||||
# and currently our ATC data and AWACS are only in the UHF band.
|
||||
radio_id = 1
|
||||
flight.assign_channel(radio_id, 1, flight.intra_flight_channel)
|
||||
# TODO: Assign 4-6 to VHF frequencies of departure, arrival, and divert.
|
||||
# TODO: Assign 2 and 3 to AWACS if it is VHF.
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "farmer"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ViggenRadioChannelAllocator(RadioChannelAllocator):
|
||||
"""Preset channel allocator for the AJS37."""
|
||||
|
||||
def assign_channels_for_flight(
|
||||
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
|
||||
# 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)
|
||||
if flight.departure.atc is not None:
|
||||
flight.assign_channel(radio_id, 4, flight.departure.atc)
|
||||
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.
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "viggen"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SCR522RadioChannelAllocator(RadioChannelAllocator):
|
||||
"""Preset channel allocator for the SCR522 WW2 radios. (4 channels)"""
|
||||
|
||||
def assign_channels_for_flight(
|
||||
self, flight: FlightData, air_support: AirSupport
|
||||
) -> None:
|
||||
radio_id = 1
|
||||
flight.assign_channel(radio_id, 1, flight.intra_flight_channel)
|
||||
if flight.departure.atc is not None:
|
||||
flight.assign_channel(radio_id, 2, flight.departure.atc)
|
||||
if flight.arrival.atc is not None:
|
||||
flight.assign_channel(radio_id, 3, flight.arrival.atc)
|
||||
|
||||
# TODO : Some GCI on Channel 4 ?
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "SCR-522"
|
||||
|
||||
|
||||
class ChannelNamer:
|
||||
"""Base class allowing channel name customization per-aircraft.
|
||||
|
||||
Most aircraft will want to customize this behavior, but the default is
|
||||
reasonable for any aircraft with numbered radios.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def channel_name(radio_id: int, channel_id: int) -> str:
|
||||
"""Returns the name of the channel for the given radio and channel."""
|
||||
return f"COMM{radio_id} Ch {channel_id}"
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "default"
|
||||
|
||||
|
||||
class SingleRadioChannelNamer(ChannelNamer):
|
||||
"""Channel namer for the aircraft with only a single radio.
|
||||
|
||||
Aircraft like the MiG-19P and the MiG-21bis only have a single radio, so
|
||||
it's not necessary for us to name the radio when naming the channel.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def channel_name(radio_id: int, channel_id: int) -> str:
|
||||
return f"Ch {channel_id}"
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "single"
|
||||
|
||||
|
||||
class HueyChannelNamer(ChannelNamer):
|
||||
"""Channel namer for the UH-1H."""
|
||||
|
||||
@staticmethod
|
||||
def channel_name(radio_id: int, channel_id: int) -> str:
|
||||
return f"COM3 Ch {channel_id}"
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "huey"
|
||||
|
||||
|
||||
class MirageChannelNamer(ChannelNamer):
|
||||
"""Channel namer for the M-2000."""
|
||||
|
||||
@staticmethod
|
||||
def channel_name(radio_id: int, channel_id: int) -> str:
|
||||
radio_name = ["V/UHF", "UHF"][radio_id - 1]
|
||||
return f"{radio_name} Ch {channel_id}"
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "mirage"
|
||||
|
||||
|
||||
class TomcatChannelNamer(ChannelNamer):
|
||||
"""Channel namer for the F-14."""
|
||||
|
||||
@staticmethod
|
||||
def channel_name(radio_id: int, channel_id: int) -> str:
|
||||
radio_name = ["UHF", "VHF/UHF"][radio_id - 1]
|
||||
return f"{radio_name} Ch {channel_id}"
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "tomcat"
|
||||
|
||||
|
||||
class ViggenChannelNamer(ChannelNamer):
|
||||
"""Channel namer for the AJS37."""
|
||||
|
||||
@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}"
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "viggen"
|
||||
|
||||
|
||||
class ViperChannelNamer(ChannelNamer):
|
||||
"""Channel namer for the F-16."""
|
||||
|
||||
@staticmethod
|
||||
def channel_name(radio_id: int, channel_id: int) -> str:
|
||||
return f"COM{radio_id} Ch {channel_id}"
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "viper"
|
||||
|
||||
|
||||
class SCR522ChannelNamer(ChannelNamer):
|
||||
"""
|
||||
Channel namer for P-51 & P-47D
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def channel_name(radio_id: int, channel_id: int) -> str:
|
||||
if channel_id > 3:
|
||||
return "?"
|
||||
else:
|
||||
return f"Button " + "ABCD"[channel_id - 1]
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "SCR-522"
|
||||
89
game/scenery_group.py
Normal file
89
game/scenery_group.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
from game.theater.theatergroundobject import NAME_BY_CATEGORY
|
||||
from dcs.triggers import TriggerZone
|
||||
|
||||
from typing import Iterable, List
|
||||
|
||||
|
||||
class SceneryGroupError(RuntimeError):
|
||||
"""Error for when there are insufficient conditions to create a SceneryGroup."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SceneryGroup:
|
||||
"""Store information about a scenery objective."""
|
||||
|
||||
def __init__(
|
||||
self, zone_def: TriggerZone, zones: Iterable[TriggerZone], category: str
|
||||
) -> None:
|
||||
|
||||
self.zone_def = zone_def
|
||||
self.zones = zones
|
||||
self.position = zone_def.position
|
||||
self.category = category
|
||||
|
||||
@staticmethod
|
||||
def from_trigger_zones(trigger_zones: Iterable[TriggerZone]) -> List[SceneryGroup]:
|
||||
"""Define scenery objectives based on their encompassing blue/red circle."""
|
||||
zone_definitions = []
|
||||
white_zones = []
|
||||
|
||||
scenery_groups = []
|
||||
|
||||
# Aggregate trigger zones into different groups based on color.
|
||||
for zone in trigger_zones:
|
||||
if SceneryGroup.is_blue(zone):
|
||||
zone_definitions.append(zone)
|
||||
if SceneryGroup.is_white(zone):
|
||||
white_zones.append(zone)
|
||||
|
||||
# For each objective definition.
|
||||
for zone_def in zone_definitions:
|
||||
|
||||
zone_def_radius = zone_def.radius
|
||||
zone_def_position = zone_def.position
|
||||
zone_def_name = zone_def.name
|
||||
|
||||
if len(zone_def.properties) == 0:
|
||||
raise SceneryGroupError(
|
||||
"Undefined SceneryGroup category in TriggerZone: " + zone_def_name
|
||||
)
|
||||
|
||||
# Arbitrary campaign design requirement: First property must define the category.
|
||||
zone_def_category = zone_def.properties[1].get("value").lower()
|
||||
|
||||
valid_white_zones = []
|
||||
|
||||
for zone in list(white_zones):
|
||||
if zone.position.distance_to_point(zone_def_position) < zone_def_radius:
|
||||
valid_white_zones.append(zone)
|
||||
white_zones.remove(zone)
|
||||
|
||||
if len(valid_white_zones) > 0 and zone_def_category in NAME_BY_CATEGORY:
|
||||
scenery_groups.append(
|
||||
SceneryGroup(zone_def, valid_white_zones, zone_def_category)
|
||||
)
|
||||
elif len(valid_white_zones) == 0:
|
||||
raise SceneryGroupError(
|
||||
"No white triggerzones found in: " + zone_def_name
|
||||
)
|
||||
elif zone_def_category not in NAME_BY_CATEGORY:
|
||||
raise SceneryGroupError(
|
||||
"Incorrect TriggerZone category definition for: "
|
||||
+ zone_def_name
|
||||
+ " in campaign definition. TriggerZone category: "
|
||||
+ zone_def_category
|
||||
)
|
||||
|
||||
return scenery_groups
|
||||
|
||||
@staticmethod
|
||||
def is_blue(zone: TriggerZone) -> bool:
|
||||
# Blue in RGB is [0 Red], [0 Green], [1 Blue]. Ignore the fourth position: Transparency.
|
||||
return zone.color[1] == 0 and zone.color[2] == 0 and zone.color[3] == 1
|
||||
|
||||
@staticmethod
|
||||
def is_white(zone: TriggerZone) -> bool:
|
||||
# White in RGB is [1 Red], [1 Green], [1 Blue]. Ignore the fourth position: Transparency.
|
||||
return zone.color[1] == 1 and zone.color[2] == 1 and zone.color[3] == 1
|
||||
@@ -1,15 +1,26 @@
|
||||
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"
|
||||
@@ -23,23 +34,44 @@ class Settings:
|
||||
player_income_multiplier: float = 1.0
|
||||
enemy_income_multiplier: float = 1.0
|
||||
|
||||
#: Feature flag for squadron limits.
|
||||
enable_squadron_pilot_limits: bool = False
|
||||
|
||||
#: The maximum number of pilots a squadron can have at one time. Changing this after
|
||||
#: the campaign has started will have no immediate effect; pilots already in the
|
||||
#: squadron will not be removed if the limit is lowered and pilots will not be
|
||||
#: immediately created if the limit is raised.
|
||||
squadron_pilot_limit: int = 12
|
||||
|
||||
#: The number of pilots a squadron can replace per turn.
|
||||
squadron_replenishment_rate: int = 4
|
||||
|
||||
default_start_type: str = "Cold"
|
||||
|
||||
# Mission specific
|
||||
desired_player_mission_duration: timedelta = timedelta(minutes=60)
|
||||
|
||||
# Campaign management
|
||||
automate_runway_repair: bool = False
|
||||
automate_front_line_reinforcements: bool = False
|
||||
automate_aircraft_reinforcements: bool = False
|
||||
restrict_weapons_by_date: bool = False
|
||||
disable_legacy_aewc: bool = 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
|
||||
|
||||
455
game/squadrons.py
Normal file
455
game/squadrons.py
Normal file
@@ -0,0 +1,455 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import unique, Enum
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Tuple,
|
||||
TYPE_CHECKING,
|
||||
Optional,
|
||||
Iterator,
|
||||
Sequence,
|
||||
)
|
||||
|
||||
import yaml
|
||||
from faker import Faker
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.settings import AutoAtoBehavior
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
|
||||
@dataclass
|
||||
class PilotRecord:
|
||||
missions_flown: int = field(default=0)
|
||||
|
||||
|
||||
@unique
|
||||
class PilotStatus(Enum):
|
||||
Active = "Active"
|
||||
OnLeave = "On leave"
|
||||
Dead = "Dead"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pilot:
|
||||
name: str
|
||||
player: bool = field(default=False)
|
||||
status: PilotStatus = field(default=PilotStatus.Active)
|
||||
record: PilotRecord = field(default_factory=PilotRecord)
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
return self.status is not PilotStatus.Dead
|
||||
|
||||
@property
|
||||
def on_leave(self) -> bool:
|
||||
return self.status is PilotStatus.OnLeave
|
||||
|
||||
def send_on_leave(self) -> None:
|
||||
if self.status is not PilotStatus.Active:
|
||||
raise RuntimeError("Only active pilots may be sent on leave")
|
||||
self.status = PilotStatus.OnLeave
|
||||
|
||||
def return_from_leave(self) -> None:
|
||||
if self.status is not PilotStatus.OnLeave:
|
||||
raise RuntimeError("Only pilots on leave may be returned from leave")
|
||||
self.status = PilotStatus.Active
|
||||
|
||||
def kill(self) -> None:
|
||||
self.status = PilotStatus.Dead
|
||||
|
||||
@classmethod
|
||||
def random(cls, faker: Faker) -> Pilot:
|
||||
return Pilot(faker.name())
|
||||
|
||||
|
||||
@dataclass
|
||||
class Squadron:
|
||||
name: str
|
||||
nickname: Optional[str]
|
||||
country: str
|
||||
role: str
|
||||
aircraft: AircraftType
|
||||
livery: Optional[str]
|
||||
mission_types: tuple[FlightType, ...]
|
||||
|
||||
#: The pool of pilots that have not yet been assigned to the squadron. This only
|
||||
#: happens when a preset squadron defines more preset pilots than the squadron limit
|
||||
#: allows. This pool will be consumed before random pilots are generated.
|
||||
pilot_pool: list[Pilot]
|
||||
|
||||
current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False)
|
||||
available_pilots: list[Pilot] = field(
|
||||
default_factory=list, init=False, hash=False, compare=False
|
||||
)
|
||||
|
||||
auto_assignable_mission_types: set[FlightType] = field(
|
||||
init=False, hash=False, compare=False
|
||||
)
|
||||
|
||||
# We need a reference to the Game so that we can access the Faker without needing to
|
||||
# persist it to the save game, or having to reconstruct it (it's not cheap) each
|
||||
# time we create or load a squadron.
|
||||
game: Game = field(hash=False, compare=False)
|
||||
player: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
|
||||
raise ValueError("Squadrons can only be created with active pilots.")
|
||||
self._recruit_pilots(self.game.settings.squadron_pilot_limit)
|
||||
self.auto_assignable_mission_types = set(self.mission_types)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.nickname is None:
|
||||
return self.name
|
||||
return f'{self.name} "{self.nickname}"'
|
||||
|
||||
@property
|
||||
def pilot_limits_enabled(self) -> bool:
|
||||
return self.game.settings.enable_squadron_pilot_limits
|
||||
|
||||
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
|
||||
if self.pilot_limits_enabled:
|
||||
return None
|
||||
self._recruit_pilots(1)
|
||||
return self.available_pilots.pop()
|
||||
|
||||
def claim_available_pilot(self) -> Optional[Pilot]:
|
||||
if not self.available_pilots:
|
||||
return self.claim_new_pilot_if_allowed()
|
||||
|
||||
# For opfor, so player/AI option is irrelevant.
|
||||
if not self.player:
|
||||
return self.available_pilots.pop()
|
||||
|
||||
preference = self.game.settings.auto_ato_behavior
|
||||
|
||||
# No preference, so the first pilot is fine.
|
||||
if preference is AutoAtoBehavior.Default:
|
||||
return self.available_pilots.pop()
|
||||
|
||||
prefer_players = preference is AutoAtoBehavior.Prefer
|
||||
for pilot in self.available_pilots:
|
||||
if pilot.player == prefer_players:
|
||||
self.available_pilots.remove(pilot)
|
||||
return pilot
|
||||
|
||||
# No pilot was found that matched the user's preference.
|
||||
#
|
||||
# If they chose to *never* assign players and only players remain in the pool,
|
||||
# we cannot fill the slot with the available pilots.
|
||||
#
|
||||
# If they only *prefer* players and we're out of players, just return an AI
|
||||
# pilot.
|
||||
if not prefer_players:
|
||||
return self.claim_new_pilot_if_allowed()
|
||||
return self.available_pilots.pop()
|
||||
|
||||
def claim_pilot(self, pilot: Pilot) -> None:
|
||||
if pilot not in self.available_pilots:
|
||||
raise ValueError(
|
||||
f"Cannot assign {pilot} to {self} because they are not available"
|
||||
)
|
||||
self.available_pilots.remove(pilot)
|
||||
|
||||
def return_pilot(self, pilot: Pilot) -> None:
|
||||
self.available_pilots.append(pilot)
|
||||
|
||||
def return_pilots(self, pilots: Sequence[Pilot]) -> None:
|
||||
# Return in reverse so that returning two pilots and then getting two more
|
||||
# results in the same ordering. This happens commonly when resetting rosters in
|
||||
# the UI, when we clear the roster because the UI is updating, then end up
|
||||
# repopulating the same size flight from the same squadron.
|
||||
self.available_pilots.extend(reversed(pilots))
|
||||
|
||||
def _recruit_pilots(self, count: int) -> None:
|
||||
new_pilots = self.pilot_pool[:count]
|
||||
self.pilot_pool = self.pilot_pool[count:]
|
||||
count -= len(new_pilots)
|
||||
new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)])
|
||||
self.current_roster.extend(new_pilots)
|
||||
self.available_pilots.extend(new_pilots)
|
||||
|
||||
def replenish_lost_pilots(self) -> None:
|
||||
if not self.pilot_limits_enabled:
|
||||
return
|
||||
|
||||
replenish_count = min(
|
||||
self.game.settings.squadron_replenishment_rate,
|
||||
self._number_of_unfilled_pilot_slots,
|
||||
)
|
||||
if replenish_count > 0:
|
||||
self._recruit_pilots(replenish_count)
|
||||
|
||||
def return_all_pilots(self) -> None:
|
||||
self.available_pilots = list(self.active_pilots)
|
||||
|
||||
@staticmethod
|
||||
def send_on_leave(pilot: Pilot) -> None:
|
||||
pilot.send_on_leave()
|
||||
|
||||
def return_from_leave(self, pilot: Pilot):
|
||||
if not self.has_unfilled_pilot_slots:
|
||||
raise RuntimeError(
|
||||
f"Cannot return {pilot} from leave because {self} is full"
|
||||
)
|
||||
pilot.return_from_leave()
|
||||
|
||||
@property
|
||||
def faker(self) -> Faker:
|
||||
return self.game.faker_for(self.player)
|
||||
|
||||
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
|
||||
return [p for p in self.current_roster if p.status == status]
|
||||
|
||||
def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]:
|
||||
return [p for p in self.current_roster if p.status != status]
|
||||
|
||||
@property
|
||||
def active_pilots(self) -> list[Pilot]:
|
||||
return self._pilots_with_status(PilotStatus.Active)
|
||||
|
||||
@property
|
||||
def pilots_on_leave(self) -> list[Pilot]:
|
||||
return self._pilots_with_status(PilotStatus.OnLeave)
|
||||
|
||||
@property
|
||||
def number_of_pilots_including_inactive(self) -> int:
|
||||
return len(self.current_roster)
|
||||
|
||||
@property
|
||||
def _number_of_unfilled_pilot_slots(self) -> int:
|
||||
return self.game.settings.squadron_pilot_limit - len(self.active_pilots)
|
||||
|
||||
@property
|
||||
def number_of_available_pilots(self) -> int:
|
||||
return len(self.available_pilots)
|
||||
|
||||
def can_provide_pilots(self, count: int) -> bool:
|
||||
return not self.pilot_limits_enabled or self.number_of_available_pilots >= count
|
||||
|
||||
@property
|
||||
def has_available_pilots(self) -> bool:
|
||||
return not self.pilot_limits_enabled or bool(self.available_pilots)
|
||||
|
||||
@property
|
||||
def has_unfilled_pilot_slots(self) -> bool:
|
||||
return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0
|
||||
|
||||
def can_auto_assign(self, task: FlightType) -> bool:
|
||||
return task in self.auto_assignable_mission_types
|
||||
|
||||
def pilot_at_index(self, index: int) -> Pilot:
|
||||
return self.current_roster[index]
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Path, game: Game, player: bool) -> Squadron:
|
||||
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
with path.open(encoding="utf8") as squadron_file:
|
||||
data = yaml.safe_load(squadron_file)
|
||||
|
||||
name = data["aircraft"]
|
||||
try:
|
||||
unit_type = AircraftType.named(name)
|
||||
except KeyError as ex:
|
||||
raise KeyError(f"Could not find any aircraft named {name}") from ex
|
||||
|
||||
pilots = [Pilot(n, player=False) for n in data.get("pilots", [])]
|
||||
pilots.extend([Pilot(n, player=True) for n in data.get("players", [])])
|
||||
|
||||
mission_types = [FlightType.from_name(n) for n in data["mission_types"]]
|
||||
tasks = tasks_for_aircraft(unit_type)
|
||||
for mission_type in list(mission_types):
|
||||
if mission_type not in tasks:
|
||||
logging.error(
|
||||
f"Squadron has mission type {mission_type} but {unit_type} is not "
|
||||
f"capable of that task: {path}"
|
||||
)
|
||||
mission_types.remove(mission_type)
|
||||
|
||||
return Squadron(
|
||||
name=data["name"],
|
||||
nickname=data.get("nickname"),
|
||||
country=data["country"],
|
||||
role=data["role"],
|
||||
aircraft=unit_type,
|
||||
livery=data.get("livery"),
|
||||
mission_types=tuple(mission_types),
|
||||
pilot_pool=pilots,
|
||||
game=game,
|
||||
player=player,
|
||||
)
|
||||
|
||||
def __setstate__(self, state) -> None:
|
||||
# TODO: Remove save compat.
|
||||
if "auto_assignable_mission_types" not in state:
|
||||
state["auto_assignable_mission_types"] = set(state["mission_types"])
|
||||
self.__dict__.update(state)
|
||||
|
||||
|
||||
class SquadronLoader:
|
||||
def __init__(self, game: Game, player: bool) -> None:
|
||||
self.game = game
|
||||
self.player = player
|
||||
|
||||
@staticmethod
|
||||
def squadron_directories() -> Iterator[Path]:
|
||||
from game import persistency
|
||||
|
||||
yield Path(persistency.base_path()) / "Liberation/Squadrons"
|
||||
yield Path("resources/squadrons")
|
||||
|
||||
def load(self) -> dict[AircraftType, list[Squadron]]:
|
||||
squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
|
||||
country = self.game.country_for(self.player)
|
||||
faction = self.game.faction_for(self.player)
|
||||
any_country = country.startswith("Combined Joint Task Forces ")
|
||||
for directory in self.squadron_directories():
|
||||
for path, squadron in self.load_squadrons_from(directory):
|
||||
if not any_country and squadron.country != country:
|
||||
logging.debug(
|
||||
"Not using squadron for non-matching country (is "
|
||||
f"{squadron.country}, need {country}: {path}"
|
||||
)
|
||||
continue
|
||||
if squadron.aircraft not in faction.aircrafts:
|
||||
logging.debug(
|
||||
f"Not using squadron because {faction.name} cannot use "
|
||||
f"{squadron.aircraft}: {path}"
|
||||
)
|
||||
continue
|
||||
logging.debug(
|
||||
f"Found {squadron.name} {squadron.aircraft} {squadron.role} "
|
||||
f"compatible with {faction.name}"
|
||||
)
|
||||
squadrons[squadron.aircraft].append(squadron)
|
||||
# Convert away from defaultdict because defaultdict doesn't unpickle so we don't
|
||||
# want it in the save state.
|
||||
return dict(squadrons)
|
||||
|
||||
def load_squadrons_from(self, directory: Path) -> Iterator[Tuple[Path, Squadron]]:
|
||||
logging.debug(f"Looking for factions in {directory}")
|
||||
# First directory level is the aircraft type so that historical squadrons that
|
||||
# have flown multiple airframes can be defined as many times as needed. The main
|
||||
# load() method is responsible for filtering out squadrons that aren't
|
||||
# compatible with the faction.
|
||||
for squadron_path in directory.glob("*/*.yaml"):
|
||||
try:
|
||||
yield squadron_path, Squadron.from_yaml(
|
||||
squadron_path, self.game, self.player
|
||||
)
|
||||
except Exception as ex:
|
||||
raise RuntimeError(
|
||||
f"Failed to load squadron defined by {squadron_path}"
|
||||
) from ex
|
||||
|
||||
|
||||
class AirWing:
|
||||
def __init__(self, game: Game, player: bool) -> None:
|
||||
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
|
||||
|
||||
self.game = game
|
||||
self.player = player
|
||||
self.squadrons = SquadronLoader(game, player).load()
|
||||
|
||||
count = itertools.count(1)
|
||||
for aircraft in game.faction_for(player).aircrafts:
|
||||
if aircraft in self.squadrons:
|
||||
continue
|
||||
self.squadrons[aircraft] = [
|
||||
Squadron(
|
||||
name=f"Squadron {next(count):03}",
|
||||
nickname=self.random_nickname(),
|
||||
country=game.country_for(player),
|
||||
role="Flying Squadron",
|
||||
aircraft=aircraft,
|
||||
livery=None,
|
||||
mission_types=tuple(tasks_for_aircraft(aircraft)),
|
||||
pilot_pool=[],
|
||||
game=game,
|
||||
player=player,
|
||||
)
|
||||
]
|
||||
|
||||
def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]:
|
||||
return self.squadrons[aircraft]
|
||||
|
||||
def can_auto_plan(self, task: FlightType) -> bool:
|
||||
try:
|
||||
next(self.auto_assignable_for_task(task))
|
||||
return True
|
||||
except StopIteration:
|
||||
return False
|
||||
|
||||
def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]:
|
||||
for squadron in self.iter_squadrons():
|
||||
if squadron.can_auto_assign(task):
|
||||
yield squadron
|
||||
|
||||
def auto_assignable_for_task_with_type(
|
||||
self, aircraft: AircraftType, task: FlightType
|
||||
) -> Iterator[Squadron]:
|
||||
for squadron in self.squadrons_for(aircraft):
|
||||
if squadron.can_auto_assign(task) and squadron.has_available_pilots:
|
||||
yield squadron
|
||||
|
||||
def squadron_for(self, aircraft: AircraftType) -> Squadron:
|
||||
return self.squadrons_for(aircraft)[0]
|
||||
|
||||
def iter_squadrons(self) -> Iterator[Squadron]:
|
||||
return itertools.chain.from_iterable(self.squadrons.values())
|
||||
|
||||
def squadron_at_index(self, index: int) -> Squadron:
|
||||
return list(self.iter_squadrons())[index]
|
||||
|
||||
def replenish(self) -> None:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.replenish_lost_pilots()
|
||||
|
||||
def reset(self) -> None:
|
||||
for squadron in self.iter_squadrons():
|
||||
squadron.return_all_pilots()
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return sum(len(s) for s in self.squadrons.values())
|
||||
|
||||
@staticmethod
|
||||
def _make_random_nickname() -> str:
|
||||
from gen.naming import ANIMALS
|
||||
|
||||
animal = random.choice(ANIMALS)
|
||||
adjective = random.choice(
|
||||
(
|
||||
None,
|
||||
"Red",
|
||||
"Blue",
|
||||
"Green",
|
||||
"Golden",
|
||||
"Black",
|
||||
"Fighting",
|
||||
"Flying",
|
||||
)
|
||||
)
|
||||
if adjective is None:
|
||||
return animal.title()
|
||||
return f"{adjective} {animal}".title()
|
||||
|
||||
def random_nickname(self) -> str:
|
||||
while True:
|
||||
nickname = self._make_random_nickname()
|
||||
for squadron in self.iter_squadrons():
|
||||
if squadron.nickname == nickname:
|
||||
break
|
||||
else:
|
||||
return nickname
|
||||
@@ -1,5 +1,6 @@
|
||||
from .base import *
|
||||
from .conflicttheater import *
|
||||
from .controlpoint import *
|
||||
from .frontline import FrontLine
|
||||
from .missiontarget import MissionTarget
|
||||
from .theatergroundobject import SamGroundObject
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import itertools
|
||||
import logging
|
||||
import math
|
||||
import typing
|
||||
from typing import Dict, Type
|
||||
from typing import Any
|
||||
|
||||
from dcs.task import AWACS, CAP, CAS, Embarking, PinpointStrike, Task
|
||||
from dcs.unittype import FlyingType, UnitType, VehicleType
|
||||
from dcs.vehicles import AirDefence, Armor
|
||||
|
||||
from game import db
|
||||
from game.db import PRICES
|
||||
from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD
|
||||
|
||||
STRENGTH_AA_ASSEMBLE_MIN = 0.2
|
||||
PLANES_SCRAMBLE_MIN_BASE = 2
|
||||
PLANES_SCRAMBLE_MAX_BASE = 8
|
||||
PLANES_SCRAMBLE_FACTOR = 0.3
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.dcs.unittype import UnitType
|
||||
|
||||
BASE_MAX_STRENGTH = 1
|
||||
BASE_MIN_STRENGTH = 0
|
||||
@@ -23,10 +12,8 @@ BASE_MIN_STRENGTH = 0
|
||||
|
||||
class Base:
|
||||
def __init__(self):
|
||||
self.aircraft: Dict[Type[FlyingType], int] = {}
|
||||
self.armor: Dict[Type[VehicleType], int] = {}
|
||||
self.aa: Dict[AirDefence, int] = {}
|
||||
self.commision_points: Dict[Type, float] = {}
|
||||
self.aircraft: dict[AircraftType, int] = {}
|
||||
self.armor: dict[GroundUnitType, int] = {}
|
||||
self.strength = 1
|
||||
|
||||
@property
|
||||
@@ -41,148 +28,52 @@ class Base:
|
||||
def total_armor_value(self) -> int:
|
||||
total = 0
|
||||
for unit_type, count in self.armor.items():
|
||||
try:
|
||||
total += PRICES[unit_type] * count
|
||||
except KeyError:
|
||||
logging.exception(f"No price found for {unit_type.id}")
|
||||
total += unit_type.price * count
|
||||
return total
|
||||
|
||||
@property
|
||||
def total_frontline_aa(self) -> int:
|
||||
return sum([v for k, v in self.armor.items() if k in TYPE_SHORAD])
|
||||
|
||||
@property
|
||||
def total_aa(self) -> int:
|
||||
return sum(self.aa.values())
|
||||
|
||||
def total_units(self, task: Task) -> int:
|
||||
def total_units_of_type(self, unit_type: UnitType) -> int:
|
||||
return sum(
|
||||
[
|
||||
c
|
||||
for t, c in itertools.chain(
|
||||
self.aircraft.items(), self.armor.items(), self.aa.items()
|
||||
)
|
||||
if t in db.UNIT_BY_TASK[task]
|
||||
]
|
||||
)
|
||||
|
||||
def total_units_of_type(self, unit_type) -> int:
|
||||
return sum(
|
||||
[
|
||||
c
|
||||
for t, c in itertools.chain(
|
||||
self.aircraft.items(), self.armor.items(), self.aa.items()
|
||||
)
|
||||
for t, c in itertools.chain(self.aircraft.items(), self.armor.items())
|
||||
if t == unit_type
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def all_units(self):
|
||||
return itertools.chain(
|
||||
self.aircraft.items(), self.armor.items(), self.aa.items()
|
||||
)
|
||||
|
||||
def _find_best_unit(
|
||||
self, available_units: Dict[UnitType, int], for_type: Task, count: int
|
||||
) -> Dict[UnitType, int]:
|
||||
if count <= 0:
|
||||
logging.warning("{}: no units for {}".format(self, for_type))
|
||||
return {}
|
||||
|
||||
sorted_units = [
|
||||
key for key in available_units if key in db.UNIT_BY_TASK[for_type]
|
||||
]
|
||||
sorted_units.sort(key=lambda x: db.PRICES[x], reverse=True)
|
||||
|
||||
result: Dict[UnitType, int] = {}
|
||||
for unit_type in sorted_units:
|
||||
existing_count = available_units[unit_type] # type: int
|
||||
if not existing_count:
|
||||
continue
|
||||
|
||||
if count <= 0:
|
||||
break
|
||||
|
||||
result_unit_count = min(count, existing_count)
|
||||
count -= result_unit_count
|
||||
|
||||
assert result_unit_count > 0
|
||||
result[unit_type] = result.get(unit_type, 0) + result_unit_count
|
||||
|
||||
logging.info("{} for {} ({}): {}".format(self, for_type, count, result))
|
||||
return result
|
||||
|
||||
def _find_best_planes(
|
||||
self, for_type: Task, count: int
|
||||
) -> typing.Dict[FlyingType, int]:
|
||||
return self._find_best_unit(self.aircraft, for_type, count)
|
||||
|
||||
def _find_best_armor(self, for_type: Task, count: int) -> typing.Dict[Armor, int]:
|
||||
return self._find_best_unit(self.armor, for_type, count)
|
||||
|
||||
def append_commision_points(self, for_type, points: float) -> int:
|
||||
self.commision_points[for_type] = (
|
||||
self.commision_points.get(for_type, 0) + points
|
||||
)
|
||||
points = self.commision_points[for_type]
|
||||
if points >= 1:
|
||||
self.commision_points[for_type] = points - math.floor(points)
|
||||
return int(math.floor(points))
|
||||
|
||||
return 0
|
||||
|
||||
def filter_units(self, applicable_units: typing.Collection):
|
||||
self.aircraft = {
|
||||
k: v for k, v in self.aircraft.items() if k in applicable_units
|
||||
}
|
||||
self.armor = {k: v for k, v in self.armor.items() if k in applicable_units}
|
||||
|
||||
def commision_units(self, units: typing.Dict[typing.Any, int]):
|
||||
|
||||
def commission_units(self, units: dict[Any, int]):
|
||||
for unit_type, unit_count in units.items():
|
||||
if unit_count <= 0:
|
||||
continue
|
||||
|
||||
for_task = db.unit_task(unit_type)
|
||||
|
||||
target_dict = None
|
||||
if (
|
||||
for_task == AWACS
|
||||
or for_task == CAS
|
||||
or for_task == CAP
|
||||
or for_task == Embarking
|
||||
):
|
||||
target_dict: dict[Any, int]
|
||||
if isinstance(unit_type, AircraftType):
|
||||
target_dict = self.aircraft
|
||||
elif for_task == PinpointStrike:
|
||||
elif isinstance(unit_type, GroundUnitType):
|
||||
target_dict = self.armor
|
||||
elif for_task == AirDefence:
|
||||
target_dict = self.aa
|
||||
|
||||
if target_dict is not None:
|
||||
target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count
|
||||
else:
|
||||
logging.error("Unable to determine target dict for " + str(unit_type))
|
||||
logging.error(f"Unexpected unit type of {unit_type}")
|
||||
return
|
||||
|
||||
def commit_losses(self, units_lost: typing.Dict[typing.Any, int]):
|
||||
target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count
|
||||
|
||||
def commit_losses(self, units_lost: dict[Any, int]):
|
||||
for unit_type, count in units_lost.items():
|
||||
|
||||
target_dict: dict[Any, int]
|
||||
if unit_type in self.aircraft:
|
||||
target_array = self.aircraft
|
||||
target_dict = self.aircraft
|
||||
elif unit_type in self.armor:
|
||||
target_array = self.armor
|
||||
target_dict = self.armor
|
||||
else:
|
||||
print("Base didn't find event type {}".format(unit_type))
|
||||
continue
|
||||
|
||||
if unit_type not in target_array:
|
||||
if unit_type not in target_dict:
|
||||
print("Base didn't find event type {}".format(unit_type))
|
||||
continue
|
||||
|
||||
target_array[unit_type] = max(target_array[unit_type] - count, 0)
|
||||
if target_array[unit_type] == 0:
|
||||
del target_array[unit_type]
|
||||
target_dict[unit_type] = max(target_dict[unit_type] - count, 0)
|
||||
if target_dict[unit_type] == 0:
|
||||
del target_dict[unit_type]
|
||||
|
||||
def affect_strength(self, amount):
|
||||
self.strength += amount
|
||||
@@ -193,55 +84,3 @@ class Base:
|
||||
|
||||
def set_strength_to_minimum(self) -> None:
|
||||
self.strength = BASE_MIN_STRENGTH
|
||||
|
||||
def scramble_count(self, multiplier: float, task: Task = None) -> int:
|
||||
if task:
|
||||
count = sum(
|
||||
[v for k, v in self.aircraft.items() if db.unit_task(k) == task]
|
||||
)
|
||||
else:
|
||||
count = self.total_aircraft
|
||||
|
||||
count = int(math.ceil(count * PLANES_SCRAMBLE_FACTOR * self.strength))
|
||||
return min(
|
||||
min(
|
||||
max(count, PLANES_SCRAMBLE_MIN_BASE),
|
||||
int(PLANES_SCRAMBLE_MAX_BASE * multiplier),
|
||||
),
|
||||
count,
|
||||
)
|
||||
|
||||
def assemble_count(self):
|
||||
return int(self.total_armor * 0.5)
|
||||
|
||||
def assemble_aa_count(self) -> int:
|
||||
# previous logic removed because we always want the full air defense capabilities.
|
||||
return self.total_aa
|
||||
|
||||
def scramble_sweep(self, multiplier: float) -> typing.Dict[FlyingType, int]:
|
||||
return self._find_best_planes(CAP, self.scramble_count(multiplier, CAP))
|
||||
|
||||
def scramble_last_defense(self):
|
||||
# return as many CAP-capable aircraft as we can since this is the last defense of the base
|
||||
# (but not more than 20 - that's just nuts)
|
||||
return self._find_best_planes(CAP, min(self.total_aircraft, 20))
|
||||
|
||||
def scramble_cas(self, multiplier: float) -> typing.Dict[FlyingType, int]:
|
||||
return self._find_best_planes(CAS, self.scramble_count(multiplier, CAS))
|
||||
|
||||
def scramble_interceptors(self, multiplier: float) -> typing.Dict[FlyingType, int]:
|
||||
return self._find_best_planes(CAP, self.scramble_count(multiplier, CAP))
|
||||
|
||||
def assemble_attack(self) -> typing.Dict[Armor, int]:
|
||||
return self._find_best_armor(PinpointStrike, self.assemble_count())
|
||||
|
||||
def assemble_defense(self) -> typing.Dict[Armor, int]:
|
||||
count = int(self.total_armor * min(self.strength + 0.5, 1))
|
||||
return self._find_best_armor(PinpointStrike, count)
|
||||
|
||||
def assemble_aa(self, count=None) -> typing.Dict[AirDefence, int]:
|
||||
return self._find_best_unit(
|
||||
self.aa,
|
||||
AirDefence,
|
||||
count and min(count, self.total_aa) or self.assemble_aa_count(),
|
||||
)
|
||||
|
||||
26
game/theater/bullseye.py
Normal file
26
game/theater/bullseye.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, TYPE_CHECKING
|
||||
|
||||
from dcs import Point
|
||||
|
||||
from game.theater import LatLon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.theater import ConflictTheater
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bullseye:
|
||||
position: Point
|
||||
|
||||
@classmethod
|
||||
def from_pydcs(cls, bulls: Dict[str, float]) -> Bullseye:
|
||||
return cls(Point(bulls["x"], bulls["y"]))
|
||||
|
||||
def to_pydcs(self) -> Dict[str, float]:
|
||||
return {"x": self.position.x, "y": self.position.y}
|
||||
|
||||
def to_lat_lon(self, theater: ConflictTheater) -> LatLon:
|
||||
return theater.point_to_ll(self.position)
|
||||
8
game/theater/caucasus.py
Normal file
8
game/theater/caucasus.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from game.theater.projections import TransverseMercator
|
||||
|
||||
PARAMETERS = TransverseMercator(
|
||||
central_meridian=33,
|
||||
false_easting=-99516.9999999732,
|
||||
false_northing=-4998114.999999984,
|
||||
scale_factor=0.9996,
|
||||
)
|
||||
@@ -1,16 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from itertools import tee
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union, cast
|
||||
|
||||
from shapely import geometry
|
||||
from shapely import ops
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
from dcs import Mission
|
||||
from dcs.countries import (
|
||||
@@ -21,11 +16,12 @@ from dcs.country import Country
|
||||
from dcs.mapping import Point
|
||||
from dcs.planes import F_15C
|
||||
from dcs.ships import (
|
||||
CVN_74_John_C__Stennis,
|
||||
LHA_1_Tarawa,
|
||||
DDG_Arleigh_Burke_IIa,
|
||||
HandyWind,
|
||||
Stennis,
|
||||
USS_Arleigh_Burke_IIa,
|
||||
LHA_Tarawa,
|
||||
)
|
||||
from dcs.statics import Fortification
|
||||
from dcs.statics import Fortification, Warehouse
|
||||
from dcs.terrain import (
|
||||
caucasus,
|
||||
nevada,
|
||||
@@ -43,22 +39,26 @@ from dcs.unitgroup import (
|
||||
VehicleGroup,
|
||||
)
|
||||
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely import geometry, ops
|
||||
|
||||
from gen.flights.flight import FlightType
|
||||
from .controlpoint import (
|
||||
Airfield,
|
||||
Carrier,
|
||||
ControlPoint,
|
||||
Fob,
|
||||
Lha,
|
||||
MissionTarget,
|
||||
OffMapSpawn,
|
||||
Fob,
|
||||
)
|
||||
from .frontline import FrontLine
|
||||
from .landmap import Landmap, load_landmap, poly_contains
|
||||
from .latlon import LatLon
|
||||
from .projections import TransverseMercator
|
||||
from ..point_with_heading import PointWithHeading
|
||||
from ..utils import Distance, meters, nautical_miles
|
||||
|
||||
Numeric = Union[int, float]
|
||||
from ..profiling import logged_duration
|
||||
from ..scenery_group import SceneryGroup
|
||||
from ..utils import Distance, meters
|
||||
|
||||
SIZE_TINY = 150
|
||||
SIZE_SMALL = 600
|
||||
@@ -70,18 +70,6 @@ IMPORTANCE_LOW = 1
|
||||
IMPORTANCE_MEDIUM = 1.2
|
||||
IMPORTANCE_HIGH = 1.4
|
||||
|
||||
FRONTLINE_MIN_CP_DISTANCE = 5000
|
||||
|
||||
|
||||
def pairwise(iterable):
|
||||
"""
|
||||
itertools recipe
|
||||
s -> (s0,s1), (s1,s2), (s2, s3), ...
|
||||
"""
|
||||
a, b = tee(iterable)
|
||||
next(b, None)
|
||||
return zip(a, b)
|
||||
|
||||
|
||||
class MizCampaignLoader:
|
||||
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
|
||||
@@ -89,43 +77,61 @@ class MizCampaignLoader:
|
||||
|
||||
OFF_MAP_UNIT_TYPE = F_15C.id
|
||||
|
||||
CV_UNIT_TYPE = CVN_74_John_C__Stennis.id
|
||||
LHA_UNIT_TYPE = LHA_1_Tarawa.id
|
||||
FRONT_LINE_UNIT_TYPE = Armor.APC_M113.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.Truck_SKP_11_Mobile_ATC.id
|
||||
FOB_UNIT_TYPE = Unarmed.SKP_11.id
|
||||
FARP_HELIPAD = "SINGLE_HELIPAD"
|
||||
|
||||
EWR_UNIT_TYPE = AirDefence.EWR_55G6.id
|
||||
SAM_UNIT_TYPE = AirDefence.SAM_SA_10_S_300_Grumble_Big_Bird_SR.id
|
||||
GARRISON_UNIT_TYPE = AirDefence.SAM_SA_19_Tunguska_Grison.id
|
||||
OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id
|
||||
SHIP_UNIT_TYPE = DDG_Arleigh_Burke_IIa.id
|
||||
MISSILE_SITE_UNIT_TYPE = MissilesSS.SSM_SS_1C_Scud_B.id
|
||||
COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.AShM_SS_N_2_Silkworm.id
|
||||
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 the required SAMs so campaign designers can more
|
||||
# accurately see the coverage of their IADS for the expected type.
|
||||
REQUIRED_LONG_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.SAM_Patriot_LN.id,
|
||||
AirDefence.SAM_SA_10_S_300_Grumble_TEL_C.id,
|
||||
AirDefence.SAM_SA_10_S_300_Grumble_TEL_D.id,
|
||||
# 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,
|
||||
}
|
||||
|
||||
REQUIRED_MEDIUM_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.SAM_Hawk_LN_M192.id,
|
||||
AirDefence.SAM_SA_2_S_75_Guideline_LN.id,
|
||||
AirDefence.SAM_SA_3_S_125_Goa_LN.id,
|
||||
MEDIUM_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.Hawk_ln.id,
|
||||
AirDefence.S_75M_Volhov.id,
|
||||
AirDefence._5p73_s_125_ln.id,
|
||||
}
|
||||
|
||||
REQUIRED_EWR_UNIT_TYPE = AirDefence.EWR_1L13.id
|
||||
SHORT_RANGE_SAM_UNIT_TYPES = {
|
||||
AirDefence.M1097_Avenger.id,
|
||||
AirDefence.Rapier_fsa_launcher.id,
|
||||
AirDefence._2S6_Tunguska.id,
|
||||
AirDefence.Strela_1_9P31.id,
|
||||
}
|
||||
|
||||
BASE_DEFENSE_RADIUS = nautical_miles(2)
|
||||
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()
|
||||
self.mission.load_file(str(miz))
|
||||
with logged_duration("Loading miz"):
|
||||
self.mission.load_file(str(miz))
|
||||
self.control_point_id = itertools.count(1000)
|
||||
|
||||
# If there are no red carriers there usually aren't red units. Make sure
|
||||
@@ -197,62 +203,62 @@ class MizCampaignLoader:
|
||||
|
||||
@property
|
||||
def ships(self) -> Iterator[ShipGroup]:
|
||||
for group in self.blue.ship_group:
|
||||
for group in self.red.ship_group:
|
||||
if group.units[0].type == self.SHIP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ewrs(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.blue.vehicle_group:
|
||||
if group.units[0].type == self.EWR_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.blue.vehicle_group:
|
||||
if group.units[0].type == self.SAM_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def garrisons(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.blue.vehicle_group:
|
||||
if group.units[0].type == self.GARRISON_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def offshore_strike_targets(self) -> Iterator[StaticGroup]:
|
||||
for group in self.blue.static_group:
|
||||
for group in self.red.static_group:
|
||||
if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def missile_sites(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.blue.vehicle_group:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def coastal_defenses(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.blue.vehicle_group:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def required_long_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
def long_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.REQUIRED_LONG_RANGE_SAM_UNIT_TYPES:
|
||||
if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def required_medium_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
def medium_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.REQUIRED_MEDIUM_RANGE_SAM_UNIT_TYPES:
|
||||
if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def required_ewrs(self) -> Iterator[VehicleGroup]:
|
||||
def short_range_sams(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.REQUIRED_EWR_UNIT_TYPE:
|
||||
if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def aaa(self) -> Iterator[VehicleGroup]:
|
||||
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
|
||||
if group.units[0].type in self.AAA_UNIT_TYPES:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ewrs(self) -> Iterator[VehicleGroup]:
|
||||
for group in self.red.vehicle_group:
|
||||
if group.units[0].type in self.EWR_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def armor_groups(self) -> Iterator[VehicleGroup]:
|
||||
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
|
||||
if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
@@ -261,6 +267,28 @@ class MizCampaignLoader:
|
||||
if group.units[0].type == self.FARP_HELIPAD:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def factories(self) -> Iterator[StaticGroup]:
|
||||
for group in self.blue.static_group:
|
||||
if group.units[0].type in self.FACTORY_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def ammunition_depots(self) -> Iterator[StaticGroup]:
|
||||
for group in itertools.chain(self.blue.static_group, self.red.static_group):
|
||||
if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def strike_targets(self) -> Iterator[StaticGroup]:
|
||||
for group in itertools.chain(self.blue.static_group, self.red.static_group):
|
||||
if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@property
|
||||
def scenery(self) -> List[SceneryGroup]:
|
||||
return SceneryGroup.from_trigger_zones(self.mission.triggers._zones)
|
||||
|
||||
@cached_property
|
||||
def control_points(self) -> Dict[int, ControlPoint]:
|
||||
control_points = {}
|
||||
@@ -307,14 +335,17 @@ class MizCampaignLoader:
|
||||
if group.units[0].type == self.FRONT_LINE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
@cached_property
|
||||
def front_lines(self) -> Dict[str, ComplexFrontLine]:
|
||||
# Dict of front line ID to a front line.
|
||||
front_lines = {}
|
||||
@property
|
||||
def shipping_lane_groups(self) -> Iterator[ShipGroup]:
|
||||
for group in self.country(blue=True).ship_group:
|
||||
if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE:
|
||||
yield group
|
||||
|
||||
def add_supply_routes(self) -> None:
|
||||
for group in self.front_line_path_groups:
|
||||
# The unit will have its first waypoint at the source CP and the
|
||||
# final waypoint at the destination CP. Intermediate waypoints
|
||||
# define the curve of the front line.
|
||||
# The unit will have its first waypoint at the source CP and the final
|
||||
# waypoint at the destination CP. Each waypoint defines the path of the
|
||||
# cargo ship.
|
||||
waypoints = [p.position for p in group.points]
|
||||
origin = self.theater.closest_control_point(waypoints[0])
|
||||
if origin is None:
|
||||
@@ -327,14 +358,32 @@ class MizCampaignLoader:
|
||||
f"No control point near the final waypoint of {group.name}"
|
||||
)
|
||||
|
||||
# Snap the begin and end points to the control points.
|
||||
waypoints[0] = origin.position
|
||||
waypoints[-1] = destination.position
|
||||
front_line_id = f"{origin.id}|{destination.id}"
|
||||
front_lines[front_line_id] = ComplexFrontLine(origin, waypoints)
|
||||
self.control_points[origin.id].connect(self.control_points[destination.id])
|
||||
self.control_points[destination.id].connect(self.control_points[origin.id])
|
||||
return front_lines
|
||||
self.control_points[origin.id].create_convoy_route(destination, waypoints)
|
||||
self.control_points[destination.id].create_convoy_route(
|
||||
origin, list(reversed(waypoints))
|
||||
)
|
||||
|
||||
def add_shipping_lanes(self) -> None:
|
||||
for group in self.shipping_lane_groups:
|
||||
# The unit will have its first waypoint at the source CP and the final
|
||||
# waypoint at the destination CP. Each waypoint defines the path of the
|
||||
# cargo ship.
|
||||
waypoints = [p.position for p in group.points]
|
||||
origin = self.theater.closest_control_point(waypoints[0])
|
||||
if origin is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the first waypoint of {group.name}"
|
||||
)
|
||||
destination = self.theater.closest_control_point(waypoints[-1])
|
||||
if destination is None:
|
||||
raise RuntimeError(
|
||||
f"No control point near the final waypoint of {group.name}"
|
||||
)
|
||||
|
||||
self.control_points[origin.id].create_shipping_lane(destination, waypoints)
|
||||
self.control_points[destination.id].create_shipping_lane(
|
||||
origin, list(reversed(waypoints))
|
||||
)
|
||||
|
||||
def objective_info(self, group: Group) -> Tuple[ControlPoint, Distance]:
|
||||
closest = self.theater.closest_control_point(group.position)
|
||||
@@ -342,37 +391,6 @@ class MizCampaignLoader:
|
||||
return closest, distance
|
||||
|
||||
def add_preset_locations(self) -> None:
|
||||
for group in self.garrisons:
|
||||
closest, distance = self.objective_info(group)
|
||||
if distance < self.BASE_DEFENSE_RADIUS:
|
||||
closest.preset_locations.base_garrisons.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
else:
|
||||
logging.warning(f"Found garrison unit too far from base: {group.name}")
|
||||
|
||||
for group in self.sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
if distance < self.BASE_DEFENSE_RADIUS:
|
||||
closest.preset_locations.base_air_defense.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
else:
|
||||
closest.preset_locations.strike_locations.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.ewrs:
|
||||
closest, distance = self.objective_info(group)
|
||||
if distance < self.BASE_DEFENSE_RADIUS:
|
||||
closest.preset_locations.base_ewrs.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
else:
|
||||
closest.preset_locations.ewrs.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.offshore_strike_targets:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.offshore_strike_locations.append(
|
||||
@@ -397,21 +415,39 @@ class MizCampaignLoader:
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.required_long_range_sams:
|
||||
for group in self.long_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.required_long_range_sams.append(
|
||||
closest.preset_locations.long_range_sams.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.required_medium_range_sams:
|
||||
for group in self.medium_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.required_medium_range_sams.append(
|
||||
closest.preset_locations.medium_range_sams.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.required_ewrs:
|
||||
for group in self.short_range_sams:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.required_ewrs.append(
|
||||
closest.preset_locations.short_range_sams.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.aaa:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.aaa.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.ewrs:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.ewrs.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.armor_groups:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.armor_groups.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
@@ -421,11 +457,34 @@ class MizCampaignLoader:
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.factories:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.factories.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.ammunition_depots:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.ammunition_depots.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.strike_targets:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.strike_locations.append(
|
||||
PointWithHeading.from_point(group.position, group.units[0].heading)
|
||||
)
|
||||
|
||||
for group in self.scenery:
|
||||
closest, distance = self.objective_info(group)
|
||||
closest.preset_locations.scenery.append(group)
|
||||
|
||||
def populate_theater(self) -> None:
|
||||
for control_point in self.control_points.values():
|
||||
self.theater.add_controlpoint(control_point)
|
||||
self.add_preset_locations()
|
||||
self.theater.set_frontline_data(self.front_lines)
|
||||
self.add_supply_routes()
|
||||
self.add_shipping_lanes()
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -444,43 +503,40 @@ class ConflictTheater:
|
||||
land_poly = None # type: Polygon
|
||||
"""
|
||||
daytime_map: Dict[str, Tuple[int, int]]
|
||||
_frontline_data: Optional[Dict[str, ComplexFrontLine]] = None
|
||||
|
||||
def __init__(self):
|
||||
self.controlpoints: List[ControlPoint] = []
|
||||
self._frontline_data: Optional[Dict[str, ComplexFrontLine]] = None
|
||||
self.point_to_ll_transformer = Transformer.from_crs(
|
||||
self.projection_parameters.to_crs(), CRS("WGS84")
|
||||
)
|
||||
self.ll_to_point_transformer = Transformer.from_crs(
|
||||
CRS("WGS84"), self.projection_parameters.to_crs()
|
||||
)
|
||||
"""
|
||||
self.land_poly = geometry.Polygon(self.landmap[0][0])
|
||||
for x in self.landmap[1]:
|
||||
self.land_poly = self.land_poly.difference(geometry.Polygon(x))
|
||||
"""
|
||||
|
||||
@property
|
||||
def frontline_data(self) -> Optional[Dict[str, ComplexFrontLine]]:
|
||||
if self._frontline_data is None:
|
||||
self.load_frontline_data_from_file()
|
||||
return self._frontline_data
|
||||
def __getstate__(self) -> Dict[str, Any]:
|
||||
state = self.__dict__.copy()
|
||||
# Avoid persisting any volatile types that can be deterministically
|
||||
# recomputed on load for the sake of save compatibility.
|
||||
del state["point_to_ll_transformer"]
|
||||
del state["ll_to_point_transformer"]
|
||||
return state
|
||||
|
||||
def load_frontline_data_from_file(self) -> None:
|
||||
if self._frontline_data is not None:
|
||||
logging.warning("Replacing existing frontline data from file")
|
||||
self._frontline_data = FrontLine.load_json_frontlines(self)
|
||||
if self._frontline_data is None:
|
||||
self._frontline_data = {}
|
||||
|
||||
def set_frontline_data(self, data: Dict[str, ComplexFrontLine]) -> None:
|
||||
if self._frontline_data is not None:
|
||||
logging.warning("Replacing existing frontline data")
|
||||
self._frontline_data = data
|
||||
|
||||
def add_controlpoint(
|
||||
self, point: ControlPoint, connected_to: Optional[List[ControlPoint]] = None
|
||||
):
|
||||
if connected_to is None:
|
||||
connected_to = []
|
||||
for connected_point in connected_to:
|
||||
point.connect(to=connected_point)
|
||||
def __setstate__(self, state: Dict[str, Any]) -> None:
|
||||
self.__dict__.update(state)
|
||||
# Regenerate any state that was not persisted.
|
||||
self.point_to_ll_transformer = Transformer.from_crs(
|
||||
self.projection_parameters.to_crs(), CRS("WGS84")
|
||||
)
|
||||
self.ll_to_point_transformer = Transformer.from_crs(
|
||||
CRS("WGS84"), self.projection_parameters.to_crs()
|
||||
)
|
||||
|
||||
def add_controlpoint(self, point: ControlPoint):
|
||||
self.controlpoints.append(point)
|
||||
|
||||
def find_ground_objects_by_obj_name(self, obj_name):
|
||||
@@ -561,12 +617,12 @@ class ConflictTheater:
|
||||
def player_points(self) -> List[ControlPoint]:
|
||||
return list(self.control_points_for(player=True))
|
||||
|
||||
def conflicts(self, from_player=True) -> Iterator[FrontLine]:
|
||||
for cp in [x for x in self.controlpoints if x.captured == from_player]:
|
||||
for connected_point in [
|
||||
x for x in cp.connected_points if x.captured != from_player
|
||||
def conflicts(self) -> Iterator[FrontLine]:
|
||||
for player_cp in [x for x in self.controlpoints if x.captured]:
|
||||
for enemy_cp in [
|
||||
x for x in player_cp.connected_points if not x.is_friendly_to(player_cp)
|
||||
]:
|
||||
yield FrontLine(cp, connected_point, self)
|
||||
yield FrontLine(player_cp, enemy_cp)
|
||||
|
||||
def enemy_points(self) -> List[ControlPoint]:
|
||||
return list(self.control_points_for(player=False))
|
||||
@@ -606,71 +662,32 @@ class ConflictTheater:
|
||||
Returns a tuple of the two nearest opposing ControlPoints in theater.
|
||||
(player_cp, enemy_cp)
|
||||
"""
|
||||
all_cp_min_distances = {}
|
||||
for idx, control_point in enumerate(self.controlpoints):
|
||||
distances = {}
|
||||
closest_distance = None
|
||||
for i, cp in enumerate(self.controlpoints):
|
||||
if i != idx and cp.captured is not control_point.captured:
|
||||
dist = cp.position.distance_to_point(control_point.position)
|
||||
if not closest_distance:
|
||||
closest_distance = dist
|
||||
distances[cp.id] = dist
|
||||
if dist < closest_distance:
|
||||
distances[cp.id] = dist
|
||||
closest_cp_id = min(distances, key=distances.get) # type: ignore
|
||||
seen = set()
|
||||
min_distance = math.inf
|
||||
closest_blue = None
|
||||
closest_red = None
|
||||
for blue_cp in self.player_points():
|
||||
for red_cp in self.enemy_points():
|
||||
if (blue_cp, red_cp) in seen:
|
||||
continue
|
||||
seen.add((blue_cp, red_cp))
|
||||
seen.add((red_cp, blue_cp))
|
||||
|
||||
all_cp_min_distances[(control_point.id, closest_cp_id)] = distances[
|
||||
closest_cp_id
|
||||
]
|
||||
closest_opposing_cps = [
|
||||
self.find_control_point_by_id(i)
|
||||
for i in min(
|
||||
all_cp_min_distances, key=all_cp_min_distances.get
|
||||
) # type: ignore
|
||||
] # type: List[ControlPoint]
|
||||
assert len(closest_opposing_cps) == 2
|
||||
if closest_opposing_cps[0].captured:
|
||||
return cast(Tuple[ControlPoint, ControlPoint], tuple(closest_opposing_cps))
|
||||
else:
|
||||
return cast(
|
||||
Tuple[ControlPoint, ControlPoint], tuple(reversed(closest_opposing_cps))
|
||||
)
|
||||
dist = red_cp.position.distance_to_point(blue_cp.position)
|
||||
if dist < min_distance:
|
||||
closest_red = red_cp
|
||||
closest_blue = blue_cp
|
||||
min_distance = dist
|
||||
|
||||
assert closest_blue is not None
|
||||
assert closest_red is not None
|
||||
return closest_blue, closest_red
|
||||
|
||||
def find_control_point_by_id(self, id: int) -> ControlPoint:
|
||||
for i in self.controlpoints:
|
||||
if i.id == id:
|
||||
return i
|
||||
raise RuntimeError(f"Cannot find ControlPoint with ID {id}")
|
||||
|
||||
def add_json_cp(self, theater, p: dict) -> ControlPoint:
|
||||
cp: ControlPoint
|
||||
if p["type"] == "airbase":
|
||||
|
||||
airbase = theater.terrain.airports[p["id"]]
|
||||
|
||||
if "size" in p.keys():
|
||||
size = p["size"]
|
||||
else:
|
||||
size = SIZE_REGULAR
|
||||
|
||||
if "importance" in p.keys():
|
||||
importance = p["importance"]
|
||||
else:
|
||||
importance = IMPORTANCE_MEDIUM
|
||||
|
||||
cp = Airfield(airbase, size, importance)
|
||||
elif p["type"] == "carrier":
|
||||
cp = Carrier("carrier", Point(p["x"], p["y"]), p["id"])
|
||||
else:
|
||||
cp = Lha("lha", Point(p["x"], p["y"]), p["id"])
|
||||
|
||||
if "captured_invert" in p.keys():
|
||||
cp.captured_invert = p["captured_invert"]
|
||||
else:
|
||||
cp.captured_invert = False
|
||||
|
||||
return cp
|
||||
raise KeyError(f"Cannot find ControlPoint with ID {id}")
|
||||
|
||||
@staticmethod
|
||||
def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater:
|
||||
@@ -686,28 +703,27 @@ class ConflictTheater:
|
||||
t = theater()
|
||||
|
||||
miz = data.get("miz", None)
|
||||
if miz is not None:
|
||||
if miz is None:
|
||||
raise RuntimeError(
|
||||
"Old format (non-miz) campaigns are no longer supported."
|
||||
)
|
||||
|
||||
with logged_duration("Importing miz data"):
|
||||
MizCampaignLoader(directory / miz, t).populate_theater()
|
||||
return t
|
||||
|
||||
cps = {}
|
||||
for p in data["player_points"]:
|
||||
cp = t.add_json_cp(theater, p)
|
||||
cp.captured = True
|
||||
cps[p["id"]] = cp
|
||||
t.add_controlpoint(cp)
|
||||
|
||||
for p in data["enemy_points"]:
|
||||
cp = t.add_json_cp(theater, p)
|
||||
cps[p["id"]] = cp
|
||||
t.add_controlpoint(cp)
|
||||
|
||||
for l in data["links"]:
|
||||
cps[l[0]].connect(cps[l[1]])
|
||||
cps[l[1]].connect(cps[l[0]])
|
||||
|
||||
return t
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
raise NotImplementedError
|
||||
|
||||
def point_to_ll(self, point: Point) -> LatLon:
|
||||
lat, lon = self.point_to_ll_transformer.transform(point.x, point.y)
|
||||
return LatLon(lat, lon)
|
||||
|
||||
def ll_to_point(self, ll: LatLon) -> Point:
|
||||
x, y = self.ll_to_point_transformer.transform(ll.latitude, ll.longitude)
|
||||
return Point(x, y)
|
||||
|
||||
|
||||
class CaucasusTheater(ConflictTheater):
|
||||
terrain = caucasus.Caucasus()
|
||||
@@ -725,6 +741,12 @@ class CaucasusTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .caucasus import PARAMETERS
|
||||
|
||||
return PARAMETERS
|
||||
|
||||
|
||||
class PersianGulfTheater(ConflictTheater):
|
||||
terrain = persiangulf.PersianGulf()
|
||||
@@ -741,6 +763,12 @@ class PersianGulfTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .persiangulf import PARAMETERS
|
||||
|
||||
return PARAMETERS
|
||||
|
||||
|
||||
class NevadaTheater(ConflictTheater):
|
||||
terrain = nevada.Nevada()
|
||||
@@ -757,6 +785,12 @@ class NevadaTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .nevada import PARAMETERS
|
||||
|
||||
return PARAMETERS
|
||||
|
||||
|
||||
class NormandyTheater(ConflictTheater):
|
||||
terrain = normandy.Normandy()
|
||||
@@ -773,6 +807,12 @@ class NormandyTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .normandy import PARAMETERS
|
||||
|
||||
return PARAMETERS
|
||||
|
||||
|
||||
class TheChannelTheater(ConflictTheater):
|
||||
terrain = thechannel.TheChannel()
|
||||
@@ -789,6 +829,12 @@ class TheChannelTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
@property
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .thechannel import PARAMETERS
|
||||
|
||||
return PARAMETERS
|
||||
|
||||
|
||||
class SyriaTheater(ConflictTheater):
|
||||
terrain = syria.Syria()
|
||||
@@ -805,213 +851,8 @@ class SyriaTheater(ConflictTheater):
|
||||
"night": (0, 5),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComplexFrontLine:
|
||||
"""
|
||||
Stores data necessary for building a multi-segment frontline.
|
||||
"points" should be ordered from closest to farthest distance originating from start_cp.position
|
||||
"""
|
||||
|
||||
start_cp: ControlPoint
|
||||
points: List[Point]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrontLineSegment:
|
||||
"""
|
||||
Describes a line segment of a FrontLine
|
||||
"""
|
||||
|
||||
point_a: Point
|
||||
point_b: Point
|
||||
|
||||
@property
|
||||
def attack_heading(self) -> Numeric:
|
||||
"""The heading of the frontline segment from player to enemy control point"""
|
||||
return self.point_a.heading_between_point(self.point_b)
|
||||
def projection_parameters(self) -> TransverseMercator:
|
||||
from .syria import PARAMETERS
|
||||
|
||||
@property
|
||||
def attack_distance(self) -> Numeric:
|
||||
"""Length of the segment"""
|
||||
return self.point_a.distance_to_point(self.point_b)
|
||||
|
||||
|
||||
class FrontLine(MissionTarget):
|
||||
"""Defines a front line location between two control points.
|
||||
Front lines are the area where ground combat happens.
|
||||
Overwrites the entirety of MissionTarget __init__ method to allow for
|
||||
dynamic position calculation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
control_point_a: ControlPoint,
|
||||
control_point_b: ControlPoint,
|
||||
theater: ConflictTheater,
|
||||
) -> None:
|
||||
self.control_point_a = control_point_a
|
||||
self.control_point_b = control_point_b
|
||||
self.segments: List[FrontLineSegment] = []
|
||||
self.theater = theater
|
||||
self._build_segments()
|
||||
self.name = f"Front line {control_point_a}/{control_point_b}"
|
||||
|
||||
def is_friendly(self, to_player: bool) -> bool:
|
||||
"""Returns True if the objective is in friendly territory."""
|
||||
return False
|
||||
|
||||
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
|
||||
yield from [
|
||||
FlightType.CAS,
|
||||
FlightType.AEWC,
|
||||
# TODO: FlightType.TROOP_TRANSPORT
|
||||
# TODO: FlightType.EVAC
|
||||
]
|
||||
yield from super().mission_types(for_player)
|
||||
|
||||
@property
|
||||
def position(self):
|
||||
"""
|
||||
The position where the conflict should occur
|
||||
according to the current strength of each control point.
|
||||
"""
|
||||
return self.point_from_a(self._position_distance)
|
||||
|
||||
@property
|
||||
def control_points(self) -> Tuple[ControlPoint, ControlPoint]:
|
||||
"""Returns a tuple of the two control points."""
|
||||
return self.control_point_a, self.control_point_b
|
||||
|
||||
@property
|
||||
def attack_distance(self):
|
||||
"""The total distance of all segments"""
|
||||
return sum(i.attack_distance for i in self.segments)
|
||||
|
||||
@property
|
||||
def attack_heading(self):
|
||||
"""The heading of the active attack segment from player to enemy control point"""
|
||||
return self.active_segment.attack_heading
|
||||
|
||||
@property
|
||||
def active_segment(self) -> FrontLineSegment:
|
||||
"""The FrontLine segment where there can be an active conflict"""
|
||||
if self._position_distance <= self.segments[0].attack_distance:
|
||||
return self.segments[0]
|
||||
|
||||
remaining_dist = self._position_distance
|
||||
for segment in self.segments:
|
||||
if remaining_dist <= segment.attack_distance:
|
||||
return segment
|
||||
else:
|
||||
remaining_dist -= segment.attack_distance
|
||||
logging.error(
|
||||
"Frontline attack distance is greater than the sum of its segments"
|
||||
)
|
||||
return self.segments[0]
|
||||
|
||||
def point_from_a(self, distance: Numeric) -> Point:
|
||||
"""
|
||||
Returns a point {distance} away from control_point_a along the frontline segments.
|
||||
"""
|
||||
if distance < self.segments[0].attack_distance:
|
||||
return self.control_point_a.position.point_from_heading(
|
||||
self.segments[0].attack_heading, distance
|
||||
)
|
||||
remaining_dist = distance
|
||||
for segment in self.segments:
|
||||
if remaining_dist < segment.attack_distance:
|
||||
return segment.point_a.point_from_heading(
|
||||
segment.attack_heading, remaining_dist
|
||||
)
|
||||
else:
|
||||
remaining_dist -= segment.attack_distance
|
||||
|
||||
@property
|
||||
def _position_distance(self) -> float:
|
||||
"""
|
||||
The distance from point "a" where the conflict should occur
|
||||
according to the current strength of each control point
|
||||
"""
|
||||
total_strength = (
|
||||
self.control_point_a.base.strength + self.control_point_b.base.strength
|
||||
)
|
||||
if self.control_point_a.base.strength == 0:
|
||||
return self._adjust_for_min_dist(0)
|
||||
if self.control_point_b.base.strength == 0:
|
||||
return self._adjust_for_min_dist(self.attack_distance)
|
||||
strength_pct = self.control_point_a.base.strength / total_strength
|
||||
return self._adjust_for_min_dist(strength_pct * self.attack_distance)
|
||||
|
||||
def _adjust_for_min_dist(self, distance: Numeric) -> Numeric:
|
||||
"""
|
||||
Ensures the frontline conflict is never located within the minimum distance
|
||||
constant of either end control point.
|
||||
"""
|
||||
if (distance > self.attack_distance / 2) and (
|
||||
distance + FRONTLINE_MIN_CP_DISTANCE > self.attack_distance
|
||||
):
|
||||
distance = self.attack_distance - FRONTLINE_MIN_CP_DISTANCE
|
||||
elif (distance < self.attack_distance / 2) and (
|
||||
distance < FRONTLINE_MIN_CP_DISTANCE
|
||||
):
|
||||
distance = FRONTLINE_MIN_CP_DISTANCE
|
||||
return distance
|
||||
|
||||
def _build_segments(self) -> None:
|
||||
"""Create line segments for the frontline"""
|
||||
control_point_ids = "|".join(
|
||||
[str(self.control_point_a.id), str(self.control_point_b.id)]
|
||||
) # from_cp.id|to_cp.id
|
||||
reversed_cp_ids = "|".join(
|
||||
[str(self.control_point_b.id), str(self.control_point_a.id)]
|
||||
)
|
||||
complex_frontlines = self.theater.frontline_data
|
||||
if (complex_frontlines) and (
|
||||
(control_point_ids in complex_frontlines)
|
||||
or (reversed_cp_ids in complex_frontlines)
|
||||
):
|
||||
# The frontline segments must be stored in the correct order for the distance algorithms to work.
|
||||
# The points in the frontline are ordered from the id before the | to the id after.
|
||||
# First, check if control point id pair matches in order, and create segments if a match is found.
|
||||
if control_point_ids in complex_frontlines:
|
||||
point_pairs = pairwise(complex_frontlines[control_point_ids].points)
|
||||
for i in point_pairs:
|
||||
self.segments.append(FrontLineSegment(i[0], i[1]))
|
||||
# Check the reverse order and build in reverse if found.
|
||||
elif reversed_cp_ids in complex_frontlines:
|
||||
point_pairs = pairwise(
|
||||
reversed(complex_frontlines[reversed_cp_ids].points)
|
||||
)
|
||||
for i in point_pairs:
|
||||
self.segments.append(FrontLineSegment(i[0], i[1]))
|
||||
# If no complex frontline has been configured, fall back to the old straight line method.
|
||||
else:
|
||||
self.segments.append(
|
||||
FrontLineSegment(
|
||||
self.control_point_a.position, self.control_point_b.position
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def load_json_frontlines(
|
||||
theater: ConflictTheater,
|
||||
) -> Optional[Dict[str, ComplexFrontLine]]:
|
||||
"""Load complex frontlines from json"""
|
||||
try:
|
||||
path = Path(f"resources/frontlines/{theater.terrain.name.lower()}.json")
|
||||
with open(path, "r") as file:
|
||||
logging.debug(f"Loading frontline from {path}...")
|
||||
data = json.load(file)
|
||||
return {
|
||||
frontline: ComplexFrontLine(
|
||||
data[frontline]["start_cp"],
|
||||
[Point(i[0], i[1]) for i in data[frontline]["points"]],
|
||||
)
|
||||
for frontline in data
|
||||
}
|
||||
except OSError:
|
||||
logging.warning(
|
||||
f"Unable to load preset frontlines for {theater.terrain.name}"
|
||||
)
|
||||
return None
|
||||
return PARAMETERS
|
||||
|
||||
@@ -3,46 +3,59 @@ from __future__ import annotations
|
||||
import heapq
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from functools import total_ordering
|
||||
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Type
|
||||
from enum import Enum, unique, auto, IntEnum
|
||||
from functools import total_ordering, cached_property
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Set,
|
||||
TYPE_CHECKING,
|
||||
Union,
|
||||
Sequence,
|
||||
Iterable,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
from dcs.mapping import Point
|
||||
from dcs.ships import (
|
||||
CVN_74_John_C__Stennis,
|
||||
CV_1143_5_Admiral_Kuznetsov,
|
||||
LHA_1_Tarawa,
|
||||
Type_071_Amphibious_Transport_Dock,
|
||||
Stennis,
|
||||
KUZNECOW,
|
||||
LHA_Tarawa,
|
||||
Type_071,
|
||||
)
|
||||
from dcs.terrain.terrain import Airport, ParkingSlot
|
||||
from dcs.unittype import FlyingType
|
||||
from dcs.unit import Unit
|
||||
|
||||
from game import db
|
||||
from game.point_with_heading import PointWithHeading
|
||||
from game.scenery_group import SceneryGroup
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
from gen.runways import RunwayAssigner, RunwayData
|
||||
from .base import Base
|
||||
from .missiontarget import MissionTarget
|
||||
from game.point_with_heading import PointWithHeading
|
||||
from .theatergroundobject import (
|
||||
BaseDefenseGroundObject,
|
||||
EwrGroundObject,
|
||||
GenericCarrierGroundObject,
|
||||
SamGroundObject,
|
||||
TheaterGroundObject,
|
||||
VehicleGroupGroundObject,
|
||||
)
|
||||
from ..db import PRICES
|
||||
from ..dcs.aircrafttype import AircraftType
|
||||
from ..dcs.groundunittype import GroundUnitType
|
||||
from ..utils import nautical_miles
|
||||
from ..weather import Conditions
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from gen.flights.flight import FlightType
|
||||
from ..transfers import PendingTransfers
|
||||
|
||||
FREE_FRONTLINE_UNIT_SUPPLY: int = 15
|
||||
AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION: int = 12
|
||||
|
||||
|
||||
class ControlPointType(Enum):
|
||||
@@ -59,41 +72,15 @@ class ControlPointType(Enum):
|
||||
OFF_MAP = 6
|
||||
|
||||
|
||||
class LocationType(Enum):
|
||||
BaseAirDefense = "base air defense"
|
||||
Coastal = "coastal defense"
|
||||
Ewr = "EWR"
|
||||
BaseEwr = "Base EWR"
|
||||
Garrison = "garrison"
|
||||
MissileSite = "missile site"
|
||||
OffshoreStrikeTarget = "offshore strike target"
|
||||
Sam = "SAM"
|
||||
Ship = "ship"
|
||||
Shorad = "SHORAD"
|
||||
StrikeTarget = "strike target"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresetLocations:
|
||||
"""Defines the preset locations loaded from the campaign mission file."""
|
||||
|
||||
#: Locations used for spawning ground defenses for bases.
|
||||
base_garrisons: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations used for spawning air defenses for bases. Used by SAMs, AAA,
|
||||
#: and SHORADs.
|
||||
base_air_defense: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations used by EWRs.
|
||||
ewrs: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations used by Base EWRs.
|
||||
base_ewrs: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations used by non-carrier ships. Carriers and LHAs are not random.
|
||||
#: Locations used by non-carrier ships that will be spawned unless the faction has
|
||||
#: no navy or the player has disabled ship generation for the owning side.
|
||||
ships: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations used by coastal defenses.
|
||||
#: Locations used by coastal defenses that are generated if the faction is capable.
|
||||
coastal_defenses: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations used by ground based strike objectives.
|
||||
@@ -102,68 +89,116 @@ class PresetLocations:
|
||||
#: Locations used by offshore strike objectives.
|
||||
offshore_strike_locations: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations used by missile sites like scuds and V-2s.
|
||||
#: Locations used by missile sites like scuds and V-2s that are generated if the
|
||||
#: faction is capable.
|
||||
missile_sites: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations of long range SAMs which should always be spawned.
|
||||
required_long_range_sams: List[PointWithHeading] = field(default_factory=list)
|
||||
#: Locations of long range SAMs.
|
||||
long_range_sams: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations of medium range SAMs which should always be spawned.
|
||||
required_medium_range_sams: List[PointWithHeading] = field(default_factory=list)
|
||||
#: Locations of medium range SAMs.
|
||||
medium_range_sams: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations of EWRs which should always be spawned.
|
||||
required_ewrs: List[PointWithHeading] = field(default_factory=list)
|
||||
#: Locations of short range SAMs.
|
||||
short_range_sams: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
@staticmethod
|
||||
def _random_from(points: List[PointWithHeading]) -> Optional[PointWithHeading]:
|
||||
"""Finds, removes, and returns a random position from the given list."""
|
||||
if not points:
|
||||
return None
|
||||
point = random.choice(points)
|
||||
points.remove(point)
|
||||
return point
|
||||
#: Locations of AAA groups.
|
||||
aaa: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
def random_for(self, location_type: LocationType) -> Optional[PointWithHeading]:
|
||||
"""Returns a position suitable for the given location type.
|
||||
#: Locations of EWRs.
|
||||
ewrs: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
The location, if found, will be claimed by the caller and not available
|
||||
to subsequent calls.
|
||||
"""
|
||||
if location_type == LocationType.BaseAirDefense:
|
||||
return self._random_from(self.base_air_defense)
|
||||
if location_type == LocationType.Coastal:
|
||||
return self._random_from(self.coastal_defenses)
|
||||
if location_type == LocationType.Ewr:
|
||||
return self._random_from(self.ewrs)
|
||||
if location_type == LocationType.BaseEwr:
|
||||
return self._random_from(self.base_ewrs)
|
||||
if location_type == LocationType.Garrison:
|
||||
return self._random_from(self.base_garrisons)
|
||||
if location_type == LocationType.MissileSite:
|
||||
return self._random_from(self.missile_sites)
|
||||
if location_type == LocationType.OffshoreStrikeTarget:
|
||||
return self._random_from(self.offshore_strike_locations)
|
||||
if location_type == LocationType.Sam:
|
||||
return self._random_from(self.strike_locations)
|
||||
if location_type == LocationType.Ship:
|
||||
return self._random_from(self.ships)
|
||||
if location_type == LocationType.Shorad:
|
||||
return self._random_from(self.base_garrisons)
|
||||
if location_type == LocationType.StrikeTarget:
|
||||
return self._random_from(self.strike_locations)
|
||||
logging.error(f"Unknown location type: {location_type}")
|
||||
return None
|
||||
#: Locations of map scenery to create zones for.
|
||||
scenery: List[SceneryGroup] = field(default_factory=list)
|
||||
|
||||
#: Locations of factories for producing ground units.
|
||||
factories: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations of ammo depots for controlling number of units on the front line at a
|
||||
#: control point.
|
||||
ammunition_depots: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
#: Locations of stationary armor groups.
|
||||
armor_groups: List[PointWithHeading] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingOccupancy:
|
||||
present: int
|
||||
ordered: int
|
||||
transferring: int
|
||||
class AircraftAllocations:
|
||||
present: dict[AircraftType, int]
|
||||
ordered: dict[AircraftType, int]
|
||||
transferring: dict[AircraftType, int]
|
||||
|
||||
@property
|
||||
def total_value(self) -> int:
|
||||
total: int = 0
|
||||
for unit_type, count in self.present.items():
|
||||
total += unit_type.price * count
|
||||
for unit_type, count in self.ordered.items():
|
||||
total += unit_type.price * count
|
||||
for unit_type, count in self.transferring.items():
|
||||
total += unit_type.price * count
|
||||
|
||||
return total
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self.present + self.ordered + self.transferring
|
||||
return self.total_present + self.total_ordered + self.total_transferring
|
||||
|
||||
@property
|
||||
def total_present(self) -> int:
|
||||
return sum(self.present.values())
|
||||
|
||||
@property
|
||||
def total_ordered(self) -> int:
|
||||
return sum(self.ordered.values())
|
||||
|
||||
@property
|
||||
def total_transferring(self) -> int:
|
||||
return sum(self.transferring.values())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroundUnitAllocations:
|
||||
present: dict[GroundUnitType, int]
|
||||
ordered: dict[GroundUnitType, int]
|
||||
transferring: dict[GroundUnitType, int]
|
||||
|
||||
@property
|
||||
def all(self) -> dict[GroundUnitType, int]:
|
||||
combined: dict[GroundUnitType, int] = defaultdict(int)
|
||||
for unit_type, count in itertools.chain(
|
||||
self.present.items(), self.ordered.items(), self.transferring.items()
|
||||
):
|
||||
combined[unit_type] += count
|
||||
return dict(combined)
|
||||
|
||||
@property
|
||||
def total_value(self) -> int:
|
||||
total: int = 0
|
||||
for unit_type, count in self.present.items():
|
||||
total += unit_type.price * count
|
||||
for unit_type, count in self.ordered.items():
|
||||
total += unit_type.price * count
|
||||
for unit_type, count in self.transferring.items():
|
||||
total += unit_type.price * count
|
||||
|
||||
return total
|
||||
|
||||
@cached_property
|
||||
def total(self) -> int:
|
||||
return self.total_present + self.total_ordered + self.total_transferring
|
||||
|
||||
@cached_property
|
||||
def total_present(self) -> int:
|
||||
return sum(self.present.values())
|
||||
|
||||
@cached_property
|
||||
def total_ordered(self) -> int:
|
||||
return sum(self.ordered.values())
|
||||
|
||||
@cached_property
|
||||
def total_transferring(self) -> int:
|
||||
return sum(self.transferring.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -227,6 +262,13 @@ class GroundUnitDestination:
|
||||
return self.total_value < other.total_value
|
||||
|
||||
|
||||
@unique
|
||||
class ControlPointStatus(IntEnum):
|
||||
Functional = auto()
|
||||
Damaged = auto()
|
||||
Destroyed = auto()
|
||||
|
||||
|
||||
class ControlPoint(MissionTarget, ABC):
|
||||
|
||||
position = None # type: Point
|
||||
@@ -257,7 +299,6 @@ class ControlPoint(MissionTarget, ABC):
|
||||
self.full_name = name
|
||||
self.at = at
|
||||
self.connected_objectives: List[TheaterGroundObject] = []
|
||||
self.base_defenses: List[BaseDefenseGroundObject] = []
|
||||
self.preset_locations = PresetLocations()
|
||||
self.helipads: List[PointWithHeading] = []
|
||||
|
||||
@@ -269,13 +310,15 @@ class ControlPoint(MissionTarget, ABC):
|
||||
# TODO: Should be Airbase specific.
|
||||
self.has_frontline = has_frontline
|
||||
self.connected_points: List[ControlPoint] = []
|
||||
self.convoy_routes: Dict[ControlPoint, Tuple[Point, ...]] = {}
|
||||
self.shipping_lanes: Dict[ControlPoint, Tuple[Point, ...]] = {}
|
||||
self.base: Base = Base()
|
||||
self.cptype = cptype
|
||||
# TODO: Should be Airbase specific.
|
||||
self.stances: Dict[int, CombatStance] = {}
|
||||
from ..event import UnitsDeliveryEvent
|
||||
from ..unitdelivery import PendingUnitDeliveries
|
||||
|
||||
self.pending_unit_deliveries = UnitsDeliveryEvent(self)
|
||||
self.pending_unit_deliveries = PendingUnitDeliveries(self)
|
||||
|
||||
self.target_position: Optional[Point] = None
|
||||
|
||||
@@ -284,7 +327,7 @@ class ControlPoint(MissionTarget, ABC):
|
||||
|
||||
@property
|
||||
def ground_objects(self) -> List[TheaterGroundObject]:
|
||||
return list(itertools.chain(self.connected_objectives, self.base_defenses))
|
||||
return list(self.connected_objectives)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
@@ -298,6 +341,69 @@ class ControlPoint(MissionTarget, ABC):
|
||||
def is_global(self):
|
||||
return not self.connected_points
|
||||
|
||||
def transitive_connected_friendly_points(
|
||||
self, seen: Optional[Set[ControlPoint]] = None
|
||||
) -> List[ControlPoint]:
|
||||
if seen is None:
|
||||
seen = {self}
|
||||
|
||||
connected = []
|
||||
for cp in self.connected_points:
|
||||
if cp.captured != self.captured:
|
||||
continue
|
||||
if cp in seen:
|
||||
continue
|
||||
seen.add(cp)
|
||||
connected.append(cp)
|
||||
connected.extend(cp.transitive_connected_friendly_points(seen))
|
||||
return connected
|
||||
|
||||
def transitive_friendly_shipping_destinations(
|
||||
self, seen: Optional[Set[ControlPoint]] = None
|
||||
) -> List[ControlPoint]:
|
||||
if seen is None:
|
||||
seen = {self}
|
||||
|
||||
connected = []
|
||||
for cp in self.shipping_lanes:
|
||||
if cp.captured != self.captured:
|
||||
continue
|
||||
if cp in seen:
|
||||
continue
|
||||
seen.add(cp)
|
||||
connected.append(cp)
|
||||
connected.extend(cp.transitive_friendly_shipping_destinations(seen))
|
||||
return connected
|
||||
|
||||
@property
|
||||
def has_factory(self) -> bool:
|
||||
for tgo in self.connected_objectives:
|
||||
if tgo.is_factory and not tgo.is_dead:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_recruit_ground_units(self, game: Game) -> bool:
|
||||
"""Returns True if this control point is capable of recruiting ground units."""
|
||||
if not self.can_deploy_ground_units:
|
||||
return False
|
||||
|
||||
if game.turn == 0:
|
||||
# Allow units to be recruited anywhere on turn 0 to avoid long delays to get
|
||||
# everyone to the front line.
|
||||
return True
|
||||
|
||||
return self.has_factory
|
||||
|
||||
def has_ground_unit_source(self, game: Game) -> bool:
|
||||
"""Returns True if this control point has access to ground reinforcements."""
|
||||
if not self.can_deploy_ground_units:
|
||||
return False
|
||||
|
||||
for cp in game.theater.controlpoints:
|
||||
if cp.is_friendly(self.captured) and cp.can_recruit_ground_units(game):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_carrier(self):
|
||||
"""
|
||||
@@ -340,10 +446,21 @@ class ControlPoint(MissionTarget, ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
# TODO: Should be Airbase specific.
|
||||
def connect(self, to: ControlPoint) -> None:
|
||||
def convoy_origin_for(self, destination: ControlPoint) -> Point:
|
||||
return self.convoy_route_to(destination)[0]
|
||||
|
||||
def convoy_route_to(self, destination: ControlPoint) -> Sequence[Point]:
|
||||
return self.convoy_routes[destination]
|
||||
|
||||
def create_convoy_route(self, to: ControlPoint, waypoints: Iterable[Point]) -> None:
|
||||
self.connected_points.append(to)
|
||||
self.stances[to.id] = CombatStance.DEFENSIVE
|
||||
self.convoy_routes[to] = tuple(waypoints)
|
||||
|
||||
def create_shipping_lane(
|
||||
self, to: ControlPoint, waypoints: Iterable[Point]
|
||||
) -> None:
|
||||
self.shipping_lanes[to] = tuple(waypoints)
|
||||
|
||||
@abstractmethod
|
||||
def runway_is_operational(self) -> bool:
|
||||
@@ -368,14 +485,14 @@ class ControlPoint(MissionTarget, ABC):
|
||||
for group in g.groups:
|
||||
for u in group.units:
|
||||
if db.unit_type_from_name(u.type) in [
|
||||
CVN_74_John_C__Stennis,
|
||||
CV_1143_5_Admiral_Kuznetsov,
|
||||
Stennis,
|
||||
KUZNECOW,
|
||||
]:
|
||||
return group.name
|
||||
elif g.dcs_identifier == "LHA":
|
||||
for group in g.groups:
|
||||
for u in group.units:
|
||||
if db.unit_type_from_name(u.type) in [LHA_1_Tarawa]:
|
||||
if db.unit_type_from_name(u.type) in [LHA_Tarawa]:
|
||||
return group.name
|
||||
return None
|
||||
|
||||
@@ -393,23 +510,8 @@ class ControlPoint(MissionTarget, ABC):
|
||||
def is_friendly(self, to_player: bool) -> bool:
|
||||
return self.captured == to_player
|
||||
|
||||
# TODO: Should be Airbase specific.
|
||||
def clear_base_defenses(self) -> None:
|
||||
for base_defense in self.base_defenses:
|
||||
p = PointWithHeading.from_point(base_defense.position, base_defense.heading)
|
||||
if isinstance(base_defense, EwrGroundObject):
|
||||
self.preset_locations.base_ewrs.append(p)
|
||||
elif isinstance(base_defense, SamGroundObject):
|
||||
self.preset_locations.base_air_defense.append(p)
|
||||
elif isinstance(base_defense, VehicleGroupGroundObject):
|
||||
self.preset_locations.base_garrisons.append(p)
|
||||
else:
|
||||
logging.error(
|
||||
"Could not determine preset location type for "
|
||||
f"{base_defense}. Assuming garrison type."
|
||||
)
|
||||
self.preset_locations.base_garrisons.append(p)
|
||||
self.base_defenses = []
|
||||
def is_friendly_to(self, control_point: ControlPoint) -> bool:
|
||||
return control_point.is_friendly(self.captured)
|
||||
|
||||
def capture_equipment(self, game: Game) -> None:
|
||||
total = self.base.total_armor_value
|
||||
@@ -438,34 +540,26 @@ class ControlPoint(MissionTarget, ABC):
|
||||
while self.base.armor:
|
||||
unit_type, count = self.base.armor.popitem()
|
||||
for _ in range(count):
|
||||
destination.control_point.base.commision_units({unit_type: 1})
|
||||
destination.control_point.base.commission_units({unit_type: 1})
|
||||
destination = heapq.heappushpop(destinations, destination)
|
||||
|
||||
def capture_aircraft(
|
||||
self, game: Game, airframe: Type[FlyingType], count: int
|
||||
) -> None:
|
||||
try:
|
||||
value = PRICES[airframe] * count
|
||||
except KeyError:
|
||||
logging.exception(f"Unknown price for {airframe.id}")
|
||||
return
|
||||
|
||||
def capture_aircraft(self, game: Game, airframe: AircraftType, count: int) -> None:
|
||||
value = airframe.price * count
|
||||
game.adjust_budget(value, player=not self.captured)
|
||||
game.message(
|
||||
f"No valid retreat destination in range of {self.name} for "
|
||||
f"{airframe.id}. {count} aircraft have been captured and sold for "
|
||||
f"${value}M."
|
||||
f"No valid retreat destination in range of {self.name} for {airframe}"
|
||||
f"{count} aircraft have been captured and sold for ${value}M."
|
||||
)
|
||||
|
||||
def aircraft_retreat_destination(
|
||||
self, game: Game, airframe: Type[FlyingType]
|
||||
self, game: Game, airframe: AircraftType
|
||||
) -> Optional[ControlPoint]:
|
||||
closest = ObjectiveDistanceCache.get_closest_airfields(self)
|
||||
# TODO: Should be airframe dependent.
|
||||
max_retreat_distance = nautical_miles(200)
|
||||
# Skip the first airbase because that's the airbase we're retreating
|
||||
# from.
|
||||
airfields = list(closest.airfields_within(max_retreat_distance))[1:]
|
||||
airfields = list(closest.operational_airfields_within(max_retreat_distance))[1:]
|
||||
for airbase in airfields:
|
||||
if not airbase.can_operate(airframe):
|
||||
continue
|
||||
@@ -476,17 +570,17 @@ class ControlPoint(MissionTarget, ABC):
|
||||
return None
|
||||
|
||||
def _retreat_air_units(
|
||||
self, game: Game, airframe: Type[FlyingType], count: int
|
||||
self, game: Game, airframe: AircraftType, count: int
|
||||
) -> None:
|
||||
while count:
|
||||
logging.debug(f"Retreating {count} {airframe.id} from {self.name}")
|
||||
logging.debug(f"Retreating {count} {airframe} from {self.name}")
|
||||
destination = self.aircraft_retreat_destination(game, airframe)
|
||||
if destination is None:
|
||||
self.capture_aircraft(game, airframe, count)
|
||||
return
|
||||
parking = destination.unclaimed_parking(game)
|
||||
transfer_amount = min([parking, count])
|
||||
destination.base.commision_units({airframe: transfer_amount})
|
||||
destination.base.commission_units({airframe: transfer_amount})
|
||||
count -= transfer_amount
|
||||
|
||||
def retreat_air_units(self, game: Game) -> None:
|
||||
@@ -495,11 +589,17 @@ class ControlPoint(MissionTarget, ABC):
|
||||
airframe, count = self.base.aircraft.popitem()
|
||||
self._retreat_air_units(game, airframe, count)
|
||||
|
||||
def depopulate_uncapturable_tgos(self) -> None:
|
||||
for tgo in self.connected_objectives:
|
||||
if not tgo.capturable:
|
||||
tgo.clear()
|
||||
|
||||
# TODO: Should be Airbase specific.
|
||||
def capture(self, game: Game, for_player: bool) -> None:
|
||||
self.pending_unit_deliveries.refund_all(game)
|
||||
self.retreat_ground_units(game)
|
||||
self.retreat_air_units(game)
|
||||
self.depopulate_uncapturable_tgos()
|
||||
|
||||
if for_player:
|
||||
self.captured = True
|
||||
@@ -508,46 +608,29 @@ class ControlPoint(MissionTarget, ABC):
|
||||
|
||||
self.base.set_strength_to_minimum()
|
||||
|
||||
self.clear_base_defenses()
|
||||
from .start_generator import BaseDefenseGenerator
|
||||
|
||||
BaseDefenseGenerator(game, self).generate()
|
||||
|
||||
@abstractmethod
|
||||
def can_operate(self, aircraft: Type[FlyingType]) -> bool:
|
||||
def can_operate(self, aircraft: AircraftType) -> bool:
|
||||
...
|
||||
|
||||
def aircraft_transferring(self, game: Game) -> int:
|
||||
def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]:
|
||||
if self.captured:
|
||||
ato = game.blue_ato
|
||||
else:
|
||||
ato = game.red_ato
|
||||
|
||||
total = 0
|
||||
transferring: defaultdict[AircraftType, int] = defaultdict(int)
|
||||
for package in ato.packages:
|
||||
for flight in package.flights:
|
||||
if flight.departure == flight.arrival:
|
||||
continue
|
||||
if flight.departure == self:
|
||||
total -= flight.count
|
||||
transferring[flight.unit_type] -= flight.count
|
||||
elif flight.arrival == self:
|
||||
total += flight.count
|
||||
return total
|
||||
|
||||
def expected_aircraft_next_turn(self, game: Game) -> PendingOccupancy:
|
||||
on_order = 0
|
||||
for unit_bought in self.pending_unit_deliveries.units:
|
||||
if issubclass(unit_bought, FlyingType):
|
||||
on_order += self.pending_unit_deliveries.units[unit_bought]
|
||||
|
||||
return PendingOccupancy(
|
||||
self.base.total_aircraft, on_order, self.aircraft_transferring(game)
|
||||
)
|
||||
transferring[flight.unit_type] += flight.count
|
||||
return transferring
|
||||
|
||||
def unclaimed_parking(self, game: Game) -> int:
|
||||
return (
|
||||
self.total_aircraft_parking - self.expected_aircraft_next_turn(game).total
|
||||
)
|
||||
return self.total_aircraft_parking - self.allocated_aircraft(game).total
|
||||
|
||||
@abstractmethod
|
||||
def active_runway(
|
||||
@@ -597,66 +680,87 @@ class ControlPoint(MissionTarget, ABC):
|
||||
u.position.x = u.position.x + delta.x
|
||||
u.position.y = u.position.y + delta.y
|
||||
|
||||
@property
|
||||
def pending_frontline_aa_deliveries_count(self):
|
||||
"""
|
||||
Get number of pending frontline aa units
|
||||
"""
|
||||
if self.pending_unit_deliveries:
|
||||
return sum(
|
||||
[
|
||||
v
|
||||
for k, v in self.pending_unit_deliveries.units.items()
|
||||
if k in TYPE_SHORAD
|
||||
]
|
||||
)
|
||||
else:
|
||||
return 0
|
||||
def allocated_aircraft(self, game: Game) -> AircraftAllocations:
|
||||
on_order = {}
|
||||
for unit_bought, count in self.pending_unit_deliveries.units.items():
|
||||
if isinstance(unit_bought, AircraftType):
|
||||
on_order[unit_bought] = count
|
||||
|
||||
@property
|
||||
def pending_deliveries_count(self):
|
||||
"""
|
||||
Get number of pending units
|
||||
"""
|
||||
if self.pending_unit_deliveries:
|
||||
return sum([v for k, v in self.pending_unit_deliveries.units.items()])
|
||||
else:
|
||||
return 0
|
||||
return AircraftAllocations(
|
||||
self.base.aircraft, on_order, self.aircraft_transferring(game)
|
||||
)
|
||||
|
||||
@property
|
||||
def expected_ground_units_next_turn(self) -> PendingOccupancy:
|
||||
on_order = 0
|
||||
for unit_bought in self.pending_unit_deliveries.units:
|
||||
if issubclass(unit_bought, FlyingType):
|
||||
continue
|
||||
if unit_bought in TYPE_SHORAD:
|
||||
continue
|
||||
on_order += self.pending_unit_deliveries.units[unit_bought]
|
||||
def allocated_ground_units(
|
||||
self, transfers: PendingTransfers
|
||||
) -> GroundUnitAllocations:
|
||||
on_order = {}
|
||||
for unit_bought, count in self.pending_unit_deliveries.units.items():
|
||||
if isinstance(unit_bought, GroundUnitType):
|
||||
on_order[unit_bought] = count
|
||||
|
||||
return PendingOccupancy(
|
||||
self.base.total_armor,
|
||||
transferring: dict[GroundUnitType, int] = defaultdict(int)
|
||||
for transfer in transfers:
|
||||
if transfer.destination == self:
|
||||
for unit_type, count in transfer.units.items():
|
||||
transferring[unit_type] += count
|
||||
|
||||
return GroundUnitAllocations(
|
||||
self.base.armor,
|
||||
on_order,
|
||||
# Ground unit transfers not yet implemented.
|
||||
transferring=0,
|
||||
transferring,
|
||||
)
|
||||
|
||||
@property
|
||||
def income_per_turn(self) -> int:
|
||||
return 0
|
||||
|
||||
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
if self.is_friendly(for_player):
|
||||
yield from [
|
||||
FlightType.AEWC,
|
||||
]
|
||||
yield from super().mission_types(for_player)
|
||||
|
||||
@property
|
||||
def has_active_frontline(self) -> bool:
|
||||
return any(not c.is_friendly(self.captured) for c in self.connected_points)
|
||||
|
||||
def front_is_active(self, other: ControlPoint) -> bool:
|
||||
if other not in self.connected_points:
|
||||
raise ValueError
|
||||
|
||||
return self.captured != other.captured
|
||||
|
||||
@property
|
||||
def frontline_unit_count_limit(self) -> int:
|
||||
return (
|
||||
FREE_FRONTLINE_UNIT_SUPPLY
|
||||
+ self.active_ammo_depots_count * AMMO_DEPOT_FRONTLINE_UNIT_CONTRIBUTION
|
||||
)
|
||||
|
||||
@property
|
||||
def active_ammo_depots_count(self) -> int:
|
||||
"""Return the number of available ammo depots"""
|
||||
return len(
|
||||
[
|
||||
obj
|
||||
for obj in self.connected_objectives
|
||||
if obj.category == "ammo" and not obj.is_dead
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def total_ammo_depots_count(self) -> int:
|
||||
"""Return the number of ammo depots, including dead ones"""
|
||||
return len([obj for obj in self.connected_objectives if obj.category == "ammo"])
|
||||
|
||||
@property
|
||||
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
|
||||
return []
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def category(self) -> str:
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def status(self) -> ControlPointStatus:
|
||||
...
|
||||
|
||||
|
||||
class Airfield(ControlPoint):
|
||||
def __init__(
|
||||
@@ -675,7 +779,7 @@ class Airfield(ControlPoint):
|
||||
self.airport = airport
|
||||
self._runway_status = RunwayStatus()
|
||||
|
||||
def can_operate(self, aircraft: FlyingType) -> bool:
|
||||
def can_operate(self, aircraft: AircraftType) -> bool:
|
||||
# TODO: Allow helicopters.
|
||||
# Need to implement ground spawns so the helos don't use the runway.
|
||||
# TODO: Allow harrier.
|
||||
@@ -686,18 +790,22 @@ class Airfield(ControlPoint):
|
||||
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
if self.is_friendly(for_player):
|
||||
yield from [
|
||||
# TODO: FlightType.INTERCEPTION
|
||||
# TODO: FlightType.LOGISTICS
|
||||
]
|
||||
else:
|
||||
if not self.is_friendly(for_player):
|
||||
yield from [
|
||||
FlightType.OCA_AIRCRAFT,
|
||||
FlightType.OCA_RUNWAY,
|
||||
]
|
||||
|
||||
yield from super().mission_types(for_player)
|
||||
|
||||
if self.is_friendly(for_player):
|
||||
yield from [
|
||||
FlightType.AEWC,
|
||||
FlightType.REFUELING,
|
||||
# TODO: FlightType.INTERCEPTION
|
||||
# TODO: FlightType.LOGISTICS
|
||||
]
|
||||
|
||||
@property
|
||||
def total_aircraft_parking(self) -> int:
|
||||
return len(self.airport.parking_slots)
|
||||
@@ -734,6 +842,19 @@ class Airfield(ControlPoint):
|
||||
def income_per_turn(self) -> int:
|
||||
return 20
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
return "airfield"
|
||||
|
||||
@property
|
||||
def status(self) -> ControlPointStatus:
|
||||
runway_staus = self.runway_status
|
||||
if runway_staus.needs_repair:
|
||||
return ControlPointStatus.Destroyed
|
||||
elif runway_staus.damaged:
|
||||
return ControlPointStatus.Damaged
|
||||
return ControlPointStatus.Functional
|
||||
|
||||
|
||||
class NavalControlPoint(ControlPoint, ABC):
|
||||
@property
|
||||
@@ -758,20 +879,24 @@ class NavalControlPoint(ControlPoint, ABC):
|
||||
def heading(self) -> int:
|
||||
return 0 # TODO compute heading
|
||||
|
||||
def find_main_tgo(self) -> TheaterGroundObject:
|
||||
for g in self.ground_objects:
|
||||
if g.dcs_identifier in ["CARRIER", "LHA"]:
|
||||
return g
|
||||
raise RuntimeError(f"Found no carrier/LHA group for {self.name}")
|
||||
|
||||
def runway_is_operational(self) -> bool:
|
||||
# Necessary because it's possible for the carrier itself to have sunk
|
||||
# while its escorts are still alive.
|
||||
for g in self.ground_objects:
|
||||
if g.dcs_identifier in ["CARRIER", "LHA"]:
|
||||
for group in g.groups:
|
||||
for u in group.units:
|
||||
if db.unit_type_from_name(u.type) in [
|
||||
CVN_74_John_C__Stennis,
|
||||
LHA_1_Tarawa,
|
||||
CV_1143_5_Admiral_Kuznetsov,
|
||||
Type_071_Amphibious_Transport_Dock,
|
||||
]:
|
||||
return True
|
||||
for group in self.find_main_tgo().groups:
|
||||
for u in group.units:
|
||||
if db.unit_type_from_name(u.type) in [
|
||||
Stennis,
|
||||
LHA_Tarawa,
|
||||
KUZNECOW,
|
||||
Type_071,
|
||||
]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def active_runway(
|
||||
@@ -797,6 +922,14 @@ class NavalControlPoint(ControlPoint, ABC):
|
||||
def can_deploy_ground_units(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def status(self) -> ControlPointStatus:
|
||||
if not self.runway_is_operational():
|
||||
return ControlPointStatus.Destroyed
|
||||
if self.find_main_tgo().dead_units:
|
||||
return ControlPointStatus.Damaged
|
||||
return ControlPointStatus.Functional
|
||||
|
||||
|
||||
class Carrier(NavalControlPoint):
|
||||
def __init__(self, name: str, at: Point, cp_id: int):
|
||||
@@ -813,6 +946,16 @@ class Carrier(NavalControlPoint):
|
||||
cptype=ControlPointType.AIRCRAFT_CARRIER_GROUP,
|
||||
)
|
||||
|
||||
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
yield from super().mission_types(for_player)
|
||||
if self.is_friendly(for_player):
|
||||
yield from [
|
||||
FlightType.AEWC,
|
||||
FlightType.REFUELING,
|
||||
]
|
||||
|
||||
def capture(self, game: Game, for_player: bool) -> None:
|
||||
raise RuntimeError("Carriers cannot be captured")
|
||||
|
||||
@@ -820,13 +963,17 @@ class Carrier(NavalControlPoint):
|
||||
def is_carrier(self):
|
||||
return True
|
||||
|
||||
def can_operate(self, aircraft: FlyingType) -> bool:
|
||||
return aircraft in db.CARRIER_CAPABLE
|
||||
def can_operate(self, aircraft: AircraftType) -> bool:
|
||||
return aircraft.carrier_capable
|
||||
|
||||
@property
|
||||
def total_aircraft_parking(self) -> int:
|
||||
return 90
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
return "cv"
|
||||
|
||||
|
||||
class Lha(NavalControlPoint):
|
||||
def __init__(self, name: str, at: Point, cp_id: int):
|
||||
@@ -850,13 +997,17 @@ class Lha(NavalControlPoint):
|
||||
def is_lha(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_operate(self, aircraft: FlyingType) -> bool:
|
||||
return aircraft in db.LHA_CAPABLE
|
||||
def can_operate(self, aircraft: AircraftType) -> bool:
|
||||
return aircraft.lha_capable
|
||||
|
||||
@property
|
||||
def total_aircraft_parking(self) -> int:
|
||||
return 20
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
return "lha"
|
||||
|
||||
|
||||
class OffMapSpawn(ControlPoint):
|
||||
def runway_is_operational(self) -> bool:
|
||||
@@ -886,7 +1037,7 @@ class OffMapSpawn(ControlPoint):
|
||||
def total_aircraft_parking(self) -> int:
|
||||
return 1000
|
||||
|
||||
def can_operate(self, aircraft: FlyingType) -> bool:
|
||||
def can_operate(self, aircraft: AircraftType) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
@@ -907,6 +1058,14 @@ class OffMapSpawn(ControlPoint):
|
||||
def can_deploy_ground_units(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
return "offmap"
|
||||
|
||||
@property
|
||||
def status(self) -> ControlPointStatus:
|
||||
return ControlPointStatus.Functional
|
||||
|
||||
|
||||
class Fob(ControlPoint):
|
||||
def __init__(self, name: str, at: Point, cp_id: int):
|
||||
@@ -940,24 +1099,16 @@ class Fob(ControlPoint):
|
||||
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
if self.is_friendly(for_player):
|
||||
yield from [
|
||||
FlightType.BARCAP,
|
||||
# TODO: FlightType.LOGISTICS
|
||||
]
|
||||
else:
|
||||
yield from [
|
||||
FlightType.STRIKE,
|
||||
FlightType.SWEEP,
|
||||
FlightType.ESCORT,
|
||||
FlightType.SEAD,
|
||||
]
|
||||
if not self.is_friendly(for_player):
|
||||
yield FlightType.STRIKE
|
||||
|
||||
yield from super().mission_types(for_player)
|
||||
|
||||
@property
|
||||
def total_aircraft_parking(self) -> int:
|
||||
return 0
|
||||
|
||||
def can_operate(self, aircraft: FlyingType) -> bool:
|
||||
def can_operate(self, aircraft: AircraftType) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
@@ -971,3 +1122,11 @@ class Fob(ControlPoint):
|
||||
@property
|
||||
def income_per_turn(self) -> int:
|
||||
return 10
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
return "fob"
|
||||
|
||||
@property
|
||||
def status(self) -> ControlPointStatus:
|
||||
return ControlPointStatus.Functional
|
||||
|
||||
180
game/theater/frontline.py
Normal file
180
game/theater/frontline.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator, List, Tuple
|
||||
|
||||
from dcs.mapping import Point
|
||||
|
||||
from gen.flights.flight import FlightType
|
||||
from .controlpoint import (
|
||||
ControlPoint,
|
||||
MissionTarget,
|
||||
)
|
||||
from ..utils import pairwise
|
||||
|
||||
|
||||
FRONTLINE_MIN_CP_DISTANCE = 5000
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrontLineSegment:
|
||||
"""
|
||||
Describes a line segment of a FrontLine
|
||||
"""
|
||||
|
||||
point_a: Point
|
||||
point_b: Point
|
||||
|
||||
@property
|
||||
def attack_heading(self) -> float:
|
||||
"""The heading of the frontline segment from player to enemy control point"""
|
||||
return self.point_a.heading_between_point(self.point_b)
|
||||
|
||||
@property
|
||||
def attack_distance(self) -> float:
|
||||
"""Length of the segment"""
|
||||
return self.point_a.distance_to_point(self.point_b)
|
||||
|
||||
|
||||
class FrontLine(MissionTarget):
|
||||
"""Defines a front line location between two control points.
|
||||
Front lines are the area where ground combat happens.
|
||||
Overwrites the entirety of MissionTarget __init__ method to allow for
|
||||
dynamic position calculation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
blue_point: ControlPoint,
|
||||
red_point: ControlPoint,
|
||||
) -> None:
|
||||
self.blue_cp = blue_point
|
||||
self.red_cp = red_point
|
||||
try:
|
||||
route = list(blue_point.convoy_route_to(red_point))
|
||||
except KeyError:
|
||||
# Some campaigns are air only and the mission generator currently relies on
|
||||
# *some* "front line" being drawn between these two. In this case there will
|
||||
# be no supply route to follow. Just create an arbitrary route between the
|
||||
# two points.
|
||||
route = [blue_point.position, red_point.position]
|
||||
# Snap the beginning and end points to the CPs rather than the convoy waypoints,
|
||||
# which are on roads.
|
||||
route[0] = blue_point.position
|
||||
route[-1] = red_point.position
|
||||
self.segments: List[FrontLineSegment] = [
|
||||
FrontLineSegment(a, b) for a, b in pairwise(route)
|
||||
]
|
||||
self.name = f"Front line {blue_point}/{red_point}"
|
||||
|
||||
def control_point_hostile_to(self, player: bool) -> ControlPoint:
|
||||
if player:
|
||||
return self.red_cp
|
||||
return self.blue_cp
|
||||
|
||||
def is_friendly(self, to_player: bool) -> bool:
|
||||
"""Returns True if the objective is in friendly territory."""
|
||||
return False
|
||||
|
||||
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
|
||||
yield from [
|
||||
FlightType.CAS,
|
||||
FlightType.AEWC,
|
||||
FlightType.REFUELING
|
||||
# TODO: FlightType.TROOP_TRANSPORT
|
||||
# TODO: FlightType.EVAC
|
||||
]
|
||||
yield from super().mission_types(for_player)
|
||||
|
||||
@property
|
||||
def position(self):
|
||||
"""
|
||||
The position where the conflict should occur
|
||||
according to the current strength of each control point.
|
||||
"""
|
||||
return self.point_from_a(self._position_distance)
|
||||
|
||||
@property
|
||||
def points(self) -> Iterator[Point]:
|
||||
yield self.segments[0].point_a
|
||||
for segment in self.segments:
|
||||
yield segment.point_b
|
||||
|
||||
@property
|
||||
def control_points(self) -> Tuple[ControlPoint, ControlPoint]:
|
||||
"""Returns a tuple of the two control points."""
|
||||
return self.blue_cp, self.red_cp
|
||||
|
||||
@property
|
||||
def attack_distance(self):
|
||||
"""The total distance of all segments"""
|
||||
return sum(i.attack_distance for i in self.segments)
|
||||
|
||||
@property
|
||||
def attack_heading(self):
|
||||
"""The heading of the active attack segment from player to enemy control point"""
|
||||
return self.active_segment.attack_heading
|
||||
|
||||
@property
|
||||
def active_segment(self) -> FrontLineSegment:
|
||||
"""The FrontLine segment where there can be an active conflict"""
|
||||
if self._position_distance <= self.segments[0].attack_distance:
|
||||
return self.segments[0]
|
||||
|
||||
remaining_dist = self._position_distance
|
||||
for segment in self.segments:
|
||||
if remaining_dist <= segment.attack_distance:
|
||||
return segment
|
||||
else:
|
||||
remaining_dist -= segment.attack_distance
|
||||
logging.error(
|
||||
"Frontline attack distance is greater than the sum of its segments"
|
||||
)
|
||||
return self.segments[0]
|
||||
|
||||
def point_from_a(self, distance: float) -> Point:
|
||||
"""
|
||||
Returns a point {distance} away from control_point_a along the frontline segments.
|
||||
"""
|
||||
if distance < self.segments[0].attack_distance:
|
||||
return self.blue_cp.position.point_from_heading(
|
||||
self.segments[0].attack_heading, distance
|
||||
)
|
||||
remaining_dist = distance
|
||||
for segment in self.segments:
|
||||
if remaining_dist < segment.attack_distance:
|
||||
return segment.point_a.point_from_heading(
|
||||
segment.attack_heading, remaining_dist
|
||||
)
|
||||
else:
|
||||
remaining_dist -= segment.attack_distance
|
||||
|
||||
@property
|
||||
def _position_distance(self) -> float:
|
||||
"""
|
||||
The distance from point "a" where the conflict should occur
|
||||
according to the current strength of each control point
|
||||
"""
|
||||
total_strength = self.blue_cp.base.strength + self.red_cp.base.strength
|
||||
if self.blue_cp.base.strength == 0:
|
||||
return self._adjust_for_min_dist(0)
|
||||
if self.red_cp.base.strength == 0:
|
||||
return self._adjust_for_min_dist(self.attack_distance)
|
||||
strength_pct = self.blue_cp.base.strength / total_strength
|
||||
return self._adjust_for_min_dist(strength_pct * self.attack_distance)
|
||||
|
||||
def _adjust_for_min_dist(self, distance: float) -> float:
|
||||
"""
|
||||
Ensures the frontline conflict is never located within the minimum distance
|
||||
constant of either end control point.
|
||||
"""
|
||||
if (distance > self.attack_distance / 2) and (
|
||||
distance + FRONTLINE_MIN_CP_DISTANCE > self.attack_distance
|
||||
):
|
||||
distance = self.attack_distance - FRONTLINE_MIN_CP_DISTANCE
|
||||
elif (distance < self.attack_distance / 2) and (
|
||||
distance < FRONTLINE_MIN_CP_DISTANCE
|
||||
):
|
||||
distance = FRONTLINE_MIN_CP_DISTANCE
|
||||
return distance
|
||||
34
game/theater/latlon.py
Normal file
34
game/theater/latlon.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LatLon:
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
def as_list(self) -> List[float]:
|
||||
return [self.latitude, self.longitude]
|
||||
|
||||
@staticmethod
|
||||
def _components(dimension: float) -> Tuple[int, int, float]:
|
||||
degrees = int(dimension)
|
||||
minutes = int(dimension * 60 % 60)
|
||||
seconds = dimension * 3600 % 60
|
||||
return degrees, minutes, seconds
|
||||
|
||||
def _format_component(
|
||||
self, dimension: float, hemispheres: Tuple[str, str], seconds_precision: int
|
||||
) -> str:
|
||||
hemisphere = hemispheres[0] if dimension >= 0 else hemispheres[1]
|
||||
degrees, minutes, seconds = self._components(dimension)
|
||||
return f"{degrees}°{minutes:02}'{seconds:02.{seconds_precision}f}\"{hemisphere}"
|
||||
|
||||
def format_dms(self, include_decimal_seconds: bool = False) -> str:
|
||||
precision = 2 if include_decimal_seconds else 0
|
||||
return " ".join(
|
||||
[
|
||||
self._format_component(self.latitude, ("N", "S"), precision),
|
||||
self._format_component(self.longitude, ("E", "W"), precision),
|
||||
]
|
||||
)
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator, TYPE_CHECKING
|
||||
from typing import Iterator, TYPE_CHECKING, List, Union
|
||||
|
||||
from dcs.mapping import Point
|
||||
from dcs.unit import Unit
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen.flights.flight import FlightType
|
||||
@@ -36,9 +37,13 @@ class MissionTarget:
|
||||
yield from [
|
||||
FlightType.ESCORT,
|
||||
FlightType.TARCAP,
|
||||
FlightType.SEAD,
|
||||
FlightType.SEAD_ESCORT,
|
||||
FlightType.SWEEP,
|
||||
# TODO: FlightType.ELINT,
|
||||
# TODO: FlightType.EWAR,
|
||||
# TODO: FlightType.RECON,
|
||||
]
|
||||
|
||||
@property
|
||||
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
|
||||
return []
|
||||
|
||||
8
game/theater/nevada.py
Normal file
8
game/theater/nevada.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from game.theater.projections import TransverseMercator
|
||||
|
||||
PARAMETERS = TransverseMercator(
|
||||
central_meridian=-117,
|
||||
false_easting=-193996.80999964548,
|
||||
false_northing=-4410028.063999966,
|
||||
scale_factor=0.9996,
|
||||
)
|
||||
8
game/theater/normandy.py
Normal file
8
game/theater/normandy.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from game.theater.projections import TransverseMercator
|
||||
|
||||
PARAMETERS = TransverseMercator(
|
||||
central_meridian=-3,
|
||||
false_easting=-195526.00000000204,
|
||||
false_northing=-5484812.999999951,
|
||||
scale_factor=0.9996,
|
||||
)
|
||||
8
game/theater/persiangulf.py
Normal file
8
game/theater/persiangulf.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from game.theater.projections import TransverseMercator
|
||||
|
||||
PARAMETERS = TransverseMercator(
|
||||
central_meridian=57,
|
||||
false_easting=75755.99999999645,
|
||||
false_northing=-2894933.0000000377,
|
||||
scale_factor=0.9996,
|
||||
)
|
||||
31
game/theater/projections.py
Normal file
31
game/theater/projections.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyproj import CRS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransverseMercator:
|
||||
central_meridian: int
|
||||
false_easting: float
|
||||
false_northing: float
|
||||
scale_factor: float
|
||||
|
||||
def to_crs(self) -> CRS:
|
||||
return CRS.from_proj4(
|
||||
" ".join(
|
||||
[
|
||||
"+proj=tmerc",
|
||||
"+lat_0=0",
|
||||
f"+lon_0={self.central_meridian}",
|
||||
f"+k_0={self.scale_factor}",
|
||||
f"+x_0={self.false_easting}",
|
||||
f"+y_0={self.false_northing}",
|
||||
"+towgs84=0,0,0,0,0,0,0",
|
||||
"+units=m",
|
||||
"+vunits=m",
|
||||
"+ellps=WGS84",
|
||||
"+no_defs",
|
||||
"+axis=neu",
|
||||
]
|
||||
)
|
||||
)
|
||||
@@ -5,7 +5,7 @@ import pickle
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
from typing import Any, Dict, Iterable, List, Set
|
||||
|
||||
from dcs.mapping import Point
|
||||
from dcs.task import CAP, CAS, PinpointStrike
|
||||
@@ -13,15 +13,18 @@ from dcs.vehicles import AirDefence
|
||||
|
||||
from game import Game, db
|
||||
from game.factions.faction import Faction
|
||||
from game.theater import Carrier, Lha, LocationType, PointWithHeading
|
||||
from game.scenery_group import SceneryGroup
|
||||
from game.theater import Carrier, Lha, PointWithHeading
|
||||
from game.theater.theatergroundobject import (
|
||||
BuildingGroundObject,
|
||||
CarrierGroundObject,
|
||||
EwrGroundObject,
|
||||
FactoryGroundObject,
|
||||
LhaGroundObject,
|
||||
MissileSiteGroundObject,
|
||||
SamGroundObject,
|
||||
ShipGroundObject,
|
||||
SceneryGroundObject,
|
||||
VehicleGroupGroundObject,
|
||||
CoastalSiteGroundObject,
|
||||
)
|
||||
@@ -34,11 +37,10 @@ from gen.fleet.ship_group_generator import (
|
||||
generate_lha_group,
|
||||
generate_ship_group,
|
||||
)
|
||||
from gen.locations.preset_location_finder import MizDataLocationFinder
|
||||
from gen.missiles.missiles_group_generator import generate_missile_group
|
||||
from gen.sam.airdefensegroupgenerator import AirDefenseRange
|
||||
from gen.sam.sam_group_generator import generate_anti_air_group
|
||||
from gen.sam.ewr_group_generator import generate_ewr_group
|
||||
from gen.sam.sam_group_generator import generate_anti_air_group
|
||||
from . import (
|
||||
ConflictTheater,
|
||||
ControlPoint,
|
||||
@@ -46,6 +48,7 @@ from . import (
|
||||
Fob,
|
||||
OffMapSpawn,
|
||||
)
|
||||
from ..profiling import logged_duration
|
||||
from ..settings import Settings
|
||||
|
||||
GroundObjectTemplates = Dict[str, Dict[str, Any]]
|
||||
@@ -75,37 +78,52 @@ class GeneratorSettings:
|
||||
no_enemy_navy: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModSettings:
|
||||
a4_skyhawk: bool = False
|
||||
f22_raptor: bool = False
|
||||
hercules: bool = False
|
||||
jas39_gripen: bool = False
|
||||
su57_felon: bool = False
|
||||
frenchpack: bool = False
|
||||
high_digit_sams: bool = False
|
||||
|
||||
|
||||
class GameGenerator:
|
||||
def __init__(
|
||||
self,
|
||||
player: str,
|
||||
enemy: str,
|
||||
player: Faction,
|
||||
enemy: Faction,
|
||||
theater: ConflictTheater,
|
||||
settings: Settings,
|
||||
generator_settings: GeneratorSettings,
|
||||
mod_settings: ModSettings,
|
||||
) -> None:
|
||||
self.player = player
|
||||
self.enemy = enemy
|
||||
self.theater = theater
|
||||
self.settings = settings
|
||||
self.generator_settings = generator_settings
|
||||
self.mod_settings = mod_settings
|
||||
|
||||
def generate(self) -> Game:
|
||||
# Reset name generator
|
||||
namegen.reset()
|
||||
self.prepare_theater()
|
||||
game = Game(
|
||||
player_name=self.player,
|
||||
enemy_name=self.enemy,
|
||||
theater=self.theater,
|
||||
start_date=self.generator_settings.start_date,
|
||||
settings=self.settings,
|
||||
player_budget=self.generator_settings.player_budget,
|
||||
enemy_budget=self.generator_settings.enemy_budget,
|
||||
)
|
||||
with logged_duration("TGO population"):
|
||||
# Reset name generator
|
||||
namegen.reset()
|
||||
self.prepare_theater()
|
||||
game = Game(
|
||||
player_faction=self.player.apply_mod_settings(self.mod_settings),
|
||||
enemy_faction=self.enemy.apply_mod_settings(self.mod_settings),
|
||||
theater=self.theater,
|
||||
start_date=self.generator_settings.start_date,
|
||||
settings=self.settings,
|
||||
player_budget=self.generator_settings.player_budget,
|
||||
enemy_budget=self.generator_settings.enemy_budget,
|
||||
)
|
||||
|
||||
GroundObjectGenerator(game, self.generator_settings).generate()
|
||||
GroundObjectGenerator(game, self.generator_settings).generate()
|
||||
game.settings.version = VERSION
|
||||
game.begin_turn_0()
|
||||
return game
|
||||
|
||||
def prepare_theater(self) -> None:
|
||||
@@ -140,175 +158,6 @@ class GameGenerator:
|
||||
cp.captured = True
|
||||
|
||||
|
||||
class LocationFinder:
|
||||
def __init__(self, game: Game, control_point: ControlPoint) -> None:
|
||||
self.game = game
|
||||
self.control_point = control_point
|
||||
self.miz_data = MizDataLocationFinder.compute_possible_locations(
|
||||
game.theater.terrain.name, control_point.full_name
|
||||
)
|
||||
|
||||
def location_for(self, location_type: LocationType) -> Optional[PointWithHeading]:
|
||||
position = self.control_point.preset_locations.random_for(location_type)
|
||||
if position is not None:
|
||||
return position
|
||||
|
||||
logging.warning(
|
||||
f"No campaign location for %s Mat %s",
|
||||
location_type.value,
|
||||
self.control_point,
|
||||
)
|
||||
position = self.random_from_miz_data(
|
||||
location_type == LocationType.OffshoreStrikeTarget
|
||||
)
|
||||
if position is not None:
|
||||
return position
|
||||
|
||||
logging.debug(
|
||||
f"No mizdata location for %s at %s", location_type.value, self.control_point
|
||||
)
|
||||
position = self.random_position(location_type)
|
||||
if position is not None:
|
||||
return position
|
||||
|
||||
logging.error(
|
||||
f"Could not find position for %s at %s",
|
||||
location_type.value,
|
||||
self.control_point,
|
||||
)
|
||||
return None
|
||||
|
||||
def random_from_miz_data(self, offshore: bool) -> Optional[PointWithHeading]:
|
||||
if offshore:
|
||||
locations = self.miz_data.offshore_locations
|
||||
else:
|
||||
locations = self.miz_data.ashore_locations
|
||||
if self.miz_data.offshore_locations:
|
||||
preset = random.choice(locations)
|
||||
locations.remove(preset)
|
||||
return PointWithHeading.from_point(preset.position, preset.heading)
|
||||
return None
|
||||
|
||||
def random_position(
|
||||
self, location_type: LocationType
|
||||
) -> Optional[PointWithHeading]:
|
||||
# TODO: Flesh out preset locations so we never hit this case.
|
||||
|
||||
if location_type == LocationType.Coastal:
|
||||
# No coastal locations generated randomly
|
||||
return None
|
||||
|
||||
logging.warning(
|
||||
"Falling back to random location for %s at %s",
|
||||
location_type.value,
|
||||
self.control_point,
|
||||
)
|
||||
|
||||
is_base_defense = location_type in {
|
||||
LocationType.BaseAirDefense,
|
||||
LocationType.Garrison,
|
||||
LocationType.Shorad,
|
||||
}
|
||||
|
||||
on_land = location_type not in {
|
||||
LocationType.OffshoreStrikeTarget,
|
||||
LocationType.Ship,
|
||||
}
|
||||
|
||||
avoid_others = location_type not in {
|
||||
LocationType.Garrison,
|
||||
LocationType.MissileSite,
|
||||
LocationType.Sam,
|
||||
LocationType.Ship,
|
||||
LocationType.Shorad,
|
||||
}
|
||||
|
||||
if is_base_defense:
|
||||
min_range = 400
|
||||
max_range = 3200
|
||||
elif location_type == LocationType.Ship:
|
||||
min_range = 5000
|
||||
max_range = 40000
|
||||
elif location_type == LocationType.MissileSite:
|
||||
min_range = 2500
|
||||
max_range = 40000
|
||||
else:
|
||||
min_range = 10000
|
||||
max_range = 40000
|
||||
|
||||
position = self._find_random_position(
|
||||
min_range, max_range, on_land, is_base_defense, avoid_others
|
||||
)
|
||||
|
||||
# Retry once, searching a bit further (On some big airbases, 3200 is too
|
||||
# short (Ex : Incirlik)), but searching farther on every base would be
|
||||
# problematic, as some base defense units would end up very far away
|
||||
# from small airfields.
|
||||
if position is None and is_base_defense:
|
||||
position = self._find_random_position(
|
||||
3200, 4800, on_land, is_base_defense, avoid_others
|
||||
)
|
||||
return position
|
||||
|
||||
def _find_random_position(
|
||||
self,
|
||||
min_range: int,
|
||||
max_range: int,
|
||||
on_ground: bool,
|
||||
is_base_defense: bool,
|
||||
avoid_others: bool,
|
||||
) -> Optional[PointWithHeading]:
|
||||
"""
|
||||
Find a valid ground object location
|
||||
:param on_ground: Whether it should be on ground or on sea (True = on
|
||||
ground)
|
||||
:param min_range: Minimal range from point
|
||||
:param max_range: Max range from point
|
||||
:param is_base_defense: True if the location is for base defense.
|
||||
:return:
|
||||
"""
|
||||
near = self.control_point.position
|
||||
others = self.control_point.ground_objects
|
||||
|
||||
def is_valid(point: Optional[PointWithHeading]) -> bool:
|
||||
if point is None:
|
||||
return False
|
||||
|
||||
if on_ground and not self.game.theater.is_on_land(point):
|
||||
return False
|
||||
elif not on_ground and not self.game.theater.is_in_sea(point):
|
||||
return False
|
||||
|
||||
if avoid_others:
|
||||
for other in others:
|
||||
if other.position.distance_to_point(point) < 10000:
|
||||
return False
|
||||
|
||||
if is_base_defense:
|
||||
# If it's a base defense we don't care how close it is to other
|
||||
# points.
|
||||
return True
|
||||
|
||||
# Else verify that it's not too close to another control point.
|
||||
for control_point in self.game.theater.controlpoints:
|
||||
if control_point != self.control_point:
|
||||
if control_point.position.distance_to_point(point) < 30000:
|
||||
return False
|
||||
for ground_obj in control_point.ground_objects:
|
||||
if ground_obj.position.distance_to_point(point) < 10000:
|
||||
return False
|
||||
return True
|
||||
|
||||
for _ in range(300):
|
||||
# Check if on land or sea
|
||||
p = PointWithHeading.from_point(
|
||||
near.random_point_within(max_range, min_range), random.randint(0, 360)
|
||||
)
|
||||
if is_valid(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
class ControlPointGroundObjectGenerator:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -319,14 +168,13 @@ class ControlPointGroundObjectGenerator:
|
||||
self.game = game
|
||||
self.generator_settings = generator_settings
|
||||
self.control_point = control_point
|
||||
self.location_finder = LocationFinder(game, control_point)
|
||||
|
||||
@property
|
||||
def faction_name(self) -> str:
|
||||
if self.control_point.captured:
|
||||
return self.game.player_name
|
||||
return self.game.player_faction.name
|
||||
else:
|
||||
return self.game.enemy_name
|
||||
return self.game.enemy_faction.name
|
||||
|
||||
@property
|
||||
def faction(self) -> Faction:
|
||||
@@ -335,10 +183,7 @@ class ControlPointGroundObjectGenerator:
|
||||
def generate(self) -> bool:
|
||||
self.control_point.connected_objectives = []
|
||||
if self.faction.navy_generators:
|
||||
# Even airbases can generate navies if they are close enough to the
|
||||
# water. This is not controlled by the control point definition, but
|
||||
# rather by whether or not the generator can find a valid position
|
||||
# for the ship.
|
||||
# Even airbases can generate navies if they are close enough to the water.
|
||||
self.generate_navy()
|
||||
|
||||
return True
|
||||
@@ -352,18 +197,14 @@ class ControlPointGroundObjectGenerator:
|
||||
if not self.control_point.captured and skip_enemy_navy:
|
||||
return
|
||||
|
||||
for _ in range(self.faction.navy_group_count):
|
||||
self.generate_ship()
|
||||
|
||||
def generate_ship(self) -> None:
|
||||
point = self.location_finder.location_for(LocationType.OffshoreStrikeTarget)
|
||||
if point is None:
|
||||
return
|
||||
for position in self.control_point.preset_locations.ships:
|
||||
self.generate_ship_at(position)
|
||||
|
||||
def generate_ship_at(self, position: PointWithHeading) -> None:
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
g = ShipGroundObject(
|
||||
namegen.random_objective_name(), group_id, point, self.control_point
|
||||
namegen.random_objective_name(), group_id, position, self.control_point
|
||||
)
|
||||
|
||||
group = generate_ship_group(self.game, g, self.faction_name)
|
||||
@@ -432,156 +273,6 @@ class LhaGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
return True
|
||||
|
||||
|
||||
class BaseDefenseGenerator:
|
||||
def __init__(self, game: Game, control_point: ControlPoint) -> None:
|
||||
self.game = game
|
||||
self.control_point = control_point
|
||||
self.location_finder = LocationFinder(game, control_point)
|
||||
|
||||
@property
|
||||
def faction_name(self) -> str:
|
||||
if self.control_point.captured:
|
||||
return self.game.player_name
|
||||
else:
|
||||
return self.game.enemy_name
|
||||
|
||||
@property
|
||||
def faction(self) -> Faction:
|
||||
return db.FACTIONS[self.faction_name]
|
||||
|
||||
def generate(self) -> None:
|
||||
self.generate_ewr()
|
||||
self.generate_garrison()
|
||||
self.generate_base_defenses()
|
||||
|
||||
def generate_ewr(self) -> None:
|
||||
position = self.location_finder.location_for(LocationType.BaseEwr)
|
||||
if position is None:
|
||||
return
|
||||
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
g = EwrGroundObject(
|
||||
namegen.random_objective_name(),
|
||||
group_id,
|
||||
position,
|
||||
self.control_point,
|
||||
True,
|
||||
)
|
||||
|
||||
group = generate_ewr_group(self.game, g, self.faction)
|
||||
if group is None:
|
||||
logging.error(f"Could not generate EWR at {self.control_point}")
|
||||
return
|
||||
|
||||
g.groups = [group]
|
||||
self.control_point.base_defenses.append(g)
|
||||
|
||||
def generate_base_defenses(self) -> None:
|
||||
# First group has a 1/2 chance of being a SAM, 1/6 chance of SHORAD,
|
||||
# and a 1/6 chance of a garrison.
|
||||
#
|
||||
# Further groups have a 1/3 chance of being SHORAD and 2/3 chance of
|
||||
# being a garrison.
|
||||
for i in range(random.randint(2, 5)):
|
||||
if i == 0 and random.randint(0, 1) == 0:
|
||||
self.generate_sam()
|
||||
elif random.randint(0, 2) == 1:
|
||||
self.generate_shorad()
|
||||
else:
|
||||
self.generate_garrison()
|
||||
|
||||
def generate_garrison(self) -> None:
|
||||
position = self.location_finder.location_for(LocationType.Garrison)
|
||||
if position is None:
|
||||
return
|
||||
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
g = VehicleGroupGroundObject(
|
||||
namegen.random_objective_name(),
|
||||
group_id,
|
||||
position,
|
||||
self.control_point,
|
||||
for_airbase=True,
|
||||
)
|
||||
|
||||
group = generate_armor_group(self.faction_name, self.game, g)
|
||||
if group is None:
|
||||
logging.error(f"Could not generate garrison at {self.control_point}")
|
||||
return
|
||||
g.groups.append(group)
|
||||
self.control_point.base_defenses.append(g)
|
||||
|
||||
def generate_sam(self) -> None:
|
||||
position = self.location_finder.location_for(LocationType.BaseAirDefense)
|
||||
if position is None:
|
||||
return
|
||||
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
g = SamGroundObject(
|
||||
namegen.random_objective_name(),
|
||||
group_id,
|
||||
position,
|
||||
self.control_point,
|
||||
for_airbase=True,
|
||||
)
|
||||
|
||||
groups = generate_anti_air_group(self.game, g, self.faction)
|
||||
if not groups:
|
||||
logging.error(f"Could not generate SAM at {self.control_point}")
|
||||
return
|
||||
g.groups = groups
|
||||
self.control_point.base_defenses.append(g)
|
||||
|
||||
def generate_shorad(self) -> None:
|
||||
position = self.location_finder.location_for(LocationType.BaseAirDefense)
|
||||
if position is None:
|
||||
return
|
||||
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
g = SamGroundObject(
|
||||
namegen.random_objective_name(),
|
||||
group_id,
|
||||
position,
|
||||
self.control_point,
|
||||
for_airbase=True,
|
||||
)
|
||||
|
||||
groups = generate_anti_air_group(
|
||||
self.game, g, self.faction, ranges=[{AirDefenseRange.Short}]
|
||||
)
|
||||
if not groups:
|
||||
logging.error(f"Could not generate SHORAD group at {self.control_point}")
|
||||
return
|
||||
g.groups = groups
|
||||
self.control_point.base_defenses.append(g)
|
||||
|
||||
|
||||
class FobDefenseGenerator(BaseDefenseGenerator):
|
||||
def generate(self) -> None:
|
||||
self.generate_garrison()
|
||||
self.generate_fob_defenses()
|
||||
|
||||
def generate_fob_defenses(self):
|
||||
# First group has a 1/2 chance of being a SHORAD,
|
||||
# and a 1/2 chance of a garrison.
|
||||
#
|
||||
# Further groups have a 1/3 chance of being SHORAD and 2/3 chance of
|
||||
# being a garrison.
|
||||
for i in range(random.randint(2, 5)):
|
||||
if i == 0 and random.randint(0, 1) == 0:
|
||||
self.generate_shorad()
|
||||
elif i == 0 and random.randint(0, 1) == 0:
|
||||
self.generate_garrison()
|
||||
elif random.randint(0, 2) == 1:
|
||||
self.generate_shorad()
|
||||
else:
|
||||
self.generate_garrison()
|
||||
|
||||
|
||||
class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -597,8 +288,19 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
if not super().generate():
|
||||
return False
|
||||
|
||||
BaseDefenseGenerator(self.game, self.control_point).generate()
|
||||
self.generate_ground_points()
|
||||
return True
|
||||
|
||||
def generate_ground_points(self) -> None:
|
||||
"""Generate ground objects and AA sites for the control point."""
|
||||
self.generate_armor_groups()
|
||||
self.generate_aa()
|
||||
self.generate_ewrs()
|
||||
self.generate_scenery_sites()
|
||||
self.generate_strike_targets()
|
||||
self.generate_offshore_strike_targets()
|
||||
self.generate_factories()
|
||||
self.generate_ammunition_depots()
|
||||
|
||||
if self.faction.missiles:
|
||||
self.generate_missile_sites()
|
||||
@@ -606,96 +308,73 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
if self.faction.coastal_defenses:
|
||||
self.generate_coastal_sites()
|
||||
|
||||
return True
|
||||
def generate_armor_groups(self) -> None:
|
||||
for position in self.control_point.preset_locations.armor_groups:
|
||||
self.generate_armor_at(position)
|
||||
|
||||
def generate_ground_points(self) -> None:
|
||||
"""Generate ground objects and AA sites for the control point."""
|
||||
skip_sams = self.generate_required_aa()
|
||||
skip_ewrs = self.generate_required_ewr()
|
||||
def generate_armor_at(self, position: PointWithHeading) -> None:
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
if self.control_point.is_global:
|
||||
g = VehicleGroupGroundObject(
|
||||
namegen.random_objective_name(),
|
||||
group_id,
|
||||
position,
|
||||
self.control_point,
|
||||
)
|
||||
|
||||
group = generate_armor_group(self.faction_name, self.game, g)
|
||||
if group is None:
|
||||
logging.error(
|
||||
"Could not generate armor group for %s at %s",
|
||||
g.name,
|
||||
self.control_point,
|
||||
)
|
||||
return
|
||||
g.groups = [group]
|
||||
self.control_point.connected_objectives.append(g)
|
||||
|
||||
# Always generate at least one AA point.
|
||||
self.generate_aa_site()
|
||||
|
||||
# And between 2 and 7 other objectives.
|
||||
amount = random.randrange(2, 7)
|
||||
for i in range(amount):
|
||||
# 1 in 4 additional objectives are AA.
|
||||
if random.randint(0, 3) == 0:
|
||||
if skip_sams > 0:
|
||||
skip_sams -= 1
|
||||
else:
|
||||
self.generate_aa_site()
|
||||
# 1 in 4 additional objectives are EWR.
|
||||
elif random.randint(0, 3) == 0:
|
||||
if skip_ewrs > 0:
|
||||
skip_ewrs -= 1
|
||||
else:
|
||||
self.generate_ewr_site()
|
||||
else:
|
||||
self.generate_ground_point()
|
||||
|
||||
def generate_required_aa(self) -> int:
|
||||
"""Generates the AA sites that are required by the campaign.
|
||||
|
||||
Returns:
|
||||
The number of AA sites that were generated.
|
||||
"""
|
||||
def generate_aa(self) -> None:
|
||||
presets = self.control_point.preset_locations
|
||||
for position in presets.required_long_range_sams:
|
||||
for position in presets.long_range_sams:
|
||||
self.generate_aa_at(
|
||||
position,
|
||||
ranges=[
|
||||
{AirDefenseRange.Long},
|
||||
{AirDefenseRange.Medium},
|
||||
{AirDefenseRange.Short},
|
||||
{AirDefenseRange.AAA},
|
||||
],
|
||||
)
|
||||
for position in presets.required_medium_range_sams:
|
||||
for position in presets.medium_range_sams:
|
||||
self.generate_aa_at(
|
||||
position,
|
||||
ranges=[
|
||||
{AirDefenseRange.Medium},
|
||||
{AirDefenseRange.Short},
|
||||
{AirDefenseRange.AAA},
|
||||
],
|
||||
)
|
||||
return len(presets.required_long_range_sams) + len(
|
||||
presets.required_medium_range_sams
|
||||
)
|
||||
for position in presets.short_range_sams:
|
||||
self.generate_aa_at(
|
||||
position,
|
||||
ranges=[{AirDefenseRange.Short}, {AirDefenseRange.AAA}],
|
||||
)
|
||||
for position in presets.aaa:
|
||||
self.generate_aa_at(
|
||||
position,
|
||||
ranges=[{AirDefenseRange.AAA}],
|
||||
)
|
||||
|
||||
def generate_required_ewr(self) -> int:
|
||||
"""Generates the EWR sites that are required by the campaign.
|
||||
|
||||
Returns:
|
||||
The number of EWR sites that were generated.
|
||||
"""
|
||||
def generate_ewrs(self) -> None:
|
||||
presets = self.control_point.preset_locations
|
||||
for position in presets.required_ewrs:
|
||||
for position in presets.ewrs:
|
||||
self.generate_ewr_at(position)
|
||||
return len(presets.required_ewrs)
|
||||
|
||||
def generate_ground_point(self) -> None:
|
||||
try:
|
||||
category = random.choice(self.faction.building_set)
|
||||
except IndexError:
|
||||
logging.exception("Faction has no buildings defined")
|
||||
return
|
||||
def generate_strike_target_at(self, category: str, position: Point) -> None:
|
||||
|
||||
obj_name = namegen.random_objective_name()
|
||||
template = random.choice(list(self.templates[category].values()))
|
||||
|
||||
if category == "oil":
|
||||
location_type = LocationType.OffshoreStrikeTarget
|
||||
else:
|
||||
location_type = LocationType.StrikeTarget
|
||||
|
||||
# Pick from preset locations
|
||||
point = self.location_finder.location_for(location_type)
|
||||
if point is None:
|
||||
return
|
||||
|
||||
object_id = 0
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
@@ -709,7 +388,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
category,
|
||||
group_id,
|
||||
object_id,
|
||||
point + template_point,
|
||||
position + template_point,
|
||||
unit["heading"],
|
||||
self.control_point,
|
||||
unit["type"],
|
||||
@@ -717,19 +396,28 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
|
||||
self.control_point.connected_objectives.append(g)
|
||||
|
||||
def generate_aa_site(self) -> None:
|
||||
position = self.location_finder.location_for(LocationType.Sam)
|
||||
if position is None:
|
||||
return
|
||||
self.generate_aa_at(
|
||||
position,
|
||||
ranges=[
|
||||
# Prefer to use proper SAMs, but fall back to SHORADs if needed.
|
||||
{AirDefenseRange.Long, AirDefenseRange.Medium},
|
||||
{AirDefenseRange.Short},
|
||||
],
|
||||
def generate_ammunition_depots(self) -> None:
|
||||
for position in self.control_point.preset_locations.ammunition_depots:
|
||||
self.generate_strike_target_at(category="ammo", position=position)
|
||||
|
||||
def generate_factories(self) -> None:
|
||||
for position in self.control_point.preset_locations.factories:
|
||||
self.generate_factory_at(position)
|
||||
|
||||
def generate_factory_at(self, point: PointWithHeading) -> None:
|
||||
obj_name = namegen.random_objective_name()
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
g = FactoryGroundObject(
|
||||
obj_name,
|
||||
group_id,
|
||||
point,
|
||||
point.heading,
|
||||
self.control_point,
|
||||
)
|
||||
|
||||
self.control_point.connected_objectives.append(g)
|
||||
|
||||
def generate_aa_at(
|
||||
self, position: Point, ranges: Iterable[Set[AirDefenseRange]]
|
||||
) -> None:
|
||||
@@ -740,7 +428,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
group_id,
|
||||
position,
|
||||
self.control_point,
|
||||
for_airbase=False,
|
||||
)
|
||||
groups = generate_anti_air_group(self.game, g, self.faction, ranges)
|
||||
if not groups:
|
||||
@@ -753,13 +440,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
g.groups = groups
|
||||
self.control_point.connected_objectives.append(g)
|
||||
|
||||
def generate_ewr_site(self) -> None:
|
||||
position = self.location_finder.location_for(LocationType.Ewr)
|
||||
if position is None:
|
||||
return
|
||||
self.generate_ewr_at(position)
|
||||
|
||||
def generate_ewr_at(self, position: Point) -> None:
|
||||
def generate_ewr_at(self, position: PointWithHeading) -> None:
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
g = EwrGroundObject(
|
||||
@@ -767,7 +448,6 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
group_id,
|
||||
position,
|
||||
self.control_point,
|
||||
for_airbase=False,
|
||||
)
|
||||
group = generate_ewr_group(self.game, g, self.faction)
|
||||
if group is None:
|
||||
@@ -780,15 +460,45 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
g.groups = [group]
|
||||
self.control_point.connected_objectives.append(g)
|
||||
|
||||
def generate_scenery_sites(self) -> None:
|
||||
presets = self.control_point.preset_locations
|
||||
for scenery_group in presets.scenery:
|
||||
self.generate_tgo_for_scenery(scenery_group)
|
||||
|
||||
def generate_tgo_for_scenery(self, scenery: SceneryGroup) -> None:
|
||||
|
||||
obj_name = namegen.random_objective_name()
|
||||
category = scenery.category
|
||||
group_id = self.game.next_group_id()
|
||||
object_id = 0
|
||||
|
||||
# Each nested trigger zone is a target/building/unit for an objective.
|
||||
for zone in scenery.zones:
|
||||
|
||||
object_id += 1
|
||||
local_position = zone.position
|
||||
local_dcs_identifier = zone.name
|
||||
|
||||
g = SceneryGroundObject(
|
||||
obj_name,
|
||||
category,
|
||||
group_id,
|
||||
object_id,
|
||||
local_position,
|
||||
self.control_point,
|
||||
local_dcs_identifier,
|
||||
zone,
|
||||
)
|
||||
|
||||
self.control_point.connected_objectives.append(g)
|
||||
|
||||
return
|
||||
|
||||
def generate_missile_sites(self) -> None:
|
||||
for i in range(self.faction.missiles_group_count):
|
||||
self.generate_missile_site()
|
||||
|
||||
def generate_missile_site(self) -> None:
|
||||
position = self.location_finder.location_for(LocationType.MissileSite)
|
||||
if position is None:
|
||||
return
|
||||
for position in self.control_point.preset_locations.missile_sites:
|
||||
self.generate_missile_site_at(position)
|
||||
|
||||
def generate_missile_site_at(self, position: PointWithHeading) -> None:
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
g = MissileSiteGroundObject(
|
||||
@@ -802,14 +512,10 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
return
|
||||
|
||||
def generate_coastal_sites(self) -> None:
|
||||
for i in range(self.faction.coastal_group_count):
|
||||
self.generate_coastal_site()
|
||||
|
||||
def generate_coastal_site(self) -> None:
|
||||
position = self.location_finder.location_for(LocationType.Coastal)
|
||||
if position is None:
|
||||
return
|
||||
for position in self.control_point.preset_locations.coastal_defenses:
|
||||
self.generate_coastal_site_at(position)
|
||||
|
||||
def generate_coastal_site_at(self, position: PointWithHeading) -> None:
|
||||
group_id = self.game.next_group_id()
|
||||
|
||||
g = CoastalSiteGroundObject(
|
||||
@@ -826,21 +532,45 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
|
||||
self.control_point.connected_objectives.append(g)
|
||||
return
|
||||
|
||||
def generate_strike_targets(self) -> None:
|
||||
building_set = list(set(self.faction.building_set) - {"oil"})
|
||||
if not building_set:
|
||||
logging.error("Faction has no buildings defined")
|
||||
return
|
||||
for position in self.control_point.preset_locations.strike_locations:
|
||||
category = random.choice(building_set)
|
||||
self.generate_strike_target_at(category, position)
|
||||
|
||||
def generate_offshore_strike_targets(self) -> None:
|
||||
if "oil" not in self.faction.building_set:
|
||||
logging.error("Faction does not support offshore strike targets")
|
||||
return
|
||||
for position in self.control_point.preset_locations.offshore_strike_locations:
|
||||
self.generate_strike_target_at("oil", position)
|
||||
|
||||
|
||||
class FobGroundObjectGenerator(AirbaseGroundObjectGenerator):
|
||||
def generate(self) -> bool:
|
||||
self.generate_fob()
|
||||
FobDefenseGenerator(self.game, self.control_point).generate()
|
||||
self.generate_required_aa()
|
||||
self.generate_armor_groups()
|
||||
self.generate_factories()
|
||||
self.generate_ammunition_depots()
|
||||
self.generate_aa()
|
||||
self.generate_ewrs()
|
||||
self.generate_scenery_sites()
|
||||
self.generate_strike_targets()
|
||||
self.generate_offshore_strike_targets()
|
||||
|
||||
if self.faction.missiles:
|
||||
self.generate_missile_sites()
|
||||
|
||||
if self.faction.coastal_defenses:
|
||||
self.generate_coastal_sites()
|
||||
|
||||
return True
|
||||
|
||||
def generate_fob(self) -> None:
|
||||
try:
|
||||
category = self.faction.building_set[self.faction.building_set.index("fob")]
|
||||
except IndexError:
|
||||
logging.exception("Faction has no fob buildings defined")
|
||||
return
|
||||
|
||||
category = "fob"
|
||||
obj_name = self.control_point.name
|
||||
template = random.choice(list(self.templates[category].values()))
|
||||
point = self.control_point.position
|
||||
@@ -862,7 +592,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator):
|
||||
unit["heading"],
|
||||
self.control_point,
|
||||
unit["type"],
|
||||
airbase_group=True,
|
||||
is_fob_structure=True,
|
||||
)
|
||||
self.control_point.connected_objectives.append(g)
|
||||
|
||||
|
||||
8
game/theater/syria.py
Normal file
8
game/theater/syria.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from game.theater.projections import TransverseMercator
|
||||
|
||||
PARAMETERS = TransverseMercator(
|
||||
central_meridian=39,
|
||||
false_easting=282801.00000003993,
|
||||
false_northing=-3879865.9999999935,
|
||||
scale_factor=0.9996,
|
||||
)
|
||||
@@ -2,14 +2,20 @@ from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
from typing import Iterator, List, TYPE_CHECKING
|
||||
from typing import Iterator, List, TYPE_CHECKING, Union
|
||||
|
||||
from dcs.mapping import Point
|
||||
from dcs.triggers import TriggerZone
|
||||
from dcs.unit import Unit
|
||||
from dcs.unitgroup import Group
|
||||
from dcs.unittype import VehicleType
|
||||
|
||||
from .. import db
|
||||
from ..data.radar_db import UNITS_WITH_RADAR
|
||||
from ..data.radar_db import (
|
||||
TRACK_RADARS,
|
||||
TELARS,
|
||||
LAUNCHER_TRACKER_PAIRS,
|
||||
)
|
||||
from ..utils import Distance, meters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -19,78 +25,25 @@ if TYPE_CHECKING:
|
||||
from .missiontarget import MissionTarget
|
||||
|
||||
NAME_BY_CATEGORY = {
|
||||
"power": "Power plant",
|
||||
"ammo": "Ammo depot",
|
||||
"fuel": "Fuel depot",
|
||||
"ewr": "Early Warning Radar",
|
||||
"aa": "AA Defense Site",
|
||||
"ware": "Warehouse",
|
||||
"farp": "FARP",
|
||||
"fob": "FOB",
|
||||
"factory": "Factory",
|
||||
"comms": "Comms. tower",
|
||||
"oil": "Oil platform",
|
||||
"derrick": "Derrick",
|
||||
"ww2bunker": "Bunker",
|
||||
"village": "Village",
|
||||
"allycamp": "Camp",
|
||||
"EWR": "EWR",
|
||||
}
|
||||
|
||||
ABBREV_NAME = {
|
||||
"power": "PLANT",
|
||||
"ammo": "AMMO",
|
||||
"fuel": "FUEL",
|
||||
"aa": "AA",
|
||||
"ware": "WARE",
|
||||
"ammo": "Ammo depot",
|
||||
"armor": "Armor group",
|
||||
"coastal": "Coastal defense",
|
||||
"comms": "Communications tower",
|
||||
"derrick": "Derrick",
|
||||
"factory": "Factory",
|
||||
"farp": "FARP",
|
||||
"fob": "FOB",
|
||||
"factory": "FACTORY",
|
||||
"comms": "COMMST",
|
||||
"oil": "OILP",
|
||||
"derrick": "DERK",
|
||||
"ww2bunker": "BUNK",
|
||||
"village": "VLG",
|
||||
"allycamp": "CMP",
|
||||
}
|
||||
|
||||
CATEGORY_MAP = {
|
||||
# Special cases
|
||||
"CARRIER": ["CARRIER"],
|
||||
"LHA": ["LHA"],
|
||||
"aa": ["AA"],
|
||||
# Buildings
|
||||
"power": [
|
||||
"Workshop A",
|
||||
"Electric power box",
|
||||
"Garage small A",
|
||||
"Farm B",
|
||||
"Repair workshop",
|
||||
"Garage B",
|
||||
],
|
||||
"ware": ["Warehouse", "Hangar A"],
|
||||
"fuel": ["Tank", "Tank 2", "Tank 3", "Fuel tank"],
|
||||
"ammo": [".Ammunition depot", "Hangar B"],
|
||||
"farp": [
|
||||
"FARP Tent",
|
||||
"FARP Ammo Dump Coating",
|
||||
"FARP Fuel Depot",
|
||||
"FARP Command Post",
|
||||
"FARP CP Blindage",
|
||||
],
|
||||
"fob": ["Bunker 2", "Bunker 1", "Garage small B", ".Command Center", "Barracks 2"],
|
||||
"factory": ["Tech combine", "Tech hangar A"],
|
||||
"comms": ["TV tower", "Comms tower M"],
|
||||
"oil": ["Oil platform"],
|
||||
"derrick": ["Oil derrick", "Pump station", "Subsidiary structure 2"],
|
||||
"ww2bunker": [
|
||||
"Siegfried Line",
|
||||
"Fire Control Bunker",
|
||||
"SK_C_28_naval_gun",
|
||||
"Concertina Wire",
|
||||
"Czech hedgehogs 1",
|
||||
],
|
||||
"village": ["Small house 1B", "Small House 1A", "Small warehouse 1"],
|
||||
"allycamp": [],
|
||||
"fuel": "Fuel depot",
|
||||
"missile": "Missile site",
|
||||
"oil": "Oil platform",
|
||||
"power": "Power plant",
|
||||
"ship": "Ship",
|
||||
"village": "Village",
|
||||
"ware": "Warehouse",
|
||||
"ww2bunker": "Bunker",
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +57,6 @@ class TheaterGroundObject(MissionTarget):
|
||||
heading: int,
|
||||
control_point: ControlPoint,
|
||||
dcs_identifier: str,
|
||||
airbase_group: bool,
|
||||
sea_object: bool,
|
||||
) -> None:
|
||||
super().__init__(name, position)
|
||||
@@ -113,7 +65,6 @@ class TheaterGroundObject(MissionTarget):
|
||||
self.heading = heading
|
||||
self.control_point = control_point
|
||||
self.dcs_identifier = dcs_identifier
|
||||
self.airbase_group = airbase_group
|
||||
self.sea_object = sea_object
|
||||
self.groups: List[Group] = []
|
||||
|
||||
@@ -128,6 +79,17 @@ class TheaterGroundObject(MissionTarget):
|
||||
"""
|
||||
return list(itertools.chain.from_iterable([g.units for g in self.groups]))
|
||||
|
||||
@property
|
||||
def dead_units(self) -> List[Unit]:
|
||||
"""
|
||||
:return: all the dead units at this location
|
||||
"""
|
||||
return list(
|
||||
itertools.chain.from_iterable(
|
||||
[getattr(g, "units_losts", []) for g in self.groups]
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def group_name(self) -> str:
|
||||
"""The name of the unit group."""
|
||||
@@ -178,12 +140,11 @@ class TheaterGroundObject(MissionTarget):
|
||||
return False
|
||||
|
||||
@property
|
||||
def has_radar(self) -> bool:
|
||||
"""Returns True if the ground object contains a unit with radar."""
|
||||
def has_live_radar_sam(self) -> bool:
|
||||
"""Returns True if the ground object contains a unit with working radar SAM."""
|
||||
for group in self.groups:
|
||||
for unit in group.units:
|
||||
if db.unit_type_from_name(unit.type) in UNITS_WITH_RADAR:
|
||||
return True
|
||||
if self.threat_range(group, radar_only=True):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _max_range_of_type(self, group: Group, range_type: str) -> Distance:
|
||||
@@ -204,19 +165,46 @@ class TheaterGroundObject(MissionTarget):
|
||||
max_range = max(max_range, meters(unit_range))
|
||||
return max_range
|
||||
|
||||
def max_detection_range(self) -> Distance:
|
||||
return max(self.detection_range(g) for g in self.groups)
|
||||
|
||||
def detection_range(self, group: Group) -> Distance:
|
||||
return self._max_range_of_type(group, "detection_range")
|
||||
|
||||
def threat_range(self, group: Group) -> Distance:
|
||||
if not self.detection_range(group):
|
||||
# For simple SAMs like shilkas, the unit has both a threat and
|
||||
# detection range. For complex sites like SA-2s, the launcher has a
|
||||
# threat range and the search/track radars have detection ranges. If
|
||||
# the site has no detection range it has no radars and can't fire,
|
||||
# so it's not actually a threat even if it still has launchers.
|
||||
return meters(0)
|
||||
def max_threat_range(self) -> Distance:
|
||||
return max(self.threat_range(g) for g in self.groups)
|
||||
|
||||
def threat_range(self, group: Group, radar_only: bool = False) -> Distance:
|
||||
return self._max_range_of_type(group, "threat_range")
|
||||
|
||||
@property
|
||||
def is_factory(self) -> bool:
|
||||
return self.category == "factory"
|
||||
|
||||
@property
|
||||
def is_control_point(self) -> bool:
|
||||
"""True if this TGO is the group for the control point itself (CVs and FOBs)."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
|
||||
return self.units
|
||||
|
||||
@property
|
||||
def mark_locations(self) -> Iterator[Point]:
|
||||
yield self.position
|
||||
|
||||
def clear(self) -> None:
|
||||
self.groups = []
|
||||
|
||||
@property
|
||||
def capturable(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def purchasable(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class BuildingGroundObject(TheaterGroundObject):
|
||||
def __init__(
|
||||
@@ -229,7 +217,7 @@ class BuildingGroundObject(TheaterGroundObject):
|
||||
heading: int,
|
||||
control_point: ControlPoint,
|
||||
dcs_identifier: str,
|
||||
airbase_group=False,
|
||||
is_fob_structure=False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=name,
|
||||
@@ -239,9 +227,9 @@ class BuildingGroundObject(TheaterGroundObject):
|
||||
heading=heading,
|
||||
control_point=control_point,
|
||||
dcs_identifier=dcs_identifier,
|
||||
airbase_group=airbase_group,
|
||||
sea_object=False,
|
||||
)
|
||||
self.is_fob_structure = is_fob_structure
|
||||
self.object_id = object_id
|
||||
# Other TGOs track deadness based on the number of alive units, but
|
||||
# buildings don't have groups assigned to the TGO.
|
||||
@@ -265,6 +253,90 @@ class BuildingGroundObject(TheaterGroundObject):
|
||||
def kill(self) -> None:
|
||||
self._dead = True
|
||||
|
||||
def iter_building_group(self) -> Iterator[TheaterGroundObject]:
|
||||
for tgo in self.control_point.ground_objects:
|
||||
if tgo.obj_name == self.obj_name and not tgo.is_dead:
|
||||
yield tgo
|
||||
|
||||
@property
|
||||
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
|
||||
return list(self.iter_building_group())
|
||||
|
||||
@property
|
||||
def mark_locations(self) -> Iterator[Point]:
|
||||
for building in self.iter_building_group():
|
||||
yield building.position
|
||||
|
||||
@property
|
||||
def is_control_point(self) -> bool:
|
||||
return self.is_fob_structure
|
||||
|
||||
@property
|
||||
def capturable(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def purchasable(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class SceneryGroundObject(BuildingGroundObject):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
category: str,
|
||||
group_id: int,
|
||||
object_id: int,
|
||||
position: Point,
|
||||
control_point: ControlPoint,
|
||||
dcs_identifier: str,
|
||||
zone: TriggerZone,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=name,
|
||||
category=category,
|
||||
group_id=group_id,
|
||||
object_id=object_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
control_point=control_point,
|
||||
dcs_identifier=dcs_identifier,
|
||||
is_fob_structure=False,
|
||||
)
|
||||
self.zone = zone
|
||||
try:
|
||||
# In the default TriggerZone using "assign as..." in the DCS Mission Editor,
|
||||
# property three has the scenery's object ID as its value.
|
||||
self.map_object_id = self.zone.properties[3]["value"]
|
||||
except (IndexError, KeyError):
|
||||
logging.exception(
|
||||
"Invalid TriggerZone for Scenery definition. The third property must "
|
||||
"be the map object ID."
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
class FactoryGroundObject(BuildingGroundObject):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
group_id: int,
|
||||
position: Point,
|
||||
heading: int,
|
||||
control_point: ControlPoint,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=name,
|
||||
category="factory",
|
||||
group_id=group_id,
|
||||
object_id=0,
|
||||
position=position,
|
||||
heading=heading,
|
||||
control_point=control_point,
|
||||
dcs_identifier="Workshop A",
|
||||
is_fob_structure=False,
|
||||
)
|
||||
|
||||
|
||||
class NavalGroundObject(TheaterGroundObject):
|
||||
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
|
||||
@@ -278,9 +350,19 @@ class NavalGroundObject(TheaterGroundObject):
|
||||
def might_have_aa(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def capturable(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def purchasable(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class GenericCarrierGroundObject(NavalGroundObject):
|
||||
pass
|
||||
@property
|
||||
def is_control_point(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# TODO: Why is this both a CP and a TGO?
|
||||
@@ -294,7 +376,6 @@ class CarrierGroundObject(GenericCarrierGroundObject):
|
||||
heading=0,
|
||||
control_point=control_point,
|
||||
dcs_identifier="CARRIER",
|
||||
airbase_group=True,
|
||||
sea_object=True,
|
||||
)
|
||||
|
||||
@@ -316,7 +397,6 @@ class LhaGroundObject(GenericCarrierGroundObject):
|
||||
heading=0,
|
||||
control_point=control_point,
|
||||
dcs_identifier="LHA",
|
||||
airbase_group=True,
|
||||
sea_object=True,
|
||||
)
|
||||
|
||||
@@ -333,16 +413,23 @@ class MissileSiteGroundObject(TheaterGroundObject):
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=name,
|
||||
category="aa",
|
||||
category="missile",
|
||||
group_id=group_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
control_point=control_point,
|
||||
dcs_identifier="AA",
|
||||
airbase_group=False,
|
||||
sea_object=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def capturable(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def purchasable(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class CoastalSiteGroundObject(TheaterGroundObject):
|
||||
def __init__(
|
||||
@@ -355,32 +442,34 @@ class CoastalSiteGroundObject(TheaterGroundObject):
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=name,
|
||||
category="aa",
|
||||
category="coastal",
|
||||
group_id=group_id,
|
||||
position=position,
|
||||
heading=heading,
|
||||
control_point=control_point,
|
||||
dcs_identifier="AA",
|
||||
airbase_group=False,
|
||||
sea_object=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def capturable(self) -> bool:
|
||||
return False
|
||||
|
||||
class BaseDefenseGroundObject(TheaterGroundObject):
|
||||
"""Base type for all base defenses."""
|
||||
@property
|
||||
def purchasable(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# TODO: Differentiate types.
|
||||
# This type gets used both for AA sites (SAM, AAA, or SHORAD). These should each
|
||||
# be split into their own types.
|
||||
class SamGroundObject(BaseDefenseGroundObject):
|
||||
class SamGroundObject(TheaterGroundObject):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
group_id: int,
|
||||
position: Point,
|
||||
control_point: ControlPoint,
|
||||
for_airbase: bool,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=name,
|
||||
@@ -390,7 +479,6 @@ class SamGroundObject(BaseDefenseGroundObject):
|
||||
heading=0,
|
||||
control_point=control_point,
|
||||
dcs_identifier="AA",
|
||||
airbase_group=for_airbase,
|
||||
sea_object=False,
|
||||
)
|
||||
# Set by the SAM unit generator if the generated group is compatible
|
||||
@@ -411,60 +499,106 @@ class SamGroundObject(BaseDefenseGroundObject):
|
||||
|
||||
if not self.is_friendly(for_player):
|
||||
yield FlightType.DEAD
|
||||
yield FlightType.SEAD
|
||||
yield from super().mission_types(for_player)
|
||||
|
||||
@property
|
||||
def might_have_aa(self) -> bool:
|
||||
return True
|
||||
|
||||
def threat_range(self, group: Group, radar_only: bool = False) -> Distance:
|
||||
max_non_radar = meters(0)
|
||||
live_trs = set()
|
||||
max_telar_range = meters(0)
|
||||
launchers = set()
|
||||
for unit in group.units:
|
||||
unit_type = db.unit_type_from_name(unit.type)
|
||||
if unit_type is None or not issubclass(unit_type, VehicleType):
|
||||
continue
|
||||
if unit_type in TRACK_RADARS:
|
||||
live_trs.add(unit_type)
|
||||
elif unit_type in TELARS:
|
||||
max_telar_range = max(
|
||||
max_telar_range, meters(getattr(unit_type, "threat_range", 0))
|
||||
)
|
||||
elif unit_type in LAUNCHER_TRACKER_PAIRS:
|
||||
launchers.add(unit_type)
|
||||
else:
|
||||
max_non_radar = max(
|
||||
max_non_radar, meters(getattr(unit_type, "threat_range", 0))
|
||||
)
|
||||
max_tel_range = meters(0)
|
||||
for launcher in launchers:
|
||||
if LAUNCHER_TRACKER_PAIRS[launcher] in live_trs:
|
||||
max_tel_range = max(
|
||||
max_tel_range, meters(getattr(launcher, "threat_range"))
|
||||
)
|
||||
if radar_only:
|
||||
return max(max_tel_range, max_telar_range)
|
||||
else:
|
||||
return max(max_tel_range, max_telar_range, max_non_radar)
|
||||
|
||||
class VehicleGroupGroundObject(BaseDefenseGroundObject):
|
||||
@property
|
||||
def capturable(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def purchasable(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class VehicleGroupGroundObject(TheaterGroundObject):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
group_id: int,
|
||||
position: Point,
|
||||
control_point: ControlPoint,
|
||||
for_airbase: bool,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=name,
|
||||
category="aa",
|
||||
category="armor",
|
||||
group_id=group_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
control_point=control_point,
|
||||
dcs_identifier="AA",
|
||||
airbase_group=for_airbase,
|
||||
sea_object=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def capturable(self) -> bool:
|
||||
return False
|
||||
|
||||
class EwrGroundObject(BaseDefenseGroundObject):
|
||||
@property
|
||||
def purchasable(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class EwrGroundObject(TheaterGroundObject):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
group_id: int,
|
||||
position: Point,
|
||||
control_point: ControlPoint,
|
||||
for_airbase: bool,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=name,
|
||||
category="EWR",
|
||||
category="ewr",
|
||||
group_id=group_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
control_point=control_point,
|
||||
dcs_identifier="EWR",
|
||||
airbase_group=for_airbase,
|
||||
sea_object=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def group_name(self) -> str:
|
||||
# Prefix the group names with the side color so Skynet can find them.
|
||||
return f"{self.faction_color}|{super().group_name}"
|
||||
# Use Group Id and uppercase EWR
|
||||
return f"{self.faction_color}|EWR|{self.group_id}"
|
||||
|
||||
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
|
||||
from gen.flights.flight import FlightType
|
||||
@@ -477,6 +611,14 @@ class EwrGroundObject(BaseDefenseGroundObject):
|
||||
def might_have_aa(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def capturable(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def purchasable(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class ShipGroundObject(NavalGroundObject):
|
||||
def __init__(
|
||||
@@ -484,13 +626,12 @@ class ShipGroundObject(NavalGroundObject):
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=name,
|
||||
category="aa",
|
||||
category="ship",
|
||||
group_id=group_id,
|
||||
position=position,
|
||||
heading=0,
|
||||
control_point=control_point,
|
||||
dcs_identifier="AA",
|
||||
airbase_group=False,
|
||||
sea_object=True,
|
||||
)
|
||||
|
||||
|
||||
8
game/theater/thechannel.py
Normal file
8
game/theater/thechannel.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from game.theater.projections import TransverseMercator
|
||||
|
||||
PARAMETERS = TransverseMercator(
|
||||
central_meridian=3,
|
||||
false_easting=99376.00000000288,
|
||||
false_northing=-5636889.00000001,
|
||||
scale_factor=0.9996,
|
||||
)
|
||||
196
game/theater/transitnetwork.py
Normal file
196
game/theater/transitnetwork.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Dict, Iterator, List, Optional, Set, Tuple
|
||||
|
||||
from game.theater import ConflictTheater
|
||||
from game.theater.controlpoint import ControlPoint
|
||||
|
||||
|
||||
class NoPathError(RuntimeError):
|
||||
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
|
||||
super().__init__(f"Could not reconstruct path to {destination} from {origin}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class FrontierNode:
|
||||
cost: float
|
||||
point: ControlPoint = field(compare=False)
|
||||
|
||||
|
||||
class Frontier:
|
||||
def __init__(self) -> None:
|
||||
self.nodes: List[FrontierNode] = []
|
||||
|
||||
def push(self, poly: ControlPoint, cost: float) -> None:
|
||||
heapq.heappush(self.nodes, FrontierNode(cost, poly))
|
||||
|
||||
def pop(self) -> Optional[FrontierNode]:
|
||||
try:
|
||||
return heapq.heappop(self.nodes)
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.nodes)
|
||||
|
||||
|
||||
class TransitConnection(Enum):
|
||||
Road = auto()
|
||||
Shipping = auto()
|
||||
Airlift = auto()
|
||||
|
||||
|
||||
class TransitNetwork:
|
||||
def __init__(self) -> None:
|
||||
self.nodes: Dict[
|
||||
ControlPoint, Dict[ControlPoint, TransitConnection]
|
||||
] = defaultdict(dict)
|
||||
|
||||
def has_destinations(self, control_point: ControlPoint) -> bool:
|
||||
return bool(self.nodes[control_point])
|
||||
|
||||
def has_link(self, a: ControlPoint, b: ControlPoint) -> bool:
|
||||
return b in self.nodes[a]
|
||||
|
||||
def link_type(self, a: ControlPoint, b: ControlPoint) -> TransitConnection:
|
||||
return self.nodes[a][b]
|
||||
|
||||
def link_with(
|
||||
self, a: ControlPoint, b: ControlPoint, link_type: TransitConnection
|
||||
) -> None:
|
||||
self.nodes[a][b] = link_type
|
||||
self.nodes[b][a] = link_type
|
||||
|
||||
def link_road(self, a: ControlPoint, b: ControlPoint) -> None:
|
||||
self.link_with(a, b, TransitConnection.Road)
|
||||
|
||||
def link_shipping(self, a: ControlPoint, b: ControlPoint) -> None:
|
||||
self.link_with(a, b, TransitConnection.Shipping)
|
||||
|
||||
def link_airport(self, a: ControlPoint, b: ControlPoint) -> None:
|
||||
self.link_with(a, b, TransitConnection.Airlift)
|
||||
|
||||
def connections_from(self, control_point: ControlPoint) -> Iterator[ControlPoint]:
|
||||
yield from self.nodes[control_point]
|
||||
|
||||
def cost(self, a: ControlPoint, b: ControlPoint) -> float:
|
||||
return {
|
||||
TransitConnection.Road: 1,
|
||||
TransitConnection.Shipping: 3,
|
||||
# Set arbitrarily high so that other methods are preferred, but still scaled
|
||||
# by distance so that when we do need it we still pick the closest airfield.
|
||||
# The units of distance are meters so there's no risk of these
|
||||
TransitConnection.Airlift: a.position.distance_to_point(b.position),
|
||||
}[self.link_type(a, b)]
|
||||
|
||||
def has_path_between(
|
||||
self,
|
||||
origin: ControlPoint,
|
||||
destination: ControlPoint,
|
||||
seen: Optional[set[ControlPoint]] = None,
|
||||
) -> bool:
|
||||
if seen is None:
|
||||
seen = set()
|
||||
seen.add(origin)
|
||||
for connection in self.connections_from(origin):
|
||||
if connection in seen:
|
||||
continue
|
||||
if connection == destination:
|
||||
return True
|
||||
if self.has_path_between(connection, destination, seen):
|
||||
return True
|
||||
return False
|
||||
|
||||
def shortest_path_between(
|
||||
self, origin: ControlPoint, destination: ControlPoint
|
||||
) -> list[ControlPoint]:
|
||||
return self.shortest_path_with_cost(origin, destination)[0]
|
||||
|
||||
def shortest_path_with_cost(
|
||||
self, origin: ControlPoint, destination: ControlPoint
|
||||
) -> Tuple[List[ControlPoint], float]:
|
||||
if origin not in self.nodes:
|
||||
raise ValueError(f"{origin} is not in the transit network.")
|
||||
if destination not in self.nodes:
|
||||
raise ValueError(f"{destination} is not in the transit network.")
|
||||
|
||||
frontier = Frontier()
|
||||
frontier.push(origin, 0)
|
||||
|
||||
came_from: Dict[ControlPoint, Optional[ControlPoint]] = {origin: None}
|
||||
|
||||
best_known: Dict[ControlPoint, float] = defaultdict(lambda: math.inf)
|
||||
best_known[origin] = 0.0
|
||||
|
||||
while (node := frontier.pop()) is not None:
|
||||
cost = node.cost
|
||||
current = node.point
|
||||
if cost > best_known[current]:
|
||||
continue
|
||||
|
||||
for neighbor in self.connections_from(current):
|
||||
new_cost = cost + self.cost(node.point, neighbor)
|
||||
if new_cost < best_known[neighbor]:
|
||||
best_known[neighbor] = new_cost
|
||||
frontier.push(neighbor, new_cost)
|
||||
came_from[neighbor] = current
|
||||
|
||||
# Reconstruct and reverse the path.
|
||||
current = destination
|
||||
path: List[ControlPoint] = []
|
||||
while current != origin:
|
||||
path.append(current)
|
||||
previous = came_from.get(current)
|
||||
if previous is None:
|
||||
raise NoPathError(origin, destination)
|
||||
current = previous
|
||||
path.reverse()
|
||||
return path, best_known[destination]
|
||||
|
||||
|
||||
class TransitNetworkBuilder:
|
||||
def __init__(self, theater: ConflictTheater, for_player: bool) -> None:
|
||||
self.control_points = list(theater.control_points_for(for_player))
|
||||
self.network = TransitNetwork()
|
||||
self.airports: Set[ControlPoint] = {
|
||||
cp
|
||||
for cp in self.control_points
|
||||
if cp.is_friendly(for_player) and cp.runway_is_operational()
|
||||
}
|
||||
|
||||
def build(self) -> TransitNetwork:
|
||||
seen = set()
|
||||
for control_point in self.control_points:
|
||||
if control_point not in seen:
|
||||
seen.add(control_point)
|
||||
self.add_transit_links(control_point)
|
||||
return self.network
|
||||
|
||||
def add_transit_links(self, control_point: ControlPoint) -> None:
|
||||
# Prefer road connections.
|
||||
for road_connection in control_point.connected_points:
|
||||
if road_connection.is_friendly_to(control_point):
|
||||
self.network.link_road(control_point, road_connection)
|
||||
|
||||
# Use sea connections if there's no road or rail connection.
|
||||
for sea_connection in control_point.shipping_lanes:
|
||||
if self.network.has_link(control_point, sea_connection):
|
||||
continue
|
||||
if sea_connection.is_friendly_to(control_point):
|
||||
self.network.link_shipping(control_point, sea_connection)
|
||||
|
||||
# And use airports as a last resort.
|
||||
if control_point in self.airports:
|
||||
for airport in self.airports:
|
||||
if control_point == airport:
|
||||
continue
|
||||
if self.network.has_link(control_point, airport):
|
||||
continue
|
||||
if not airport.is_friendly_to(control_point):
|
||||
continue
|
||||
self.network.link_airport(control_point, airport)
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import singledispatchmethod
|
||||
from typing import Optional, TYPE_CHECKING, Union
|
||||
from typing import Optional, TYPE_CHECKING, Union, Iterable
|
||||
|
||||
from dcs.mapping import Point as DcsPoint
|
||||
from shapely.geometry import (
|
||||
@@ -13,11 +13,10 @@ from shapely.geometry import (
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
from shapely.ops import nearest_points, unary_union
|
||||
|
||||
from game.theater import ControlPoint
|
||||
from game.theater import ControlPoint, MissionTarget
|
||||
from game.utils import Distance, meters, nautical_miles
|
||||
from gen import Conflict
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import Flight
|
||||
from gen.flights.flight import Flight, FlightWaypoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
@@ -27,9 +26,12 @@ ThreatPoly = Union[MultiPolygon, Polygon]
|
||||
|
||||
|
||||
class ThreatZones:
|
||||
def __init__(self, airbases: ThreatPoly, air_defenses: ThreatPoly) -> None:
|
||||
def __init__(
|
||||
self, airbases: ThreatPoly, air_defenses: ThreatPoly, radar_sam_threats
|
||||
) -> None:
|
||||
self.airbases = airbases
|
||||
self.air_defenses = air_defenses
|
||||
self.radar_sam_threats = radar_sam_threats
|
||||
self.all = unary_union([airbases, air_defenses])
|
||||
|
||||
def closest_boundary(self, point: DcsPoint) -> DcsPoint:
|
||||
@@ -38,6 +40,10 @@ class ThreatZones:
|
||||
)
|
||||
return DcsPoint(boundary.x, boundary.y)
|
||||
|
||||
def distance_to_threat(self, point: DcsPoint) -> Distance:
|
||||
boundary = self.closest_boundary(point)
|
||||
return meters(boundary.distance_to_point(point))
|
||||
|
||||
@singledispatchmethod
|
||||
def threatened(self, position) -> bool:
|
||||
raise NotImplementedError
|
||||
@@ -69,6 +75,13 @@ class ThreatZones:
|
||||
LineString((self.dcs_to_shapely_point(p.position) for p in flight.points))
|
||||
)
|
||||
|
||||
def waypoints_threatened_by_aircraft(
|
||||
self, waypoints: Iterable[FlightWaypoint]
|
||||
) -> bool:
|
||||
return self.threatened_by_aircraft(
|
||||
LineString((self.dcs_to_shapely_point(p.position) for p in waypoints))
|
||||
)
|
||||
|
||||
@singledispatchmethod
|
||||
def threatened_by_air_defense(self, target) -> bool:
|
||||
raise NotImplementedError
|
||||
@@ -83,12 +96,39 @@ class ThreatZones:
|
||||
LineString((self.dcs_to_shapely_point(p.position) for p in flight.points))
|
||||
)
|
||||
|
||||
@threatened_by_air_defense.register
|
||||
def _threatened_by_air_defense_mission_target(self, target: MissionTarget) -> bool:
|
||||
return self.threatened_by_air_defense(
|
||||
self.dcs_to_shapely_point(target.position)
|
||||
)
|
||||
|
||||
@singledispatchmethod
|
||||
def threatened_by_radar_sam(self, target) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@threatened_by_radar_sam.register
|
||||
def _threatened_by_radar_sam_geom(self, position: BaseGeometry) -> bool:
|
||||
return self.radar_sam_threats.intersects(position)
|
||||
|
||||
@threatened_by_radar_sam.register
|
||||
def _threatened_by_radar_sam_flight(self, flight: Flight) -> bool:
|
||||
return self.threatened_by_radar_sam(
|
||||
LineString((self.dcs_to_shapely_point(p.position) for p in flight.points))
|
||||
)
|
||||
|
||||
def waypoints_threatened_by_radar_sam(
|
||||
self, waypoints: Iterable[FlightWaypoint]
|
||||
) -> bool:
|
||||
return self.threatened_by_radar_sam(
|
||||
LineString((self.dcs_to_shapely_point(p.position) for p in waypoints))
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def closest_enemy_airbase(
|
||||
cls, location: ControlPoint, max_distance: Distance
|
||||
) -> Optional[ControlPoint]:
|
||||
airfields = ObjectiveDistanceCache.get_closest_airfields(location)
|
||||
for airfield in airfields.airfields_within(max_distance):
|
||||
for airfield in airfields.all_airfields_within(max_distance):
|
||||
if airfield.captured != location.captured:
|
||||
return airfield
|
||||
return None
|
||||
@@ -134,6 +174,7 @@ class ThreatZones:
|
||||
"""
|
||||
air_threats = []
|
||||
air_defenses = []
|
||||
radar_sam_threats = []
|
||||
for control_point in game.theater.controlpoints:
|
||||
if control_point.captured != player:
|
||||
continue
|
||||
@@ -151,9 +192,16 @@ class ThreatZones:
|
||||
point = ShapelyPoint(tgo.position.x, tgo.position.y)
|
||||
threat_zone = point.buffer(threat_range.meters)
|
||||
air_defenses.append(threat_zone)
|
||||
radar_threat_range = tgo.threat_range(group, radar_only=True)
|
||||
if radar_threat_range > nautical_miles(3):
|
||||
point = ShapelyPoint(tgo.position.x, tgo.position.y)
|
||||
threat_zone = point.buffer(threat_range.meters)
|
||||
radar_sam_threats.append(threat_zone)
|
||||
|
||||
return cls(
|
||||
airbases=unary_union(air_threats), air_defenses=unary_union(air_defenses)
|
||||
airbases=unary_union(air_threats),
|
||||
air_defenses=unary_union(air_defenses),
|
||||
radar_sam_threats=unary_union(radar_sam_threats),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
649
game/transfers.py
Normal file
649
game/transfers.py
Normal file
@@ -0,0 +1,649 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from functools import singledispatchmethod
|
||||
from typing import (
|
||||
Generic,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
TYPE_CHECKING,
|
||||
TypeVar,
|
||||
Sequence,
|
||||
)
|
||||
|
||||
from dcs.mapping import Point
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.procurement import AircraftProcurementRequest
|
||||
from game.squadrons import Squadron
|
||||
from game.theater import ControlPoint, MissionTarget
|
||||
from game.theater.transitnetwork import (
|
||||
TransitConnection,
|
||||
TransitNetwork,
|
||||
)
|
||||
from game.utils import meters, nautical_miles
|
||||
from gen.ato import Package
|
||||
from gen.flights.ai_flight_planner_db import TRANSPORT_CAPABLE, aircraft_for_task
|
||||
from gen.flights.closestairfields import ObjectiveDistanceCache
|
||||
from gen.flights.flight import Flight, FlightType
|
||||
from gen.flights.flightplan import FlightPlanBuilder
|
||||
from gen.naming import namegen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.inventory import ControlPointAircraftInventory
|
||||
|
||||
|
||||
class Transport:
|
||||
def __init__(self, destination: ControlPoint):
|
||||
self.destination = destination
|
||||
|
||||
def find_escape_route(self) -> Optional[ControlPoint]:
|
||||
raise NotImplementedError
|
||||
|
||||
def description(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransferOrder:
|
||||
"""The base type of all transfer orders.
|
||||
|
||||
A transfer order can transfer multiple units of multiple types.
|
||||
"""
|
||||
|
||||
#: The location the units are transferring from.
|
||||
origin: ControlPoint
|
||||
|
||||
#: The location the units are transferring to.
|
||||
destination: ControlPoint
|
||||
|
||||
#: The current position of the group being transferred. Groups may make multiple
|
||||
#: stops and can switch transport modes before reaching their destination.
|
||||
position: ControlPoint = field(init=False)
|
||||
|
||||
#: True if the transfer order belongs to the player.
|
||||
player: bool = field(init=False)
|
||||
|
||||
#: The units being transferred.
|
||||
units: dict[GroundUnitType, int]
|
||||
|
||||
transport: Optional[Transport] = field(default=None)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the text that should be displayed for the transfer."""
|
||||
count = self.size
|
||||
origin = self.origin.name
|
||||
destination = self.destination.name
|
||||
description = "Transfer" if self.player else "Enemy transfer"
|
||||
return f"{description} of {count} units from {origin} to {destination}"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.position = self.origin
|
||||
self.player = self.origin.is_friendly(to_player=True)
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
if self.transport is None:
|
||||
return "No transports available"
|
||||
return self.transport.description()
|
||||
|
||||
def kill_all(self) -> None:
|
||||
self.units.clear()
|
||||
|
||||
def kill_unit(self, unit_type: GroundUnitType) -> None:
|
||||
if unit_type not in self.units or not self.units[unit_type]:
|
||||
raise KeyError(f"{self} has no {unit_type} remaining")
|
||||
self.units[unit_type] -= 1
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return sum(self.units.values())
|
||||
|
||||
def iter_units(self) -> Iterator[GroundUnitType]:
|
||||
for unit_type, count in self.units.items():
|
||||
for _ in range(count):
|
||||
yield unit_type
|
||||
|
||||
@property
|
||||
def completed(self) -> bool:
|
||||
return self.destination == self.position or not self.size
|
||||
|
||||
def disband_at(self, location: ControlPoint) -> None:
|
||||
logging.info(f"Units halting at {location}.")
|
||||
location.base.commission_units(self.units)
|
||||
self.units.clear()
|
||||
|
||||
@property
|
||||
def next_stop(self) -> ControlPoint:
|
||||
if self.transport is None:
|
||||
raise RuntimeError(
|
||||
"TransferOrder.next_stop called with no transport assigned"
|
||||
)
|
||||
return self.transport.destination
|
||||
|
||||
def find_escape_route(self) -> Optional[ControlPoint]:
|
||||
if self.transport is not None:
|
||||
return self.transport.find_escape_route()
|
||||
return None
|
||||
|
||||
def proceed(self) -> None:
|
||||
if not self.destination.is_friendly(self.player):
|
||||
logging.info(f"Transfer destination {self.destination} was captured.")
|
||||
if self.position.is_friendly(self.player):
|
||||
self.disband_at(self.position)
|
||||
elif (escape_route := self.find_escape_route()) is not None:
|
||||
self.disband_at(escape_route)
|
||||
else:
|
||||
logging.info(
|
||||
f"No escape route available. Units were surrounded and destroyed "
|
||||
"during transfer."
|
||||
)
|
||||
self.kill_all()
|
||||
return
|
||||
|
||||
if self.transport is None:
|
||||
return
|
||||
|
||||
self.position = self.next_stop
|
||||
self.transport = None
|
||||
|
||||
if self.completed:
|
||||
self.disband_at(self.position)
|
||||
|
||||
|
||||
class Airlift(Transport):
|
||||
"""A transfer order that moves units by cargo planes and helicopters."""
|
||||
|
||||
def __init__(
|
||||
self, transfer: TransferOrder, flight: Flight, next_stop: ControlPoint
|
||||
) -> None:
|
||||
super().__init__(next_stop)
|
||||
self.transfer = transfer
|
||||
self.flight = flight
|
||||
|
||||
@property
|
||||
def units(self) -> dict[GroundUnitType, int]:
|
||||
return self.transfer.units
|
||||
|
||||
@property
|
||||
def player_owned(self) -> bool:
|
||||
return self.transfer.player
|
||||
|
||||
def find_escape_route(self) -> Optional[ControlPoint]:
|
||||
# TODO: Move units to closest base.
|
||||
return None
|
||||
|
||||
def description(self) -> str:
|
||||
return (
|
||||
f"Being airlifted from {self.transfer.position} to {self.destination} by "
|
||||
f"{self.flight}"
|
||||
)
|
||||
|
||||
|
||||
class AirliftPlanner:
|
||||
#: Maximum range from for any link in the route of takeoff, pickup, dropoff, and RTB
|
||||
#: for a helicopter to be considered for airlift. Total route length is not
|
||||
#: considered because the helicopter can refuel at each stop. Cargo planes have no
|
||||
#: maximum range.
|
||||
HELO_MAX_RANGE = nautical_miles(100)
|
||||
|
||||
def __init__(
|
||||
self, game: Game, transfer: TransferOrder, next_stop: ControlPoint
|
||||
) -> None:
|
||||
self.game = game
|
||||
self.transfer = transfer
|
||||
self.next_stop = next_stop
|
||||
self.for_player = transfer.destination.captured
|
||||
self.package = Package(target=next_stop, auto_asap=True)
|
||||
|
||||
def compatible_with_mission(
|
||||
self, unit_type: AircraftType, airfield: ControlPoint
|
||||
) -> bool:
|
||||
if unit_type not in aircraft_for_task(FlightType.TRANSPORT):
|
||||
return False
|
||||
if not self.transfer.origin.can_operate(unit_type):
|
||||
return False
|
||||
if not self.next_stop.can_operate(unit_type):
|
||||
return False
|
||||
|
||||
# Cargo planes have no maximum range.
|
||||
if not unit_type.dcs_unit_type.helicopter:
|
||||
return True
|
||||
|
||||
# A helicopter that is transport capable and able to operate at both bases. Need
|
||||
# to check that no leg of the journey exceeds the maximum range. This doesn't
|
||||
# account for any routing around threats that might take place, but it's close
|
||||
# enough.
|
||||
|
||||
home = airfield.position
|
||||
pickup = self.transfer.position.position
|
||||
drop_off = self.transfer.position.position
|
||||
if meters(home.distance_to_point(pickup)) > self.HELO_MAX_RANGE:
|
||||
return False
|
||||
|
||||
if meters(pickup.distance_to_point(drop_off)) > self.HELO_MAX_RANGE:
|
||||
return False
|
||||
|
||||
if meters(drop_off.distance_to_point(home)) > self.HELO_MAX_RANGE:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def create_package_for_airlift(self) -> None:
|
||||
distance_cache = ObjectiveDistanceCache.get_closest_airfields(
|
||||
self.transfer.position
|
||||
)
|
||||
air_wing = self.game.air_wing_for(self.for_player)
|
||||
for cp in distance_cache.closest_airfields:
|
||||
if cp.captured != self.for_player:
|
||||
continue
|
||||
|
||||
inventory = self.game.aircraft_inventory.for_control_point(cp)
|
||||
for unit_type, available in inventory.all_aircraft:
|
||||
squadrons = air_wing.auto_assignable_for_task_with_type(
|
||||
unit_type, FlightType.TRANSPORT
|
||||
)
|
||||
for squadron in squadrons:
|
||||
if self.compatible_with_mission(unit_type, cp):
|
||||
while (
|
||||
available
|
||||
and squadron.has_available_pilots
|
||||
and self.transfer.transport is None
|
||||
):
|
||||
flight_size = self.create_airlift_flight(
|
||||
squadron, inventory
|
||||
)
|
||||
available -= flight_size
|
||||
if self.package.flights:
|
||||
self.game.ato_for(self.for_player).add_package(self.package)
|
||||
|
||||
def create_airlift_flight(
|
||||
self, squadron: Squadron, inventory: ControlPointAircraftInventory
|
||||
) -> int:
|
||||
available_aircraft = inventory.available(squadron.aircraft)
|
||||
capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2
|
||||
required = math.ceil(self.transfer.size / capacity_each)
|
||||
flight_size = min(
|
||||
required,
|
||||
available_aircraft,
|
||||
squadron.aircraft.dcs_unit_type.group_size_max,
|
||||
)
|
||||
# TODO: Use number_of_available_pilots directly once feature flag is gone.
|
||||
# The number of currently available pilots is not relevant when pilot limits
|
||||
# are disabled.
|
||||
if not squadron.can_provide_pilots(flight_size):
|
||||
flight_size = squadron.number_of_available_pilots
|
||||
capacity = flight_size * capacity_each
|
||||
|
||||
if capacity < self.transfer.size:
|
||||
transfer = self.game.transfers.split_transfer(self.transfer, capacity)
|
||||
else:
|
||||
transfer = self.transfer
|
||||
|
||||
player = inventory.control_point.captured
|
||||
flight = Flight(
|
||||
self.package,
|
||||
self.game.country_for(player),
|
||||
squadron,
|
||||
flight_size,
|
||||
FlightType.TRANSPORT,
|
||||
self.game.settings.default_start_type,
|
||||
departure=inventory.control_point,
|
||||
arrival=inventory.control_point,
|
||||
divert=None,
|
||||
cargo=transfer,
|
||||
)
|
||||
|
||||
transport = Airlift(transfer, flight, self.next_stop)
|
||||
transfer.transport = transport
|
||||
|
||||
self.package.add_flight(flight)
|
||||
planner = FlightPlanBuilder(self.game, self.package, self.for_player)
|
||||
planner.populate_flight_plan(flight)
|
||||
self.game.aircraft_inventory.claim_for_flight(flight)
|
||||
return flight_size
|
||||
|
||||
|
||||
class MultiGroupTransport(MissionTarget, Transport):
|
||||
def __init__(
|
||||
self, name: str, origin: ControlPoint, destination: ControlPoint
|
||||
) -> None:
|
||||
MissionTarget.__init__(self, name, origin.position)
|
||||
Transport.__init__(self, destination)
|
||||
self.origin = origin
|
||||
self.transfers: List[TransferOrder] = []
|
||||
|
||||
def is_friendly(self, to_player: bool) -> bool:
|
||||
return self.origin.captured
|
||||
|
||||
def add_units(self, transfer: TransferOrder) -> None:
|
||||
self.transfers.append(transfer)
|
||||
transfer.transport = self
|
||||
|
||||
def remove_units(self, transfer: TransferOrder) -> None:
|
||||
transfer.transport = None
|
||||
self.transfers.remove(transfer)
|
||||
|
||||
def kill_unit(self, unit_type: GroundUnitType) -> None:
|
||||
for transfer in self.transfers:
|
||||
try:
|
||||
transfer.kill_unit(unit_type)
|
||||
return
|
||||
except KeyError:
|
||||
pass
|
||||
raise KeyError
|
||||
|
||||
def kill_all(self) -> None:
|
||||
for transfer in self.transfers:
|
||||
transfer.kill_all()
|
||||
|
||||
def disband(self) -> None:
|
||||
for transfer in list(self.transfers):
|
||||
self.remove_units(transfer)
|
||||
self.transfers.clear()
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return sum(t.size for t in self.transfers)
|
||||
|
||||
@property
|
||||
def units(self) -> dict[GroundUnitType, int]:
|
||||
units: dict[GroundUnitType, int] = defaultdict(int)
|
||||
for transfer in self.transfers:
|
||||
for unit_type, count in transfer.units.items():
|
||||
units[unit_type] += count
|
||||
return units
|
||||
|
||||
def iter_units(self) -> Iterator[GroundUnitType]:
|
||||
for unit_type, count in self.units.items():
|
||||
for _ in range(count):
|
||||
yield unit_type
|
||||
|
||||
@property
|
||||
def player_owned(self) -> bool:
|
||||
return self.origin.captured
|
||||
|
||||
def find_escape_route(self) -> Optional[ControlPoint]:
|
||||
raise NotImplementedError
|
||||
|
||||
def description(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Convoy(MultiGroupTransport):
|
||||
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
|
||||
super().__init__(namegen.next_convoy_name(), origin, destination)
|
||||
|
||||
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
|
||||
if self.is_friendly(for_player):
|
||||
return
|
||||
|
||||
yield FlightType.BAI
|
||||
yield from super().mission_types(for_player)
|
||||
|
||||
@property
|
||||
def route_start(self) -> Point:
|
||||
return self.origin.convoy_origin_for(self.destination)
|
||||
|
||||
@property
|
||||
def route_end(self) -> Point:
|
||||
return self.destination.convoy_origin_for(self.origin)
|
||||
|
||||
def description(self) -> str:
|
||||
return f"In a convoy from {self.origin} to {self.destination}"
|
||||
|
||||
def find_escape_route(self) -> Optional[ControlPoint]:
|
||||
return None
|
||||
|
||||
|
||||
class CargoShip(MultiGroupTransport):
|
||||
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
|
||||
super().__init__(namegen.next_cargo_ship_name(), origin, destination)
|
||||
|
||||
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
|
||||
if self.is_friendly(for_player):
|
||||
return
|
||||
|
||||
yield FlightType.ANTISHIP
|
||||
yield from super().mission_types(for_player)
|
||||
|
||||
@property
|
||||
def route(self) -> Sequence[Point]:
|
||||
return self.origin.shipping_lanes[self.destination]
|
||||
|
||||
def description(self) -> str:
|
||||
return f"On a ship from {self.origin} to {self.destination}"
|
||||
|
||||
def find_escape_route(self) -> Optional[ControlPoint]:
|
||||
return None
|
||||
|
||||
|
||||
TransportType = TypeVar("TransportType", bound=MultiGroupTransport)
|
||||
|
||||
|
||||
class TransportMap(Generic[TransportType]):
|
||||
def __init__(self) -> None:
|
||||
# Dict of origin -> destination -> transport.
|
||||
self.transports: dict[
|
||||
ControlPoint, dict[ControlPoint, TransportType]
|
||||
] = defaultdict(dict)
|
||||
|
||||
def create_transport(
|
||||
self, origin: ControlPoint, destination: ControlPoint
|
||||
) -> TransportType:
|
||||
raise NotImplementedError
|
||||
|
||||
def transport_exists(self, origin: ControlPoint, destination: ControlPoint) -> bool:
|
||||
return destination in self.transports[origin]
|
||||
|
||||
def find_transport(
|
||||
self, origin: ControlPoint, destination: ControlPoint
|
||||
) -> Optional[TransportType]:
|
||||
return self.transports[origin].get(destination)
|
||||
|
||||
def find_or_create_transport(
|
||||
self, origin: ControlPoint, destination: ControlPoint
|
||||
) -> TransportType:
|
||||
transport = self.find_transport(origin, destination)
|
||||
if transport is None:
|
||||
transport = self.create_transport(origin, destination)
|
||||
self.transports[origin][destination] = transport
|
||||
return transport
|
||||
|
||||
def departing_from(self, origin: ControlPoint) -> Iterator[TransportType]:
|
||||
yield from self.transports[origin].values()
|
||||
|
||||
def travelling_to(self, destination: ControlPoint) -> Iterator[TransportType]:
|
||||
for destination_dict in self.transports.values():
|
||||
if destination in destination_dict:
|
||||
yield destination_dict[destination]
|
||||
|
||||
def disband_transport(self, transport: TransportType) -> None:
|
||||
transport.disband()
|
||||
del self.transports[transport.origin][transport.destination]
|
||||
|
||||
def add(self, transfer: TransferOrder, next_stop: ControlPoint) -> None:
|
||||
self.find_or_create_transport(transfer.position, next_stop).add_units(transfer)
|
||||
|
||||
def remove(self, transport: TransportType, transfer: TransferOrder) -> None:
|
||||
transport.remove_units(transfer)
|
||||
if not transport.transfers:
|
||||
self.disband_transport(transport)
|
||||
|
||||
def disband_all(self) -> None:
|
||||
for transport in list(self):
|
||||
self.disband_transport(transport)
|
||||
|
||||
def __iter__(self) -> Iterator[TransportType]:
|
||||
for destination_dict in self.transports.values():
|
||||
yield from destination_dict.values()
|
||||
|
||||
|
||||
class ConvoyMap(TransportMap):
|
||||
def create_transport(
|
||||
self, origin: ControlPoint, destination: ControlPoint
|
||||
) -> Convoy:
|
||||
return Convoy(origin, destination)
|
||||
|
||||
|
||||
class CargoShipMap(TransportMap):
|
||||
def create_transport(
|
||||
self, origin: ControlPoint, destination: ControlPoint
|
||||
) -> CargoShip:
|
||||
return CargoShip(origin, destination)
|
||||
|
||||
|
||||
class PendingTransfers:
|
||||
def __init__(self, game: Game) -> None:
|
||||
self.game = game
|
||||
self.convoys = ConvoyMap()
|
||||
self.cargo_ships = CargoShipMap()
|
||||
self.pending_transfers: List[TransferOrder] = []
|
||||
|
||||
def __iter__(self) -> Iterator[TransferOrder]:
|
||||
yield from self.pending_transfers
|
||||
|
||||
@property
|
||||
def pending_transfer_count(self) -> int:
|
||||
return len(self.pending_transfers)
|
||||
|
||||
def transfer_at_index(self, index: int) -> TransferOrder:
|
||||
return self.pending_transfers[index]
|
||||
|
||||
def index_of_transfer(self, transfer: TransferOrder) -> int:
|
||||
return self.pending_transfers.index(transfer)
|
||||
|
||||
def network_for(self, control_point: ControlPoint) -> TransitNetwork:
|
||||
return self.game.transit_network_for(control_point.captured)
|
||||
|
||||
def arrange_transport(self, transfer: TransferOrder) -> None:
|
||||
network = self.network_for(transfer.position)
|
||||
path = network.shortest_path_between(transfer.position, transfer.destination)
|
||||
next_stop = path[0]
|
||||
if network.link_type(transfer.position, next_stop) == TransitConnection.Road:
|
||||
self.convoys.add(transfer, next_stop)
|
||||
elif (
|
||||
network.link_type(transfer.position, next_stop)
|
||||
== TransitConnection.Shipping
|
||||
):
|
||||
self.cargo_ships.add(transfer, next_stop)
|
||||
else:
|
||||
AirliftPlanner(self.game, transfer, next_stop).create_package_for_airlift()
|
||||
|
||||
def new_transfer(self, transfer: TransferOrder) -> None:
|
||||
transfer.origin.base.commit_losses(transfer.units)
|
||||
self.pending_transfers.append(transfer)
|
||||
self.arrange_transport(transfer)
|
||||
|
||||
def split_transfer(self, transfer: TransferOrder, size: int) -> TransferOrder:
|
||||
"""Creates a smaller transfer that is a subset of the original."""
|
||||
if transfer.size <= size:
|
||||
raise ValueError
|
||||
|
||||
units = {}
|
||||
for unit_type, remaining in transfer.units.items():
|
||||
take = min(remaining, size)
|
||||
size -= take
|
||||
transfer.units[unit_type] -= take
|
||||
units[unit_type] = take
|
||||
if not size:
|
||||
break
|
||||
new_transfer = TransferOrder(transfer.origin, transfer.destination, units)
|
||||
self.pending_transfers.append(new_transfer)
|
||||
return new_transfer
|
||||
|
||||
@singledispatchmethod
|
||||
def cancel_transport(self, transport, transfer: TransferOrder) -> None:
|
||||
pass
|
||||
|
||||
@cancel_transport.register
|
||||
def _cancel_transport_air(
|
||||
self, transport: Airlift, _transfer: TransferOrder
|
||||
) -> None:
|
||||
flight = transport.flight
|
||||
flight.package.remove_flight(flight)
|
||||
if not flight.package.flights:
|
||||
self.game.ato_for(transport.player_owned).remove_package(flight.package)
|
||||
self.game.aircraft_inventory.return_from_flight(flight)
|
||||
flight.clear_roster()
|
||||
|
||||
@cancel_transport.register
|
||||
def _cancel_transport_convoy(
|
||||
self, transport: Convoy, transfer: TransferOrder
|
||||
) -> None:
|
||||
self.convoys.remove(transport, transfer)
|
||||
|
||||
@cancel_transport.register
|
||||
def _cancel_transport_cargo_ship(
|
||||
self, transport: CargoShip, transfer: TransferOrder
|
||||
) -> None:
|
||||
self.cargo_ships.remove(transport, transfer)
|
||||
|
||||
def cancel_transfer(self, transfer: TransferOrder) -> None:
|
||||
if transfer.transport is not None:
|
||||
self.cancel_transport(transfer.transport, transfer)
|
||||
self.pending_transfers.remove(transfer)
|
||||
transfer.origin.base.commission_units(transfer.units)
|
||||
|
||||
def perform_transfers(self) -> None:
|
||||
incomplete = []
|
||||
for transfer in self.pending_transfers:
|
||||
transfer.proceed()
|
||||
if not transfer.completed:
|
||||
incomplete.append(transfer)
|
||||
self.pending_transfers = incomplete
|
||||
self.convoys.disband_all()
|
||||
self.cargo_ships.disband_all()
|
||||
|
||||
def plan_transports(self) -> None:
|
||||
for transfer in self.pending_transfers:
|
||||
if transfer.transport is None:
|
||||
self.arrange_transport(transfer)
|
||||
|
||||
def order_airlift_assets(self) -> None:
|
||||
for control_point in self.game.theater.controlpoints:
|
||||
if self.game.air_wing_for(control_point.captured).can_auto_plan(
|
||||
FlightType.TRANSPORT
|
||||
):
|
||||
self.order_airlift_assets_at(control_point)
|
||||
|
||||
@staticmethod
|
||||
def desired_airlift_capacity(control_point: ControlPoint) -> int:
|
||||
return 4 if control_point.has_factory else 0
|
||||
|
||||
def current_airlift_capacity(self, control_point: ControlPoint) -> int:
|
||||
inventory = self.game.aircraft_inventory.for_control_point(control_point)
|
||||
squadrons = self.game.air_wing_for(
|
||||
control_point.captured
|
||||
).auto_assignable_for_task(FlightType.TRANSPORT)
|
||||
unit_types = {s.aircraft for s in squadrons}
|
||||
return sum(
|
||||
count
|
||||
for unit_type, count in inventory.all_aircraft
|
||||
if unit_type in unit_types
|
||||
)
|
||||
|
||||
def order_airlift_assets_at(self, control_point: ControlPoint) -> None:
|
||||
gap = self.desired_airlift_capacity(
|
||||
control_point
|
||||
) - self.current_airlift_capacity(control_point)
|
||||
|
||||
if gap <= 0:
|
||||
return
|
||||
|
||||
if gap % 2:
|
||||
# Always buy in pairs since we're not trying to fill odd squadrons. Purely
|
||||
# aesthetic.
|
||||
gap += 1
|
||||
|
||||
self.game.procurement_requests_for(player=control_point.captured).append(
|
||||
AircraftProcurementRequest(
|
||||
control_point, nautical_miles(200), FlightType.TRANSPORT, gap
|
||||
)
|
||||
)
|
||||
167
game/unitdelivery.py
Normal file
167
game/unitdelivery.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, TYPE_CHECKING, Any
|
||||
|
||||
from game.theater import ControlPoint
|
||||
from .dcs.groundunittype import GroundUnitType
|
||||
from .dcs.unittype import UnitType
|
||||
from .theater.transitnetwork import (
|
||||
NoPathError,
|
||||
TransitNetwork,
|
||||
)
|
||||
from .transfers import TransferOrder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .game import Game
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroundUnitSource:
|
||||
control_point: ControlPoint
|
||||
|
||||
|
||||
class PendingUnitDeliveries:
|
||||
def __init__(self, destination: ControlPoint) -> None:
|
||||
self.destination = destination
|
||||
|
||||
# Maps unit type to order quantity.
|
||||
self.units: dict[UnitType, int] = defaultdict(int)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Pending delivery to {self.destination}"
|
||||
|
||||
def order(self, units: dict[UnitType, int]) -> None:
|
||||
for k, v in units.items():
|
||||
self.units[k] += v
|
||||
|
||||
def sell(self, units: dict[UnitType, int]) -> None:
|
||||
for k, v in units.items():
|
||||
self.units[k] -= v
|
||||
|
||||
def refund_all(self, game: Game) -> None:
|
||||
self.refund(game, self.units)
|
||||
self.units = defaultdict(int)
|
||||
|
||||
def refund_ground_units(self, game: Game) -> None:
|
||||
ground_units: dict[UnitType[Any], int] = {
|
||||
u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType)
|
||||
}
|
||||
self.refund(game, ground_units)
|
||||
for gu in ground_units.keys():
|
||||
del self.units[gu]
|
||||
|
||||
def refund(self, game: Game, units: dict[UnitType, int]) -> None:
|
||||
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
|
||||
)
|
||||
|
||||
def pending_orders(self, unit_type: UnitType) -> int:
|
||||
pending_units = self.units.get(unit_type)
|
||||
if pending_units is None:
|
||||
pending_units = 0
|
||||
return pending_units
|
||||
|
||||
def available_next_turn(self, unit_type: UnitType) -> int:
|
||||
current_units = self.destination.base.total_units_of_type(unit_type)
|
||||
return self.pending_orders(unit_type) + current_units
|
||||
|
||||
def process(self, game: Game) -> None:
|
||||
ground_unit_source = self.find_ground_unit_source(game)
|
||||
if ground_unit_source is None:
|
||||
game.message(
|
||||
f"{self.destination.name} lost its source for ground unit "
|
||||
"reinforcements. Refunding purchase price."
|
||||
)
|
||||
self.refund_ground_units(game)
|
||||
|
||||
bought_units: dict[UnitType, int] = {}
|
||||
units_needing_transfer: dict[GroundUnitType, int] = {}
|
||||
sold_units: dict[UnitType, int] = {}
|
||||
for unit_type, count in self.units.items():
|
||||
coalition = "Ally" if self.destination.captured else "Enemy"
|
||||
d: dict[Any, int]
|
||||
if (
|
||||
isinstance(unit_type, GroundUnitType)
|
||||
and self.destination != ground_unit_source
|
||||
):
|
||||
source = ground_unit_source
|
||||
d = units_needing_transfer
|
||||
else:
|
||||
source = self.destination
|
||||
d = bought_units
|
||||
|
||||
if count >= 0:
|
||||
d[unit_type] = count
|
||||
game.message(
|
||||
f"{coalition} reinforcements: {unit_type} x {count} at {source}"
|
||||
)
|
||||
else:
|
||||
sold_units[unit_type] = -count
|
||||
game.message(f"{coalition} sold: {unit_type} x {-count} at {source}")
|
||||
|
||||
self.units = defaultdict(int)
|
||||
self.destination.base.commission_units(bought_units)
|
||||
self.destination.base.commit_losses(sold_units)
|
||||
|
||||
if units_needing_transfer:
|
||||
if ground_unit_source is None:
|
||||
raise RuntimeError(
|
||||
f"ground unit source could not be found for {self.destination} but still tried to "
|
||||
f"transfer units to there"
|
||||
)
|
||||
ground_unit_source.base.commission_units(units_needing_transfer)
|
||||
self.create_transfer(game, ground_unit_source, units_needing_transfer)
|
||||
|
||||
def create_transfer(
|
||||
self, game: Game, source: ControlPoint, units: dict[GroundUnitType, int]
|
||||
) -> None:
|
||||
game.transfers.new_transfer(TransferOrder(source, self.destination, units))
|
||||
|
||||
def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]:
|
||||
# This is running *after* the turn counter has been incremented, so this is the
|
||||
# reaction to turn 0. On turn zero we allow units to be recruited anywhere for
|
||||
# delivery on turn 1 so that turn 1 always starts with units on the front line.
|
||||
if game.turn == 1:
|
||||
return self.destination
|
||||
|
||||
# Fast path if the destination is a valid source.
|
||||
if self.destination.can_recruit_ground_units(game):
|
||||
return self.destination
|
||||
|
||||
try:
|
||||
return self.find_ground_unit_source_in_network(
|
||||
game.transit_network_for(self.destination.captured), game
|
||||
)
|
||||
except NoPathError:
|
||||
return None
|
||||
|
||||
def find_ground_unit_source_in_network(
|
||||
self, network: TransitNetwork, game: Game
|
||||
) -> Optional[ControlPoint]:
|
||||
sources = []
|
||||
for control_point in game.theater.control_points_for(self.destination.captured):
|
||||
if control_point.can_recruit_ground_units(
|
||||
game
|
||||
) and network.has_path_between(self.destination, control_point):
|
||||
sources.append(control_point)
|
||||
|
||||
if not sources:
|
||||
return None
|
||||
|
||||
# Fast path to skip the distance calculation if we have only one option.
|
||||
if len(sources) == 1:
|
||||
return sources[0]
|
||||
|
||||
closest = sources[0]
|
||||
_, cost = network.shortest_path_with_cost(self.destination, closest)
|
||||
for source in sources:
|
||||
_, new_cost = network.shortest_path_with_cost(self.destination, source)
|
||||
if new_cost < cost:
|
||||
closest = source
|
||||
cost = new_cost
|
||||
return closest
|
||||
117
game/unitmap.py
117
game/unitmap.py
@@ -1,20 +1,29 @@
|
||||
"""Maps generated units back to their Liberation types."""
|
||||
import itertools
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, Type
|
||||
from typing import Dict, Optional
|
||||
|
||||
from dcs.unit import Unit
|
||||
from dcs.unitgroup import FlyingGroup, Group, VehicleGroup
|
||||
from dcs.unittype import VehicleType
|
||||
|
||||
from game import db
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.squadrons import Pilot
|
||||
from game.theater import Airfield, ControlPoint, TheaterGroundObject
|
||||
from game.theater.theatergroundobject import BuildingGroundObject
|
||||
from game.theater.theatergroundobject import BuildingGroundObject, SceneryGroundObject
|
||||
from game.transfers import CargoShip, Convoy, TransferOrder
|
||||
from gen.flights.flight import Flight
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlyingUnit:
|
||||
flight: Flight
|
||||
pilot: Optional[Pilot]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrontLineUnit:
|
||||
unit_type: Type[VehicleType]
|
||||
unit_type: GroundUnitType
|
||||
origin: ControlPoint
|
||||
|
||||
|
||||
@@ -25,6 +34,18 @@ class GroundObjectUnit:
|
||||
unit: Unit
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConvoyUnit:
|
||||
unit_type: GroundUnitType
|
||||
convoy: Convoy
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AirliftUnits:
|
||||
cargo: tuple[GroundUnitType, ...]
|
||||
transfer: TransferOrder
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Building:
|
||||
ground_object: BuildingGroundObject
|
||||
@@ -32,22 +53,27 @@ class Building:
|
||||
|
||||
class UnitMap:
|
||||
def __init__(self) -> None:
|
||||
self.aircraft: Dict[str, Flight] = {}
|
||||
self.aircraft: Dict[str, FlyingUnit] = {}
|
||||
self.airfields: Dict[str, Airfield] = {}
|
||||
self.front_line_units: Dict[str, FrontLineUnit] = {}
|
||||
self.ground_object_units: Dict[str, GroundObjectUnit] = {}
|
||||
self.buildings: Dict[str, Building] = {}
|
||||
self.convoys: Dict[str, ConvoyUnit] = {}
|
||||
self.cargo_ships: Dict[str, CargoShip] = {}
|
||||
self.airlifts: Dict[str, AirliftUnits] = {}
|
||||
|
||||
def add_aircraft(self, group: FlyingGroup, flight: Flight) -> None:
|
||||
for unit in group.units:
|
||||
for pilot, unit in zip(flight.roster.pilots, group.units):
|
||||
# The actual name is a String (the pydcs translatable string), which
|
||||
# doesn't define __eq__.
|
||||
name = str(unit.name)
|
||||
if name in self.aircraft:
|
||||
raise RuntimeError(f"Duplicate unit name: {name}")
|
||||
self.aircraft[name] = flight
|
||||
self.aircraft[name] = FlyingUnit(flight, pilot)
|
||||
if flight.cargo is not None:
|
||||
self.add_airlift_units(group, flight.cargo)
|
||||
|
||||
def flight(self, unit_name: str) -> Optional[Flight]:
|
||||
def flight(self, unit_name: str) -> Optional[FlyingUnit]:
|
||||
return self.aircraft.get(unit_name, None)
|
||||
|
||||
def add_airfield(self, airfield: Airfield) -> None:
|
||||
@@ -58,20 +84,15 @@ class UnitMap:
|
||||
def airfield(self, name: str) -> Optional[Airfield]:
|
||||
return self.airfields.get(name, None)
|
||||
|
||||
def add_front_line_units(self, group: Group, origin: ControlPoint) -> None:
|
||||
def add_front_line_units(
|
||||
self, group: Group, origin: ControlPoint, unit_type: GroundUnitType
|
||||
) -> None:
|
||||
for unit in group.units:
|
||||
# The actual name is a String (the pydcs translatable string), which
|
||||
# doesn't define __eq__.
|
||||
name = str(unit.name)
|
||||
if name in self.front_line_units:
|
||||
raise RuntimeError(f"Duplicate front line unit: {name}")
|
||||
unit_type = db.unit_type_from_name(unit.type)
|
||||
if unit_type is None:
|
||||
raise RuntimeError(f"Unknown unit type: {unit.type}")
|
||||
if not issubclass(unit_type, VehicleType):
|
||||
raise RuntimeError(
|
||||
f"{name} is a {unit_type.__name__}, expected a VehicleType"
|
||||
)
|
||||
self.front_line_units[name] = FrontLineUnit(unit_type, origin)
|
||||
|
||||
def front_line_unit(self, name: str) -> Optional[FrontLineUnit]:
|
||||
@@ -113,6 +134,58 @@ class UnitMap:
|
||||
def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit]:
|
||||
return self.ground_object_units.get(name, None)
|
||||
|
||||
def add_convoy_units(self, group: Group, convoy: Convoy) -> None:
|
||||
for unit, unit_type in zip(group.units, convoy.iter_units()):
|
||||
# The actual name is a String (the pydcs translatable string), which
|
||||
# doesn't define __eq__.
|
||||
name = str(unit.name)
|
||||
if name in self.convoys:
|
||||
raise RuntimeError(f"Duplicate convoy unit: {name}")
|
||||
self.convoys[name] = ConvoyUnit(unit_type, convoy)
|
||||
|
||||
def convoy_unit(self, name: str) -> Optional[ConvoyUnit]:
|
||||
return self.convoys.get(name, None)
|
||||
|
||||
def add_cargo_ship(self, group: Group, ship: CargoShip) -> None:
|
||||
if len(group.units) > 1:
|
||||
# Cargo ship "groups" are single units. Killing the one ship kills the whole
|
||||
# transfer. If we ever want to add escorts or create multiple cargo ships in
|
||||
# a convoy of ships that logic needs to change.
|
||||
raise ValueError("Expected cargo ship to be a single unit group.")
|
||||
unit = group.units[0]
|
||||
# The actual name is a String (the pydcs translatable string), which
|
||||
# doesn't define __eq__.
|
||||
name = str(unit.name)
|
||||
if name in self.cargo_ships:
|
||||
raise RuntimeError(f"Duplicate cargo ship: {name}")
|
||||
self.cargo_ships[name] = ship
|
||||
|
||||
def cargo_ship(self, name: str) -> Optional[CargoShip]:
|
||||
return self.cargo_ships.get(name, None)
|
||||
|
||||
def add_airlift_units(self, group: FlyingGroup, transfer: TransferOrder) -> None:
|
||||
capacity_each = math.ceil(transfer.size / len(group.units))
|
||||
for idx, transport in enumerate(group.units):
|
||||
# Slice the units in groups based on the capacity of each unit. Cargo is
|
||||
# assigned arbitrarily to units in the order of the group. The last unit in
|
||||
# the group will receive a partial load if there is not enough cargo to fill
|
||||
# every transport.
|
||||
base_idx = idx * capacity_each
|
||||
cargo = tuple(
|
||||
itertools.islice(
|
||||
transfer.iter_units(), base_idx, base_idx + capacity_each
|
||||
)
|
||||
)
|
||||
# The actual name is a String (the pydcs translatable string), which
|
||||
# doesn't define __eq__.
|
||||
name = str(transport.name)
|
||||
if name in self.airlifts:
|
||||
raise RuntimeError(f"Duplicate airlift unit: {name}")
|
||||
self.airlifts[name] = AirliftUnits(cargo, transfer)
|
||||
|
||||
def airlift_unit(self, name: str) -> Optional[AirliftUnits]:
|
||||
return self.airlifts.get(name, None)
|
||||
|
||||
def add_building(self, ground_object: BuildingGroundObject, group: Group) -> None:
|
||||
# The actual name is a String (the pydcs translatable string), which
|
||||
# doesn't define __eq__.
|
||||
@@ -136,5 +209,15 @@ class UnitMap:
|
||||
raise RuntimeError(f"Duplicate TGO unit: {name}")
|
||||
self.buildings[name] = Building(ground_object)
|
||||
|
||||
def add_scenery(self, ground_object: SceneryGroundObject) -> None:
|
||||
name = str(ground_object.map_object_id)
|
||||
if name in self.buildings:
|
||||
raise RuntimeError(
|
||||
f"Duplicate TGO unit: {name}. TriggerZone name: "
|
||||
f"{ground_object.dcs_identifier}"
|
||||
)
|
||||
|
||||
self.buildings[name] = Building(ground_object)
|
||||
|
||||
def building_or_fortification(self, name: str) -> Optional[Building]:
|
||||
return self.buildings.get(name, None)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Union
|
||||
@@ -57,6 +58,10 @@ class Distance:
|
||||
def from_nautical_miles(cls, value: float) -> Distance:
|
||||
return cls(value * NM_TO_METERS)
|
||||
|
||||
@classmethod
|
||||
def inf(cls) -> Distance:
|
||||
return cls.from_meters(math.inf)
|
||||
|
||||
def __add__(self, other: Distance) -> Distance:
|
||||
return meters(self.meters + other.meters)
|
||||
|
||||
@@ -178,3 +183,13 @@ def mach(value: float, altitude: Distance) -> Speed:
|
||||
|
||||
|
||||
SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5)
|
||||
|
||||
|
||||
def pairwise(iterable):
|
||||
"""
|
||||
itertools recipe
|
||||
s -> (s0,s1), (s1,s2), (s2, s3), ...
|
||||
"""
|
||||
a, b = itertools.tee(iterable)
|
||||
next(b, None)
|
||||
return zip(a, b)
|
||||
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
def _build_version_string() -> str:
|
||||
components = ["2.5"]
|
||||
components = ["4.0.0"]
|
||||
build_number_path = Path("resources/buildnumber")
|
||||
if build_number_path.exists():
|
||||
with build_number_path.open("r") as build_number_file:
|
||||
@@ -16,3 +16,84 @@ def _build_version_string() -> str:
|
||||
|
||||
#: Current version of Liberation.
|
||||
VERSION = _build_version_string()
|
||||
|
||||
#: The latest version of the campaign format. Increment this version whenever all
|
||||
#: existing campaigns should be flagged as incompatible in the UI. We will still attempt
|
||||
#: to load old campaigns, but this provides a warning to the user that the campaign may
|
||||
#: not work correctly.
|
||||
#:
|
||||
#: There is no verification that the campaign author updated their campaign correctly
|
||||
#: this is just a UI hint.
|
||||
#:
|
||||
#: Version history:
|
||||
#:
|
||||
#: Version 0
|
||||
#: * Unknown compatibility.
|
||||
#:
|
||||
#: Version 1
|
||||
#: * Compatible with Liberation 2.5.
|
||||
#:
|
||||
#: Version 2
|
||||
#: * Front line endpoints now define convoy origin/destination waypoints. They should be
|
||||
#: placed on or near roads.
|
||||
#: * Factories (Workshop_A) define factory objectives. Only control points with
|
||||
#: factories will be able to recruit ground units, so they should exist in sufficient
|
||||
#: number and be protected by IADS.
|
||||
#:
|
||||
#: Version 3
|
||||
#: * Bulker Handy Winds define shipping lanes. They should be placed in port areas that
|
||||
#: are navigable by ships and have a route to another port area. DCS ships *will not*
|
||||
#: avoid driving into islands, so ensure that their waypoints plot a navigable route.
|
||||
#:
|
||||
#: Version 4
|
||||
#: * TriggerZones define map based building targets. White TriggerZones created by right
|
||||
#: clicking an object and using "assign as..." define the buildings within an objective.
|
||||
#: Blue circular TriggerZones created normally must surround groups of one or more
|
||||
#: white TriggerZones to define an objective. If a white TriggerZone is not surrounded
|
||||
#: by a blue circular TriggerZone, campaign creation will fail. Blue circular
|
||||
#: TriggerZones must also have their first property's value field define the type of
|
||||
#: objective (a valid value for a building TGO category, from `game.db.PRICES`).
|
||||
#:
|
||||
#: Version 4.1
|
||||
#: * All objective types may now be set as required generation (similar to the required
|
||||
#: IADS generation). This includes:
|
||||
#: * SHORADS
|
||||
#: * Armor groups
|
||||
#: * Strike targets
|
||||
#: * Offshore strike targets
|
||||
#: * Ships
|
||||
#: * Missile sites
|
||||
#: * Coastal defenses
|
||||
#:
|
||||
#: See the unit lists in MizCampaignLoader in conflicttheater.py for unit types.
|
||||
#:
|
||||
#: Version 4.2
|
||||
#: * Adds support for AAA objectives. Place with any of the following units (either red
|
||||
#: or blue):
|
||||
#: * Flak18,
|
||||
#: * Vulcan,
|
||||
#: * ZSU_23_4_Shilka,
|
||||
#:
|
||||
#: Version 5.0
|
||||
#: * Ammunition Depots objective locations are now predetermined using the "Ammunition
|
||||
# Depot" Warehouse object, and through trigger zone based scenery objects.
|
||||
#: * The number of alive Ammunition Depot objective buildings connected to a control
|
||||
#: point directly influences how many ground units can be supported on the front
|
||||
#: line.
|
||||
#: * The number of supported ground units at any control point is artificially
|
||||
#: capped at 50, even if the number of alive Ammunition Depot objectives can
|
||||
#: support more.
|
||||
#:
|
||||
#: Version 6.0
|
||||
#: * Random objective generation no is longer supported. Fixed objective locations were
|
||||
#: added in 4.1.
|
||||
#:
|
||||
#: Version 6.1
|
||||
#: * Support for new Syrian airfields in DCS 2.7.2.7910.1 (Cyprus update).
|
||||
#:
|
||||
#: Version 7.0
|
||||
#: * DCS 2.7.2.7910.1 (Cyprus update) changed the IDs of scenery strike targets. Any
|
||||
#: mission using map buildings as strike targets must check and potentially recreate
|
||||
#: all those objectives. This definitely affects all Syria campaigns, other maps are
|
||||
#: not yet verified.
|
||||
CAMPAIGN_FORMAT_VERSION = (7, 0)
|
||||
|
||||
1105
gen/aircraft.py
1105
gen/aircraft.py
File diff suppressed because it is too large
Load Diff
@@ -451,7 +451,7 @@ AIRFIELD_DATA = {
|
||||
"12": ("IMA", MHz(111, 750)),
|
||||
},
|
||||
),
|
||||
"Al Minhad Intl": AirfieldData(
|
||||
"Al Minhad AFB": AirfieldData(
|
||||
theater="Persian Gulf",
|
||||
icao="OMDM",
|
||||
elevation=190,
|
||||
@@ -620,9 +620,9 @@ AIRFIELD_DATA = {
|
||||
tacan=TacanChannel(78, TacanBand.X),
|
||||
tacan_callsign="BND",
|
||||
vor=("BND", MHz(117, 200)),
|
||||
atc=AtcData(MHz(4, 250), MHz(39, 401), MHz(118, 100), MHz(251, 0)),
|
||||
atc=AtcData(MHz(4, 250), MHz(39, 400), MHz(118, 100), MHz(251, 0)),
|
||||
ils={
|
||||
"21": ("IBND", MHz(333, 800)),
|
||||
"21": ("IBND", MHz(109, 900)),
|
||||
},
|
||||
),
|
||||
"Jiroft": AirfieldData(
|
||||
@@ -674,7 +674,7 @@ AIRFIELD_DATA = {
|
||||
vor=("DAN", MHz(108, 400)),
|
||||
atc=AtcData(MHz(3, 900), MHz(38, 700), MHz(122, 100), MHz(360, 100)),
|
||||
ils={
|
||||
"50": ("IDAN", MHz(109, 300)),
|
||||
"05": ("IDAN", MHz(109, 300)),
|
||||
"23": ("DANM", MHz(111, 700)),
|
||||
},
|
||||
),
|
||||
@@ -966,7 +966,96 @@ AIRFIELD_DATA = {
|
||||
runway_length=8871,
|
||||
atc=AtcData(MHz(3, 775), MHz(38, 450), MHz(120, 100), MHz(250, 50)),
|
||||
ils={
|
||||
"28": ("IGNP", MHz(109, 10)),
|
||||
"28": ("IGNP", MHz(109, 100)),
|
||||
},
|
||||
),
|
||||
"Gecitkale": AirfieldData(
|
||||
theater="Syria",
|
||||
icao="LCGK",
|
||||
elevation=147,
|
||||
runway_length=8156,
|
||||
vor=("GKE", MHz(114, 300)),
|
||||
atc=AtcData(MHz(3, 775), MHz(4, 800), MHz(40, 500), MHz(252, 50)),
|
||||
),
|
||||
"Kingsfield": AirfieldData(
|
||||
theater="Syria",
|
||||
icao="CY-0004",
|
||||
elevation=270,
|
||||
runway_length=3069,
|
||||
atc=AtcData(MHz(4, 650), MHz(40, 200), MHz(121), MHz(251, 750)),
|
||||
),
|
||||
"Larnaca": AirfieldData(
|
||||
theater="Syria",
|
||||
icao="LCRE",
|
||||
elevation=16,
|
||||
runway_length=8009,
|
||||
vor=("LCA", MHz(112, 80)),
|
||||
atc=AtcData(MHz(4, 700), MHz(40, 300), MHz(121, 200), MHz(251, 850)),
|
||||
ils={
|
||||
"22": ("ILC", MHz(110, 300)),
|
||||
},
|
||||
),
|
||||
"Ercan": AirfieldData(
|
||||
theater="Syria",
|
||||
icao="LCEN",
|
||||
elevation=383,
|
||||
runway_length=7559,
|
||||
vor=("ECN", MHz(117)),
|
||||
atc=AtcData(MHz(4, 750), MHz(40, 400), MHz(120, 200), MHz(251, 950)),
|
||||
),
|
||||
"Lakatamia": AirfieldData(
|
||||
theater="Syria",
|
||||
icao="CY-0001",
|
||||
elevation=757,
|
||||
runway_length=1230,
|
||||
atc=AtcData(MHz(4, 725), MHz(40, 350), MHz(120, 200), MHz(251, 900)),
|
||||
),
|
||||
"Nicosia": AirfieldData(
|
||||
theater="Syria",
|
||||
icao="LCNC",
|
||||
elevation=716,
|
||||
runway_length=0,
|
||||
),
|
||||
"Pinarbashi": AirfieldData(
|
||||
theater="Syria",
|
||||
icao="CY-0003",
|
||||
elevation=770,
|
||||
runway_length=3364,
|
||||
atc=AtcData(MHz(4, 825), MHz(40, 550), MHz(121), MHz(252, 100)),
|
||||
),
|
||||
"Akrotiri": AirfieldData(
|
||||
theater="Syria",
|
||||
icao="LCRA",
|
||||
elevation=62,
|
||||
runway_length=8276,
|
||||
tacan=TacanChannel(107, TacanBand.X),
|
||||
tacan_callsign="AKR",
|
||||
vor=("AKR", MHz(116)),
|
||||
atc=AtcData(MHz(4, 625), MHz(40, 150), MHz(128), MHz(251, 700)),
|
||||
ils={
|
||||
"28": ("IAK", MHz(109, 700)),
|
||||
},
|
||||
),
|
||||
"Paphos": AirfieldData(
|
||||
theater="Syria",
|
||||
icao="LCPH",
|
||||
elevation=40,
|
||||
runway_length=8425,
|
||||
vor=("PHA", MHz(117, 900)),
|
||||
atc=AtcData(MHz(4, 675), MHz(40, 250), MHz(119, 900), MHz(251, 800)),
|
||||
ils={
|
||||
"29": ("IPA", MHz(108, 900)),
|
||||
},
|
||||
),
|
||||
"Gazipasa": AirfieldData(
|
||||
theater="Syria",
|
||||
icao="LTFG",
|
||||
elevation=36,
|
||||
runway_length=6885,
|
||||
vor=("GZP", MHz(114, 200)),
|
||||
atc=AtcData(MHz(4, 600), MHz(40, 100), MHz(119, 250), MHz(251, 650)),
|
||||
ils={
|
||||
"8": ("IGZP", MHz(108, 500)),
|
||||
},
|
||||
),
|
||||
# NTTR
|
||||
|
||||
@@ -16,6 +16,7 @@ from dcs.task import (
|
||||
)
|
||||
|
||||
from game import db
|
||||
from .flights.ai_flight_planner_db import AEWC_CAPABLE
|
||||
from .naming import namegen
|
||||
from .callsigns import callsign_for_support_unit
|
||||
from .conflictgen import Conflict
|
||||
@@ -35,23 +36,27 @@ AWACS_ALT = 13000
|
||||
class AwacsInfo:
|
||||
"""AWACS information for the kneeboard."""
|
||||
|
||||
dcsGroupName: str
|
||||
group_name: str
|
||||
callsign: str
|
||||
freq: RadioFrequency
|
||||
depature_location: Optional[str]
|
||||
start_time: Optional[timedelta]
|
||||
end_time: Optional[timedelta]
|
||||
blue: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class TankerInfo:
|
||||
"""Tanker information for the kneeboard."""
|
||||
|
||||
dcsGroupName: str
|
||||
group_name: str
|
||||
callsign: str
|
||||
variant: str
|
||||
freq: RadioFrequency
|
||||
tacan: TacanChannel
|
||||
start_time: Optional[timedelta]
|
||||
end_time: Optional[timedelta]
|
||||
blue: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -92,117 +97,131 @@ class AirSupportConflictGenerator:
|
||||
|
||||
def generate(self):
|
||||
player_cp = (
|
||||
self.conflict.from_cp
|
||||
if self.conflict.from_cp.captured
|
||||
else self.conflict.to_cp
|
||||
self.conflict.blue_cp
|
||||
if self.conflict.blue_cp.captured
|
||||
else self.conflict.red_cp
|
||||
)
|
||||
|
||||
fallback_tanker_number = 0
|
||||
if not self.game.settings.disable_legacy_tanker:
|
||||
fallback_tanker_number = 0
|
||||
|
||||
for i, tanker_unit_type in enumerate(
|
||||
db.find_unittype(Refueling, self.conflict.attackers_side)
|
||||
):
|
||||
alt, airspeed = self._get_tanker_params(tanker_unit_type)
|
||||
variant = db.unit_type_name(tanker_unit_type)
|
||||
freq = self.radio_registry.alloc_uhf()
|
||||
tacan = self.tacan_registry.alloc_for_band(TacanBand.Y)
|
||||
tanker_heading = (
|
||||
self.conflict.to_cp.position.heading_between_point(
|
||||
self.conflict.from_cp.position
|
||||
)
|
||||
+ TANKER_HEADING_OFFSET * i
|
||||
)
|
||||
tanker_position = player_cp.position.point_from_heading(
|
||||
tanker_heading, TANKER_DISTANCE
|
||||
)
|
||||
tanker_group = self.mission.refuel_flight(
|
||||
country=self.mission.country(self.game.player_country),
|
||||
name=namegen.next_tanker_name(
|
||||
self.mission.country(self.game.player_country), tanker_unit_type
|
||||
),
|
||||
airport=None,
|
||||
plane_type=tanker_unit_type,
|
||||
position=tanker_position,
|
||||
altitude=alt,
|
||||
race_distance=58000,
|
||||
frequency=freq.mhz,
|
||||
start_type=StartType.Warm,
|
||||
speed=airspeed,
|
||||
tacanchannel=str(tacan),
|
||||
)
|
||||
tanker_group.set_frequency(freq.mhz)
|
||||
|
||||
callsign = callsign_for_support_unit(tanker_group)
|
||||
tacan_callsign = {
|
||||
"Texaco": "TEX",
|
||||
"Arco": "ARC",
|
||||
"Shell": "SHL",
|
||||
}.get(callsign)
|
||||
if tacan_callsign is None:
|
||||
# The dict above is all the callsigns currently in the game, but
|
||||
# non-Western countries don't use the callsigns and instead just
|
||||
# use numbers. It's possible that none of those nations have
|
||||
# TACAN compatible refueling aircraft, but fallback just in
|
||||
# case.
|
||||
tacan_callsign = f"TK{fallback_tanker_number}"
|
||||
fallback_tanker_number += 1
|
||||
|
||||
if tanker_unit_type != IL_78M:
|
||||
# Override PyDCS tacan channel.
|
||||
tanker_group.points[0].tasks.pop()
|
||||
tanker_group.points[0].tasks.append(
|
||||
ActivateBeaconCommand(
|
||||
tacan.number,
|
||||
tacan.band.value,
|
||||
tacan_callsign,
|
||||
True,
|
||||
tanker_group.units[0].id,
|
||||
True,
|
||||
)
|
||||
)
|
||||
|
||||
tanker_group.points[0].tasks.append(SetInvisibleCommand(True))
|
||||
tanker_group.points[0].tasks.append(SetImmortalCommand(True))
|
||||
|
||||
self.air_support.tankers.append(
|
||||
TankerInfo(str(tanker_group.name), callsign, variant, freq, tacan)
|
||||
)
|
||||
|
||||
if not self.game.settings.disable_legacy_aewc:
|
||||
possible_awacs = db.find_unittype(AWACS, self.conflict.attackers_side)
|
||||
|
||||
if len(possible_awacs) > 0:
|
||||
awacs_unit = possible_awacs[0]
|
||||
for i, tanker_unit_type in enumerate(
|
||||
self.game.faction_for(player=True).tankers
|
||||
):
|
||||
# TODO: Make loiter altitude a property of the unit type.
|
||||
alt, airspeed = self._get_tanker_params(tanker_unit_type.dcs_unit_type)
|
||||
freq = self.radio_registry.alloc_uhf()
|
||||
|
||||
awacs_flight = self.mission.awacs_flight(
|
||||
tacan = self.tacan_registry.alloc_for_band(TacanBand.Y)
|
||||
tanker_heading = (
|
||||
self.conflict.red_cp.position.heading_between_point(
|
||||
self.conflict.blue_cp.position
|
||||
)
|
||||
+ TANKER_HEADING_OFFSET * i
|
||||
)
|
||||
tanker_position = player_cp.position.point_from_heading(
|
||||
tanker_heading, TANKER_DISTANCE
|
||||
)
|
||||
tanker_group = self.mission.refuel_flight(
|
||||
country=self.mission.country(self.game.player_country),
|
||||
name=namegen.next_awacs_name(
|
||||
self.mission.country(self.game.player_country)
|
||||
name=namegen.next_tanker_name(
|
||||
self.mission.country(self.game.player_country), tanker_unit_type
|
||||
),
|
||||
plane_type=awacs_unit,
|
||||
altitude=AWACS_ALT,
|
||||
airport=None,
|
||||
position=self.conflict.position.random_point_within(
|
||||
AWACS_DISTANCE, AWACS_DISTANCE
|
||||
),
|
||||
plane_type=tanker_unit_type,
|
||||
position=tanker_position,
|
||||
altitude=alt,
|
||||
race_distance=58000,
|
||||
frequency=freq.mhz,
|
||||
start_type=StartType.Warm,
|
||||
speed=airspeed,
|
||||
tacanchannel=str(tacan),
|
||||
)
|
||||
awacs_flight.set_frequency(freq.mhz)
|
||||
tanker_group.set_frequency(freq.mhz)
|
||||
|
||||
awacs_flight.points[0].tasks.append(SetInvisibleCommand(True))
|
||||
awacs_flight.points[0].tasks.append(SetImmortalCommand(True))
|
||||
callsign = callsign_for_support_unit(tanker_group)
|
||||
tacan_callsign = {
|
||||
"Texaco": "TEX",
|
||||
"Arco": "ARC",
|
||||
"Shell": "SHL",
|
||||
}.get(callsign)
|
||||
if tacan_callsign is None:
|
||||
# The dict above is all the callsigns currently in the game, but
|
||||
# non-Western countries don't use the callsigns and instead just
|
||||
# use numbers. It's possible that none of those nations have
|
||||
# TACAN compatible refueling aircraft, but fallback just in
|
||||
# case.
|
||||
tacan_callsign = f"TK{fallback_tanker_number}"
|
||||
fallback_tanker_number += 1
|
||||
|
||||
self.air_support.awacs.append(
|
||||
AwacsInfo(
|
||||
dcsGroupName=str(awacs_flight.name),
|
||||
callsign=callsign_for_support_unit(awacs_flight),
|
||||
freq=freq,
|
||||
depature_location=None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
if tanker_unit_type != IL_78M:
|
||||
# Override PyDCS tacan channel.
|
||||
tanker_group.points[0].tasks.pop()
|
||||
tanker_group.points[0].tasks.append(
|
||||
ActivateBeaconCommand(
|
||||
tacan.number,
|
||||
tacan.band.value,
|
||||
tacan_callsign,
|
||||
True,
|
||||
tanker_group.units[0].id,
|
||||
True,
|
||||
)
|
||||
)
|
||||
|
||||
tanker_group.points[0].tasks.append(SetInvisibleCommand(True))
|
||||
tanker_group.points[0].tasks.append(SetImmortalCommand(True))
|
||||
|
||||
self.air_support.tankers.append(
|
||||
TankerInfo(
|
||||
str(tanker_group.name),
|
||||
callsign,
|
||||
tanker_unit_type.name,
|
||||
freq,
|
||||
tacan,
|
||||
blue=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
||||
if not self.game.settings.disable_legacy_aewc:
|
||||
possible_awacs = [
|
||||
a
|
||||
for a in self.game.faction_for(player=True).aircrafts
|
||||
if a in AEWC_CAPABLE
|
||||
]
|
||||
|
||||
if not possible_awacs:
|
||||
logging.warning("No AWACS for faction")
|
||||
return
|
||||
|
||||
awacs_unit = possible_awacs[0]
|
||||
freq = self.radio_registry.alloc_uhf()
|
||||
|
||||
awacs_flight = self.mission.awacs_flight(
|
||||
country=self.mission.country(self.game.player_country),
|
||||
name=namegen.next_awacs_name(
|
||||
self.mission.country(self.game.player_country)
|
||||
),
|
||||
plane_type=awacs_unit,
|
||||
altitude=AWACS_ALT,
|
||||
airport=None,
|
||||
position=self.conflict.position.random_point_within(
|
||||
AWACS_DISTANCE, AWACS_DISTANCE
|
||||
),
|
||||
frequency=freq.mhz,
|
||||
start_type=StartType.Warm,
|
||||
)
|
||||
awacs_flight.set_frequency(freq.mhz)
|
||||
|
||||
awacs_flight.points[0].tasks.append(SetInvisibleCommand(True))
|
||||
awacs_flight.points[0].tasks.append(SetImmortalCommand(True))
|
||||
|
||||
self.air_support.awacs.append(
|
||||
AwacsInfo(
|
||||
group_name=str(awacs_flight.name),
|
||||
callsign=callsign_for_support_unit(awacs_flight),
|
||||
freq=freq,
|
||||
depature_location=None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
blue=True,
|
||||
)
|
||||
)
|
||||
|
||||
123
gen/armor.py
123
gen/armor.py
@@ -10,7 +10,6 @@ from dcs.action import AITaskPush
|
||||
from dcs.condition import GroupLifeLess, Or, TimeAfter, UnitDamaged
|
||||
from dcs.country import Country
|
||||
from dcs.mapping import Point
|
||||
from dcs.planes import MQ_9_Reaper
|
||||
from dcs.point import PointAction
|
||||
from dcs.task import (
|
||||
EPLRS,
|
||||
@@ -26,18 +25,18 @@ from dcs.task import (
|
||||
from dcs.triggers import Event, TriggerOnce
|
||||
from dcs.unit import Vehicle
|
||||
from dcs.unitgroup import VehicleGroup
|
||||
from dcs.unittype import VehicleType
|
||||
from game import db
|
||||
|
||||
from game.data.groundunitclass import GroundUnitClass
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.theater.controlpoint import ControlPoint
|
||||
from game.unitmap import UnitMap
|
||||
from game.utils import heading_sum, opposite_heading
|
||||
from game.theater.controlpoint import ControlPoint
|
||||
|
||||
from gen.ground_forces.ai_ground_planner import (
|
||||
DISTANCE_FROM_FRONTLINE,
|
||||
CombatGroup,
|
||||
CombatGroupRole,
|
||||
)
|
||||
|
||||
from .callsigns import callsign_for_support_unit
|
||||
from .conflictgen import Conflict
|
||||
from .ground_forces.combat_stance import CombatStance
|
||||
@@ -68,11 +67,12 @@ INFANTRY_GROUP_SIZE = 5
|
||||
class JtacInfo:
|
||||
"""JTAC information."""
|
||||
|
||||
dcsGroupName: str
|
||||
group_name: str
|
||||
unit_name: str
|
||||
callsign: str
|
||||
region: str
|
||||
code: str
|
||||
blue: bool
|
||||
# TODO: Radio info? Type?
|
||||
|
||||
|
||||
@@ -134,10 +134,10 @@ class GroundConflictGenerator:
|
||||
|
||||
def generate(self):
|
||||
position = Conflict.frontline_position(
|
||||
self.conflict.from_cp, self.conflict.to_cp, self.game.theater
|
||||
self.conflict.front_line, self.game.theater
|
||||
)
|
||||
frontline_vector = Conflict.frontline_vector(
|
||||
self.conflict.from_cp, self.conflict.to_cp, self.game.theater
|
||||
self.conflict.front_line, self.game.theater
|
||||
)
|
||||
|
||||
# Create player groups at random position
|
||||
@@ -156,31 +156,31 @@ class GroundConflictGenerator:
|
||||
player_groups,
|
||||
enemy_groups,
|
||||
self.conflict.heading + 90,
|
||||
self.conflict.from_cp,
|
||||
self.conflict.to_cp,
|
||||
self.conflict.blue_cp,
|
||||
self.conflict.red_cp,
|
||||
)
|
||||
self.plan_action_for_groups(
|
||||
self.enemy_stance,
|
||||
enemy_groups,
|
||||
player_groups,
|
||||
self.conflict.heading - 90,
|
||||
self.conflict.to_cp,
|
||||
self.conflict.from_cp,
|
||||
self.conflict.red_cp,
|
||||
self.conflict.blue_cp,
|
||||
)
|
||||
|
||||
# Add JTAC
|
||||
if self.game.player_faction.has_jtac:
|
||||
n = "JTAC" + str(self.conflict.from_cp.id) + str(self.conflict.to_cp.id)
|
||||
n = "JTAC" + str(self.conflict.blue_cp.id) + str(self.conflict.red_cp.id)
|
||||
code = 1688 - len(self.jtacs)
|
||||
|
||||
utype = MQ_9_Reaper
|
||||
if self.game.player_faction.jtac_unit is not None:
|
||||
utype = self.game.player_faction.jtac_unit
|
||||
utype = self.game.player_faction.jtac_unit
|
||||
if self.game.player_faction.jtac_unit is None:
|
||||
utype = AircraftType.named("MQ-9 Reaper")
|
||||
|
||||
jtac = self.mission.flight_group(
|
||||
country=self.mission.country(self.game.player_country),
|
||||
name=n,
|
||||
aircraft_type=utype,
|
||||
aircraft_type=utype.dcs_unit_type,
|
||||
position=position[0],
|
||||
airport=None,
|
||||
altitude=5000,
|
||||
@@ -191,12 +191,19 @@ class GroundConflictGenerator:
|
||||
OrbitAction(5000, 300, OrbitAction.OrbitPattern.Circle)
|
||||
)
|
||||
frontline = (
|
||||
f"Frontline {self.conflict.from_cp.name}/{self.conflict.to_cp.name}"
|
||||
f"Frontline {self.conflict.blue_cp.name}/{self.conflict.red_cp.name}"
|
||||
)
|
||||
# Note: Will need to change if we ever add ground based JTAC.
|
||||
callsign = callsign_for_support_unit(jtac)
|
||||
self.jtacs.append(
|
||||
JtacInfo(str(jtac.name), n, callsign, frontline, str(code))
|
||||
JtacInfo(
|
||||
str(jtac.name),
|
||||
n,
|
||||
callsign,
|
||||
frontline,
|
||||
str(code),
|
||||
blue=True,
|
||||
)
|
||||
)
|
||||
|
||||
def gen_infantry_group_for_group(
|
||||
@@ -213,27 +220,26 @@ class GroundConflictGenerator:
|
||||
logging.warning("Could not find infantry position")
|
||||
return
|
||||
if side == self.conflict.attackers_country:
|
||||
cp = self.conflict.from_cp
|
||||
cp = self.conflict.blue_cp
|
||||
else:
|
||||
cp = self.conflict.to_cp
|
||||
cp = self.conflict.red_cp
|
||||
|
||||
if is_player:
|
||||
faction = self.game.player_name
|
||||
else:
|
||||
faction = self.game.enemy_name
|
||||
faction = self.game.faction_for(is_player)
|
||||
|
||||
# Disable infantry unit gen if disabled
|
||||
if not self.game.settings.perf_infantry:
|
||||
if self.game.settings.manpads:
|
||||
# 50% of armored units protected by manpad
|
||||
if random.choice([True, False]):
|
||||
manpads = db.find_manpad(faction)
|
||||
if len(manpads) > 0:
|
||||
u = random.choice(manpads)
|
||||
manpads = list(faction.infantry_with_class(GroundUnitClass.Manpads))
|
||||
if manpads:
|
||||
u = random.choices(
|
||||
manpads, weights=[m.spawn_weight for m in manpads]
|
||||
)[0]
|
||||
self.mission.vehicle_group(
|
||||
side,
|
||||
namegen.next_infantry_name(side, cp.id, u),
|
||||
u,
|
||||
u.dcs_unit_type,
|
||||
position=infantry_position,
|
||||
group_size=1,
|
||||
heading=forward_heading,
|
||||
@@ -241,30 +247,38 @@ class GroundConflictGenerator:
|
||||
)
|
||||
return
|
||||
|
||||
possible_infantry_units = db.find_infantry(
|
||||
faction, allow_manpad=self.game.settings.manpads
|
||||
possible_infantry_units = set(
|
||||
faction.infantry_with_class(GroundUnitClass.Infantry)
|
||||
)
|
||||
if len(possible_infantry_units) == 0:
|
||||
if self.game.settings.manpads:
|
||||
possible_infantry_units |= set(
|
||||
faction.infantry_with_class(GroundUnitClass.Manpads)
|
||||
)
|
||||
if not possible_infantry_units:
|
||||
return
|
||||
|
||||
u = random.choice(possible_infantry_units)
|
||||
infantry_choices = list(possible_infantry_units)
|
||||
units = random.choices(
|
||||
infantry_choices,
|
||||
weights=[u.spawn_weight for u in infantry_choices],
|
||||
k=INFANTRY_GROUP_SIZE,
|
||||
)
|
||||
self.mission.vehicle_group(
|
||||
side,
|
||||
namegen.next_infantry_name(side, cp.id, u),
|
||||
u,
|
||||
namegen.next_infantry_name(side, cp.id, units[0]),
|
||||
units[0].dcs_unit_type,
|
||||
position=infantry_position,
|
||||
group_size=1,
|
||||
heading=forward_heading,
|
||||
move_formation=PointAction.OffRoad,
|
||||
)
|
||||
|
||||
for i in range(INFANTRY_GROUP_SIZE):
|
||||
u = random.choice(possible_infantry_units)
|
||||
for unit in units[1:]:
|
||||
position = infantry_position.random_point_within(55, 5)
|
||||
self.mission.vehicle_group(
|
||||
side,
|
||||
namegen.next_infantry_name(side, cp.id, u),
|
||||
u,
|
||||
namegen.next_infantry_name(side, cp.id, unit),
|
||||
unit.dcs_unit_type,
|
||||
position=position,
|
||||
group_size=1,
|
||||
heading=forward_heading,
|
||||
@@ -304,7 +318,7 @@ class GroundConflictGenerator:
|
||||
)
|
||||
artillery_trigger.add_condition(TimeAfter(seconds=random.randint(1, 45) * 60))
|
||||
# TODO: Update to fire at group instead of point
|
||||
fire_task = FireAtPoint(target, len(gen_group.units) * 10, 100)
|
||||
fire_task = FireAtPoint(target, gen_group.size * 10, 100)
|
||||
fire_task.number = 2 if stance != CombatStance.RETREAT else 1
|
||||
dcs_group.add_trigger_action(fire_task)
|
||||
artillery_trigger.add_action(AITaskPush(dcs_group.id, len(dcs_group.tasks)))
|
||||
@@ -494,7 +508,7 @@ class GroundConflictGenerator:
|
||||
return
|
||||
|
||||
for dcs_group, group in ally_groups:
|
||||
if hasattr(group.units[0], "eplrs") and group.units[0].eplrs:
|
||||
if group.unit_type.eplrs_capable:
|
||||
dcs_group.points[0].tasks.append(EPLRS(dcs_group.id))
|
||||
|
||||
if group.role == CombatGroupRole.ARTILLERY:
|
||||
@@ -665,7 +679,7 @@ class GroundConflictGenerator:
|
||||
Search the enemy groups for a potential target suitable to an artillery unit
|
||||
"""
|
||||
# TODO: Update to return a list of groups instead of a single point
|
||||
rng = group.units[0].threat_range
|
||||
rng = getattr(group.unit_type.dcs_unit_type, "threat_range", 0)
|
||||
if not enemy_groups:
|
||||
return None
|
||||
for _ in range(10):
|
||||
@@ -682,7 +696,7 @@ class GroundConflictGenerator:
|
||||
"""
|
||||
For artilery group, decide the distance from frontline with the range of the unit
|
||||
"""
|
||||
rg = group.units[0].threat_range - 7500
|
||||
rg = getattr(group.unit_type.dcs_unit_type, "threat_range", 0) - 7500
|
||||
if rg > DISTANCE_FROM_FRONTLINE[CombatGroupRole.ARTILLERY][1]:
|
||||
rg = random.randint(
|
||||
DISTANCE_FROM_FRONTLINE[CombatGroupRole.ARTILLERY][0],
|
||||
@@ -715,7 +729,7 @@ class GroundConflictGenerator:
|
||||
|
||||
def _generate_groups(
|
||||
self,
|
||||
groups: List[CombatGroup],
|
||||
groups: list[CombatGroup],
|
||||
frontline_vector: Tuple[Point, int, int],
|
||||
is_player: bool,
|
||||
) -> List[Tuple[VehicleGroup, CombatGroup]]:
|
||||
@@ -746,10 +760,9 @@ class GroundConflictGenerator:
|
||||
if final_position is not None:
|
||||
g = self._generate_group(
|
||||
self.mission.country(country),
|
||||
group.units[0],
|
||||
len(group.units),
|
||||
group.unit_type,
|
||||
group.size,
|
||||
final_position,
|
||||
distance_from_frontline,
|
||||
heading=opposite_heading(spawn_heading),
|
||||
)
|
||||
if is_player:
|
||||
@@ -773,31 +786,29 @@ class GroundConflictGenerator:
|
||||
def _generate_group(
|
||||
self,
|
||||
side: Country,
|
||||
unit: VehicleType,
|
||||
unit_type: GroundUnitType,
|
||||
count: int,
|
||||
at: Point,
|
||||
distance_from_frontline,
|
||||
move_formation: PointAction = PointAction.OffRoad,
|
||||
heading=0,
|
||||
) -> VehicleGroup:
|
||||
|
||||
if side == self.conflict.attackers_country:
|
||||
cp = self.conflict.from_cp
|
||||
cp = self.conflict.blue_cp
|
||||
else:
|
||||
cp = self.conflict.to_cp
|
||||
cp = self.conflict.red_cp
|
||||
|
||||
logging.info("armorgen: {} for {}".format(unit, side.id))
|
||||
group = self.mission.vehicle_group(
|
||||
side,
|
||||
namegen.next_unit_name(side, cp.id, unit),
|
||||
unit,
|
||||
namegen.next_unit_name(side, cp.id, unit_type),
|
||||
unit_type.dcs_unit_type,
|
||||
position=at,
|
||||
group_size=count,
|
||||
heading=heading,
|
||||
move_formation=move_formation,
|
||||
)
|
||||
|
||||
self.unit_map.add_front_line_units(group, cp)
|
||||
self.unit_map.add_front_line_units(group, cp, unit_type)
|
||||
|
||||
for c in range(count):
|
||||
vehicle: Vehicle = group.units[c]
|
||||
|
||||
17
gen/ato.py
17
gen/ato.py
@@ -8,6 +8,8 @@ example, the package to strike an enemy airfield may contain an escort flight,
|
||||
a SEAD flight, and the strike aircraft themselves. CAP packages may contain only
|
||||
the single CAP flight.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
@@ -65,6 +67,10 @@ class Package:
|
||||
|
||||
waypoints: Optional[PackageWaypoints] = field(default=None)
|
||||
|
||||
@property
|
||||
def has_players(self) -> bool:
|
||||
return any(flight.client_count for flight in self.flights)
|
||||
|
||||
@property
|
||||
def formation_speed(self) -> Optional[Speed]:
|
||||
"""The speed of the package when in formation.
|
||||
@@ -162,9 +168,10 @@ class Package:
|
||||
# likely to be the main task than others. For example, a package with
|
||||
# only CAP flights is a CAP package, a flight with CAP and strike is a
|
||||
# strike package, a flight with CAP and DEAD is a DEAD package, and a
|
||||
# flight with strike and SEAD is an OCA/Strike package. The type of
|
||||
# package is determined by the highest priority flight in the package.
|
||||
task_priorities = [
|
||||
# flight with strike and SEAD is an OCA/Strike package. This list defines the
|
||||
# priority order for package task names. The package's primary task will be the
|
||||
# first task in this list that matches a flight in the package.
|
||||
tasks_by_priority = [
|
||||
FlightType.CAS,
|
||||
FlightType.STRIKE,
|
||||
FlightType.ANTISHIP,
|
||||
@@ -172,14 +179,16 @@ class Package:
|
||||
FlightType.OCA_RUNWAY,
|
||||
FlightType.BAI,
|
||||
FlightType.DEAD,
|
||||
FlightType.TRANSPORT,
|
||||
FlightType.SEAD,
|
||||
FlightType.TARCAP,
|
||||
FlightType.BARCAP,
|
||||
FlightType.AEWC,
|
||||
FlightType.REFUELING,
|
||||
FlightType.SWEEP,
|
||||
FlightType.ESCORT,
|
||||
]
|
||||
for task in task_priorities:
|
||||
for task in tasks_by_priority:
|
||||
if flight_counts[task]:
|
||||
return task
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ class CommInfo:
|
||||
class FrontLineInfo:
|
||||
def __init__(self, front_line: FrontLine):
|
||||
self.front_line: FrontLine = front_line
|
||||
self.player_base: ControlPoint = front_line.control_point_a
|
||||
self.enemy_base: ControlPoint = front_line.control_point_b
|
||||
self.player_base: ControlPoint = front_line.blue_cp
|
||||
self.enemy_base: ControlPoint = front_line.red_cp
|
||||
self.player_zero: bool = self.player_base.base.total_armor == 0
|
||||
self.enemy_zero: bool = self.enemy_base.base.total_armor == 0
|
||||
self.advantage: bool = (
|
||||
@@ -164,7 +164,7 @@ class BriefingGenerator(MissionInfoGenerator):
|
||||
|
||||
def _generate_frontline_info(self) -> None:
|
||||
"""Build FrontLineInfo objects from FrontLine type and append to briefing."""
|
||||
for front_line in self.game.theater.conflicts(from_player=True):
|
||||
for front_line in self.game.theater.conflicts():
|
||||
self.add_frontline(FrontLineInfo(front_line))
|
||||
|
||||
# TODO: This should determine if runway is friendly through a method more robust than the existing string match
|
||||
|
||||
47
gen/cargoshipgen.py
Normal file
47
gen/cargoshipgen.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dcs import Mission
|
||||
from dcs.ships import HandyWind
|
||||
from dcs.unitgroup import ShipGroup
|
||||
|
||||
from game.transfers import CargoShip
|
||||
from game.unitmap import UnitMap
|
||||
from game.utils import knots
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
|
||||
|
||||
class CargoShipGenerator:
|
||||
def __init__(self, mission: Mission, game: Game, unit_map: UnitMap) -> None:
|
||||
self.mission = mission
|
||||
self.game = game
|
||||
self.unit_map = unit_map
|
||||
self.count = itertools.count()
|
||||
|
||||
def generate(self) -> None:
|
||||
# Reset the count to make generation deterministic.
|
||||
for ship in self.game.transfers.cargo_ships:
|
||||
self.generate_cargo_ship(ship)
|
||||
|
||||
def generate_cargo_ship(self, ship: CargoShip) -> ShipGroup:
|
||||
country = self.mission.country(
|
||||
self.game.player_country if ship.player_owned else self.game.enemy_country
|
||||
)
|
||||
waypoints = ship.route
|
||||
group = self.mission.ship_group(
|
||||
country,
|
||||
ship.name,
|
||||
HandyWind,
|
||||
position=waypoints[0],
|
||||
group_size=1,
|
||||
)
|
||||
for waypoint in waypoints[1:]:
|
||||
# 12 knots is very slow but it's also nearly the max allowed by DCS for this
|
||||
# type of ship.
|
||||
group.add_waypoint(waypoint, speed=knots(12).kph)
|
||||
self.unit_map.add_cargo_ship(group, ship)
|
||||
return group
|
||||
@@ -13,7 +13,7 @@ class SilkwormGenerator(GroupGenerator):
|
||||
positions = self.get_circular_position(5, launcher_distance=120, coverage=180)
|
||||
|
||||
self.add_unit(
|
||||
MissilesSS.AShM_Silkworm_SR,
|
||||
MissilesSS.Silkworm_SR,
|
||||
"SR#0",
|
||||
self.position.x,
|
||||
self.position.y,
|
||||
@@ -23,7 +23,7 @@ class SilkwormGenerator(GroupGenerator):
|
||||
# Launchers
|
||||
for i, p in enumerate(positions):
|
||||
self.add_unit(
|
||||
MissilesSS.AShM_SS_N_2_Silkworm,
|
||||
MissilesSS.Silkworm_SR,
|
||||
"Missile#" + str(i),
|
||||
p[0],
|
||||
p[1],
|
||||
@@ -32,7 +32,7 @@ class SilkwormGenerator(GroupGenerator):
|
||||
|
||||
# Commander
|
||||
self.add_unit(
|
||||
Unarmed.Truck_KAMAZ_43101,
|
||||
Unarmed.KAMAZ_Truck,
|
||||
"KAMAZ#0",
|
||||
self.position.x - 35,
|
||||
self.position.y - 20,
|
||||
@@ -41,7 +41,7 @@ class SilkwormGenerator(GroupGenerator):
|
||||
|
||||
# Shorad
|
||||
self.add_unit(
|
||||
AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish,
|
||||
AirDefence.ZSU_23_4_Shilka,
|
||||
"SHILKA#0",
|
||||
self.position.x - 55,
|
||||
self.position.y - 38,
|
||||
@@ -50,7 +50,7 @@ class SilkwormGenerator(GroupGenerator):
|
||||
|
||||
# Shorad 2
|
||||
self.add_unit(
|
||||
AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL,
|
||||
AirDefence.Strela_1_9P31,
|
||||
"STRELA#0",
|
||||
self.position.x + 200,
|
||||
self.position.y + 15,
|
||||
|
||||
@@ -17,8 +17,7 @@ class Conflict:
|
||||
def __init__(
|
||||
self,
|
||||
theater: ConflictTheater,
|
||||
from_cp: ControlPoint,
|
||||
to_cp: ControlPoint,
|
||||
front_line: FrontLine,
|
||||
attackers_side: str,
|
||||
defenders_side: str,
|
||||
attackers_country: Country,
|
||||
@@ -33,22 +32,28 @@ class Conflict:
|
||||
self.attackers_country = attackers_country
|
||||
self.defenders_country = defenders_country
|
||||
|
||||
self.from_cp = from_cp
|
||||
self.to_cp = to_cp
|
||||
self.front_line = front_line
|
||||
self.theater = theater
|
||||
self.position = position
|
||||
self.heading = heading
|
||||
self.size = size
|
||||
|
||||
@property
|
||||
def blue_cp(self) -> ControlPoint:
|
||||
return self.front_line.blue_cp
|
||||
|
||||
@property
|
||||
def red_cp(self) -> ControlPoint:
|
||||
return self.front_line.red_cp
|
||||
|
||||
@classmethod
|
||||
def has_frontline_between(cls, from_cp: ControlPoint, to_cp: ControlPoint) -> bool:
|
||||
return from_cp.has_frontline and to_cp.has_frontline
|
||||
|
||||
@classmethod
|
||||
def frontline_position(
|
||||
cls, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater
|
||||
cls, frontline: FrontLine, theater: ConflictTheater
|
||||
) -> Tuple[Point, int]:
|
||||
frontline = FrontLine(from_cp, to_cp, theater)
|
||||
attack_heading = frontline.attack_heading
|
||||
position = cls.find_ground_position(
|
||||
frontline.position,
|
||||
@@ -60,12 +65,12 @@ class Conflict:
|
||||
|
||||
@classmethod
|
||||
def frontline_vector(
|
||||
cls, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater
|
||||
cls, front_line: FrontLine, theater: ConflictTheater
|
||||
) -> Tuple[Point, int, int]:
|
||||
"""
|
||||
Returns a vector for a valid frontline location avoiding exclusion zones.
|
||||
"""
|
||||
center_position, heading = cls.frontline_position(from_cp, to_cp, theater)
|
||||
center_position, heading = cls.frontline_position(front_line, theater)
|
||||
left_heading = heading_sum(heading, -90)
|
||||
right_heading = heading_sum(heading, 90)
|
||||
left_position = cls.extend_ground_position(
|
||||
@@ -84,18 +89,16 @@ class Conflict:
|
||||
defender_name: str,
|
||||
attacker: Country,
|
||||
defender: Country,
|
||||
from_cp: ControlPoint,
|
||||
to_cp: ControlPoint,
|
||||
front_line: FrontLine,
|
||||
theater: ConflictTheater,
|
||||
):
|
||||
assert cls.has_frontline_between(from_cp, to_cp)
|
||||
position, heading, distance = cls.frontline_vector(from_cp, to_cp, theater)
|
||||
assert cls.has_frontline_between(front_line.blue_cp, front_line.red_cp)
|
||||
position, heading, distance = cls.frontline_vector(front_line, theater)
|
||||
conflict = cls(
|
||||
position=position,
|
||||
heading=heading,
|
||||
theater=theater,
|
||||
from_cp=from_cp,
|
||||
to_cp=to_cp,
|
||||
front_line=front_line,
|
||||
attackers_side=attacker_name,
|
||||
defenders_side=defender_name,
|
||||
attackers_country=attacker,
|
||||
@@ -150,6 +153,8 @@ class Conflict:
|
||||
if theater.is_on_land(pos):
|
||||
return pos
|
||||
pos = initial.point_from_heading(opposite_heading(heading), distance)
|
||||
if theater.is_on_land(pos):
|
||||
return pos
|
||||
if coerce:
|
||||
pos = theater.nearest_land_pos(initial)
|
||||
return pos
|
||||
|
||||
92
gen/convoygen.py
Normal file
92
gen/convoygen.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dcs import Mission
|
||||
from dcs.mapping import Point
|
||||
from dcs.point import PointAction
|
||||
from dcs.unit import Vehicle
|
||||
from dcs.unitgroup import VehicleGroup
|
||||
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.transfers import Convoy
|
||||
from game.unitmap import UnitMap
|
||||
from game.utils import kph
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
|
||||
|
||||
class ConvoyGenerator:
|
||||
def __init__(self, mission: Mission, game: Game, unit_map: UnitMap) -> None:
|
||||
self.mission = mission
|
||||
self.game = game
|
||||
self.unit_map = unit_map
|
||||
self.count = itertools.count()
|
||||
|
||||
def generate(self) -> None:
|
||||
# Reset the count to make generation deterministic.
|
||||
for convoy in self.game.transfers.convoys:
|
||||
self.generate_convoy(convoy)
|
||||
|
||||
def generate_convoy(self, convoy: Convoy) -> VehicleGroup:
|
||||
group = self._create_mixed_unit_group(
|
||||
convoy.name,
|
||||
convoy.route_start,
|
||||
convoy.units,
|
||||
convoy.player_owned,
|
||||
)
|
||||
group.add_waypoint(
|
||||
convoy.route_end,
|
||||
speed=kph(40).kph,
|
||||
move_formation=PointAction.OnRoad,
|
||||
)
|
||||
self.make_drivable(group)
|
||||
self.unit_map.add_convoy_units(group, convoy)
|
||||
return group
|
||||
|
||||
def _create_mixed_unit_group(
|
||||
self,
|
||||
name: str,
|
||||
position: Point,
|
||||
units: dict[GroundUnitType, int],
|
||||
for_player: bool,
|
||||
) -> VehicleGroup:
|
||||
country = self.mission.country(
|
||||
self.game.player_country if for_player else self.game.enemy_country
|
||||
)
|
||||
|
||||
unit_types = list(units.items())
|
||||
main_unit_type, main_unit_count = unit_types[0]
|
||||
|
||||
group = self.mission.vehicle_group(
|
||||
country,
|
||||
name,
|
||||
main_unit_type.dcs_unit_type,
|
||||
position=position,
|
||||
group_size=main_unit_count,
|
||||
move_formation=PointAction.OnRoad,
|
||||
)
|
||||
|
||||
unit_name_counter = itertools.count(main_unit_count + 1)
|
||||
# pydcs spreads units out by 20 in the Y axis by default. Pick up where it left
|
||||
# off.
|
||||
y = itertools.count(position.y + main_unit_count * 20, 20)
|
||||
for unit_type, count in unit_types[1:]:
|
||||
for i in range(count):
|
||||
v = self.mission.vehicle(
|
||||
f"{name} Unit #{next(unit_name_counter)}", unit_type.dcs_unit_type
|
||||
)
|
||||
v.position.x = position.x
|
||||
v.position.y = next(y)
|
||||
v.heading = 0
|
||||
group.add_unit(v)
|
||||
|
||||
return group
|
||||
|
||||
@staticmethod
|
||||
def make_drivable(group: VehicleGroup) -> None:
|
||||
for v in group.units:
|
||||
if isinstance(v, Vehicle):
|
||||
v.player_can_drive = True
|
||||
@@ -1,8 +1,11 @@
|
||||
import random
|
||||
|
||||
from dcs.vehicles import Armor
|
||||
from dcs.unitgroup import VehicleGroup
|
||||
|
||||
from game import db
|
||||
from game import db, Game
|
||||
from game.data.groundunitclass import GroundUnitClass
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.theater.theatergroundobject import VehicleGroupGroundObject
|
||||
from gen.defenses.armored_group_generator import (
|
||||
ArmoredGroupGenerator,
|
||||
FixedSizeArmorGroupGenerator,
|
||||
@@ -14,8 +17,14 @@ def generate_armor_group(faction: str, game, ground_object):
|
||||
This generate a group of ground units
|
||||
:return: Generated group
|
||||
"""
|
||||
armor_types = (
|
||||
GroundUnitClass.Apc,
|
||||
GroundUnitClass.Atgm,
|
||||
GroundUnitClass.Ifv,
|
||||
GroundUnitClass.Tank,
|
||||
)
|
||||
possible_unit = [
|
||||
u for u in db.FACTIONS[faction].frontline_units if u in Armor.__dict__.values()
|
||||
u for u in db.FACTIONS[faction].frontline_units if u.unit_class in armor_types
|
||||
]
|
||||
if len(possible_unit) > 0:
|
||||
unit_type = random.choice(possible_unit)
|
||||
@@ -23,7 +32,9 @@ def generate_armor_group(faction: str, game, ground_object):
|
||||
return None
|
||||
|
||||
|
||||
def generate_armor_group_of_type(game, ground_object, unit_type):
|
||||
def generate_armor_group_of_type(
|
||||
game: Game, ground_object: VehicleGroupGroundObject, unit_type: GroundUnitType
|
||||
) -> VehicleGroup:
|
||||
"""
|
||||
This generate a group of ground units of given type
|
||||
:return: Generated group
|
||||
@@ -33,7 +44,12 @@ def generate_armor_group_of_type(game, ground_object, unit_type):
|
||||
return generator.get_generated_group()
|
||||
|
||||
|
||||
def generate_armor_group_of_type_and_size(game, ground_object, unit_type, size: int):
|
||||
def generate_armor_group_of_type_and_size(
|
||||
game: Game,
|
||||
ground_object: VehicleGroupGroundObject,
|
||||
unit_type: GroundUnitType,
|
||||
size: int,
|
||||
) -> VehicleGroup:
|
||||
"""
|
||||
This generate a group of ground units of given type and size
|
||||
:return: Generated group
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import random
|
||||
|
||||
from game import Game
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.theater.theatergroundobject import VehicleGroupGroundObject
|
||||
from gen.sam.group_generator import GroupGenerator
|
||||
|
||||
|
||||
class ArmoredGroupGenerator(GroupGenerator):
|
||||
def __init__(self, game, ground_object, unit_type):
|
||||
super(ArmoredGroupGenerator, self).__init__(game, ground_object)
|
||||
def __init__(
|
||||
self,
|
||||
game: Game,
|
||||
ground_object: VehicleGroupGroundObject,
|
||||
unit_type: GroundUnitType,
|
||||
) -> None:
|
||||
super().__init__(game, ground_object)
|
||||
self.unit_type = unit_type
|
||||
|
||||
def generate(self):
|
||||
|
||||
def generate(self) -> None:
|
||||
grid_x = random.randint(2, 3)
|
||||
grid_y = random.randint(1, 2)
|
||||
|
||||
@@ -20,7 +27,7 @@ class ArmoredGroupGenerator(GroupGenerator):
|
||||
for j in range(grid_y):
|
||||
index = index + 1
|
||||
self.add_unit(
|
||||
self.unit_type,
|
||||
self.unit_type.dcs_unit_type,
|
||||
"Armor#" + str(index),
|
||||
self.position.x + spacing * i,
|
||||
self.position.y + spacing * j,
|
||||
@@ -29,8 +36,14 @@ class ArmoredGroupGenerator(GroupGenerator):
|
||||
|
||||
|
||||
class FixedSizeArmorGroupGenerator(GroupGenerator):
|
||||
def __init__(self, game, ground_object, unit_type, size):
|
||||
super(FixedSizeArmorGroupGenerator, self).__init__(game, ground_object)
|
||||
def __init__(
|
||||
self,
|
||||
game: Game,
|
||||
ground_object: VehicleGroupGroundObject,
|
||||
unit_type: GroundUnitType,
|
||||
size: int,
|
||||
) -> None:
|
||||
super().__init__(game, ground_object)
|
||||
self.unit_type = unit_type
|
||||
self.size = size
|
||||
|
||||
@@ -41,7 +54,7 @@ class FixedSizeArmorGroupGenerator(GroupGenerator):
|
||||
for i in range(self.size):
|
||||
index = index + 1
|
||||
self.add_unit(
|
||||
self.unit_type,
|
||||
self.unit_type.dcs_unit_type,
|
||||
"Armor#" + str(index),
|
||||
self.position.x + spacing * i,
|
||||
self.position.y,
|
||||
|
||||
@@ -2,7 +2,7 @@ import random
|
||||
|
||||
from gen.sam.group_generator import ShipGroupGenerator
|
||||
|
||||
from dcs.ships import DDG_Arleigh_Burke_IIa, CG_Ticonderoga
|
||||
from dcs.ships import USS_Arleigh_Burke_IIa, TICONDEROG
|
||||
|
||||
|
||||
class CarrierGroupGenerator(ShipGroupGenerator):
|
||||
@@ -22,7 +22,7 @@ class CarrierGroupGenerator(ShipGroupGenerator):
|
||||
|
||||
# Add Arleigh Burke escort
|
||||
self.add_unit(
|
||||
DDG_Arleigh_Burke_IIa,
|
||||
USS_Arleigh_Burke_IIa,
|
||||
"USS Ramage",
|
||||
self.position.x + 6482,
|
||||
self.position.y + 6667,
|
||||
@@ -30,7 +30,7 @@ class CarrierGroupGenerator(ShipGroupGenerator):
|
||||
)
|
||||
|
||||
self.add_unit(
|
||||
DDG_Arleigh_Burke_IIa,
|
||||
USS_Arleigh_Burke_IIa,
|
||||
"USS Mitscher",
|
||||
self.position.x - 7963,
|
||||
self.position.y + 7037,
|
||||
@@ -38,7 +38,7 @@ class CarrierGroupGenerator(ShipGroupGenerator):
|
||||
)
|
||||
|
||||
self.add_unit(
|
||||
DDG_Arleigh_Burke_IIa,
|
||||
USS_Arleigh_Burke_IIa,
|
||||
"USS Forrest Sherman",
|
||||
self.position.x - 7408,
|
||||
self.position.y - 7408,
|
||||
@@ -46,7 +46,7 @@ class CarrierGroupGenerator(ShipGroupGenerator):
|
||||
)
|
||||
|
||||
self.add_unit(
|
||||
DDG_Arleigh_Burke_IIa,
|
||||
USS_Arleigh_Burke_IIa,
|
||||
"USS Lassen",
|
||||
self.position.x + 8704,
|
||||
self.position.y - 6296,
|
||||
@@ -56,7 +56,7 @@ class CarrierGroupGenerator(ShipGroupGenerator):
|
||||
# Add Ticonderoga escort
|
||||
if self.heading >= 180:
|
||||
self.add_unit(
|
||||
CG_Ticonderoga,
|
||||
TICONDEROG,
|
||||
"USS Hué City",
|
||||
self.position.x + 2222,
|
||||
self.position.y - 3333,
|
||||
@@ -64,7 +64,7 @@ class CarrierGroupGenerator(ShipGroupGenerator):
|
||||
)
|
||||
else:
|
||||
self.add_unit(
|
||||
CG_Ticonderoga,
|
||||
TICONDEROG,
|
||||
"USS Hué City",
|
||||
self.position.x - 3333,
|
||||
self.position.y + 2222,
|
||||
|
||||
@@ -5,9 +5,9 @@ from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
from dcs.ships import (
|
||||
Type_052C_Destroyer,
|
||||
Type_052B_Destroyer,
|
||||
Type_054A_Frigate,
|
||||
Type_052C,
|
||||
Type_052B,
|
||||
Type_054A,
|
||||
)
|
||||
|
||||
from game.factions.faction import Faction
|
||||
@@ -30,14 +30,14 @@ class ChineseNavyGroupGenerator(ShipGroupGenerator):
|
||||
|
||||
if include_frigate:
|
||||
self.add_unit(
|
||||
Type_054A_Frigate,
|
||||
Type_054A,
|
||||
"FF1",
|
||||
self.position.x + 1200,
|
||||
self.position.y + 900,
|
||||
self.heading,
|
||||
)
|
||||
self.add_unit(
|
||||
Type_054A_Frigate,
|
||||
Type_054A,
|
||||
"FF2",
|
||||
self.position.x + 1200,
|
||||
self.position.y - 900,
|
||||
@@ -45,7 +45,7 @@ class ChineseNavyGroupGenerator(ShipGroupGenerator):
|
||||
)
|
||||
|
||||
if include_dd:
|
||||
dd_type = random.choice([Type_052C_Destroyer, Type_052B_Destroyer])
|
||||
dd_type = random.choice([Type_052C, Type_052B])
|
||||
self.add_unit(
|
||||
dd_type,
|
||||
"DD1",
|
||||
@@ -69,5 +69,5 @@ class Type54GroupGenerator(DDGroupGenerator):
|
||||
self, game: Game, ground_object: TheaterGroundObject, faction: Faction
|
||||
):
|
||||
super(Type54GroupGenerator, self).__init__(
|
||||
game, ground_object, faction, Type_054A_Frigate
|
||||
game, ground_object, faction, Type_054A
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Type
|
||||
|
||||
from game.factions.faction import Faction
|
||||
from game.theater.theatergroundobject import TheaterGroundObject
|
||||
|
||||
from gen.sam.group_generator import ShipGroupGenerator
|
||||
from dcs.unittype import ShipType
|
||||
from dcs.ships import FFG_Oliver_Hazzard_Perry, DDG_Arleigh_Burke_IIa
|
||||
from dcs.ships import PERRY, USS_Arleigh_Burke_IIa
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.game import Game
|
||||
@@ -18,7 +18,7 @@ class DDGroupGenerator(ShipGroupGenerator):
|
||||
game: Game,
|
||||
ground_object: TheaterGroundObject,
|
||||
faction: Faction,
|
||||
ddtype: ShipType,
|
||||
ddtype: Type[ShipType],
|
||||
):
|
||||
super(DDGroupGenerator, self).__init__(game, ground_object, faction)
|
||||
self.ddtype = ddtype
|
||||
@@ -46,7 +46,7 @@ class OliverHazardPerryGroupGenerator(DDGroupGenerator):
|
||||
self, game: Game, ground_object: TheaterGroundObject, faction: Faction
|
||||
):
|
||||
super(OliverHazardPerryGroupGenerator, self).__init__(
|
||||
game, ground_object, faction, FFG_Oliver_Hazzard_Perry
|
||||
game, ground_object, faction, PERRY
|
||||
)
|
||||
|
||||
|
||||
@@ -55,5 +55,5 @@ class ArleighBurkeGroupGenerator(DDGroupGenerator):
|
||||
self, game: Game, ground_object: TheaterGroundObject, faction: Faction
|
||||
):
|
||||
super(ArleighBurkeGroupGenerator, self).__init__(
|
||||
game, ground_object, faction, DDG_Arleigh_Burke_IIa
|
||||
game, ground_object, faction, USS_Arleigh_Burke_IIa
|
||||
)
|
||||
|
||||
12
gen/fleet/lacombattanteII.py
Normal file
12
gen/fleet/lacombattanteII.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from dcs.ships import La_Combattante_II
|
||||
|
||||
from game.factions.faction import Faction
|
||||
from game.theater import TheaterGroundObject
|
||||
from gen.fleet.dd_group import DDGroupGenerator
|
||||
|
||||
|
||||
class LaCombattanteIIGroupGenerator(DDGroupGenerator):
|
||||
def __init__(self, game, ground_object: TheaterGroundObject, faction: Faction):
|
||||
super(LaCombattanteIIGroupGenerator, self).__init__(
|
||||
game, ground_object, faction, La_Combattante_II
|
||||
)
|
||||
@@ -3,13 +3,13 @@ import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dcs.ships import (
|
||||
Corvette_1124_4_Grisha,
|
||||
Corvette_1241_1_Molniya,
|
||||
Frigate_11540_Neustrashimy,
|
||||
Frigate_1135M_Rezky,
|
||||
Cruiser_1164_Moskva,
|
||||
SSK_877V_Kilo,
|
||||
SSK_641B_Tango,
|
||||
ALBATROS,
|
||||
MOLNIYA,
|
||||
NEUSTRASH,
|
||||
REZKY,
|
||||
MOSCOW,
|
||||
KILO,
|
||||
SOM,
|
||||
)
|
||||
|
||||
from gen.fleet.dd_group import DDGroupGenerator
|
||||
@@ -37,9 +37,7 @@ class RussianNavyGroupGenerator(ShipGroupGenerator):
|
||||
include_frigate = True
|
||||
|
||||
if include_frigate:
|
||||
frigate_type = random.choice(
|
||||
[Corvette_1124_4_Grisha, Corvette_1241_1_Molniya]
|
||||
)
|
||||
frigate_type = random.choice([ALBATROS, MOLNIYA])
|
||||
self.add_unit(
|
||||
frigate_type,
|
||||
"FF1",
|
||||
@@ -56,7 +54,7 @@ class RussianNavyGroupGenerator(ShipGroupGenerator):
|
||||
)
|
||||
|
||||
if include_dd:
|
||||
dd_type = random.choice([Frigate_11540_Neustrashimy, Frigate_1135M_Rezky])
|
||||
dd_type = random.choice([NEUSTRASH, REZKY])
|
||||
self.add_unit(
|
||||
dd_type,
|
||||
"DD1",
|
||||
@@ -74,9 +72,9 @@ class RussianNavyGroupGenerator(ShipGroupGenerator):
|
||||
|
||||
if include_cc:
|
||||
# Only include the Moskva for now, the Pyotry Velikiy is an unkillable monster.
|
||||
# See https://github.com/Khopa/dcs_liberation/issues/567
|
||||
# See https://github.com/dcs-liberation/dcs_liberation/issues/567
|
||||
self.add_unit(
|
||||
Cruiser_1164_Moskva,
|
||||
MOSCOW,
|
||||
"CC1",
|
||||
self.position.x,
|
||||
self.position.y,
|
||||
@@ -91,7 +89,7 @@ class GrishaGroupGenerator(DDGroupGenerator):
|
||||
self, game: Game, ground_object: TheaterGroundObject, faction: Faction
|
||||
):
|
||||
super(GrishaGroupGenerator, self).__init__(
|
||||
game, ground_object, faction, Corvette_1124_4_Grisha
|
||||
game, ground_object, faction, ALBATROS
|
||||
)
|
||||
|
||||
|
||||
@@ -100,7 +98,7 @@ class MolniyaGroupGenerator(DDGroupGenerator):
|
||||
self, game: Game, ground_object: TheaterGroundObject, faction: Faction
|
||||
):
|
||||
super(MolniyaGroupGenerator, self).__init__(
|
||||
game, ground_object, faction, Corvette_1241_1_Molniya
|
||||
game, ground_object, faction, MOLNIYA
|
||||
)
|
||||
|
||||
|
||||
@@ -108,15 +106,11 @@ class KiloSubGroupGenerator(DDGroupGenerator):
|
||||
def __init__(
|
||||
self, game: Game, ground_object: TheaterGroundObject, faction: Faction
|
||||
):
|
||||
super(KiloSubGroupGenerator, self).__init__(
|
||||
game, ground_object, faction, SSK_877V_Kilo
|
||||
)
|
||||
super(KiloSubGroupGenerator, self).__init__(game, ground_object, faction, KILO)
|
||||
|
||||
|
||||
class TangoSubGroupGenerator(DDGroupGenerator):
|
||||
def __init__(
|
||||
self, game: Game, ground_object: TheaterGroundObject, faction: Faction
|
||||
):
|
||||
super(TangoSubGroupGenerator, self).__init__(
|
||||
game, ground_object, faction, SSK_641B_Tango
|
||||
)
|
||||
super(TangoSubGroupGenerator, self).__init__(game, ground_object, faction, SOM)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import random
|
||||
|
||||
from dcs.ships import Boat_Schnellboot_type_S130
|
||||
from dcs.ships import Schnellboot_type_S130
|
||||
|
||||
from gen.sam.group_generator import ShipGroupGenerator
|
||||
|
||||
@@ -10,7 +10,7 @@ class SchnellbootGroupGenerator(ShipGroupGenerator):
|
||||
|
||||
for i in range(random.randint(2, 4)):
|
||||
self.add_unit(
|
||||
Boat_Schnellboot_type_S130,
|
||||
Schnellboot_type_S130,
|
||||
"Schnellboot" + str(i),
|
||||
self.position.x + i * random.randint(100, 250),
|
||||
self.position.y + (random.randint(100, 200) - 100),
|
||||
|
||||
@@ -8,6 +8,7 @@ from gen.fleet.dd_group import (
|
||||
ArleighBurkeGroupGenerator,
|
||||
OliverHazardPerryGroupGenerator,
|
||||
)
|
||||
from gen.fleet.lacombattanteII import LaCombattanteIIGroupGenerator
|
||||
from gen.fleet.lha_group import LHAGroupGenerator
|
||||
from gen.fleet.ru_dd_group import (
|
||||
RussianNavyGroupGenerator,
|
||||
@@ -34,6 +35,7 @@ SHIP_MAP = {
|
||||
"KiloSubGroupGenerator": KiloSubGroupGenerator,
|
||||
"TangoSubGroupGenerator": TangoSubGroupGenerator,
|
||||
"Type54GroupGenerator": Type54GroupGenerator,
|
||||
"LaCombattanteIIGroupGenerator": LaCombattanteIIGroupGenerator,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import random
|
||||
|
||||
from dcs.ships import U_boat_VIIC_U_flak
|
||||
from dcs.ships import Uboat_VIIC
|
||||
|
||||
from gen.sam.group_generator import ShipGroupGenerator
|
||||
|
||||
@@ -10,7 +10,7 @@ class UBoatGroupGenerator(ShipGroupGenerator):
|
||||
|
||||
for i in range(random.randint(1, 4)):
|
||||
self.add_unit(
|
||||
U_boat_VIIC_U_flak,
|
||||
Uboat_VIIC,
|
||||
"Uboat" + str(i),
|
||||
self.position.x + i * random.randint(100, 250),
|
||||
self.position.y + (random.randint(100, 200) - 100),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import random
|
||||
|
||||
from dcs.ships import LS_Samuel_Chase, LST_Mk_II
|
||||
from dcs.ships import USS_Samuel_Chase, LST_Mk2
|
||||
|
||||
from gen.sam.group_generator import ShipGroupGenerator
|
||||
|
||||
@@ -10,7 +10,7 @@ class WW2LSTGroupGenerator(ShipGroupGenerator):
|
||||
|
||||
# Add LS Samuel Chase
|
||||
self.add_unit(
|
||||
LS_Samuel_Chase,
|
||||
USS_Samuel_Chase,
|
||||
"SamuelChase",
|
||||
self.position.x,
|
||||
self.position.y,
|
||||
@@ -19,7 +19,7 @@ class WW2LSTGroupGenerator(ShipGroupGenerator):
|
||||
|
||||
for i in range(1, random.randint(3, 4)):
|
||||
self.add_unit(
|
||||
LST_Mk_II,
|
||||
LST_Mk2,
|
||||
"LST" + str(i),
|
||||
self.position.x + i * random.randint(800, 1200),
|
||||
self.position.y,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import operator
|
||||
import random
|
||||
from collections import defaultdict
|
||||
@@ -16,13 +17,14 @@ from typing import (
|
||||
Set,
|
||||
TYPE_CHECKING,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from dcs.unittype import FlyingType
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.infos.information import Information
|
||||
from game.procurement import AircraftProcurementRequest
|
||||
from game.profiling import logged_duration, MultiEventTracer
|
||||
from game.squadrons import AirWing, Squadron
|
||||
from game.theater import (
|
||||
Airfield,
|
||||
ControlPoint,
|
||||
@@ -39,8 +41,8 @@ from game.theater.theatergroundobject import (
|
||||
NavalGroundObject,
|
||||
VehicleGroupGroundObject,
|
||||
)
|
||||
from game.utils import Distance, nautical_miles
|
||||
from gen import Conflict
|
||||
from game.transfers import CargoShip, Convoy
|
||||
from game.utils import Distance, nautical_miles, meters
|
||||
from gen.ato import Package
|
||||
from gen.flights.ai_flight_planner_db import aircraft_for_task
|
||||
from gen.flights.closestairfields import (
|
||||
@@ -108,6 +110,8 @@ class ProposedMission:
|
||||
#: The proposed flights that are required for the mission.
|
||||
flights: List[ProposedFlight]
|
||||
|
||||
asap: bool = field(default=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
flights = ", ".join([str(f) for f in self.flights])
|
||||
return f"{self.location.name}: {flights}"
|
||||
@@ -118,17 +122,19 @@ class AircraftAllocator:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
air_wing: AirWing,
|
||||
closest_airfields: ClosestAirfields,
|
||||
global_inventory: GlobalAircraftInventory,
|
||||
is_player: bool,
|
||||
) -> None:
|
||||
self.air_wing = air_wing
|
||||
self.closest_airfields = closest_airfields
|
||||
self.global_inventory = global_inventory
|
||||
self.is_player = is_player
|
||||
|
||||
def find_aircraft_for_flight(
|
||||
def find_squadron_for_flight(
|
||||
self, flight: ProposedFlight
|
||||
) -> Optional[Tuple[ControlPoint, Type[FlyingType]]]:
|
||||
) -> Optional[Tuple[ControlPoint, Squadron]]:
|
||||
"""Finds aircraft suitable for the given mission.
|
||||
|
||||
Searches for aircraft capable of performing the given mission within the
|
||||
@@ -147,27 +153,34 @@ class AircraftAllocator:
|
||||
on subsequent calls. If the found aircraft are not used, the caller is
|
||||
responsible for returning them to the inventory.
|
||||
"""
|
||||
return self.find_aircraft_of_type(flight, aircraft_for_task(flight.task))
|
||||
return self.find_aircraft_for_task(flight, flight.task)
|
||||
|
||||
def find_aircraft_of_type(
|
||||
self,
|
||||
flight: ProposedFlight,
|
||||
types: List[Type[FlyingType]],
|
||||
) -> Optional[Tuple[ControlPoint, Type[FlyingType]]]:
|
||||
airfields_in_range = self.closest_airfields.airfields_within(
|
||||
def find_aircraft_for_task(
|
||||
self, flight: ProposedFlight, task: FlightType
|
||||
) -> Optional[Tuple[ControlPoint, Squadron]]:
|
||||
types = aircraft_for_task(task)
|
||||
airfields_in_range = self.closest_airfields.operational_airfields_within(
|
||||
flight.max_distance
|
||||
)
|
||||
|
||||
for airfield in airfields_in_range:
|
||||
if not airfield.is_friendly(self.is_player):
|
||||
continue
|
||||
inventory = self.global_inventory.for_control_point(airfield)
|
||||
for aircraft, available in inventory.all_aircraft:
|
||||
for aircraft in types:
|
||||
if not airfield.can_operate(aircraft):
|
||||
continue
|
||||
if aircraft in types and available >= flight.num_aircraft:
|
||||
inventory.remove_aircraft(aircraft, flight.num_aircraft)
|
||||
return airfield, aircraft
|
||||
|
||||
if inventory.available(aircraft) < flight.num_aircraft:
|
||||
continue
|
||||
# Valid location with enough aircraft available. Find a squadron to fit
|
||||
# the role.
|
||||
squadrons = self.air_wing.auto_assignable_for_task_with_type(
|
||||
aircraft, task
|
||||
)
|
||||
for squadron in squadrons:
|
||||
if squadron.can_provide_pilots(flight.num_aircraft):
|
||||
inventory.remove_aircraft(aircraft, flight.num_aircraft)
|
||||
return airfield, squadron
|
||||
return None
|
||||
|
||||
|
||||
@@ -179,16 +192,18 @@ class PackageBuilder:
|
||||
location: MissionTarget,
|
||||
closest_airfields: ClosestAirfields,
|
||||
global_inventory: GlobalAircraftInventory,
|
||||
air_wing: AirWing,
|
||||
is_player: bool,
|
||||
package_country: str,
|
||||
start_type: str,
|
||||
asap: bool,
|
||||
) -> None:
|
||||
self.closest_airfields = closest_airfields
|
||||
self.is_player = is_player
|
||||
self.package_country = package_country
|
||||
self.package = Package(location)
|
||||
self.package = Package(location, auto_asap=asap)
|
||||
self.allocator = AircraftAllocator(
|
||||
closest_airfields, global_inventory, is_player
|
||||
air_wing, closest_airfields, global_inventory, is_player
|
||||
)
|
||||
self.global_inventory = global_inventory
|
||||
self.start_type = start_type
|
||||
@@ -201,10 +216,10 @@ class PackageBuilder:
|
||||
caller should return any previously planned flights to the inventory
|
||||
using release_planned_aircraft.
|
||||
"""
|
||||
assignment = self.allocator.find_aircraft_for_flight(plan)
|
||||
assignment = self.allocator.find_squadron_for_flight(plan)
|
||||
if assignment is None:
|
||||
return False
|
||||
airfield, aircraft = assignment
|
||||
airfield, squadron = assignment
|
||||
if isinstance(airfield, OffMapSpawn):
|
||||
start_type = "In Flight"
|
||||
else:
|
||||
@@ -213,22 +228,24 @@ class PackageBuilder:
|
||||
flight = Flight(
|
||||
self.package,
|
||||
self.package_country,
|
||||
aircraft,
|
||||
squadron,
|
||||
plan.num_aircraft,
|
||||
plan.task,
|
||||
start_type,
|
||||
departure=airfield,
|
||||
arrival=airfield,
|
||||
divert=self.find_divert_field(aircraft, airfield),
|
||||
divert=self.find_divert_field(squadron.aircraft, airfield),
|
||||
)
|
||||
self.package.add_flight(flight)
|
||||
return True
|
||||
|
||||
def find_divert_field(
|
||||
self, aircraft: Type[FlyingType], arrival: ControlPoint
|
||||
self, aircraft: AircraftType, arrival: ControlPoint
|
||||
) -> Optional[ControlPoint]:
|
||||
divert_limit = nautical_miles(150)
|
||||
for airfield in self.closest_airfields.airfields_within(divert_limit):
|
||||
for airfield in self.closest_airfields.operational_airfields_within(
|
||||
divert_limit
|
||||
):
|
||||
if airfield.captured != self.is_player:
|
||||
continue
|
||||
if airfield == arrival:
|
||||
@@ -249,9 +266,13 @@ class PackageBuilder:
|
||||
flights = list(self.package.flights)
|
||||
for flight in flights:
|
||||
self.global_inventory.return_from_flight(flight)
|
||||
flight.clear_roster()
|
||||
self.package.remove_flight(flight)
|
||||
|
||||
|
||||
MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget)
|
||||
|
||||
|
||||
class ObjectiveFinder:
|
||||
"""Identifies potential objectives for the mission planner."""
|
||||
|
||||
@@ -263,41 +284,53 @@ class ObjectiveFinder:
|
||||
self.game = game
|
||||
self.is_player = is_player
|
||||
|
||||
def enemy_sams(self) -> Iterator[TheaterGroundObject]:
|
||||
def enemy_air_defenses(self) -> Iterator[tuple[TheaterGroundObject, Distance]]:
|
||||
"""Iterates over all enemy SAM sites."""
|
||||
# Control points might have the same ground object several times, for
|
||||
# some reason.
|
||||
found_targets: Set[str] = set()
|
||||
doctrine = self.game.faction_for(self.is_player).doctrine
|
||||
threat_zones = self.game.threat_zone_for(not self.is_player)
|
||||
for cp in self.enemy_control_points():
|
||||
for ground_object in cp.ground_objects:
|
||||
is_ewr = isinstance(ground_object, EwrGroundObject)
|
||||
is_sam = isinstance(ground_object, SamGroundObject)
|
||||
if not is_ewr and not is_sam:
|
||||
continue
|
||||
|
||||
if ground_object.is_dead:
|
||||
continue
|
||||
|
||||
if ground_object.name in found_targets:
|
||||
if isinstance(ground_object, EwrGroundObject):
|
||||
if threat_zones.threatened_by_air_defense(ground_object):
|
||||
# This is a very weak heuristic for determining whether the EWR
|
||||
# is close enough to be worth targeting before a SAM that is
|
||||
# covering it. Ingress distance corresponds to the beginning of
|
||||
# the attack range and is sufficient for most standoff weapons,
|
||||
# so treating the ingress distance as the threat distance sorts
|
||||
# these EWRs such that they will be attacked before SAMs that do
|
||||
# not threaten the ingress point, but after those that do.
|
||||
target_range = doctrine.ingress_egress_distance
|
||||
else:
|
||||
# But if the EWR isn't covered then we should only be worrying
|
||||
# about its detection range.
|
||||
target_range = ground_object.max_detection_range()
|
||||
elif isinstance(ground_object, SamGroundObject):
|
||||
target_range = ground_object.max_threat_range()
|
||||
else:
|
||||
continue
|
||||
|
||||
if not ground_object.has_radar:
|
||||
continue
|
||||
yield ground_object, target_range
|
||||
|
||||
# TODO: Yield in order of most threatening.
|
||||
# Need to sort in order of how close their defensive range comes
|
||||
# to friendly assets. To do that we need to add effective range
|
||||
# information to the database.
|
||||
yield ground_object
|
||||
found_targets.add(ground_object.name)
|
||||
|
||||
def threatening_sams(self) -> Iterator[MissionTarget]:
|
||||
def threatening_air_defenses(self) -> Iterator[TheaterGroundObject]:
|
||||
"""Iterates over enemy SAMs in threat range of friendly control points.
|
||||
|
||||
SAM sites are sorted by their closest proximity to any friendly control
|
||||
point (airfield or fleet).
|
||||
"""
|
||||
return self._targets_by_range(self.enemy_sams())
|
||||
|
||||
target_ranges: list[tuple[TheaterGroundObject, Distance]] = []
|
||||
for target, threat_range in self.enemy_air_defenses():
|
||||
ranges: list[Distance] = []
|
||||
for cp in self.friendly_control_points():
|
||||
ranges.append(meters(target.distance_to(cp)) - threat_range)
|
||||
target_ranges.append((target, min(ranges)))
|
||||
|
||||
target_ranges = sorted(target_ranges, key=operator.itemgetter(1))
|
||||
for target, _range in target_ranges:
|
||||
yield target
|
||||
|
||||
def enemy_vehicle_groups(self) -> Iterator[VehicleGroupGroundObject]:
|
||||
"""Iterates over all enemy vehicle groups."""
|
||||
@@ -339,9 +372,9 @@ class ObjectiveFinder:
|
||||
return self._targets_by_range(self.enemy_ships())
|
||||
|
||||
def _targets_by_range(
|
||||
self, targets: Iterable[MissionTarget]
|
||||
) -> Iterator[MissionTarget]:
|
||||
target_ranges: List[Tuple[MissionTarget, int]] = []
|
||||
self, targets: Iterable[MissionTargetType]
|
||||
) -> Iterator[MissionTargetType]:
|
||||
target_ranges: List[Tuple[MissionTargetType, int]] = []
|
||||
for target in targets:
|
||||
ranges: List[int] = []
|
||||
for cp in self.friendly_control_points():
|
||||
@@ -387,7 +420,7 @@ class ObjectiveFinder:
|
||||
|
||||
is_building = isinstance(ground_object, BuildingGroundObject)
|
||||
is_fob = isinstance(enemy_cp, Fob)
|
||||
if is_building and is_fob and ground_object.airbase_group:
|
||||
if is_building and is_fob and ground_object.is_control_point:
|
||||
# This is the FOB structure itself. Can't be repaired or
|
||||
# targeted by the player, so shouldn't be targetable by the
|
||||
# AI.
|
||||
@@ -408,13 +441,7 @@ class ObjectiveFinder:
|
||||
|
||||
def front_lines(self) -> Iterator[FrontLine]:
|
||||
"""Iterates over all active front lines in the theater."""
|
||||
for cp in self.friendly_control_points():
|
||||
for connected in cp.connected_points:
|
||||
if connected.is_friendly(self.is_player):
|
||||
continue
|
||||
|
||||
if Conflict.has_frontline_between(cp, connected):
|
||||
yield FrontLine(cp, connected, self.game.theater)
|
||||
yield from self.game.theater.conflicts()
|
||||
|
||||
def vulnerable_control_points(self) -> Iterator[ControlPoint]:
|
||||
"""Iterates over friendly CPs that are vulnerable to enemy CPs.
|
||||
@@ -427,8 +454,10 @@ class ObjectiveFinder:
|
||||
# Off-map spawn locations don't need protection.
|
||||
continue
|
||||
airfields_in_proximity = self.closest_airfields_to(cp)
|
||||
airfields_in_threat_range = airfields_in_proximity.airfields_within(
|
||||
self.AIRFIELD_THREAT_RANGE
|
||||
airfields_in_threat_range = (
|
||||
airfields_in_proximity.operational_airfields_within(
|
||||
self.AIRFIELD_THREAT_RANGE
|
||||
)
|
||||
)
|
||||
for airfield in airfields_in_threat_range:
|
||||
if not airfield.is_friendly(self.is_player):
|
||||
@@ -444,37 +473,59 @@ class ObjectiveFinder:
|
||||
airfields.append(control_point)
|
||||
return self._targets_by_range(airfields)
|
||||
|
||||
def convoys(self) -> Iterator[Convoy]:
|
||||
for front_line in self.front_lines():
|
||||
yield from self.game.transfers.convoys.travelling_to(
|
||||
front_line.control_point_hostile_to(self.is_player)
|
||||
)
|
||||
|
||||
def cargo_ships(self) -> Iterator[CargoShip]:
|
||||
for front_line in self.front_lines():
|
||||
yield from self.game.transfers.cargo_ships.travelling_to(
|
||||
front_line.control_point_hostile_to(self.is_player)
|
||||
)
|
||||
|
||||
def friendly_control_points(self) -> Iterator[ControlPoint]:
|
||||
"""Iterates over all friendly control points."""
|
||||
return (
|
||||
c for c in self.game.theater.controlpoints if c.is_friendly(self.is_player)
|
||||
)
|
||||
|
||||
def farthest_friendly_control_point(self) -> Optional[ControlPoint]:
|
||||
"""
|
||||
Iterates over all friendly control points and find the one farthest away from the frontline
|
||||
BUT! prefer Cvs. Everybody likes CVs!
|
||||
"""
|
||||
from_frontline = 0
|
||||
cp = None
|
||||
first_friendly_cp = None
|
||||
def farthest_friendly_control_point(self) -> ControlPoint:
|
||||
"""Finds the friendly control point that is farthest from any threats."""
|
||||
threat_zones = self.game.threat_zone_for(not self.is_player)
|
||||
|
||||
for c in self.game.theater.controlpoints:
|
||||
if c.is_friendly(self.is_player):
|
||||
if first_friendly_cp is None:
|
||||
first_friendly_cp = c
|
||||
if c.is_carrier:
|
||||
return c
|
||||
if c.has_active_frontline:
|
||||
if c.distance_to(self.front_lines().__next__()) > from_frontline:
|
||||
from_frontline = c.distance_to(self.front_lines().__next__())
|
||||
cp = c
|
||||
farthest = None
|
||||
max_distance = meters(0)
|
||||
for cp in self.friendly_control_points():
|
||||
if isinstance(cp, OffMapSpawn):
|
||||
continue
|
||||
distance = threat_zones.distance_to_threat(cp.position)
|
||||
if distance > max_distance:
|
||||
farthest = cp
|
||||
max_distance = distance
|
||||
|
||||
# If no frontlines on the map, return the first friendly cp
|
||||
if cp is None:
|
||||
return first_friendly_cp
|
||||
else:
|
||||
return cp
|
||||
if farthest is None:
|
||||
raise RuntimeError("Found no friendly control points. You probably lost.")
|
||||
return farthest
|
||||
|
||||
def 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."""
|
||||
@@ -533,7 +584,8 @@ class CoalitionMissionPlanner:
|
||||
MAX_OCA_RANGE = nautical_miles(150)
|
||||
MAX_SEAD_RANGE = nautical_miles(150)
|
||||
MAX_STRIKE_RANGE = nautical_miles(150)
|
||||
MAX_AWEC_RANGE = nautical_miles(200)
|
||||
MAX_AWEC_RANGE = Distance.inf()
|
||||
MAX_TANKER_RANGE = nautical_miles(200)
|
||||
|
||||
def __init__(self, game: Game, is_player: bool) -> None:
|
||||
self.game = game
|
||||
@@ -541,7 +593,18 @@ class CoalitionMissionPlanner:
|
||||
self.objective_finder = ObjectiveFinder(self.game, self.is_player)
|
||||
self.ato = self.game.blue_ato if is_player else self.game.red_ato
|
||||
self.threat_zones = self.game.threat_zone_for(not self.is_player)
|
||||
self.procurement_requests: List[AircraftProcurementRequest] = []
|
||||
self.procurement_requests = self.game.procurement_requests_for(self.is_player)
|
||||
self.faction = self.game.faction_for(self.is_player)
|
||||
|
||||
def air_wing_can_plan(self, mission_type: FlightType) -> bool:
|
||||
"""Returns True if it is possible for the air wing to plan this mission type.
|
||||
|
||||
Not all mission types can be fulfilled by all air wings. Many factions do not
|
||||
have AEW&C aircraft, so they will never be able to plan those missions. It's
|
||||
also possible for the player to exclude mission types from their squadron
|
||||
designs.
|
||||
"""
|
||||
return self.game.air_wing_for(self.is_player).can_auto_plan(mission_type)
|
||||
|
||||
def critical_missions(self) -> Iterator[ProposedMission]:
|
||||
"""Identifies the most important missions to plan this turn.
|
||||
@@ -554,35 +617,33 @@ class CoalitionMissionPlanner:
|
||||
eliminated this turn.
|
||||
"""
|
||||
|
||||
# Find farthest, friendly CP for AEWC
|
||||
cp = self.objective_finder.farthest_friendly_control_point()
|
||||
if cp is not None:
|
||||
yield ProposedMission(
|
||||
cp, [ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)]
|
||||
)
|
||||
# Find farthest, friendly CP for AEWC.
|
||||
yield ProposedMission(
|
||||
self.objective_finder.farthest_friendly_control_point(),
|
||||
[ProposedFlight(FlightType.AEWC, 1, self.MAX_AWEC_RANGE)],
|
||||
# Supports all the early CAP flights, so should be in the air ASAP.
|
||||
asap=True,
|
||||
)
|
||||
|
||||
yield ProposedMission(
|
||||
self.objective_finder.closest_friendly_control_point(),
|
||||
[ProposedFlight(FlightType.REFUELING, 1, self.MAX_TANKER_RANGE)],
|
||||
)
|
||||
|
||||
# Find friendly CPs within 100 nmi from an enemy airfield, plan CAP.
|
||||
for cp in self.objective_finder.vulnerable_control_points():
|
||||
# Plan three rounds of CAP to give ~90 minutes coverage. Spacing
|
||||
# these out appropriately is done in stagger_missions.
|
||||
yield ProposedMission(
|
||||
cp,
|
||||
[
|
||||
ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
|
||||
],
|
||||
)
|
||||
yield ProposedMission(
|
||||
cp,
|
||||
[
|
||||
ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
|
||||
],
|
||||
)
|
||||
yield ProposedMission(
|
||||
cp,
|
||||
[
|
||||
ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
|
||||
],
|
||||
)
|
||||
# Plan CAP in such a way, that it is established during the whole desired mission length
|
||||
for _ in range(
|
||||
0,
|
||||
int(self.game.settings.desired_player_mission_duration.total_seconds()),
|
||||
int(self.faction.doctrine.cap_duration.total_seconds()),
|
||||
):
|
||||
yield ProposedMission(
|
||||
cp,
|
||||
[
|
||||
ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
|
||||
],
|
||||
)
|
||||
|
||||
# Find front lines, plan CAS.
|
||||
for front_line in self.objective_finder.front_lines():
|
||||
@@ -618,14 +679,76 @@ class CoalitionMissionPlanner:
|
||||
# or objects, plan DEAD.
|
||||
# Find enemy SAM sites with ranges that extend to within 50 nmi of
|
||||
# friendly CPs, front, lines, or objects, plan DEAD.
|
||||
for sam in self.objective_finder.threatening_sams():
|
||||
for sam in self.objective_finder.threatening_air_defenses():
|
||||
flights = [ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE)]
|
||||
|
||||
# Only include SEAD against SAMs that still have emitters. No need to
|
||||
# suppress an EWR, and SEAD isn't useful against a SAM that no longer has a
|
||||
# working track radar.
|
||||
#
|
||||
# For SAMs without track radars and EWRs, we still want a SEAD escort if
|
||||
# needed.
|
||||
#
|
||||
# Note that there is a quirk here: we should potentially be included a SEAD
|
||||
# escort *and* SEAD when the target is a radar SAM but the flight path is
|
||||
# also threatened by SAMs. We don't want to include a SEAD escort if the
|
||||
# package is *only* threatened by the target though. Could be improved, but
|
||||
# needs a decent refactor to the escort planning to do so.
|
||||
if sam.has_live_radar_sam:
|
||||
flights.append(ProposedFlight(FlightType.SEAD, 2, self.MAX_SEAD_RANGE))
|
||||
else:
|
||||
flights.append(
|
||||
ProposedFlight(
|
||||
FlightType.SEAD_ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.Sead
|
||||
)
|
||||
)
|
||||
# TODO: Max escort range.
|
||||
flights.append(
|
||||
ProposedFlight(
|
||||
FlightType.ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.AirToAir
|
||||
)
|
||||
)
|
||||
yield ProposedMission(sam, flights)
|
||||
|
||||
# These will only rarely get planned. When a convoy is travelling multiple legs,
|
||||
# they're targetable after the first leg. The reason for this is that
|
||||
# procurement happens *after* mission planning so that the missions that could
|
||||
# not be filled will guide the procurement process. Procurement is the stage
|
||||
# that convoys are created (because they're created to move ground units that
|
||||
# were just purchased), so we haven't created any yet. Any incomplete transfers
|
||||
# from the previous turn (multi-leg journeys) will still be present though so
|
||||
# they can be targeted.
|
||||
#
|
||||
# Even after this is fixed, the player's convoys that were created through the
|
||||
# UI will never be targeted on the first turn of their journey because the AI
|
||||
# stops planning after the start of the turn. We could potentially fix this by
|
||||
# moving opfor mission planning until the takeoff button is pushed.
|
||||
for convoy in self.objective_finder.convoys():
|
||||
yield ProposedMission(
|
||||
sam,
|
||||
convoy,
|
||||
[
|
||||
ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE),
|
||||
ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE),
|
||||
# TODO: Max escort range.
|
||||
ProposedFlight(
|
||||
FlightType.ESCORT, 2, self.MAX_SEAD_RANGE, EscortType.AirToAir
|
||||
FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
|
||||
),
|
||||
ProposedFlight(
|
||||
FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
for ship in self.objective_finder.cargo_ships():
|
||||
yield ProposedMission(
|
||||
ship,
|
||||
[
|
||||
ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE),
|
||||
# TODO: Max escort range.
|
||||
ProposedFlight(
|
||||
FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
|
||||
),
|
||||
ProposedFlight(
|
||||
FlightType.SEAD_ESCORT, 2, self.MAX_BAI_RANGE, EscortType.Sead
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -655,7 +778,7 @@ class CoalitionMissionPlanner:
|
||||
FlightType.ESCORT, 2, self.MAX_BAI_RANGE, EscortType.AirToAir
|
||||
),
|
||||
ProposedFlight(
|
||||
FlightType.SEAD, 2, self.MAX_OCA_RANGE, EscortType.Sead
|
||||
FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -677,7 +800,7 @@ class CoalitionMissionPlanner:
|
||||
FlightType.ESCORT, 2, self.MAX_OCA_RANGE, EscortType.AirToAir
|
||||
),
|
||||
ProposedFlight(
|
||||
FlightType.SEAD, 2, self.MAX_OCA_RANGE, EscortType.Sead
|
||||
FlightType.SEAD_ESCORT, 2, self.MAX_OCA_RANGE, EscortType.Sead
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -694,25 +817,34 @@ class CoalitionMissionPlanner:
|
||||
FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE, EscortType.AirToAir
|
||||
),
|
||||
ProposedFlight(
|
||||
FlightType.SEAD, 2, self.MAX_STRIKE_RANGE, EscortType.Sead
|
||||
FlightType.SEAD_ESCORT,
|
||||
2,
|
||||
self.MAX_STRIKE_RANGE,
|
||||
EscortType.Sead,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def plan_missions(self) -> None:
|
||||
"""Identifies and plans mission for the turn."""
|
||||
for proposed_mission in self.propose_missions():
|
||||
self.plan_mission(proposed_mission)
|
||||
player = "Blue" if self.is_player else "Red"
|
||||
with logged_duration(f"{player} mission identification and fulfillment"):
|
||||
with MultiEventTracer() as tracer:
|
||||
for proposed_mission in self.propose_missions():
|
||||
self.plan_mission(proposed_mission, tracer)
|
||||
|
||||
for critical_mission in self.critical_missions():
|
||||
self.plan_mission(critical_mission, reserves=True)
|
||||
with logged_duration(f"{player} reserve mission planning"):
|
||||
with MultiEventTracer() as tracer:
|
||||
for critical_mission in self.critical_missions():
|
||||
self.plan_mission(critical_mission, tracer, reserves=True)
|
||||
|
||||
self.stagger_missions()
|
||||
with logged_duration(f"{player} mission scheduling"):
|
||||
self.stagger_missions()
|
||||
|
||||
for cp in self.objective_finder.friendly_control_points():
|
||||
inventory = self.game.aircraft_inventory.for_control_point(cp)
|
||||
for aircraft, available in inventory.all_aircraft:
|
||||
self.message("Unused aircraft", f"{available} {aircraft.id} from {cp}")
|
||||
self.message("Unused aircraft", f"{available} {aircraft} from {cp}")
|
||||
|
||||
def plan_flight(
|
||||
self,
|
||||
@@ -762,27 +894,29 @@ class CoalitionMissionPlanner:
|
||||
def check_needed_escorts(self, builder: PackageBuilder) -> Dict[EscortType, bool]:
|
||||
threats = defaultdict(bool)
|
||||
for flight in builder.package.flights:
|
||||
if self.threat_zones.threatened_by_aircraft(flight):
|
||||
if self.threat_zones.waypoints_threatened_by_aircraft(
|
||||
flight.flight_plan.escorted_waypoints()
|
||||
):
|
||||
threats[EscortType.AirToAir] = True
|
||||
if self.threat_zones.threatened_by_air_defense(flight):
|
||||
if self.threat_zones.waypoints_threatened_by_radar_sam(
|
||||
list(flight.flight_plan.escorted_waypoints())
|
||||
):
|
||||
threats[EscortType.Sead] = True
|
||||
return threats
|
||||
|
||||
def plan_mission(self, mission: ProposedMission, reserves: bool = False) -> None:
|
||||
def plan_mission(
|
||||
self, mission: ProposedMission, tracer: MultiEventTracer, reserves: bool = False
|
||||
) -> None:
|
||||
"""Allocates aircraft for a proposed mission and adds it to the ATO."""
|
||||
|
||||
if self.is_player:
|
||||
package_country = self.game.player_country
|
||||
else:
|
||||
package_country = self.game.enemy_country
|
||||
|
||||
builder = PackageBuilder(
|
||||
mission.location,
|
||||
self.objective_finder.closest_airfields_to(mission.location),
|
||||
self.game.aircraft_inventory,
|
||||
self.game.air_wing_for(self.is_player),
|
||||
self.is_player,
|
||||
package_country,
|
||||
self.game.country_for(self.is_player),
|
||||
self.game.settings.default_start_type,
|
||||
mission.asap,
|
||||
)
|
||||
|
||||
# Attempt to plan all the main elements of the mission first. Escorts
|
||||
@@ -791,12 +925,20 @@ class CoalitionMissionPlanner:
|
||||
missing_types: Set[FlightType] = set()
|
||||
escorts = []
|
||||
for proposed_flight in mission.flights:
|
||||
if not self.air_wing_can_plan(proposed_flight.task):
|
||||
# This air wing can never plan this mission type because they do not
|
||||
# have compatible aircraft or squadrons. Skip fulfillment so that we
|
||||
# don't place the purchase request.
|
||||
continue
|
||||
if proposed_flight.escort_type is not None:
|
||||
# Escorts are planned after the primary elements of the package.
|
||||
# If the package does not need escorts they may be pruned.
|
||||
escorts.append(proposed_flight)
|
||||
continue
|
||||
self.plan_flight(mission, proposed_flight, builder, missing_types, reserves)
|
||||
with tracer.trace("Flight planning"):
|
||||
self.plan_flight(
|
||||
mission, proposed_flight, builder, missing_types, reserves
|
||||
)
|
||||
|
||||
if missing_types:
|
||||
self.scrub_mission_missing_aircraft(
|
||||
@@ -804,6 +946,12 @@ class CoalitionMissionPlanner:
|
||||
)
|
||||
return
|
||||
|
||||
if not builder.package.flights:
|
||||
# The non-escort part of this mission is unplannable by this faction. Scrub
|
||||
# the mission and do not attempt planning escorts because there's no reason
|
||||
# to buy them because this mission will never be planned.
|
||||
return
|
||||
|
||||
# Create flight plans for the main flights of the package so we can
|
||||
# determine threats. This is done *after* creating all of the flights
|
||||
# rather than as each flight is added because the flight plan for
|
||||
@@ -814,7 +962,8 @@ class CoalitionMissionPlanner:
|
||||
self.game, builder.package, self.is_player
|
||||
)
|
||||
for flight in builder.package.flights:
|
||||
flight_plan_builder.populate_flight_plan(flight)
|
||||
with tracer.trace("Flight plan population"):
|
||||
flight_plan_builder.populate_flight_plan(flight)
|
||||
|
||||
needed_escorts = self.check_needed_escorts(builder)
|
||||
for escort in escorts:
|
||||
@@ -822,7 +971,8 @@ class CoalitionMissionPlanner:
|
||||
# impossible.
|
||||
assert escort.escort_type is not None
|
||||
if needed_escorts[escort.escort_type]:
|
||||
self.plan_flight(mission, escort, builder, missing_types, reserves)
|
||||
with tracer.trace("Flight planning"):
|
||||
self.plan_flight(mission, escort, builder, missing_types, reserves)
|
||||
|
||||
# Check again for unavailable aircraft. If the escort was required and
|
||||
# none were found, scrub the mission.
|
||||
@@ -842,7 +992,13 @@ class CoalitionMissionPlanner:
|
||||
# Add flight plans for escorts.
|
||||
for flight in package.flights:
|
||||
if not flight.flight_plan.waypoints:
|
||||
flight_plan_builder.populate_flight_plan(flight)
|
||||
with tracer.trace("Flight plan population"):
|
||||
flight_plan_builder.populate_flight_plan(flight)
|
||||
|
||||
if package.has_players and self.game.settings.auto_ato_player_missions_asap:
|
||||
package.auto_asap = True
|
||||
package.set_tot_asap()
|
||||
|
||||
self.ato.add_package(package)
|
||||
|
||||
def stagger_missions(self) -> None:
|
||||
@@ -852,7 +1008,7 @@ class CoalitionMissionPlanner:
|
||||
interval = (latest - earliest) // count
|
||||
for time in range(earliest, latest, interval):
|
||||
error = random.randint(-margin, margin)
|
||||
yield timedelta(minutes=max(0, time + error))
|
||||
yield timedelta(seconds=max(0, time + error))
|
||||
|
||||
dca_types = {
|
||||
FlightType.BARCAP,
|
||||
@@ -865,7 +1021,12 @@ class CoalitionMissionPlanner:
|
||||
]
|
||||
|
||||
start_time = start_time_generator(
|
||||
count=len(non_dca_packages), earliest=5, latest=90, margin=5
|
||||
count=len(non_dca_packages),
|
||||
earliest=5 * 60,
|
||||
latest=int(
|
||||
self.game.settings.desired_player_mission_duration.total_seconds()
|
||||
),
|
||||
margin=5 * 60,
|
||||
)
|
||||
for package in self.ato.packages:
|
||||
tot = TotEstimator(package).earliest_tot()
|
||||
@@ -885,6 +1046,8 @@ class CoalitionMissionPlanner:
|
||||
logging.error(f"Could not determine mission end time for {package}")
|
||||
continue
|
||||
previous_cap_end_time[package.target] = departure_time
|
||||
elif package.auto_asap:
|
||||
package.set_tot_asap()
|
||||
else:
|
||||
# But other packages should be spread out a bit. Note that take
|
||||
# times are delayed, but all aircraft will become active at
|
||||
|
||||
@@ -5,8 +5,12 @@ from dcs.helicopters import (
|
||||
AH_1W,
|
||||
AH_64A,
|
||||
AH_64D,
|
||||
CH_47D,
|
||||
CH_53E,
|
||||
Ka_50,
|
||||
Mi_24P,
|
||||
Mi_24V,
|
||||
Mi_26,
|
||||
Mi_28N,
|
||||
Mi_8MT,
|
||||
OH_58D,
|
||||
@@ -14,6 +18,7 @@ from dcs.helicopters import (
|
||||
SA342M,
|
||||
SH_60B,
|
||||
UH_1H,
|
||||
UH_60A,
|
||||
)
|
||||
from dcs.planes import (
|
||||
AJS37,
|
||||
@@ -23,11 +28,14 @@ from dcs.planes import (
|
||||
A_10C_2,
|
||||
A_20G,
|
||||
A_50,
|
||||
An_26B,
|
||||
B_17G,
|
||||
B_1B,
|
||||
B_52H,
|
||||
Bf_109K_4,
|
||||
C_101CC,
|
||||
C_130,
|
||||
C_17A,
|
||||
E_2C,
|
||||
E_3A,
|
||||
FA_18C_hornet,
|
||||
@@ -43,10 +51,14 @@ from dcs.planes import (
|
||||
F_4E,
|
||||
F_5E_3,
|
||||
F_86F_Sabre,
|
||||
I_16,
|
||||
IL_76MD,
|
||||
IL_78M,
|
||||
JF_17,
|
||||
J_11A,
|
||||
Ju_88A4,
|
||||
KC130,
|
||||
KC135MPRS,
|
||||
KC_135,
|
||||
KJ_2000,
|
||||
L_39ZA,
|
||||
MQ_9_Reaper,
|
||||
@@ -69,6 +81,7 @@ from dcs.planes import (
|
||||
P_51D_30_NA,
|
||||
RQ_1A_Predator,
|
||||
S_3B,
|
||||
S_3B_Tanker,
|
||||
SpitfireLFMkIX,
|
||||
SpitfireLFMkIXCW,
|
||||
Su_17M4,
|
||||
@@ -88,14 +101,16 @@ from dcs.planes import (
|
||||
Tu_95MS,
|
||||
WingLoong_I,
|
||||
I_16,
|
||||
Yak_40,
|
||||
)
|
||||
from dcs.unittype import FlyingType
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from gen.flights.flight import FlightType
|
||||
from pydcs_extensions.a4ec.a4ec import A_4E_C
|
||||
from pydcs_extensions.f22a.f22a import F_22A
|
||||
from pydcs_extensions.hercules.hercules import Hercules
|
||||
from pydcs_extensions.mb339.mb339 import MB_339PAN
|
||||
from pydcs_extensions.jas39.jas39 import JAS39Gripen, JAS39Gripen_AG
|
||||
from pydcs_extensions.su57.su57 import Su_57
|
||||
|
||||
# All aircraft lists are in priority order. Aircraft higher in the list will be
|
||||
@@ -123,14 +138,15 @@ CAP_CAPABLE = [
|
||||
MiG_29A,
|
||||
F_16C_50,
|
||||
FA_18C_hornet,
|
||||
F_15E,
|
||||
F_16A,
|
||||
F_4E,
|
||||
JAS39Gripen,
|
||||
JF_17,
|
||||
MiG_23MLD,
|
||||
MiG_21Bis,
|
||||
Mirage_2000_5,
|
||||
M_2000C,
|
||||
F_15E,
|
||||
F_5E_3,
|
||||
MiG_19P,
|
||||
A_4E_C,
|
||||
@@ -155,10 +171,8 @@ CAP_CAPABLE = [
|
||||
# Used for CAS (Close air support) and BAI (Battlefield Interdiction)
|
||||
CAS_CAPABLE = [
|
||||
A_10C_2,
|
||||
B_1B,
|
||||
A_10C,
|
||||
F_14B,
|
||||
F_14A_135_GR,
|
||||
Hercules,
|
||||
Su_25TM,
|
||||
Su_25T,
|
||||
Su_25,
|
||||
@@ -167,14 +181,19 @@ CAS_CAPABLE = [
|
||||
FA_18C_hornet,
|
||||
Tornado_GR4,
|
||||
Tornado_IDS,
|
||||
JAS39Gripen_AG,
|
||||
JF_17,
|
||||
AV8BNA,
|
||||
A_10A,
|
||||
B_1B,
|
||||
A_4E_C,
|
||||
F_14B,
|
||||
F_14A_135_GR,
|
||||
AJS37,
|
||||
Su_24MR,
|
||||
Su_24M,
|
||||
Su_17M4,
|
||||
AV8BNA,
|
||||
F_4E,
|
||||
S_3B,
|
||||
Su_34,
|
||||
Su_30,
|
||||
@@ -190,6 +209,7 @@ CAS_CAPABLE = [
|
||||
SA342L,
|
||||
Ka_50,
|
||||
Mi_28N,
|
||||
Mi_24P,
|
||||
Mi_24V,
|
||||
Mi_8MT,
|
||||
UH_1H,
|
||||
@@ -198,9 +218,9 @@ CAS_CAPABLE = [
|
||||
F_5E_3,
|
||||
F_86F_Sabre,
|
||||
C_101CC,
|
||||
MB_339PAN,
|
||||
L_39ZA,
|
||||
A_20G,
|
||||
Ju_88A4,
|
||||
P_47D_40,
|
||||
P_47D_30bl1,
|
||||
P_47D_30,
|
||||
@@ -218,7 +238,7 @@ CAS_CAPABLE = [
|
||||
]
|
||||
|
||||
|
||||
# Aircraft used for SEAD tasks
|
||||
# Aircraft used for SEAD and SEAD Escort tasks. Must be capable of the CAS DCS task.
|
||||
SEAD_CAPABLE = [
|
||||
JF_17,
|
||||
F_16C_50,
|
||||
@@ -228,6 +248,9 @@ SEAD_CAPABLE = [
|
||||
Su_25TM,
|
||||
F_4E,
|
||||
A_4E_C,
|
||||
F_14B,
|
||||
F_14A_135_GR,
|
||||
JAS39Gripen_AG,
|
||||
AV8BNA,
|
||||
Su_24M,
|
||||
Su_17M4,
|
||||
@@ -235,9 +258,21 @@ SEAD_CAPABLE = [
|
||||
Su_30,
|
||||
MiG_27K,
|
||||
Tornado_GR4,
|
||||
F_117A,
|
||||
B_17G,
|
||||
]
|
||||
|
||||
|
||||
# Aircraft used for DEAD tasks. Must be capable of the CAS DCS task.
|
||||
DEAD_CAPABLE = [
|
||||
AJS37,
|
||||
F_14B,
|
||||
F_14A_135_GR,
|
||||
JAS39Gripen_AG,
|
||||
B_1B,
|
||||
B_52H,
|
||||
Tu_160,
|
||||
Tu_95MS,
|
||||
A_20G,
|
||||
Ju_88A4,
|
||||
P_47D_40,
|
||||
P_47D_30bl1,
|
||||
P_47D_30,
|
||||
@@ -248,18 +283,6 @@ SEAD_CAPABLE = [
|
||||
Bf_109K_4,
|
||||
FW_190D9,
|
||||
FW_190A8,
|
||||
]
|
||||
|
||||
|
||||
# Aircraft used for DEAD tasks
|
||||
DEAD_CAPABLE = [
|
||||
AJS37,
|
||||
F_14B,
|
||||
F_14A_135_GR,
|
||||
B_1B,
|
||||
B_52H,
|
||||
Tu_160,
|
||||
Tu_95MS,
|
||||
] + SEAD_CAPABLE
|
||||
|
||||
|
||||
@@ -279,6 +302,7 @@ STRIKE_CAPABLE = [
|
||||
F_16A,
|
||||
F_14B,
|
||||
F_14A_135_GR,
|
||||
JAS39Gripen_AG,
|
||||
Tornado_IDS,
|
||||
Su_17M4,
|
||||
Su_24MR,
|
||||
@@ -294,6 +318,7 @@ STRIKE_CAPABLE = [
|
||||
MiG_29G,
|
||||
MiG_29A,
|
||||
JF_17,
|
||||
F_4E,
|
||||
A_10C_2,
|
||||
A_10C,
|
||||
AV8BNA,
|
||||
@@ -305,11 +330,11 @@ STRIKE_CAPABLE = [
|
||||
MiG_15bis,
|
||||
F_5E_3,
|
||||
F_86F_Sabre,
|
||||
MB_339PAN,
|
||||
C_101CC,
|
||||
L_39ZA,
|
||||
B_17G,
|
||||
A_20G,
|
||||
Ju_88A4,
|
||||
P_47D_40,
|
||||
P_47D_30bl1,
|
||||
P_47D_30,
|
||||
@@ -327,6 +352,7 @@ ANTISHIP_CAPABLE = [
|
||||
AJS37,
|
||||
Tu_22M3,
|
||||
FA_18C_hornet,
|
||||
JAS39Gripen_AG,
|
||||
Su_24M,
|
||||
Su_17M4,
|
||||
JF_17,
|
||||
@@ -354,9 +380,20 @@ RUNWAY_ATTACK_CAPABLE = [
|
||||
# For any aircraft that isn't necessarily directly involved in strike
|
||||
# missions in a direct combat sense, but can transport objects and infantry.
|
||||
TRANSPORT_CAPABLE = [
|
||||
C_17A,
|
||||
Hercules,
|
||||
Mi_8MT,
|
||||
C_130,
|
||||
IL_76MD,
|
||||
An_26B,
|
||||
Yak_40,
|
||||
CH_53E,
|
||||
CH_47D,
|
||||
SH_60B,
|
||||
UH_60A,
|
||||
UH_1H,
|
||||
Mi_8MT,
|
||||
Mi_8MT,
|
||||
Mi_26,
|
||||
]
|
||||
|
||||
DRONES = [MQ_9_Reaper, RQ_1A_Predator, WingLoong_I]
|
||||
@@ -368,9 +405,18 @@ AEWC_CAPABLE = [
|
||||
KJ_2000,
|
||||
]
|
||||
|
||||
# Priority is given to the tankers that can carry the most fuel.
|
||||
REFUELING_CAPABALE = [
|
||||
KC_135,
|
||||
KC135MPRS,
|
||||
IL_78M,
|
||||
KC130,
|
||||
S_3B_Tanker,
|
||||
]
|
||||
|
||||
def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]:
|
||||
cap_missions = (FlightType.BARCAP, FlightType.TARCAP)
|
||||
|
||||
def dcs_types_for_task(task: FlightType) -> list[Type[FlyingType]]:
|
||||
cap_missions = (FlightType.BARCAP, FlightType.TARCAP, FlightType.SWEEP)
|
||||
if task in cap_missions:
|
||||
return CAP_CAPABLE
|
||||
elif task == FlightType.ANTISHIP:
|
||||
@@ -381,6 +427,8 @@ def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]:
|
||||
return CAS_CAPABLE
|
||||
elif task == FlightType.SEAD:
|
||||
return SEAD_CAPABLE
|
||||
elif task == FlightType.SEAD_ESCORT:
|
||||
return SEAD_CAPABLE
|
||||
elif task == FlightType.DEAD:
|
||||
return DEAD_CAPABLE
|
||||
elif task == FlightType.OCA_AIRCRAFT:
|
||||
@@ -393,6 +441,26 @@ def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]:
|
||||
return CAP_CAPABLE
|
||||
elif task == FlightType.AEWC:
|
||||
return AEWC_CAPABLE
|
||||
elif task == FlightType.REFUELING:
|
||||
return REFUELING_CAPABALE
|
||||
elif task == FlightType.TRANSPORT:
|
||||
return TRANSPORT_CAPABLE
|
||||
else:
|
||||
logging.error(f"Unplannable flight type: {task}")
|
||||
return []
|
||||
|
||||
|
||||
def aircraft_for_task(task: FlightType) -> list[AircraftType]:
|
||||
dcs_types = dcs_types_for_task(task)
|
||||
types: list[AircraftType] = []
|
||||
for dcs_type in dcs_types:
|
||||
types.extend(AircraftType.for_dcs_type(dcs_type))
|
||||
return types
|
||||
|
||||
|
||||
def tasks_for_aircraft(aircraft: AircraftType) -> list[FlightType]:
|
||||
tasks = []
|
||||
for task in FlightType:
|
||||
if aircraft in aircraft_for_task(task):
|
||||
tasks.append(task)
|
||||
return tasks
|
||||
|
||||
@@ -18,7 +18,7 @@ class ClosestAirfields:
|
||||
self.target = target
|
||||
# This cache is configured once on load, so it's important that it is
|
||||
# complete and deterministic to avoid different behaviors across loads.
|
||||
# E.g. https://github.com/Khopa/dcs_liberation/issues/819
|
||||
# E.g. https://github.com/dcs-liberation/dcs_liberation/issues/819
|
||||
self.closest_airfields: List[ControlPoint] = sorted(
|
||||
all_control_points, key=lambda c: self.target.distance_to(c)
|
||||
)
|
||||
@@ -27,17 +27,35 @@ class ClosestAirfields:
|
||||
def operational_airfields(self) -> Iterator[ControlPoint]:
|
||||
return (c for c in self.closest_airfields if c.runway_is_operational())
|
||||
|
||||
def airfields_within(self, distance: Distance) -> Iterator[ControlPoint]:
|
||||
def _airfields_within(
|
||||
self, distance: Distance, operational: bool
|
||||
) -> Iterator[ControlPoint]:
|
||||
airfields = (
|
||||
self.operational_airfields if operational else self.closest_airfields
|
||||
)
|
||||
for cp in airfields:
|
||||
if cp.distance_to(self.target) < distance.meters:
|
||||
yield cp
|
||||
else:
|
||||
break
|
||||
|
||||
def operational_airfields_within(
|
||||
self, distance: Distance
|
||||
) -> Iterator[ControlPoint]:
|
||||
"""Iterates over all airfields within the given range of the target.
|
||||
|
||||
Note that this iterates over *all* airfields, not just friendly
|
||||
airfields.
|
||||
"""
|
||||
for cp in self.closest_airfields:
|
||||
if cp.distance_to(self.target) < distance.meters:
|
||||
yield cp
|
||||
else:
|
||||
break
|
||||
return self._airfields_within(distance, operational=True)
|
||||
|
||||
def all_airfields_within(self, distance: Distance) -> Iterator[ControlPoint]:
|
||||
"""Iterates over all airfields within the given range of the target.
|
||||
|
||||
Note that this iterates over *all* airfields, not just friendly
|
||||
airfields.
|
||||
"""
|
||||
return self._airfields_within(distance, operational=False)
|
||||
|
||||
|
||||
class ObjectiveDistanceCache:
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, TYPE_CHECKING, Type
|
||||
from typing import List, Optional, TYPE_CHECKING, Union
|
||||
|
||||
from dcs.mapping import Point
|
||||
from dcs.point import MovingPoint, PointAction
|
||||
from dcs.unittype import FlyingType
|
||||
from dcs.unit import Unit
|
||||
|
||||
from game import db
|
||||
from game.data.weapons import Weapon
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.squadrons import Pilot, Squadron
|
||||
from game.theater.controlpoint import ControlPoint, MissionTarget
|
||||
from game.utils import Distance, meters
|
||||
from gen.flights.loadouts import Loadout
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.transfers import TransferOrder
|
||||
from gen.ato import Package
|
||||
from gen.flights.flightplan import FlightPlan
|
||||
|
||||
@@ -27,6 +29,27 @@ class FlightType(Enum):
|
||||
These values are persisted to the save game as well since they are a part of
|
||||
each flight and thus a part of the ATO, so changing these values will break
|
||||
save compat.
|
||||
|
||||
When adding new mission types to this list, you will also need to update:
|
||||
|
||||
* flightplan.py: Add waypoint population in generate_flight_plan. Add a new flight
|
||||
plan type if necessary, though most are a subclass of StrikeFlightPlan.
|
||||
* aircraft.py: Add a configuration method and call it in setup_flight_group. This is
|
||||
responsible for configuring waypoint 0 actions like setting ROE, threat reaction,
|
||||
and mission abort parameters (winchester, bingo, etc).
|
||||
* Implementations of MissionTarget.mission_types: A mission type can only be planned
|
||||
against compatible targets. The mission_types method of each target class defines
|
||||
which missions may target it.
|
||||
* ai_flight_planner_db.py: Add the new mission type to aircraft_for_task that
|
||||
returns the list of compatible aircraft in order of preference.
|
||||
|
||||
You may also need to update:
|
||||
|
||||
* flight.py: Add a new waypoint type if necessary. Most mission types will need
|
||||
these, as aircraft.py uses the ingress point type to specialize AI tasks, and non-
|
||||
strike-like missions will need more specialized control.
|
||||
* ai_flight_planner.py: Use the new mission type in propose_missions so the AI will
|
||||
plan the new mission type.
|
||||
"""
|
||||
|
||||
TARCAP = "TARCAP"
|
||||
@@ -43,12 +66,36 @@ class FlightType(Enum):
|
||||
OCA_RUNWAY = "OCA/Runway"
|
||||
OCA_AIRCRAFT = "OCA/Aircraft"
|
||||
AEWC = "AEW&C"
|
||||
TRANSPORT = "Transport"
|
||||
SEAD_ESCORT = "SEAD Escort"
|
||||
REFUELING = "Refueling"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@classmethod
|
||||
def from_name(cls, name: str) -> FlightType:
|
||||
for entry in cls:
|
||||
if name == entry.value:
|
||||
return entry
|
||||
raise KeyError(f"No FlightType with name {name}")
|
||||
|
||||
|
||||
class FlightWaypointType(Enum):
|
||||
"""Enumeration of waypoint types.
|
||||
|
||||
The value of the enum has no meaning but should remain stable to prevent breaking
|
||||
save game compatibility.
|
||||
|
||||
When adding a new waypoint type, you will also need to update:
|
||||
|
||||
* waypointbuilder.py: Add a builder to simplify construction of the new waypoint
|
||||
type unless the new waypoint type will be a parameter to an existing builder
|
||||
method (such as how escort ingress waypoints work).
|
||||
* aircraft.py: Associate AI actions with the new waypoint type by subclassing
|
||||
PydcsWaypointBuilder and using it in PydcsWaypointBuilder.for_waypoint.
|
||||
"""
|
||||
|
||||
TAKEOFF = 0 # Take off point
|
||||
ASCEND_POINT = 1 # Ascension point after take off
|
||||
PATROL = 2 # Patrol point
|
||||
@@ -63,7 +110,7 @@ class FlightWaypointType(Enum):
|
||||
LANDING_POINT = 11 # Should land there
|
||||
TARGET_POINT = 12 # A target building or static object, position
|
||||
TARGET_GROUP_LOC = 13 # A target group approximate location
|
||||
TARGET_SHIP = 14 # A target ship known location
|
||||
TARGET_SHIP = 14 # Unused.
|
||||
CUSTOM = 15 # User waypoint (no specific behaviour)
|
||||
JOIN = 16
|
||||
SPLIT = 17
|
||||
@@ -75,6 +122,9 @@ class FlightWaypointType(Enum):
|
||||
DIVERT = 23
|
||||
INGRESS_OCA_RUNWAY = 24
|
||||
INGRESS_OCA_AIRCRAFT = 25
|
||||
PICKUP = 26
|
||||
DROP_OFF = 27
|
||||
BULLSEYE = 28
|
||||
|
||||
|
||||
class FlightWaypoint:
|
||||
@@ -104,7 +154,7 @@ class FlightWaypoint:
|
||||
# Only used in the waypoint list in the flight edit page. No sense
|
||||
# having three names. A short and long form is enough.
|
||||
self.description = ""
|
||||
self.targets: List[MissionTarget] = []
|
||||
self.targets: List[Union[MissionTarget, Unit]] = []
|
||||
self.obj_name = ""
|
||||
self.pretty_name = ""
|
||||
self.only_for_player = False
|
||||
@@ -151,12 +201,55 @@ class FlightWaypoint:
|
||||
return waypoint
|
||||
|
||||
|
||||
class FlightRoster:
|
||||
def __init__(self, squadron: Squadron, initial_size: int = 0) -> None:
|
||||
self.squadron = squadron
|
||||
self.pilots: list[Optional[Pilot]] = []
|
||||
self.resize(initial_size)
|
||||
|
||||
@property
|
||||
def max_size(self) -> int:
|
||||
return len(self.pilots)
|
||||
|
||||
@property
|
||||
def player_count(self) -> int:
|
||||
return len([p for p in self.pilots if p is not None and p.player])
|
||||
|
||||
@property
|
||||
def missing_pilots(self) -> int:
|
||||
return len([p for p in self.pilots if p is None])
|
||||
|
||||
def resize(self, new_size: int) -> None:
|
||||
if self.max_size > new_size:
|
||||
self.squadron.return_pilots(
|
||||
[p for p in self.pilots[new_size:] if p is not None]
|
||||
)
|
||||
self.pilots = self.pilots[:new_size]
|
||||
return
|
||||
self.pilots.extend(
|
||||
[
|
||||
self.squadron.claim_available_pilot()
|
||||
for _ in range(new_size - self.max_size)
|
||||
]
|
||||
)
|
||||
|
||||
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
|
||||
if pilot is not None:
|
||||
self.squadron.claim_pilot(pilot)
|
||||
if (current_pilot := self.pilots[index]) is not None:
|
||||
self.squadron.return_pilot(current_pilot)
|
||||
self.pilots[index] = pilot
|
||||
|
||||
def clear(self) -> None:
|
||||
self.squadron.return_pilots([p for p in self.pilots if p is not None])
|
||||
|
||||
|
||||
class Flight:
|
||||
def __init__(
|
||||
self,
|
||||
package: Package,
|
||||
country: str,
|
||||
unit_type: Type[FlyingType],
|
||||
squadron: Squadron,
|
||||
count: int,
|
||||
flight_type: FlightType,
|
||||
start_type: str,
|
||||
@@ -164,23 +257,30 @@ class Flight:
|
||||
arrival: ControlPoint,
|
||||
divert: Optional[ControlPoint],
|
||||
custom_name: Optional[str] = None,
|
||||
cargo: Optional[TransferOrder] = None,
|
||||
roster: Optional[FlightRoster] = None,
|
||||
) -> None:
|
||||
self.package = package
|
||||
self.country = country
|
||||
self.unit_type = unit_type
|
||||
self.count = count
|
||||
self.squadron = squadron
|
||||
if roster is None:
|
||||
self.roster = FlightRoster(self.squadron, initial_size=count)
|
||||
else:
|
||||
self.roster = roster
|
||||
self.departure = departure
|
||||
self.arrival = arrival
|
||||
self.divert = divert
|
||||
self.flight_type = flight_type
|
||||
# TODO: Replace with FlightPlan.
|
||||
self.targets: List[MissionTarget] = []
|
||||
self.loadout: Dict[int, Optional[Weapon]] = {}
|
||||
self.loadout = Loadout.default_for(self)
|
||||
self.start_type = start_type
|
||||
self.use_custom_loadout = False
|
||||
self.client_count = 0
|
||||
self.custom_name = custom_name
|
||||
|
||||
# Only used by transport missions.
|
||||
self.cargo = cargo
|
||||
|
||||
# Will be replaced with a more appropriate FlightPlan by
|
||||
# FlightPlanBuilder, but an empty flight plan the flight begins with an
|
||||
# empty flight plan.
|
||||
@@ -190,6 +290,18 @@ class Flight:
|
||||
package=package, flight=self, custom_waypoints=[]
|
||||
)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return self.roster.max_size
|
||||
|
||||
@property
|
||||
def client_count(self) -> int:
|
||||
return self.roster.player_count
|
||||
|
||||
@property
|
||||
def unit_type(self) -> AircraftType:
|
||||
return self.squadron.aircraft
|
||||
|
||||
@property
|
||||
def from_cp(self) -> ControlPoint:
|
||||
return self.departure
|
||||
@@ -198,14 +310,25 @@ class Flight:
|
||||
def points(self) -> List[FlightWaypoint]:
|
||||
return self.flight_plan.waypoints[1:]
|
||||
|
||||
def resize(self, new_size: int) -> None:
|
||||
self.roster.resize(new_size)
|
||||
|
||||
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
|
||||
self.roster.set_pilot(index, pilot)
|
||||
|
||||
@property
|
||||
def missing_pilots(self) -> int:
|
||||
return self.roster.missing_pilots
|
||||
|
||||
def clear_roster(self) -> None:
|
||||
self.roster.clear()
|
||||
|
||||
def __repr__(self):
|
||||
name = db.unit_type_name(self.unit_type)
|
||||
if self.custom_name:
|
||||
return f"{self.custom_name} {self.count} x {name}"
|
||||
return f"[{self.flight_type}] {self.count} x {name}"
|
||||
return f"{self.custom_name} {self.count} x {self.unit_type}"
|
||||
return f"[{self.flight_type}] {self.count} x {self.unit_type}"
|
||||
|
||||
def __str__(self):
|
||||
name = db.unit_get_expanded_info(self.country, self.unit_type, "name")
|
||||
if self.custom_name:
|
||||
return f"{self.custom_name} {self.count} x {name}"
|
||||
return f"[{self.flight_type}] {self.count} x {name}"
|
||||
return f"{self.custom_name} {self.count} x {self.unit_type}"
|
||||
return f"[{self.flight_type}] {self.count} x {self.unit_type}"
|
||||
|
||||
@@ -10,13 +10,11 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from functools import cached_property
|
||||
from typing import Iterator, List, Optional, Set, TYPE_CHECKING, Tuple
|
||||
|
||||
from dcs.planes import E_3A, E_2C, A_50, KJ_2000
|
||||
|
||||
from dcs.mapping import Point
|
||||
from dcs.unit import Unit
|
||||
from shapely.geometry import Point as ShapelyPoint
|
||||
@@ -29,9 +27,10 @@ from game.theater import (
|
||||
MissionTarget,
|
||||
SamGroundObject,
|
||||
TheaterGroundObject,
|
||||
NavalControlPoint,
|
||||
)
|
||||
from game.theater.theatergroundobject import EwrGroundObject
|
||||
from game.utils import Distance, Speed, feet, meters, nautical_miles
|
||||
from game.theater.theatergroundobject import EwrGroundObject, NavalGroundObject
|
||||
from game.utils import Distance, Speed, feet, meters, nautical_miles, knots
|
||||
from .closestairfields import ObjectiveDistanceCache
|
||||
from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType
|
||||
from .traveltime import GroundSpeed, TravelTime
|
||||
@@ -41,6 +40,7 @@ from ..conflictgen import Conflict, FRONTLINE_LENGTH
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from gen.ato import Package
|
||||
from game.transfers import Convoy
|
||||
|
||||
INGRESS_TYPES = {
|
||||
FlightWaypointType.INGRESS_CAS,
|
||||
@@ -124,6 +124,10 @@ class FlightPlan:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def tot(self) -> timedelta:
|
||||
return self.package.time_over_target + self.tot_offset
|
||||
|
||||
@cached_property
|
||||
def bingo_fuel(self) -> int:
|
||||
"""Bingo fuel value for the FlightPlan"""
|
||||
@@ -197,15 +201,28 @@ class FlightPlan:
|
||||
def dismiss_escort_at(self) -> Optional[FlightWaypoint]:
|
||||
return None
|
||||
|
||||
def escorted_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
begin = self.request_escort_at()
|
||||
end = self.dismiss_escort_at()
|
||||
if begin is None or end is None:
|
||||
return
|
||||
escorting = False
|
||||
for waypoint in self.waypoints:
|
||||
if waypoint == begin:
|
||||
escorting = True
|
||||
if escorting:
|
||||
yield waypoint
|
||||
if waypoint == end:
|
||||
return
|
||||
|
||||
def takeoff_time(self) -> Optional[timedelta]:
|
||||
tot_waypoint = self.tot_waypoint
|
||||
if tot_waypoint is None:
|
||||
return None
|
||||
|
||||
time = self.tot_for_waypoint(tot_waypoint)
|
||||
time = self.tot
|
||||
if time is None:
|
||||
return None
|
||||
time += self.tot_offset
|
||||
return time - self._travel_time_to_waypoint(tot_waypoint)
|
||||
|
||||
def startup_time(self) -> Optional[timedelta]:
|
||||
@@ -242,7 +259,7 @@ class FlightPlan:
|
||||
if self.flight.from_cp.is_fleet:
|
||||
return timedelta(minutes=2)
|
||||
else:
|
||||
return timedelta(minutes=5)
|
||||
return timedelta(minutes=8)
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
@@ -425,6 +442,7 @@ class BarCapFlightPlan(PatrollingFlightPlan):
|
||||
takeoff: FlightWaypoint
|
||||
land: FlightWaypoint
|
||||
divert: Optional[FlightWaypoint]
|
||||
bullseye: FlightWaypoint
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.takeoff
|
||||
@@ -437,6 +455,7 @@ class BarCapFlightPlan(PatrollingFlightPlan):
|
||||
yield self.land
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -445,6 +464,7 @@ class CasFlightPlan(PatrollingFlightPlan):
|
||||
target: FlightWaypoint
|
||||
land: FlightWaypoint
|
||||
divert: Optional[FlightWaypoint]
|
||||
bullseye: FlightWaypoint
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.takeoff
|
||||
@@ -458,6 +478,7 @@ class CasFlightPlan(PatrollingFlightPlan):
|
||||
yield self.land
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
|
||||
def request_escort_at(self) -> Optional[FlightWaypoint]:
|
||||
return self.patrol_start
|
||||
@@ -471,6 +492,7 @@ class TarCapFlightPlan(PatrollingFlightPlan):
|
||||
takeoff: FlightWaypoint
|
||||
land: FlightWaypoint
|
||||
divert: Optional[FlightWaypoint]
|
||||
bullseye: FlightWaypoint
|
||||
lead_time: timedelta
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
@@ -484,6 +506,7 @@ class TarCapFlightPlan(PatrollingFlightPlan):
|
||||
yield self.land
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
|
||||
@property
|
||||
def tot_offset(self) -> timedelta:
|
||||
@@ -499,7 +522,7 @@ class TarCapFlightPlan(PatrollingFlightPlan):
|
||||
start = self.package.escort_start_time
|
||||
if start is not None:
|
||||
return start + self.tot_offset
|
||||
return super().patrol_start_time + self.tot_offset
|
||||
return self.tot
|
||||
|
||||
@property
|
||||
def patrol_end_time(self) -> timedelta:
|
||||
@@ -522,6 +545,8 @@ class StrikeFlightPlan(FormationFlightPlan):
|
||||
nav_from: List[FlightWaypoint]
|
||||
land: FlightWaypoint
|
||||
divert: Optional[FlightWaypoint]
|
||||
bullseye: FlightWaypoint
|
||||
lead_time: timedelta = field(default_factory=timedelta)
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.takeoff
|
||||
@@ -536,6 +561,7 @@ class StrikeFlightPlan(FormationFlightPlan):
|
||||
yield self.land
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
|
||||
@property
|
||||
def package_speed_waypoints(self) -> Set[FlightWaypoint]:
|
||||
@@ -559,6 +585,13 @@ class StrikeFlightPlan(FormationFlightPlan):
|
||||
def tot_waypoint(self) -> FlightWaypoint:
|
||||
return self.targets[0]
|
||||
|
||||
@property
|
||||
def tot_offset(self) -> timedelta:
|
||||
try:
|
||||
return -self.lead_time
|
||||
except AttributeError:
|
||||
return timedelta()
|
||||
|
||||
@property
|
||||
def target_area_waypoint(self) -> FlightWaypoint:
|
||||
return FlightWaypoint(
|
||||
@@ -591,10 +624,6 @@ class StrikeFlightPlan(FormationFlightPlan):
|
||||
)
|
||||
return total
|
||||
|
||||
@property
|
||||
def mission_speed(self) -> Speed:
|
||||
return GroundSpeed.for_flight(self.flight, self.ingress.alt)
|
||||
|
||||
@property
|
||||
def join_time(self) -> timedelta:
|
||||
travel_time = self.travel_time_between_waypoints(self.join, self.ingress)
|
||||
@@ -607,7 +636,7 @@ class StrikeFlightPlan(FormationFlightPlan):
|
||||
|
||||
@property
|
||||
def ingress_time(self) -> timedelta:
|
||||
tot = self.package.time_over_target
|
||||
tot = self.tot
|
||||
travel_time = self.travel_time_between_waypoints(
|
||||
self.ingress, self.target_area_waypoint
|
||||
)
|
||||
@@ -615,7 +644,7 @@ class StrikeFlightPlan(FormationFlightPlan):
|
||||
|
||||
@property
|
||||
def egress_time(self) -> timedelta:
|
||||
tot = self.package.time_over_target
|
||||
tot = self.tot
|
||||
travel_time = self.travel_time_between_waypoints(
|
||||
self.target_area_waypoint, self.egress
|
||||
)
|
||||
@@ -627,7 +656,7 @@ class StrikeFlightPlan(FormationFlightPlan):
|
||||
elif waypoint == self.egress:
|
||||
return self.egress_time
|
||||
elif waypoint in self.targets:
|
||||
return self.package.time_over_target
|
||||
return self.tot
|
||||
return super().tot_for_waypoint(waypoint)
|
||||
|
||||
|
||||
@@ -640,6 +669,7 @@ class SweepFlightPlan(LoiterFlightPlan):
|
||||
nav_from: List[FlightWaypoint]
|
||||
land: FlightWaypoint
|
||||
divert: Optional[FlightWaypoint]
|
||||
bullseye: FlightWaypoint
|
||||
lead_time: timedelta
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
@@ -652,6 +682,7 @@ class SweepFlightPlan(LoiterFlightPlan):
|
||||
yield self.land
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
|
||||
@property
|
||||
def tot_waypoint(self) -> Optional[FlightWaypoint]:
|
||||
@@ -670,7 +701,7 @@ class SweepFlightPlan(LoiterFlightPlan):
|
||||
|
||||
@property
|
||||
def sweep_end_time(self) -> timedelta:
|
||||
return self.package.time_over_target + self.tot_offset
|
||||
return self.tot
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]:
|
||||
if waypoint == self.sweep_start:
|
||||
@@ -703,6 +734,7 @@ class AwacsFlightPlan(LoiterFlightPlan):
|
||||
nav_from: List[FlightWaypoint]
|
||||
land: FlightWaypoint
|
||||
divert: Optional[FlightWaypoint]
|
||||
bullseye: FlightWaypoint
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.takeoff
|
||||
@@ -712,6 +744,7 @@ class AwacsFlightPlan(LoiterFlightPlan):
|
||||
yield self.land
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
|
||||
@property
|
||||
def mission_start_time(self) -> Optional[timedelta]:
|
||||
@@ -735,6 +768,70 @@ class AwacsFlightPlan(LoiterFlightPlan):
|
||||
return self.push_time
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RefuelingFlightPlan(PatrollingFlightPlan):
|
||||
takeoff: FlightWaypoint
|
||||
land: FlightWaypoint
|
||||
divert: Optional[FlightWaypoint]
|
||||
bullseye: FlightWaypoint
|
||||
|
||||
#: Racetrack speed.
|
||||
patrol_speed: Speed
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.takeoff
|
||||
yield from self.nav_to
|
||||
yield self.patrol_start
|
||||
yield self.patrol_end
|
||||
yield from self.nav_from
|
||||
yield self.land
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AirliftFlightPlan(FlightPlan):
|
||||
takeoff: FlightWaypoint
|
||||
nav_to_pickup: List[FlightWaypoint]
|
||||
pickup: Optional[FlightWaypoint]
|
||||
nav_to_drop_off: List[FlightWaypoint]
|
||||
drop_off: FlightWaypoint
|
||||
nav_to_home: List[FlightWaypoint]
|
||||
land: FlightWaypoint
|
||||
divert: Optional[FlightWaypoint]
|
||||
bullseye: FlightWaypoint
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.takeoff
|
||||
yield from self.nav_to_pickup
|
||||
if self.pickup:
|
||||
yield self.pickup
|
||||
yield from self.nav_to_drop_off
|
||||
yield self.drop_off
|
||||
yield from self.nav_to_home
|
||||
yield self.land
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
|
||||
@property
|
||||
def tot_waypoint(self) -> Optional[FlightWaypoint]:
|
||||
return self.drop_off
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]:
|
||||
# TOT planning isn't really useful for transports. They're behind the front
|
||||
# lines so no need to wait for escorts or for other missions to complete.
|
||||
return None
|
||||
|
||||
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mission_departure_time(self) -> timedelta:
|
||||
return self.package.time_over_target
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomFlightPlan(FlightPlan):
|
||||
custom_waypoints: List[FlightWaypoint]
|
||||
@@ -782,11 +879,7 @@ class FlightPlanBuilder:
|
||||
self.game = game
|
||||
self.package = package
|
||||
self.is_player = is_player
|
||||
if is_player:
|
||||
faction = self.game.player_faction
|
||||
else:
|
||||
faction = self.game.enemy_faction
|
||||
self.doctrine: Doctrine = faction.doctrine
|
||||
self.doctrine: Doctrine = self.game.faction_for(self.is_player).doctrine
|
||||
self.threat_zones = self.game.threat_zone_for(not self.is_player)
|
||||
|
||||
def populate_flight_plan(
|
||||
@@ -798,12 +891,12 @@ class FlightPlanBuilder:
|
||||
"""Creates a default flight plan for the given mission."""
|
||||
if flight not in self.package.flights:
|
||||
raise RuntimeError("Flight must be a part of the package")
|
||||
if self.package.waypoints is None:
|
||||
self.regenerate_package_waypoints()
|
||||
|
||||
from game.navmesh import NavMeshError
|
||||
|
||||
try:
|
||||
if self.package.waypoints is None:
|
||||
self.regenerate_package_waypoints()
|
||||
flight.flight_plan = self.generate_flight_plan(flight, custom_targets)
|
||||
except NavMeshError as ex:
|
||||
color = "blue" if self.is_player else "red"
|
||||
@@ -835,6 +928,8 @@ class FlightPlanBuilder:
|
||||
return self.generate_runway_attack(flight)
|
||||
elif task == FlightType.SEAD:
|
||||
return self.generate_sead(flight, custom_targets)
|
||||
elif task == FlightType.SEAD_ESCORT:
|
||||
return self.generate_escort(flight)
|
||||
elif task == FlightType.STRIKE:
|
||||
return self.generate_strike(flight)
|
||||
elif task == FlightType.SWEEP:
|
||||
@@ -843,6 +938,10 @@ class FlightPlanBuilder:
|
||||
return self.generate_tarcap(flight)
|
||||
elif task == FlightType.AEWC:
|
||||
return self.generate_aewc(flight)
|
||||
elif task == FlightType.TRANSPORT:
|
||||
return self.generate_transport(flight)
|
||||
elif task == FlightType.REFUELING:
|
||||
return self.generate_refueling_racetrack(flight)
|
||||
raise PlanningError(f"{task} flight plan generation not implemented")
|
||||
|
||||
def regenerate_package_waypoints(self) -> None:
|
||||
@@ -981,40 +1080,34 @@ class FlightPlanBuilder:
|
||||
"""
|
||||
location = self.package.target
|
||||
|
||||
start = self.aewc_orbit(location)
|
||||
orbit_location = self.aewc_orbit(location)
|
||||
|
||||
# As high as possible to maximize detection and on-station time.
|
||||
if flight.unit_type == E_2C:
|
||||
patrol_alt = feet(30000)
|
||||
elif flight.unit_type == E_3A:
|
||||
patrol_alt = feet(35000)
|
||||
elif flight.unit_type == A_50:
|
||||
patrol_alt = feet(33000)
|
||||
elif flight.unit_type == KJ_2000:
|
||||
patrol_alt = feet(40000)
|
||||
if flight.unit_type.patrol_altitude is not None:
|
||||
patrol_alt = flight.unit_type.patrol_altitude
|
||||
else:
|
||||
patrol_alt = feet(25000)
|
||||
|
||||
builder = WaypointBuilder(flight, self.game, self.is_player)
|
||||
start = builder.orbit(start, patrol_alt)
|
||||
orbit_location = builder.orbit(orbit_location, patrol_alt)
|
||||
|
||||
return AwacsFlightPlan(
|
||||
package=self.package,
|
||||
flight=flight,
|
||||
takeoff=builder.takeoff(flight.departure),
|
||||
nav_to=builder.nav_path(
|
||||
flight.departure.position, start.position, patrol_alt
|
||||
flight.departure.position, orbit_location.position, patrol_alt
|
||||
),
|
||||
nav_from=builder.nav_path(
|
||||
start.position, flight.arrival.position, patrol_alt
|
||||
orbit_location.position, flight.arrival.position, patrol_alt
|
||||
),
|
||||
land=builder.land(flight.arrival),
|
||||
divert=builder.divert(flight.divert),
|
||||
hold=start,
|
||||
bullseye=builder.bullseye(),
|
||||
hold=orbit_location,
|
||||
hold_duration=timedelta(hours=4),
|
||||
)
|
||||
|
||||
def generate_bai(self, flight: Flight) -> StrikeFlightPlan:
|
||||
def generate_bai(self, flight: Flight) -> FlightPlan:
|
||||
"""Generates a BAI flight plan.
|
||||
|
||||
Args:
|
||||
@@ -1022,18 +1115,28 @@ class FlightPlanBuilder:
|
||||
"""
|
||||
location = self.package.target
|
||||
|
||||
if not isinstance(location, TheaterGroundObject):
|
||||
raise InvalidObjectiveLocation(flight.flight_type, location)
|
||||
from game.transfers import Convoy
|
||||
|
||||
targets: List[StrikeTarget] = []
|
||||
for group in location.groups:
|
||||
if group.units:
|
||||
targets.append(StrikeTarget(f"{group.name} at {location.name}", group))
|
||||
if isinstance(location, TheaterGroundObject):
|
||||
for group in location.groups:
|
||||
if group.units:
|
||||
targets.append(
|
||||
StrikeTarget(f"{group.name} at {location.name}", group)
|
||||
)
|
||||
elif isinstance(location, Convoy):
|
||||
targets.append(StrikeTarget(location.name, location))
|
||||
else:
|
||||
raise InvalidObjectiveLocation(flight.flight_type, location)
|
||||
|
||||
return self.strike_flightplan(
|
||||
flight, location, FlightWaypointType.INGRESS_BAI, targets
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def anti_ship_targets_for_tgo(tgo: TheaterGroundObject) -> List[StrikeTarget]:
|
||||
return [StrikeTarget(f"{g.name} at {tgo.name}", g) for g in tgo.groups]
|
||||
|
||||
def generate_anti_ship(self, flight: Flight) -> StrikeFlightPlan:
|
||||
"""Generates an anti-ship flight plan.
|
||||
|
||||
@@ -1042,20 +1145,17 @@ class FlightPlanBuilder:
|
||||
"""
|
||||
location = self.package.target
|
||||
|
||||
if isinstance(location, ControlPoint):
|
||||
if location.is_fleet:
|
||||
# The first group generated will be the carrier group itself.
|
||||
location = location.ground_objects[0]
|
||||
else:
|
||||
raise InvalidObjectiveLocation(flight.flight_type, location)
|
||||
from game.transfers import CargoShip
|
||||
|
||||
if not isinstance(location, TheaterGroundObject):
|
||||
if isinstance(location, NavalControlPoint):
|
||||
targets = self.anti_ship_targets_for_tgo(location.find_main_tgo())
|
||||
elif isinstance(location, NavalGroundObject):
|
||||
targets = self.anti_ship_targets_for_tgo(location)
|
||||
elif isinstance(location, CargoShip):
|
||||
targets = [StrikeTarget(location.name, location)]
|
||||
else:
|
||||
raise InvalidObjectiveLocation(flight.flight_type, location)
|
||||
|
||||
targets: List[StrikeTarget] = []
|
||||
for group in location.groups:
|
||||
targets.append(StrikeTarget(f"{group.name} at {location.name}", group))
|
||||
|
||||
return self.strike_flightplan(
|
||||
flight, location, FlightWaypointType.INGRESS_BAI, targets
|
||||
)
|
||||
@@ -1098,6 +1198,7 @@ class FlightPlanBuilder:
|
||||
patrol_end=end,
|
||||
land=builder.land(flight.arrival),
|
||||
divert=builder.divert(flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def generate_sweep(self, flight: Flight) -> SweepFlightPlan:
|
||||
@@ -1134,6 +1235,59 @@ class FlightPlanBuilder:
|
||||
sweep_end=end,
|
||||
land=builder.land(flight.arrival),
|
||||
divert=builder.divert(flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def generate_transport(self, flight: Flight) -> AirliftFlightPlan:
|
||||
"""Generate an airlift flight at a given location.
|
||||
|
||||
Args:
|
||||
flight: The flight to generate the flight plan for.
|
||||
"""
|
||||
cargo = flight.cargo
|
||||
if cargo is None:
|
||||
raise PlanningError(
|
||||
"Cannot plan transport mission for flight with no cargo."
|
||||
)
|
||||
|
||||
altitude = feet(1500)
|
||||
altitude_is_agl = True
|
||||
|
||||
builder = WaypointBuilder(flight, self.game, self.is_player)
|
||||
|
||||
pickup = None
|
||||
nav_to_pickup = []
|
||||
if cargo.origin != flight.departure:
|
||||
pickup = builder.pickup(cargo.origin)
|
||||
nav_to_pickup = builder.nav_path(
|
||||
flight.departure.position,
|
||||
cargo.origin.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
)
|
||||
|
||||
return AirliftFlightPlan(
|
||||
package=self.package,
|
||||
flight=flight,
|
||||
takeoff=builder.takeoff(flight.departure),
|
||||
nav_to_pickup=nav_to_pickup,
|
||||
pickup=pickup,
|
||||
nav_to_drop_off=builder.nav_path(
|
||||
cargo.origin.position,
|
||||
cargo.next_stop.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
),
|
||||
drop_off=builder.drop_off(cargo.next_stop),
|
||||
nav_to_home=builder.nav_path(
|
||||
cargo.origin.position,
|
||||
flight.arrival.position,
|
||||
altitude,
|
||||
altitude_is_agl,
|
||||
),
|
||||
land=builder.land(flight.arrival),
|
||||
divert=builder.divert(flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def racetrack_for_objective(
|
||||
@@ -1198,29 +1352,31 @@ class FlightPlanBuilder:
|
||||
return start, end
|
||||
|
||||
def aewc_orbit(self, location: MissionTarget) -> Point:
|
||||
# in threat zone
|
||||
closest_boundary = self.threat_zones.closest_boundary(location.position)
|
||||
heading_to_threat_boundary = location.position.heading_between_point(
|
||||
closest_boundary
|
||||
)
|
||||
distance_to_threat = meters(
|
||||
location.position.distance_to_point(closest_boundary)
|
||||
)
|
||||
orbit_heading = heading_to_threat_boundary
|
||||
# Station 100nm outside the threat zone.
|
||||
threat_buffer = nautical_miles(100)
|
||||
if self.threat_zones.threatened(location.position):
|
||||
# Borderpoint
|
||||
closest_boundary = self.threat_zones.closest_boundary(location.position)
|
||||
|
||||
# Heading + Distance to border point
|
||||
heading = location.position.heading_between_point(closest_boundary)
|
||||
distance = location.position.distance_to_point(closest_boundary)
|
||||
|
||||
return location.position.point_from_heading(heading, distance)
|
||||
|
||||
# this Part is fine. No threat zone, just use our point
|
||||
orbit_distance = distance_to_threat + threat_buffer
|
||||
else:
|
||||
return location.position
|
||||
orbit_distance = distance_to_threat - threat_buffer
|
||||
|
||||
return location.position.point_from_heading(
|
||||
orbit_heading, orbit_distance.meters
|
||||
)
|
||||
|
||||
def racetrack_for_frontline(
|
||||
self, origin: Point, front_line: FrontLine
|
||||
) -> Tuple[Point, Point]:
|
||||
ally_cp, enemy_cp = front_line.control_points
|
||||
|
||||
# Find targets waypoints
|
||||
ingress, heading, distance = Conflict.frontline_vector(
|
||||
ally_cp, enemy_cp, self.game.theater
|
||||
front_line, self.game.theater
|
||||
)
|
||||
center = ingress.point_from_heading(heading, distance / 2)
|
||||
orbit_center = center.point_from_heading(
|
||||
@@ -1287,6 +1443,7 @@ class FlightPlanBuilder:
|
||||
patrol_end=end,
|
||||
land=builder.land(flight.arrival),
|
||||
divert=builder.divert(flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def generate_dead(
|
||||
@@ -1380,7 +1537,11 @@ class FlightPlanBuilder:
|
||||
targets.append(StrikeTarget(location.name, target))
|
||||
|
||||
return self.strike_flightplan(
|
||||
flight, location, FlightWaypointType.INGRESS_SEAD, targets
|
||||
flight,
|
||||
location,
|
||||
FlightWaypointType.INGRESS_SEAD,
|
||||
targets,
|
||||
lead_time=timedelta(minutes=1),
|
||||
)
|
||||
|
||||
def generate_escort(self, flight: Flight) -> StrikeFlightPlan:
|
||||
@@ -1415,6 +1576,7 @@ class FlightPlanBuilder:
|
||||
),
|
||||
land=builder.land(flight.arrival),
|
||||
divert=builder.divert(flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def generate_cas(self, flight: Flight) -> CasFlightPlan:
|
||||
@@ -1429,7 +1591,7 @@ class FlightPlanBuilder:
|
||||
raise InvalidObjectiveLocation(flight.flight_type, location)
|
||||
|
||||
ingress, heading, distance = Conflict.frontline_vector(
|
||||
location.control_points[0], location.control_points[1], self.game.theater
|
||||
location, self.game.theater
|
||||
)
|
||||
center = ingress.point_from_heading(heading, distance / 2)
|
||||
egress = ingress.point_from_heading(heading, distance)
|
||||
@@ -1460,6 +1622,75 @@ class FlightPlanBuilder:
|
||||
patrol_end=builder.egress(egress, location),
|
||||
land=builder.land(flight.arrival),
|
||||
divert=builder.divert(flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def generate_refueling_racetrack(self, flight: Flight) -> RefuelingFlightPlan:
|
||||
location = self.package.target
|
||||
|
||||
closest_boundary = self.threat_zones.closest_boundary(location.position)
|
||||
heading_to_threat_boundary = location.position.heading_between_point(
|
||||
closest_boundary
|
||||
)
|
||||
distance_to_threat = meters(
|
||||
location.position.distance_to_point(closest_boundary)
|
||||
)
|
||||
orbit_heading = heading_to_threat_boundary
|
||||
|
||||
# Station 70nm outside the threat zone.
|
||||
threat_buffer = nautical_miles(70)
|
||||
if self.threat_zones.threatened(location.position):
|
||||
orbit_distance = distance_to_threat + threat_buffer
|
||||
else:
|
||||
orbit_distance = distance_to_threat - threat_buffer
|
||||
|
||||
racetrack_center = location.position.point_from_heading(
|
||||
orbit_heading, orbit_distance.meters
|
||||
)
|
||||
|
||||
racetrack_half_distance = Distance.from_nautical_miles(20).meters
|
||||
|
||||
racetrack_start = racetrack_center.point_from_heading(
|
||||
orbit_heading + 90, racetrack_half_distance
|
||||
)
|
||||
racetrack_end = racetrack_center.point_from_heading(
|
||||
orbit_heading - 90, racetrack_half_distance
|
||||
)
|
||||
|
||||
builder = WaypointBuilder(flight, self.game, self.is_player)
|
||||
|
||||
tanker_type = flight.unit_type
|
||||
if tanker_type.patrol_altitude is not None:
|
||||
altitude = tanker_type.patrol_altitude
|
||||
else:
|
||||
altitude = feet(21000)
|
||||
|
||||
if tanker_type.patrol_speed is not None:
|
||||
speed = tanker_type.patrol_speed
|
||||
else:
|
||||
# ~280 knots IAS at 21000.
|
||||
speed = knots(400)
|
||||
|
||||
racetrack = builder.race_track(racetrack_start, racetrack_end, altitude)
|
||||
|
||||
return RefuelingFlightPlan(
|
||||
package=self.package,
|
||||
flight=flight,
|
||||
takeoff=builder.takeoff(flight.departure),
|
||||
nav_to=builder.nav_path(
|
||||
flight.departure.position, racetrack_start, altitude
|
||||
),
|
||||
nav_from=builder.nav_path(racetrack_end, flight.arrival.position, altitude),
|
||||
patrol_start=racetrack[0],
|
||||
patrol_end=racetrack[1],
|
||||
land=builder.land(flight.arrival),
|
||||
divert=builder.divert(flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
patrol_duration=timedelta(hours=1),
|
||||
patrol_speed=speed,
|
||||
# TODO: Factor out a common base of the combat and non-combat race-tracks.
|
||||
# No harm in setting this, but we ought to clean up a bit.
|
||||
engagement_distance=meters(0),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -1556,6 +1787,7 @@ class FlightPlanBuilder:
|
||||
location: MissionTarget,
|
||||
ingress_type: FlightWaypointType,
|
||||
targets: Optional[List[StrikeTarget]] = None,
|
||||
lead_time: timedelta = timedelta(),
|
||||
) -> StrikeFlightPlan:
|
||||
assert self.package.waypoints is not None
|
||||
builder = WaypointBuilder(flight, self.game, self.is_player, targets)
|
||||
@@ -1594,6 +1826,8 @@ class FlightPlanBuilder:
|
||||
),
|
||||
land=builder.land(flight.arrival),
|
||||
divert=builder.divert(flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
lead_time=lead_time,
|
||||
)
|
||||
|
||||
def _retreating_rendezvous_point(self, attack_transition: Point) -> Point:
|
||||
@@ -1658,7 +1892,7 @@ class FlightPlanBuilder:
|
||||
# We'll always have a package, but if this is being planned via the UI
|
||||
# it could be the first flight in the package.
|
||||
if not self.package.flights:
|
||||
raise RuntimeError(
|
||||
raise PlanningError(
|
||||
"Cannot determine source airfield for package with no flights"
|
||||
)
|
||||
|
||||
@@ -1670,4 +1904,4 @@ class FlightPlanBuilder:
|
||||
for flight in self.package.flights:
|
||||
if flight.departure == airfield:
|
||||
return airfield
|
||||
raise RuntimeError("Could not find any airfield assigned to this package")
|
||||
raise PlanningError("Could not find any airfield assigned to this package")
|
||||
|
||||
136
gen/flights/loadouts.py
Normal file
136
gen/flights/loadouts.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Optional, List, Iterator, TYPE_CHECKING, Mapping
|
||||
|
||||
from game.data.weapons import Weapon, Pylon
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gen.flights.flight import Flight
|
||||
|
||||
|
||||
class Loadout:
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
pylons: Mapping[int, Optional[Weapon]],
|
||||
date: Optional[datetime.date],
|
||||
is_custom: bool = False,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.pylons = {k: v for k, v in pylons.items() if v is not None}
|
||||
self.date = date
|
||||
self.is_custom = is_custom
|
||||
|
||||
def derive_custom(self, name: str) -> Loadout:
|
||||
return Loadout(name, self.pylons, self.date, is_custom=True)
|
||||
|
||||
def degrade_for_date(self, unit_type: AircraftType, date: datetime.date) -> Loadout:
|
||||
if self.date is not None and self.date <= date:
|
||||
return Loadout(self.name, self.pylons, self.date)
|
||||
|
||||
new_pylons = dict(self.pylons)
|
||||
for pylon_number, weapon in self.pylons.items():
|
||||
if weapon is None:
|
||||
del new_pylons[pylon_number]
|
||||
continue
|
||||
if not weapon.available_on(date):
|
||||
pylon = Pylon.for_aircraft(unit_type, pylon_number)
|
||||
for fallback in weapon.fallbacks:
|
||||
if not pylon.can_equip(fallback):
|
||||
continue
|
||||
if not fallback.available_on(date):
|
||||
continue
|
||||
new_pylons[pylon_number] = fallback
|
||||
break
|
||||
else:
|
||||
del new_pylons[pylon_number]
|
||||
return Loadout(f"{self.name} ({date.year})", new_pylons, date)
|
||||
|
||||
@classmethod
|
||||
def iter_for(cls, flight: Flight) -> Iterator[Loadout]:
|
||||
# Dict of payload ID (numeric) to:
|
||||
#
|
||||
# {
|
||||
# "name": The name the user set in the ME
|
||||
# "pylons": List (as a dict) of dicts of:
|
||||
# {"CLSID": class ID, "num": pylon number}
|
||||
# "tasks": List (as a dict) of task IDs the payload is used by.
|
||||
# }
|
||||
payloads = flight.unit_type.dcs_unit_type.load_payloads()
|
||||
for payload in payloads.values():
|
||||
name = payload["name"]
|
||||
pylons = payload["pylons"]
|
||||
yield Loadout(
|
||||
name,
|
||||
{p["num"]: Weapon.from_clsid(p["CLSID"]) for p in pylons.values()},
|
||||
date=None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def all_for(cls, flight: Flight) -> List[Loadout]:
|
||||
return list(cls.iter_for(flight))
|
||||
|
||||
@classmethod
|
||||
def default_loadout_names_for(cls, flight: Flight) -> Iterator[str]:
|
||||
from gen.flights.flight import FlightType
|
||||
|
||||
# This is a list of mappings from the FlightType of a Flight to the type of
|
||||
# payload defined in the resources/payloads/UNIT_TYPE.lua file. A Flight has no
|
||||
# concept of a PyDCS task, so COMMON_OVERRIDE cannot be used here. This is used
|
||||
# in the payload editor, for setting the default loadout of an object. The left
|
||||
# element is the FlightType name, and the right element is a tuple containing
|
||||
# what is used in the lua file. Some aircraft differ from the standard loadout
|
||||
# names, so those have been included here too. The priority goes from first to
|
||||
# last - the first element in the tuple will be tried first, then the second,
|
||||
# etc.
|
||||
loadout_names = {t: [f"Liberation {t.value}"] for t in FlightType}
|
||||
legacy_names = {
|
||||
FlightType.TARCAP: ("CAP HEAVY", "CAP", "Liberation BARCAP"),
|
||||
FlightType.BARCAP: ("CAP HEAVY", "CAP", "Liberation TARCAP"),
|
||||
FlightType.CAS: ("CAS MAVERICK F", "CAS"),
|
||||
FlightType.STRIKE: ("STRIKE",),
|
||||
FlightType.ANTISHIP: ("ANTISHIP",),
|
||||
FlightType.SEAD: ("SEAD",),
|
||||
FlightType.BAI: ("BAI",),
|
||||
FlightType.OCA_RUNWAY: ("RUNWAY_ATTACK", "RUNWAY_STRIKE"),
|
||||
FlightType.OCA_AIRCRAFT: ("OCA",),
|
||||
}
|
||||
for flight_type, names in legacy_names.items():
|
||||
loadout_names[flight_type].extend(names)
|
||||
# A SEAD escort typically does not need a different loadout than a regular
|
||||
# SEAD flight, so fall back to SEAD if needed.
|
||||
loadout_names[FlightType.SEAD_ESCORT].extend(loadout_names[FlightType.SEAD])
|
||||
# Sweep and escort can fall back to TARCAP.
|
||||
loadout_names[FlightType.ESCORT].extend(loadout_names[FlightType.TARCAP])
|
||||
loadout_names[FlightType.SWEEP].extend(loadout_names[FlightType.TARCAP])
|
||||
# Intercept can fall back to BARCAP.
|
||||
loadout_names[FlightType.INTERCEPTION].extend(loadout_names[FlightType.BARCAP])
|
||||
# OCA/Aircraft falls back to BAI, which falls back to CAS.
|
||||
loadout_names[FlightType.BAI].extend(loadout_names[FlightType.CAS])
|
||||
loadout_names[FlightType.OCA_AIRCRAFT].extend(loadout_names[FlightType.BAI])
|
||||
# DEAD also falls back to BAI.
|
||||
loadout_names[FlightType.DEAD].extend(loadout_names[FlightType.BAI])
|
||||
# OCA/Runway falls back to Strike
|
||||
loadout_names[FlightType.OCA_RUNWAY].extend(loadout_names[FlightType.STRIKE])
|
||||
yield from loadout_names[flight.flight_type]
|
||||
|
||||
@classmethod
|
||||
def default_for(cls, flight: Flight) -> Loadout:
|
||||
# Iterate through each possible payload type for a given aircraft.
|
||||
# Some aircraft have custom loadouts that in aren't the standard set.
|
||||
for name in cls.default_loadout_names_for(flight):
|
||||
# This operation is cached, but must be called before load_by_name will
|
||||
# work.
|
||||
flight.unit_type.dcs_unit_type.load_payloads()
|
||||
payload = flight.unit_type.dcs_unit_type.loadout_by_name(name)
|
||||
if payload is not None:
|
||||
return Loadout(
|
||||
name,
|
||||
{i: Weapon.from_clsid(d["clsid"]) for i, d in payload},
|
||||
date=None,
|
||||
)
|
||||
|
||||
# TODO: Try group.load_task_default_loadout(loadout_for_task)
|
||||
return Loadout("Empty", {}, date=None)
|
||||
@@ -25,34 +25,31 @@ if TYPE_CHECKING:
|
||||
class GroundSpeed:
|
||||
@classmethod
|
||||
def for_flight(cls, flight: Flight, altitude: Distance) -> Speed:
|
||||
if not issubclass(flight.unit_type, FlyingType):
|
||||
raise TypeError("Flight has non-flying unit")
|
||||
|
||||
# TODO: Expose both a cruise speed and target speed.
|
||||
# The cruise speed can be used for ascent, hold, join, and RTB to save
|
||||
# on fuel, but mission speed will be fast enough to keep the flight
|
||||
# safer.
|
||||
|
||||
# DCS's max speed is in kph at 0 MSL.
|
||||
max_speed = kph(flight.unit_type.max_speed)
|
||||
max_speed = flight.unit_type.max_speed
|
||||
if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL:
|
||||
# Aircraft is supersonic. Limit to mach 0.8 to conserve fuel and
|
||||
# Aircraft is supersonic. Limit to mach 0.85 to conserve fuel and
|
||||
# account for heavily loaded jets.
|
||||
return mach(0.8, altitude)
|
||||
return mach(0.85, altitude)
|
||||
|
||||
# For subsonic aircraft, assume the aircraft can reasonably perform at
|
||||
# 80% of its maximum, and that it can maintain the same mach at altitude
|
||||
# as it can at sea level. This probably isn't great assumption, but
|
||||
# might. be sufficient given the wiggle room. We can come up with
|
||||
# another heuristic if needed.
|
||||
cruise_mach = max_speed.mach() * 0.8
|
||||
cruise_mach = max_speed.mach() * 0.85
|
||||
return mach(cruise_mach, altitude)
|
||||
|
||||
|
||||
class TravelTime:
|
||||
@staticmethod
|
||||
def between_points(a: Point, b: Point, speed: Speed) -> timedelta:
|
||||
error_factor = 1.1
|
||||
error_factor = 1.05
|
||||
distance = meters(a.distance_to_point(b))
|
||||
return timedelta(hours=distance.nautical_miles / speed.knots * error_factor)
|
||||
|
||||
@@ -72,6 +69,9 @@ class TotEstimator:
|
||||
return startup_time
|
||||
|
||||
def earliest_tot(self) -> timedelta:
|
||||
if not self.package.flights:
|
||||
return timedelta(0)
|
||||
|
||||
earliest_tot = max(
|
||||
(self.earliest_tot_for_flight(f) for f in self.package.flights)
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from dcs.unitgroup import Group, VehicleGroup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
from game.transfers import MultiGroupTransport
|
||||
|
||||
from game.theater import (
|
||||
ControlPoint,
|
||||
@@ -32,7 +33,7 @@ from .flight import Flight, FlightWaypoint, FlightWaypointType
|
||||
@dataclass(frozen=True)
|
||||
class StrikeTarget:
|
||||
name: str
|
||||
target: Union[VehicleGroup, TheaterGroundObject, Unit, Group]
|
||||
target: Union[VehicleGroup, TheaterGroundObject, Unit, Group, MultiGroupTransport]
|
||||
|
||||
|
||||
class WaypointBuilder:
|
||||
@@ -49,6 +50,7 @@ class WaypointBuilder:
|
||||
self.threat_zones = game.threat_zone_for(not player)
|
||||
self.navmesh = game.navmesh_for(player)
|
||||
self.targets = targets
|
||||
self._bullseye = game.bullseye_for(player)
|
||||
|
||||
@property
|
||||
def is_helo(self) -> bool:
|
||||
@@ -144,6 +146,19 @@ class WaypointBuilder:
|
||||
waypoint.only_for_player = True
|
||||
return waypoint
|
||||
|
||||
def bullseye(self) -> FlightWaypoint:
|
||||
waypoint = FlightWaypoint(
|
||||
FlightWaypointType.BULLSEYE,
|
||||
self._bullseye.position.x,
|
||||
self._bullseye.position.y,
|
||||
meters(0),
|
||||
)
|
||||
waypoint.pretty_name = "Bullseye"
|
||||
waypoint.description = "Bullseye"
|
||||
waypoint.name = "BULLSEYE"
|
||||
waypoint.only_for_player = True
|
||||
return waypoint
|
||||
|
||||
def hold(self, position: Point) -> FlightWaypoint:
|
||||
waypoint = FlightWaypoint(
|
||||
FlightWaypointType.LOITER,
|
||||
@@ -201,8 +216,7 @@ class WaypointBuilder:
|
||||
waypoint.pretty_name = "INGRESS on " + objective.name
|
||||
waypoint.description = "INGRESS on " + objective.name
|
||||
waypoint.name = "INGRESS"
|
||||
# TODO: This seems wrong, but it's what was there before.
|
||||
waypoint.targets.append(objective)
|
||||
waypoint.targets = objective.strike_targets
|
||||
return waypoint
|
||||
|
||||
def egress(self, position: Point, target: MissionTarget) -> FlightWaypoint:
|
||||
@@ -406,10 +420,13 @@ class WaypointBuilder:
|
||||
end: The end of the sweep.
|
||||
altitude: The sweep altitude.
|
||||
"""
|
||||
return (self.sweep_start(start, altitude), self.sweep_end(end, altitude))
|
||||
return self.sweep_start(start, altitude), self.sweep_end(end, altitude)
|
||||
|
||||
def escort(
|
||||
self, ingress: Point, target: MissionTarget, egress: Point
|
||||
self,
|
||||
ingress: Point,
|
||||
target: MissionTarget,
|
||||
egress: Point,
|
||||
) -> Tuple[FlightWaypoint, FlightWaypoint, FlightWaypoint]:
|
||||
"""Creates the waypoints needed to escort the package.
|
||||
|
||||
@@ -442,24 +459,69 @@ class WaypointBuilder:
|
||||
return ingress, waypoint, egress
|
||||
|
||||
@staticmethod
|
||||
def nav(position: Point, altitude: Distance) -> FlightWaypoint:
|
||||
def pickup(control_point: ControlPoint) -> FlightWaypoint:
|
||||
"""Creates a cargo pickup waypoint.
|
||||
|
||||
Args:
|
||||
control_point: Pick up location.
|
||||
"""
|
||||
waypoint = FlightWaypoint(
|
||||
FlightWaypointType.PICKUP,
|
||||
control_point.position.x,
|
||||
control_point.position.y,
|
||||
meters(0),
|
||||
)
|
||||
waypoint.alt_type = "RADIO"
|
||||
waypoint.name = "PICKUP"
|
||||
waypoint.description = f"Pick up cargo from {control_point}"
|
||||
waypoint.pretty_name = "Pick up location"
|
||||
return waypoint
|
||||
|
||||
@staticmethod
|
||||
def drop_off(control_point: ControlPoint) -> FlightWaypoint:
|
||||
"""Creates a cargo drop-off waypoint.
|
||||
|
||||
Args:
|
||||
control_point: Drop-off location.
|
||||
"""
|
||||
waypoint = FlightWaypoint(
|
||||
FlightWaypointType.PICKUP,
|
||||
control_point.position.x,
|
||||
control_point.position.y,
|
||||
meters(0),
|
||||
)
|
||||
waypoint.alt_type = "RADIO"
|
||||
waypoint.name = "DROP OFF"
|
||||
waypoint.description = f"Drop off cargo at {control_point}"
|
||||
waypoint.pretty_name = "Drop off location"
|
||||
return waypoint
|
||||
|
||||
@staticmethod
|
||||
def nav(
|
||||
position: Point, altitude: Distance, altitude_is_agl: bool = False
|
||||
) -> FlightWaypoint:
|
||||
"""Creates a navigation point.
|
||||
|
||||
Args:
|
||||
position: Position of the waypoint.
|
||||
altitude: Altitude of the waypoint.
|
||||
altitude_is_agl: True for altitude is AGL. False if altitude is MSL.
|
||||
"""
|
||||
waypoint = FlightWaypoint(
|
||||
FlightWaypointType.NAV, position.x, position.y, altitude
|
||||
)
|
||||
if altitude_is_agl:
|
||||
waypoint.alt_type = "RADIO"
|
||||
waypoint.name = "NAV"
|
||||
waypoint.description = "NAV"
|
||||
waypoint.pretty_name = "Nav"
|
||||
return waypoint
|
||||
|
||||
def nav_path(self, a: Point, b: Point, altitude: Distance) -> List[FlightWaypoint]:
|
||||
def nav_path(
|
||||
self, a: Point, b: Point, altitude: Distance, altitude_is_agl: bool = False
|
||||
) -> List[FlightWaypoint]:
|
||||
path = self.clean_nav_points(self.navmesh.shortest_path(a, b))
|
||||
return [self.nav(self.perturb(p), altitude) for p in path]
|
||||
return [self.nav(self.perturb(p), altitude, altitude_is_agl) for p in path]
|
||||
|
||||
def clean_nav_points(self, points: Iterable[Point]) -> Iterator[Point]:
|
||||
# Examine a sliding window of three waypoints. `current` is the waypoint
|
||||
|
||||
@@ -32,6 +32,8 @@ class ForcedOptionsGenerator:
|
||||
self.mission.forced_options.labels = ForcedOptions.Labels.Abbreviate
|
||||
elif self.game.settings.labels == "Dot Only":
|
||||
self.mission.forced_options.labels = ForcedOptions.Labels.DotOnly
|
||||
elif self.game.settings.labels == "Neutral Dot":
|
||||
self.mission.forced_options.labels = ForcedOptions.Labels.NeutralDot
|
||||
elif self.game.settings.labels == "Off":
|
||||
self.mission.forced_options.labels = ForcedOptions.Labels.None_
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
import random
|
||||
from enum import Enum
|
||||
from typing import Dict, List
|
||||
|
||||
from dcs.unittype import VehicleType
|
||||
|
||||
from game.data.groundunitclass import GroundUnitClass
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.theater import ControlPoint
|
||||
from gen.ground_forces.ai_ground_planner_db import *
|
||||
from gen.ground_forces.combat_stance import CombatStance
|
||||
|
||||
MAX_COMBAT_GROUP_PER_CP = 10
|
||||
@@ -20,17 +20,19 @@ class CombatGroupRole(Enum):
|
||||
LOGI = 6
|
||||
INFANTRY = 7
|
||||
ATGM = 8
|
||||
RECON = 9
|
||||
|
||||
|
||||
DISTANCE_FROM_FRONTLINE = {
|
||||
CombatGroupRole.TANK: (2200, 3200),
|
||||
CombatGroupRole.APC: (7500, 8500),
|
||||
CombatGroupRole.APC: (2700, 3700),
|
||||
CombatGroupRole.IFV: (2700, 3700),
|
||||
CombatGroupRole.ARTILLERY: (16000, 18000),
|
||||
CombatGroupRole.SHORAD: (12000, 13000),
|
||||
CombatGroupRole.SHORAD: (5000, 8000),
|
||||
CombatGroupRole.LOGI: (18000, 20000),
|
||||
CombatGroupRole.INFANTRY: (2800, 3300),
|
||||
CombatGroupRole.ATGM: (5200, 6200),
|
||||
CombatGroupRole.RECON: (2000, 3000),
|
||||
}
|
||||
|
||||
GROUP_SIZES_BY_COMBAT_STANCE = {
|
||||
@@ -44,17 +46,19 @@ GROUP_SIZES_BY_COMBAT_STANCE = {
|
||||
|
||||
|
||||
class CombatGroup:
|
||||
def __init__(self, role: CombatGroupRole):
|
||||
self.units: List[VehicleType] = []
|
||||
def __init__(
|
||||
self, role: CombatGroupRole, unit_type: GroundUnitType, size: int
|
||||
) -> None:
|
||||
self.unit_type = unit_type
|
||||
self.size = size
|
||||
self.role = role
|
||||
self.assigned_enemy_cp = None
|
||||
self.start_position = None
|
||||
|
||||
def __str__(self):
|
||||
s = ""
|
||||
s += "ROLE : " + str(self.role) + "\n"
|
||||
if len(self.units) > 0:
|
||||
s += "UNITS " + self.units[0].name + " * " + str(len(self.units))
|
||||
s = f"ROLE : {self.role}\n"
|
||||
if self.size:
|
||||
s += f"UNITS {self.unit_type} * {self.size}"
|
||||
return s
|
||||
|
||||
|
||||
@@ -72,6 +76,7 @@ class GroundPlanner:
|
||||
self.atgm_group: List[CombatGroup] = []
|
||||
self.logi_groups: List[CombatGroup] = []
|
||||
self.shorad_groups: List[CombatGroup] = []
|
||||
self.recon_groups: List[CombatGroup] = []
|
||||
|
||||
self.units_per_cp: Dict[int, List[CombatGroup]] = {}
|
||||
for cp in self.connected_enemy_cp:
|
||||
@@ -80,6 +85,10 @@ class GroundPlanner:
|
||||
|
||||
def plan_groundwar(self):
|
||||
|
||||
ground_unit_limit = self.cp.frontline_unit_count_limit
|
||||
|
||||
remaining_available_frontline_units = ground_unit_limit
|
||||
|
||||
if hasattr(self.cp, "stance"):
|
||||
group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[self.cp.stance]
|
||||
else:
|
||||
@@ -87,51 +96,59 @@ class GroundPlanner:
|
||||
group_size_choice = GROUP_SIZES_BY_COMBAT_STANCE[CombatStance.DEFENSIVE]
|
||||
|
||||
# Create combat groups and assign them randomly to each enemy CP
|
||||
for key in self.cp.base.armor.keys():
|
||||
|
||||
role = None
|
||||
collection = None
|
||||
if key in TYPE_TANKS:
|
||||
for unit_type in self.cp.base.armor:
|
||||
unit_class = unit_type.unit_class
|
||||
if unit_class is GroundUnitClass.Tank:
|
||||
collection = self.tank_groups
|
||||
role = CombatGroupRole.TANK
|
||||
elif key in TYPE_APC:
|
||||
elif unit_class is GroundUnitClass.Apc:
|
||||
collection = self.apc_group
|
||||
role = CombatGroupRole.APC
|
||||
elif key in TYPE_ARTILLERY:
|
||||
elif unit_class is GroundUnitClass.Artillery:
|
||||
collection = self.art_group
|
||||
role = CombatGroupRole.ARTILLERY
|
||||
elif key in TYPE_IFV:
|
||||
elif unit_class is GroundUnitClass.Ifv:
|
||||
collection = self.ifv_group
|
||||
role = CombatGroupRole.IFV
|
||||
elif key in TYPE_LOGI:
|
||||
elif unit_class is GroundUnitClass.Logistics:
|
||||
collection = self.logi_groups
|
||||
role = CombatGroupRole.LOGI
|
||||
elif key in TYPE_ATGM:
|
||||
elif unit_class is GroundUnitClass.Atgm:
|
||||
collection = self.atgm_group
|
||||
role = CombatGroupRole.ATGM
|
||||
elif key in TYPE_SHORAD:
|
||||
elif unit_class is GroundUnitClass.Shorads:
|
||||
collection = self.shorad_groups
|
||||
role = CombatGroupRole.SHORAD
|
||||
elif unit_class is GroundUnitClass.Recon:
|
||||
collection = self.recon_groups
|
||||
role = CombatGroupRole.RECON
|
||||
else:
|
||||
print("Warning unit type not handled by ground generator")
|
||||
print(key)
|
||||
logging.warning(
|
||||
f"Unused front line vehicle at base {unit_type}: unknown unit class"
|
||||
)
|
||||
continue
|
||||
|
||||
available = self.cp.base.armor[key]
|
||||
available = self.cp.base.armor[unit_type]
|
||||
|
||||
if available > remaining_available_frontline_units:
|
||||
available = remaining_available_frontline_units
|
||||
|
||||
remaining_available_frontline_units -= available
|
||||
|
||||
while available > 0:
|
||||
|
||||
if role == CombatGroupRole.SHORAD:
|
||||
n = 1
|
||||
count = 1
|
||||
else:
|
||||
n = random.choice(group_size_choice)
|
||||
if n > available:
|
||||
count = random.choice(group_size_choice)
|
||||
if count > available:
|
||||
if available >= 2:
|
||||
n = 2
|
||||
count = 2
|
||||
else:
|
||||
n = 1
|
||||
available -= n
|
||||
count = 1
|
||||
available -= count
|
||||
|
||||
group = CombatGroup(role)
|
||||
group = CombatGroup(role, unit_type, count)
|
||||
if len(self.connected_enemy_cp) > 0:
|
||||
enemy_cp = random.choice(self.connected_enemy_cp).id
|
||||
self.units_per_cp[enemy_cp].append(group)
|
||||
@@ -139,16 +156,16 @@ class GroundPlanner:
|
||||
else:
|
||||
self.reserve.append(group)
|
||||
group.assigned_enemy_cp = "__reserve__"
|
||||
|
||||
for i in range(n):
|
||||
group.units.append(key)
|
||||
collection.append(group)
|
||||
|
||||
if remaining_available_frontline_units == 0:
|
||||
break
|
||||
|
||||
print("------------------")
|
||||
print("Ground Planner : ")
|
||||
print(self.cp.name)
|
||||
print("------------------")
|
||||
for key in self.units_per_cp.keys():
|
||||
print("For : #" + str(key))
|
||||
for group in self.units_per_cp[key]:
|
||||
for unit_type in self.units_per_cp.keys():
|
||||
print("For : #" + str(unit_type))
|
||||
for group in self.units_per_cp[unit_type]:
|
||||
print(str(group))
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
from dcs.vehicles import AirDefence, Infantry, Unarmed, Artillery, Armor
|
||||
|
||||
from pydcs_extensions.frenchpack import frenchpack
|
||||
|
||||
TYPE_TANKS = [
|
||||
Armor.MBT_T_55,
|
||||
Armor.MBT_T_72B,
|
||||
Armor.MBT_T_72B3,
|
||||
Armor.MBT_T_80U,
|
||||
Armor.MBT_T_90,
|
||||
Armor.MBT_Leopard_2,
|
||||
Armor.MBT_Leopard_1A3,
|
||||
Armor.MBT_Leclerc,
|
||||
Armor.MBT_Challenger_II,
|
||||
Armor.MBT_M1A2_Abrams,
|
||||
Armor.MBT_M60A3_Patton,
|
||||
Armor.MBT_Merkava_IV,
|
||||
Armor.ZTZ_96B,
|
||||
# WW2
|
||||
Armor.MT_Pz_Kpfw_V_Panther_Ausf_G,
|
||||
Armor.MT_PzIV_H,
|
||||
Armor.HT_Pz_Kpfw_VI_Tiger_I,
|
||||
Armor.HT_Pz_Kpfw_VI_Ausf__B_Tiger_II,
|
||||
Armor.MT_M4_Sherman,
|
||||
Armor.MT_M4A4_Sherman_Firefly,
|
||||
Armor.SPG_StuG_IV,
|
||||
Armor.CT_Centaur_IV,
|
||||
Armor.CT_Cromwell_IV,
|
||||
Armor.HIT_Churchill_VII,
|
||||
Armor.LT_Mk_VII_Tetrarch,
|
||||
# Mods
|
||||
frenchpack.DIM__TOYOTA_BLUE,
|
||||
frenchpack.DIM__TOYOTA_GREEN,
|
||||
frenchpack.DIM__TOYOTA_DESERT,
|
||||
frenchpack.DIM__KAMIKAZE,
|
||||
frenchpack.AMX_10RCR,
|
||||
frenchpack.AMX_10RCR_SEPAR,
|
||||
frenchpack.AMX_30B2,
|
||||
frenchpack.Leclerc_Serie_XXI,
|
||||
]
|
||||
|
||||
TYPE_ATGM = [
|
||||
Armor.ATGM_HMMWV,
|
||||
Armor.ATGM_Stryker,
|
||||
Armor.IFV_BMP_2,
|
||||
# WW2 (Tank Destroyers)
|
||||
Unarmed.Carrier_M30_Cargo,
|
||||
Armor.SPG_Jagdpanzer_IV,
|
||||
Armor.SPG_Jagdpanther_G1,
|
||||
Armor.SPG_M10_GMC,
|
||||
# Mods
|
||||
frenchpack.VBAE_CRAB_MMP,
|
||||
frenchpack.VAB_MEPHISTO,
|
||||
frenchpack.TRM_2000_PAMELA,
|
||||
]
|
||||
|
||||
TYPE_IFV = [
|
||||
Armor.IFV_BMP_3,
|
||||
Armor.IFV_BMP_2,
|
||||
Armor.IFV_BMP_1,
|
||||
Armor.IFV_Marder,
|
||||
Armor.IFV_Warrior,
|
||||
Armor.IFV_LAV_25,
|
||||
Armor.SPG_Stryker_MGS,
|
||||
Armor.IFV_Sd_Kfz_234_2_Puma,
|
||||
Armor.IFV_M2A2_Bradley,
|
||||
Armor.IFV_BMD_1,
|
||||
Armor.ZBD_04A,
|
||||
# WW2
|
||||
Armor.IFV_Sd_Kfz_234_2_Puma,
|
||||
Armor.Car_M8_Greyhound_Armored,
|
||||
Armor.Car_Daimler_Armored,
|
||||
# Mods
|
||||
frenchpack.ERC_90,
|
||||
frenchpack.VBAE_CRAB,
|
||||
frenchpack.VAB_T20_13,
|
||||
]
|
||||
|
||||
TYPE_APC = [
|
||||
Armor.APC_HMMWV__Scout,
|
||||
Armor.IFV_M1126_Stryker_ICV,
|
||||
Armor.APC_M113,
|
||||
Armor.APC_BTR_80,
|
||||
Armor.APC_BTR_82A,
|
||||
Armor.APC_MTLB,
|
||||
Armor.APC_M2A1_Halftrack,
|
||||
Armor.APC_Cobra__Scout,
|
||||
Armor.APC_Sd_Kfz_251_Halftrack,
|
||||
Armor.APC_AAV_7_Amphibious,
|
||||
Armor.APC_TPz_Fuchs,
|
||||
Armor.IFV_BRDM_2,
|
||||
Armor.APC_BTR_RD,
|
||||
Artillery.Grad_MRL_FDDM__FC,
|
||||
# WW2
|
||||
Armor.APC_M2A1_Halftrack,
|
||||
Armor.APC_Sd_Kfz_251_Halftrack,
|
||||
# Mods
|
||||
frenchpack.VAB__50,
|
||||
frenchpack.VBL__50,
|
||||
frenchpack.VBL_AANF1,
|
||||
]
|
||||
|
||||
TYPE_ARTILLERY = [
|
||||
Artillery.MLRS_9A52_Smerch_HE_300mm,
|
||||
Artillery.SPH_2S1_Gvozdika_122mm,
|
||||
Artillery.SPH_2S3_Akatsia_152mm,
|
||||
Artillery.MLRS_BM_21_Grad_122mm,
|
||||
Artillery.MLRS_BM_27_Uragan_220mm,
|
||||
Artillery.SPH_M109_Paladin_155mm,
|
||||
Artillery.MLRS_M270_227mm,
|
||||
Artillery.SPH_2S9_Nona_120mm_M,
|
||||
Artillery.SPH_Dana_vz77_152mm,
|
||||
Artillery.PLZ_05,
|
||||
Artillery.SPH_2S19_Msta_152mm,
|
||||
Artillery.MLRS_9A52_Smerch_CM_300mm,
|
||||
# WW2
|
||||
Artillery.SPG_Sturmpanzer_IV_Brummbar,
|
||||
Artillery.SPG_M12_GMC_155mm,
|
||||
]
|
||||
|
||||
TYPE_LOGI = [
|
||||
Unarmed.Truck_M818_6x6,
|
||||
Unarmed.Truck_KAMAZ_43101,
|
||||
Unarmed.Truck_Ural_375,
|
||||
Unarmed.Truck_GAZ_66,
|
||||
Unarmed.Truck_GAZ_3307,
|
||||
Unarmed.Truck_GAZ_3308,
|
||||
Unarmed.Truck_Ural_4320_31_Arm_d,
|
||||
Unarmed.Truck_Ural_4320T,
|
||||
Unarmed.Truck_Opel_Blitz,
|
||||
Unarmed.LUV_Kubelwagen_82,
|
||||
Unarmed.Carrier_Sd_Kfz_7_Tractor,
|
||||
Unarmed.LUV_Kettenrad,
|
||||
Unarmed.Car_Willys_Jeep,
|
||||
Unarmed.LUV_Land_Rover_109,
|
||||
Unarmed.Truck_Land_Rover_101_FC,
|
||||
# Mods
|
||||
frenchpack.VBL,
|
||||
frenchpack.VAB,
|
||||
]
|
||||
|
||||
TYPE_INFANTRY = [
|
||||
Infantry.Insurgent_AK_74,
|
||||
Infantry.Infantry_AK_74,
|
||||
Infantry.Infantry_M1_Garand,
|
||||
Infantry.Infantry_Mauser_98,
|
||||
Infantry.Infantry_SMLE_No_4_Mk_1,
|
||||
Infantry.Infantry_M4_Georgia,
|
||||
Infantry.Infantry_AK_74_Rus,
|
||||
Infantry.Paratrooper_AKS,
|
||||
Infantry.Paratrooper_RPG_16,
|
||||
Infantry.Infantry_M249,
|
||||
Infantry.Infantry_M4,
|
||||
Infantry.Infantry_RPG,
|
||||
]
|
||||
|
||||
TYPE_SHORAD = [
|
||||
AirDefence.SPAAA_ZU_23_2_Mounted_Ural_375,
|
||||
AirDefence.SPAAA_ZU_23_2_Insurgent_Mounted_Ural_375,
|
||||
AirDefence.SPAAA_ZSU_57_2,
|
||||
AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish,
|
||||
AirDefence.SAM_SA_8_Osa_Gecko_TEL,
|
||||
AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL,
|
||||
AirDefence.SAM_SA_13_Strela_10M3_Gopher_TEL,
|
||||
AirDefence.SAM_SA_15_Tor_Gauntlet,
|
||||
AirDefence.SAM_SA_19_Tunguska_Grison,
|
||||
AirDefence.SPAAA_Gepard,
|
||||
AirDefence.SPAAA_Vulcan_M163,
|
||||
AirDefence.SAM_Linebacker___Bradley_M6,
|
||||
AirDefence.SAM_Chaparral_M48,
|
||||
AirDefence.SAM_Avenger__Stinger,
|
||||
AirDefence.SAM_Roland_ADS,
|
||||
AirDefence.HQ_7_Self_Propelled_LN,
|
||||
AirDefence.AAA_8_8cm_Flak_18,
|
||||
AirDefence.AAA_8_8cm_Flak_36,
|
||||
AirDefence.AAA_8_8cm_Flak_37,
|
||||
AirDefence.AAA_8_8cm_Flak_41,
|
||||
AirDefence.AAA_40mm_Bofors,
|
||||
AirDefence.AAA_S_60_57mm,
|
||||
AirDefence.AAA_M1_37mm,
|
||||
AirDefence.AAA_QF_3_7,
|
||||
]
|
||||
@@ -12,9 +12,10 @@ import random
|
||||
from typing import Dict, Iterator, Optional, TYPE_CHECKING, Type, List
|
||||
|
||||
from dcs import Mission, Point, unitgroup
|
||||
from dcs.action import SceneryDestructionZone
|
||||
from dcs.country import Country
|
||||
from dcs.point import StaticPoint
|
||||
from dcs.statics import fortification_map, warehouse_map, Warehouse
|
||||
from dcs.statics import Fortification, fortification_map, warehouse_map
|
||||
from dcs.task import (
|
||||
ActivateBeaconCommand,
|
||||
ActivateICLSCommand,
|
||||
@@ -22,7 +23,8 @@ from dcs.task import (
|
||||
OptAlarmState,
|
||||
FireAtPoint,
|
||||
)
|
||||
from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad, Static
|
||||
from dcs.triggers import TriggerStart, TriggerZone
|
||||
from dcs.unit import Ship, Unit, Vehicle, SingleHeliPad
|
||||
from dcs.unitgroup import Group, ShipGroup, StaticGroup, VehicleGroup
|
||||
from dcs.unittype import StaticType, UnitType
|
||||
from dcs.vehicles import vehicle_map
|
||||
@@ -34,13 +36,15 @@ from game.theater import ControlPoint, TheaterGroundObject
|
||||
from game.theater.theatergroundobject import (
|
||||
BuildingGroundObject,
|
||||
CarrierGroundObject,
|
||||
FactoryGroundObject,
|
||||
GenericCarrierGroundObject,
|
||||
LhaGroundObject,
|
||||
ShipGroundObject,
|
||||
MissileSiteGroundObject,
|
||||
SceneryGroundObject,
|
||||
)
|
||||
from game.unitmap import UnitMap
|
||||
from game.utils import knots, mps
|
||||
from game.utils import feet, knots, mps
|
||||
from .radios import RadioFrequency, RadioRegistry
|
||||
from .runways import RunwayData
|
||||
from .tacan import TacanBand, TacanChannel, TacanRegistry
|
||||
@@ -72,8 +76,12 @@ class GenericGroundObjectGenerator:
|
||||
self.m = mission
|
||||
self.unit_map = unit_map
|
||||
|
||||
@property
|
||||
def culled(self) -> bool:
|
||||
return self.game.position_culled(self.ground_object.position)
|
||||
|
||||
def generate(self) -> None:
|
||||
if self.game.position_culled(self.ground_object.position):
|
||||
if self.culled:
|
||||
return
|
||||
|
||||
for group in self.ground_object.groups:
|
||||
@@ -92,13 +100,11 @@ class GenericGroundObjectGenerator:
|
||||
position=group.position,
|
||||
heading=group.units[0].heading,
|
||||
)
|
||||
vg.units[0].name = self.m.string(group.units[0].name)
|
||||
vg.units[0].name = group.units[0].name
|
||||
vg.units[0].player_can_drive = True
|
||||
for i, u in enumerate(group.units):
|
||||
if i > 0:
|
||||
vehicle = Vehicle(
|
||||
self.m.next_unit_id(), self.m.string(u.name), u.type
|
||||
)
|
||||
vehicle = Vehicle(self.m.next_unit_id(), u.name, u.type)
|
||||
vehicle.position.x = u.position.x
|
||||
vehicle.position.y = u.position.y
|
||||
vehicle.heading = u.heading
|
||||
@@ -128,6 +134,12 @@ class GenericGroundObjectGenerator:
|
||||
|
||||
|
||||
class MissileSiteGenerator(GenericGroundObjectGenerator):
|
||||
@property
|
||||
def culled(self) -> bool:
|
||||
# Don't cull missile sites - their range is long enough to make them easily
|
||||
# culled despite being a threat.
|
||||
return False
|
||||
|
||||
def generate(self) -> None:
|
||||
super(MissileSiteGenerator, self).generate()
|
||||
# Note : Only the SCUD missiles group can fire (V1 site cannot fire in game right now)
|
||||
@@ -213,7 +225,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator):
|
||||
f"{self.ground_object.dcs_identifier} not found in static maps"
|
||||
)
|
||||
|
||||
def generate_vehicle_group(self, unit_type: UnitType) -> None:
|
||||
def generate_vehicle_group(self, unit_type: Type[UnitType]) -> None:
|
||||
if not self.ground_object.is_dead:
|
||||
group = self.m.vehicle_group(
|
||||
country=self.country,
|
||||
@@ -224,7 +236,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator):
|
||||
)
|
||||
self._register_fortification(group)
|
||||
|
||||
def generate_static(self, static_type: StaticType) -> None:
|
||||
def generate_static(self, static_type: Type[StaticType]) -> None:
|
||||
group = self.m.static_group(
|
||||
country=self.country,
|
||||
name=self.ground_object.group_name,
|
||||
@@ -244,6 +256,74 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator):
|
||||
self.unit_map.add_building(self.ground_object, building)
|
||||
|
||||
|
||||
class FactoryGenerator(BuildingSiteGenerator):
|
||||
"""Generator for factory sites.
|
||||
|
||||
Factory sites are the buildings that allow the recruitment of ground units.
|
||||
Destroying these prevents the owner from recruiting ground units at the connected
|
||||
control point.
|
||||
"""
|
||||
|
||||
def generate(self) -> None:
|
||||
if self.game.position_culled(self.ground_object.position):
|
||||
return
|
||||
|
||||
# TODO: Faction specific?
|
||||
self.generate_static(Fortification.Workshop_A)
|
||||
|
||||
|
||||
class SceneryGenerator(BuildingSiteGenerator):
|
||||
def generate(self) -> None:
|
||||
assert isinstance(self.ground_object, SceneryGroundObject)
|
||||
|
||||
trigger_zone = self.generate_trigger_zone(self.ground_object)
|
||||
|
||||
# DCS only visually shows a scenery object is dead when
|
||||
# this trigger rule is applied. Otherwise you can kill a
|
||||
# structure twice.
|
||||
if self.ground_object.is_dead:
|
||||
self.generate_dead_trigger_rule(trigger_zone)
|
||||
|
||||
# Tell Liberation to manage this groundobjectsgen as part of the campaign.
|
||||
self.register_scenery()
|
||||
|
||||
def generate_trigger_zone(self, scenery: SceneryGroundObject) -> TriggerZone:
|
||||
|
||||
zone = scenery.zone
|
||||
|
||||
# Align the trigger zones to the faction color on the DCS briefing/F10 map.
|
||||
if scenery.is_friendly(to_player=True):
|
||||
color = {1: 0.2, 2: 0.7, 3: 1, 4: 0.15}
|
||||
else:
|
||||
color = {1: 1, 2: 0.2, 3: 0.2, 4: 0.15}
|
||||
|
||||
# Create the smallest valid size trigger zone (16 feet) so that risk of overlap is minimized.
|
||||
# As long as the triggerzone is over the scenery object, we're ok.
|
||||
smallest_valid_radius = feet(16).meters
|
||||
|
||||
return self.m.triggers.add_triggerzone(
|
||||
zone.position,
|
||||
smallest_valid_radius,
|
||||
zone.hidden,
|
||||
zone.name,
|
||||
color,
|
||||
zone.properties,
|
||||
)
|
||||
|
||||
def generate_dead_trigger_rule(self, trigger_zone: TriggerZone) -> None:
|
||||
# Add destruction zone trigger
|
||||
t = TriggerStart(comment="Destruction")
|
||||
t.actions.append(
|
||||
SceneryDestructionZone(destruction_level=100, zone=trigger_zone.id)
|
||||
)
|
||||
self.m.triggerrules.triggers.append(t)
|
||||
|
||||
def register_scenery(self) -> None:
|
||||
scenery = self.ground_object
|
||||
assert isinstance(scenery, SceneryGroundObject)
|
||||
self.unit_map.add_scenery(scenery)
|
||||
|
||||
|
||||
class GenericCarrierGenerator(GenericGroundObjectGenerator):
|
||||
"""Base type for carrier group generation.
|
||||
|
||||
@@ -313,13 +393,13 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator):
|
||||
heading=group.units[0].heading,
|
||||
)
|
||||
ship_group.set_frequency(atc_channel.hertz)
|
||||
ship_group.units[0].name = self.m.string(group.units[0].name)
|
||||
ship_group.units[0].name = group.units[0].name
|
||||
return ship_group
|
||||
|
||||
def create_ship(self, unit: Unit, atc_channel: RadioFrequency) -> Ship:
|
||||
ship = Ship(
|
||||
self.m.next_unit_id(),
|
||||
self.m.string(unit.name),
|
||||
unit.name,
|
||||
unit_type_from_name(unit.type),
|
||||
)
|
||||
ship.position.x = unit.position.x
|
||||
@@ -464,11 +544,11 @@ class ShipObjectGenerator(GenericGroundObjectGenerator):
|
||||
position=group_def.position,
|
||||
heading=group_def.units[0].heading,
|
||||
)
|
||||
group.units[0].name = self.m.string(group_def.units[0].name)
|
||||
group.units[0].name = group_def.units[0].name
|
||||
# TODO: Skipping the first unit looks like copy pasta from the carrier.
|
||||
for unit in group_def.units[1:]:
|
||||
unit_type = unit_type_from_name(unit.type)
|
||||
ship = Ship(self.m.next_unit_id(), self.m.string(unit.name), unit_type)
|
||||
ship = Ship(self.m.next_unit_id(), unit.name, unit_type)
|
||||
ship.position.x = unit.position.x
|
||||
ship.position.y = unit.position.y
|
||||
ship.heading = unit.heading
|
||||
@@ -507,11 +587,11 @@ class HelipadGenerator:
|
||||
for i, helipad in enumerate(self.cp.helipads):
|
||||
name = self.cp.name + "_helipad_" + str(i)
|
||||
logging.info("Generating helipad : " + name)
|
||||
pad = SingleHeliPad(name=self.m.string(name + "_unit"))
|
||||
pad = SingleHeliPad(name=(name + "_unit"))
|
||||
pad.position = Point(helipad.x, helipad.y)
|
||||
pad.heading = helipad.heading
|
||||
# pad.heliport_frequency = self.radio_registry.alloc_uhf() TODO : alloc radio & callsign
|
||||
sg = unitgroup.StaticGroup(self.m.next_group_id(), self.m.string(name))
|
||||
sg = unitgroup.StaticGroup(self.m.next_group_id(), name)
|
||||
sg.add_unit(pad)
|
||||
sp = StaticPoint()
|
||||
sp.position = pad.position
|
||||
@@ -557,7 +637,15 @@ class GroundObjectsGenerator:
|
||||
).generate()
|
||||
|
||||
for ground_object in cp.ground_objects:
|
||||
if isinstance(ground_object, BuildingGroundObject):
|
||||
if isinstance(ground_object, FactoryGroundObject):
|
||||
generator = FactoryGenerator(
|
||||
ground_object, country, self.game, self.m, self.unit_map
|
||||
)
|
||||
elif isinstance(ground_object, SceneryGroundObject):
|
||||
generator = SceneryGenerator(
|
||||
ground_object, country, self.game, self.m, self.unit_map
|
||||
)
|
||||
elif isinstance(ground_object, BuildingGroundObject):
|
||||
generator = BuildingSiteGenerator(
|
||||
ground_object, country, self.game, self.m, self.unit_map
|
||||
)
|
||||
|
||||
280
gen/kneeboard.py
280
gen/kneeboard.py
@@ -23,25 +23,30 @@ only be added per airframe, so PvP missions where each side have the same
|
||||
aircraft will be able to see the enemy's kneeboard for the same airframe.
|
||||
"""
|
||||
import datetime
|
||||
import textwrap
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, TYPE_CHECKING, Tuple
|
||||
from typing import Dict, List, Optional, TYPE_CHECKING, Tuple, Iterator
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from dcs.mission import Mission
|
||||
from dcs.unittype import FlyingType
|
||||
from dcs.unit import Unit
|
||||
from tabulate import tabulate
|
||||
|
||||
from game.data.alic import AlicCodes
|
||||
from game.db import unit_type_from_name
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.theater import ConflictTheater, TheaterGroundObject, LatLon
|
||||
from game.theater.bullseye import Bullseye
|
||||
from game.utils import meters
|
||||
from .aircraft import AIRCRAFT_DATA, FlightData
|
||||
from .aircraft import FlightData
|
||||
from .airsupportgen import AwacsInfo, TankerInfo
|
||||
from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator
|
||||
from .flights.flight import FlightWaypoint, FlightWaypointType
|
||||
from .flights.flight import FlightWaypoint, FlightWaypointType, FlightType
|
||||
from .radios import RadioFrequency
|
||||
from .runways import RunwayData
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
|
||||
@@ -137,6 +142,12 @@ class KneeboardPage:
|
||||
"""Writes the kneeboard page to the given path."""
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def format_ll(ll: LatLon) -> str:
|
||||
ns = "N" if ll.latitude >= 0 else "S"
|
||||
ew = "E" if ll.longitude >= 0 else "W"
|
||||
return f"{ll.latitude:.4}°{ns} {ll.longitude:.4}°{ew}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NumberedWaypoint:
|
||||
@@ -249,21 +260,16 @@ class BriefingPage(KneeboardPage):
|
||||
def __init__(
|
||||
self,
|
||||
flight: FlightData,
|
||||
comms: List[CommInfo],
|
||||
awacs: List[AwacsInfo],
|
||||
tankers: List[TankerInfo],
|
||||
jtacs: List[JtacInfo],
|
||||
bullseye: Bullseye,
|
||||
theater: ConflictTheater,
|
||||
start_time: datetime.datetime,
|
||||
dark_kneeboard: bool,
|
||||
) -> None:
|
||||
self.flight = flight
|
||||
self.comms = list(comms)
|
||||
self.awacs = awacs
|
||||
self.tankers = tankers
|
||||
self.jtacs = jtacs
|
||||
self.bullseye = bullseye
|
||||
self.theater = theater
|
||||
self.start_time = start_time
|
||||
self.dark_kneeboard = dark_kneeboard
|
||||
self.comms.append(CommInfo("Flight", self.flight.intra_flight_channel))
|
||||
|
||||
def write(self, path: Path) -> None:
|
||||
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
|
||||
@@ -293,7 +299,8 @@ class BriefingPage(KneeboardPage):
|
||||
headers=["#", "Action", "Alt", "Dist", "GSPD", "Time", "Departure"],
|
||||
)
|
||||
|
||||
flight_plan_builder
|
||||
writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}")
|
||||
|
||||
writer.table(
|
||||
[
|
||||
[
|
||||
@@ -304,6 +311,87 @@ class BriefingPage(KneeboardPage):
|
||||
["Bingo", "Joker"],
|
||||
)
|
||||
|
||||
writer.write(path)
|
||||
|
||||
def airfield_info_row(
|
||||
self, row_title: str, runway: Optional[RunwayData]
|
||||
) -> List[str]:
|
||||
"""Creates a table row for a given airfield.
|
||||
|
||||
Args:
|
||||
row_title: Purpose of the airfield. e.g. "Departure", "Arrival" or
|
||||
"Divert".
|
||||
runway: The runway described by this row.
|
||||
|
||||
Returns:
|
||||
A list of strings to be used as a row of the airfield table.
|
||||
"""
|
||||
if runway is None:
|
||||
return [row_title, "", "", "", "", ""]
|
||||
|
||||
atc = ""
|
||||
if runway.atc is not None:
|
||||
atc = self.format_frequency(runway.atc)
|
||||
if runway.tacan is None:
|
||||
tacan = ""
|
||||
else:
|
||||
tacan = str(runway.tacan)
|
||||
if runway.ils is not None:
|
||||
ils = str(runway.ils)
|
||||
elif runway.icls is not None:
|
||||
ils = str(runway.icls)
|
||||
else:
|
||||
ils = ""
|
||||
return [
|
||||
row_title,
|
||||
"\n".join(textwrap.wrap(runway.airfield_name, width=24)),
|
||||
atc,
|
||||
tacan,
|
||||
ils,
|
||||
runway.runway_name,
|
||||
]
|
||||
|
||||
def format_frequency(self, frequency: RadioFrequency) -> str:
|
||||
channel = self.flight.channel_for(frequency)
|
||||
if channel is None:
|
||||
return str(frequency)
|
||||
|
||||
channel_name = self.flight.aircraft_type.channel_name(
|
||||
channel.radio_id, channel.channel
|
||||
)
|
||||
return f"{channel_name}\n{frequency}"
|
||||
|
||||
|
||||
class SupportPage(KneeboardPage):
|
||||
"""A kneeboard page containing information about support units."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
flight: FlightData,
|
||||
comms: List[CommInfo],
|
||||
awacs: List[AwacsInfo],
|
||||
tankers: List[TankerInfo],
|
||||
jtacs: List[JtacInfo],
|
||||
start_time: datetime.datetime,
|
||||
dark_kneeboard: bool,
|
||||
) -> None:
|
||||
self.flight = flight
|
||||
self.comms = list(comms)
|
||||
self.awacs = awacs
|
||||
self.tankers = tankers
|
||||
self.jtacs = jtacs
|
||||
self.start_time = start_time
|
||||
self.dark_kneeboard = dark_kneeboard
|
||||
self.comms.append(CommInfo("Flight", self.flight.intra_flight_channel))
|
||||
|
||||
def write(self, path: Path) -> None:
|
||||
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
|
||||
if self.flight.custom_name is not None:
|
||||
custom_name_title = ' ("{}")'.format(self.flight.custom_name)
|
||||
else:
|
||||
custom_name_title = ""
|
||||
writer.title(f"{self.flight.callsign} Support Info{custom_name_title}")
|
||||
|
||||
# AEW&C
|
||||
writer.heading("AEW&C")
|
||||
aewc_ladder = []
|
||||
@@ -361,52 +449,15 @@ class BriefingPage(KneeboardPage):
|
||||
|
||||
writer.write(path)
|
||||
|
||||
def airfield_info_row(
|
||||
self, row_title: str, runway: Optional[RunwayData]
|
||||
) -> List[str]:
|
||||
"""Creates a table row for a given airfield.
|
||||
|
||||
Args:
|
||||
row_title: Purpose of the airfield. e.g. "Departure", "Arrival" or
|
||||
"Divert".
|
||||
runway: The runway described by this row.
|
||||
|
||||
Returns:
|
||||
A list of strings to be used as a row of the airfield table.
|
||||
"""
|
||||
if runway is None:
|
||||
return [row_title, "", "", "", "", ""]
|
||||
|
||||
atc = ""
|
||||
if runway.atc is not None:
|
||||
atc = self.format_frequency(runway.atc)
|
||||
if runway.tacan is None:
|
||||
tacan = ""
|
||||
else:
|
||||
tacan = str(runway.tacan)
|
||||
if runway.ils is not None:
|
||||
ils = str(runway.ils)
|
||||
elif runway.icls is not None:
|
||||
ils = str(runway.icls)
|
||||
else:
|
||||
ils = ""
|
||||
return [
|
||||
row_title,
|
||||
runway.airfield_name,
|
||||
atc,
|
||||
tacan,
|
||||
ils,
|
||||
runway.runway_name,
|
||||
]
|
||||
|
||||
def format_frequency(self, frequency: RadioFrequency) -> str:
|
||||
channel = self.flight.channel_for(frequency)
|
||||
if channel is None:
|
||||
return str(frequency)
|
||||
|
||||
namer = AIRCRAFT_DATA[self.flight.aircraft_type.id].channel_namer
|
||||
channel_name = namer.channel_name(channel.radio_id, channel.channel)
|
||||
return f"{channel_name} {frequency}"
|
||||
channel_name = self.flight.aircraft_type.channel_name(
|
||||
channel.radio_id, channel.channel
|
||||
)
|
||||
return f"{channel_name}\n{frequency}"
|
||||
|
||||
def _format_time(self, time: Optional[datetime.timedelta]) -> str:
|
||||
if time is None:
|
||||
@@ -415,6 +466,94 @@ class BriefingPage(KneeboardPage):
|
||||
return local_time.strftime(f"%H:%M:%S")
|
||||
|
||||
|
||||
class SeadTaskPage(KneeboardPage):
|
||||
"""A kneeboard page containing SEAD/DEAD target information."""
|
||||
|
||||
def __init__(
|
||||
self, flight: FlightData, dark_kneeboard: bool, theater: ConflictTheater
|
||||
) -> None:
|
||||
self.flight = flight
|
||||
self.dark_kneeboard = dark_kneeboard
|
||||
self.theater = theater
|
||||
|
||||
@property
|
||||
def target_units(self) -> Iterator[Unit]:
|
||||
if isinstance(self.flight.package.target, TheaterGroundObject):
|
||||
yield from self.flight.package.target.units
|
||||
|
||||
@staticmethod
|
||||
def alic_for(unit: Unit) -> str:
|
||||
try:
|
||||
return str(AlicCodes.code_for(unit))
|
||||
except KeyError:
|
||||
return ""
|
||||
|
||||
def write(self, path: Path) -> None:
|
||||
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
|
||||
if self.flight.custom_name is not None:
|
||||
custom_name_title = ' ("{}")'.format(self.flight.custom_name)
|
||||
else:
|
||||
custom_name_title = ""
|
||||
task = "DEAD" if self.flight.flight_type == FlightType.DEAD else "SEAD"
|
||||
writer.title(f"{self.flight.callsign} {task} Target Info{custom_name_title}")
|
||||
|
||||
writer.table(
|
||||
[self.target_info_row(t) for t in self.target_units],
|
||||
headers=["Description", "ALIC", "Location"],
|
||||
)
|
||||
|
||||
writer.write(path)
|
||||
|
||||
def target_info_row(self, unit: Unit) -> List[str]:
|
||||
ll = self.theater.point_to_ll(unit.position)
|
||||
unit_type = unit_type_from_name(unit.type)
|
||||
name = unit.name if unit_type is None else unit_type.name
|
||||
return [name, self.alic_for(unit), ll.format_dms(include_decimal_seconds=True)]
|
||||
|
||||
|
||||
class StrikeTaskPage(KneeboardPage):
|
||||
"""A kneeboard page containing strike target information."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
flight: FlightData,
|
||||
dark_kneeboard: bool,
|
||||
theater: ConflictTheater,
|
||||
) -> None:
|
||||
self.flight = flight
|
||||
self.dark_kneeboard = dark_kneeboard
|
||||
self.theater = theater
|
||||
|
||||
@property
|
||||
def targets(self) -> Iterator[NumberedWaypoint]:
|
||||
for idx, waypoint in enumerate(self.flight.waypoints):
|
||||
if waypoint.waypoint_type == FlightWaypointType.TARGET_POINT:
|
||||
yield NumberedWaypoint(idx, waypoint)
|
||||
|
||||
def write(self, path: Path) -> None:
|
||||
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
|
||||
if self.flight.custom_name is not None:
|
||||
custom_name_title = ' ("{}")'.format(self.flight.custom_name)
|
||||
else:
|
||||
custom_name_title = ""
|
||||
writer.title(f"{self.flight.callsign} Strike Task Info{custom_name_title}")
|
||||
|
||||
writer.table(
|
||||
[self.target_info_row(t) for t in self.targets],
|
||||
headers=["Steerpoint", "Description", "Location"],
|
||||
)
|
||||
|
||||
writer.write(path)
|
||||
|
||||
def target_info_row(self, target: NumberedWaypoint) -> List[str]:
|
||||
ll = self.theater.point_to_ll(target.waypoint.position)
|
||||
return [
|
||||
str(target.number),
|
||||
target.waypoint.pretty_name,
|
||||
ll.format_dms(include_decimal_seconds=True),
|
||||
]
|
||||
|
||||
|
||||
class KneeboardGenerator(MissionInfoGenerator):
|
||||
"""Creates kneeboard pages for each client flight in the mission."""
|
||||
|
||||
@@ -429,14 +568,14 @@ class KneeboardGenerator(MissionInfoGenerator):
|
||||
temp_dir = Path("kneeboards")
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
for aircraft, pages in self.pages_by_airframe().items():
|
||||
aircraft_dir = temp_dir / aircraft.id
|
||||
aircraft_dir = temp_dir / aircraft.dcs_unit_type.id
|
||||
aircraft_dir.mkdir(exist_ok=True)
|
||||
for idx, page in enumerate(pages):
|
||||
page_path = aircraft_dir / f"page{idx:02}.png"
|
||||
page.write(page_path)
|
||||
self.mission.add_aircraft_kneeboard(aircraft, page_path)
|
||||
self.mission.add_aircraft_kneeboard(aircraft.dcs_unit_type, page_path)
|
||||
|
||||
def pages_by_airframe(self) -> Dict[FlyingType, List[KneeboardPage]]:
|
||||
def pages_by_airframe(self) -> Dict[AircraftType, List[KneeboardPage]]:
|
||||
"""Returns a list of kneeboard pages per airframe in the mission.
|
||||
|
||||
Only client flights will be included, but because DCS does not support
|
||||
@@ -447,7 +586,7 @@ class KneeboardGenerator(MissionInfoGenerator):
|
||||
A dict mapping aircraft types to the list of kneeboard pages for
|
||||
that aircraft.
|
||||
"""
|
||||
all_flights: Dict[FlyingType, List[KneeboardPage]] = defaultdict(list)
|
||||
all_flights: Dict[AircraftType, List[KneeboardPage]] = defaultdict(list)
|
||||
for flight in self.flights:
|
||||
if not flight.client_units:
|
||||
continue
|
||||
@@ -456,10 +595,24 @@ class KneeboardGenerator(MissionInfoGenerator):
|
||||
)
|
||||
return all_flights
|
||||
|
||||
def generate_task_page(self, flight: FlightData) -> Optional[KneeboardPage]:
|
||||
if flight.flight_type in (FlightType.DEAD, FlightType.SEAD):
|
||||
return SeadTaskPage(flight, self.dark_kneeboard, self.game.theater)
|
||||
elif flight.flight_type is FlightType.STRIKE:
|
||||
return StrikeTaskPage(flight, self.dark_kneeboard, self.game.theater)
|
||||
return None
|
||||
|
||||
def generate_flight_kneeboard(self, flight: FlightData) -> List[KneeboardPage]:
|
||||
"""Returns a list of kneeboard pages for the given flight."""
|
||||
return [
|
||||
pages: List[KneeboardPage] = [
|
||||
BriefingPage(
|
||||
flight,
|
||||
self.game.bullseye_for(flight.friendly),
|
||||
self.game.theater,
|
||||
self.mission.start_time,
|
||||
self.dark_kneeboard,
|
||||
),
|
||||
SupportPage(
|
||||
flight,
|
||||
self.comms,
|
||||
self.awacs,
|
||||
@@ -469,3 +622,8 @@ class KneeboardGenerator(MissionInfoGenerator):
|
||||
self.dark_kneeboard,
|
||||
),
|
||||
]
|
||||
|
||||
if (target_page := self.generate_task_page(flight)) is not None:
|
||||
pages.append(target_page)
|
||||
|
||||
return pages
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from dcs import Mission, ships
|
||||
from dcs.vehicles import MissilesSS
|
||||
|
||||
from gen.locations.preset_control_point_locations import PresetControlPointLocations
|
||||
from gen.locations.preset_locations import PresetLocation
|
||||
|
||||
|
||||
class MizDataLocationFinder:
|
||||
@staticmethod
|
||||
def compute_possible_locations(
|
||||
terrain_name: str, cp_name: str
|
||||
) -> PresetControlPointLocations:
|
||||
"""
|
||||
Extract the list of preset locations from miz data
|
||||
:param terrain_name: Terrain/Map name
|
||||
:param cp_name: Control Point / Airbase name
|
||||
:return:
|
||||
"""
|
||||
|
||||
miz_file = Path("./resources/mizdata/", terrain_name.lower(), cp_name + ".miz")
|
||||
|
||||
offshore_locations: List[PresetLocation] = []
|
||||
ashore_locations: List[PresetLocation] = []
|
||||
powerplants_locations: List[PresetLocation] = []
|
||||
antiship_locations: List[PresetLocation] = []
|
||||
|
||||
if miz_file.exists():
|
||||
m = Mission()
|
||||
m.load_file(miz_file.absolute())
|
||||
|
||||
for vehicle_group in m.country("USA").vehicle_group:
|
||||
if len(vehicle_group.units) > 0:
|
||||
ashore_locations.append(
|
||||
PresetLocation(
|
||||
vehicle_group.position,
|
||||
vehicle_group.units[0].heading,
|
||||
vehicle_group.name,
|
||||
)
|
||||
)
|
||||
|
||||
for ship_group in m.country("USA").ship_group:
|
||||
if (
|
||||
len(ship_group.units) > 0
|
||||
and ship_group.units[0].type == ships.FFG_Oliver_Hazzard_Perry.id
|
||||
):
|
||||
offshore_locations.append(
|
||||
PresetLocation(
|
||||
ship_group.position,
|
||||
ship_group.units[0].heading,
|
||||
ship_group.name,
|
||||
)
|
||||
)
|
||||
|
||||
for static_group in m.country("USA").static_group:
|
||||
if len(static_group.units) > 0:
|
||||
powerplants_locations.append(
|
||||
PresetLocation(
|
||||
static_group.position,
|
||||
static_group.units[0].heading,
|
||||
static_group.name,
|
||||
)
|
||||
)
|
||||
|
||||
if m.country("Iran") is not None:
|
||||
for vehicle_group in m.country("Iran").vehicle_group:
|
||||
if (
|
||||
len(vehicle_group.units) > 0
|
||||
and vehicle_group.units[0].type
|
||||
== MissilesSS.AShM_SS_N_2_Silkworm.id
|
||||
):
|
||||
antiship_locations.append(
|
||||
PresetLocation(
|
||||
vehicle_group.position,
|
||||
vehicle_group.units[0].heading,
|
||||
vehicle_group.name,
|
||||
)
|
||||
)
|
||||
|
||||
return PresetControlPointLocations(
|
||||
ashore_locations,
|
||||
offshore_locations,
|
||||
antiship_locations,
|
||||
powerplants_locations,
|
||||
)
|
||||
@@ -14,21 +14,21 @@ class ScudGenerator(GroupGenerator):
|
||||
|
||||
# Scuds
|
||||
self.add_unit(
|
||||
MissilesSS.SSM_SS_1C_Scud_B,
|
||||
MissilesSS.Scud_B,
|
||||
"V1#0",
|
||||
self.position.x,
|
||||
self.position.y + random.randint(1, 8),
|
||||
self.heading,
|
||||
)
|
||||
self.add_unit(
|
||||
MissilesSS.SSM_SS_1C_Scud_B,
|
||||
MissilesSS.Scud_B,
|
||||
"V1#1",
|
||||
self.position.x + 50,
|
||||
self.position.y + random.randint(1, 8),
|
||||
self.heading,
|
||||
)
|
||||
self.add_unit(
|
||||
MissilesSS.SSM_SS_1C_Scud_B,
|
||||
MissilesSS.Scud_B,
|
||||
"V1#2",
|
||||
self.position.x + 100,
|
||||
self.position.y + random.randint(1, 8),
|
||||
@@ -37,7 +37,7 @@ class ScudGenerator(GroupGenerator):
|
||||
|
||||
# Commander
|
||||
self.add_unit(
|
||||
Unarmed.LUV_UAZ_469_Jeep,
|
||||
Unarmed.UAZ_469,
|
||||
"Kubel#0",
|
||||
self.position.x - 35,
|
||||
self.position.y - 20,
|
||||
@@ -46,7 +46,7 @@ class ScudGenerator(GroupGenerator):
|
||||
|
||||
# Shorad
|
||||
self.add_unit(
|
||||
AirDefence.SPAAA_ZSU_23_4_Shilka_Gun_Dish,
|
||||
AirDefence.ZSU_23_4_Shilka,
|
||||
"SHILKA#0",
|
||||
self.position.x - 55,
|
||||
self.position.y - 38,
|
||||
@@ -54,7 +54,7 @@ class ScudGenerator(GroupGenerator):
|
||||
)
|
||||
|
||||
self.add_unit(
|
||||
AirDefence.SAM_SA_9_Strela_1_Gaskin_TEL,
|
||||
AirDefence.Strela_1_9P31,
|
||||
"STRELA#0",
|
||||
self.position.x + 200,
|
||||
self.position.y + 15,
|
||||
|
||||
@@ -14,21 +14,21 @@ class V1GroupGenerator(GroupGenerator):
|
||||
|
||||
# Ramps
|
||||
self.add_unit(
|
||||
MissilesSS.SSM_V_1_Launcher,
|
||||
MissilesSS.V1_launcher,
|
||||
"V1#0",
|
||||
self.position.x,
|
||||
self.position.y + random.randint(1, 8),
|
||||
self.heading,
|
||||
)
|
||||
self.add_unit(
|
||||
MissilesSS.SSM_V_1_Launcher,
|
||||
MissilesSS.V1_launcher,
|
||||
"V1#1",
|
||||
self.position.x + 50,
|
||||
self.position.y + random.randint(1, 8),
|
||||
self.heading,
|
||||
)
|
||||
self.add_unit(
|
||||
MissilesSS.SSM_V_1_Launcher,
|
||||
MissilesSS.V1_launcher,
|
||||
"V1#2",
|
||||
self.position.x + 100,
|
||||
self.position.y + random.randint(1, 8),
|
||||
@@ -37,7 +37,7 @@ class V1GroupGenerator(GroupGenerator):
|
||||
|
||||
# Commander
|
||||
self.add_unit(
|
||||
Unarmed.LUV_Kubelwagen_82,
|
||||
Unarmed.Kubelwagen_82,
|
||||
"Kubel#0",
|
||||
self.position.x - 35,
|
||||
self.position.y - 20,
|
||||
@@ -45,9 +45,7 @@ class V1GroupGenerator(GroupGenerator):
|
||||
)
|
||||
|
||||
# Self defense flak
|
||||
flak_unit = random.choice(
|
||||
[AirDefence.AAA_Flak_Vierling_38_Quad_20mm, AirDefence.AAA_Flak_38_20mm]
|
||||
)
|
||||
flak_unit = random.choice([AirDefence.Flak38, AirDefence.Flak30])
|
||||
|
||||
self.add_unit(
|
||||
flak_unit,
|
||||
@@ -58,7 +56,7 @@ class V1GroupGenerator(GroupGenerator):
|
||||
)
|
||||
|
||||
self.add_unit(
|
||||
Unarmed.Truck_Opel_Blitz,
|
||||
Unarmed.Blitz_36_6700A,
|
||||
"Blitz#0",
|
||||
self.position.x + 200,
|
||||
self.position.y + 15,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user