mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-18 21:38:13 -07:00
Compare commits
734 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e5d7f872 | ||
|
|
e68b1ad428 | ||
|
|
072e2ece15 | ||
|
|
11130037fe | ||
|
|
ba66ef14cc | ||
|
|
8aacc23882 | ||
|
|
03e5fd3dae | ||
|
|
da52598c08 | ||
|
|
52389731eb | ||
|
|
21864f6f95 | ||
|
|
00f8625280 | ||
|
|
c34e29c712 | ||
|
|
e0ae3359f1 | ||
|
|
c2666bacd7 | ||
|
|
4eefd9c3ce | ||
|
|
211456242e | ||
|
|
6f244c4661 | ||
|
|
47bf6d724b | ||
|
|
5c710ad032 | ||
|
|
dda5a05cbb | ||
|
|
e0a63e0290 | ||
|
|
9246659589 | ||
|
|
377cdb84b4 | ||
|
|
0e759f25fd | ||
|
|
b408bb4f6e | ||
|
|
1356479415 | ||
|
|
ec5b4e704f | ||
|
|
aa9e617510 | ||
|
|
ecb739ce96 | ||
|
|
3b72140435 | ||
|
|
27a6770569 | ||
|
|
2ff611167a | ||
|
|
e83e178b63 | ||
|
|
068a757373 | ||
|
|
0ad4527719 | ||
|
|
8c6327d024 | ||
|
|
aecbb2ab02 | ||
|
|
52b11083fe | ||
|
|
a8c87ce54b | ||
|
|
ddb3240591 | ||
|
|
f25ef639f2 | ||
|
|
ab7d3ce4aa | ||
|
|
50db922cef | ||
|
|
a2708edc37 | ||
|
|
603a5005e2 | ||
|
|
b4f68bce76 | ||
|
|
a76cec1539 | ||
|
|
694e6bcae3 | ||
|
|
b85b18cf5f | ||
|
|
04c707f874 | ||
|
|
99142fd662 | ||
|
|
0c5cb17d96 | ||
|
|
cabde313b5 | ||
|
|
8f68bb342d | ||
|
|
fab75d3a32 | ||
|
|
d19bf98dc4 | ||
|
|
b0f41c0360 | ||
|
|
6ebd60feaa | ||
|
|
dd6007b309 | ||
|
|
fde203379d | ||
|
|
fcb3efee01 | ||
|
|
19a21099ed | ||
|
|
20ca7e71c7 | ||
|
|
002202ff5f | ||
|
|
32487137e8 | ||
|
|
f327ab30a6 | ||
|
|
e7545cbc28 | ||
|
|
eba757d2cd | ||
|
|
4119763e23 | ||
|
|
e830a6d6f5 | ||
|
|
704cd97f21 | ||
|
|
47a0dd696f | ||
|
|
c64791e3a8 | ||
|
|
e82d50a3c5 | ||
|
|
0a7aa9e3e2 | ||
|
|
13ca134d12 | ||
|
|
8671e9a391 | ||
|
|
a7de89f45c | ||
|
|
e9f51e3302 | ||
|
|
5491f8c459 | ||
|
|
de71677208 | ||
|
|
653ee2b625 | ||
|
|
62694b1ce7 | ||
|
|
9c0ad2b825 | ||
|
|
88b529593f | ||
|
|
0351698ef7 | ||
|
|
984df75f83 | ||
|
|
402a8fb967 | ||
|
|
45e3027f81 | ||
|
|
1d655a07cd | ||
|
|
c5e768ffe3 | ||
|
|
8cc6f10634 | ||
|
|
aeac83d643 | ||
|
|
95efcf6803 | ||
|
|
44a78cc821 | ||
|
|
e0918a7a89 | ||
|
|
b52310f641 | ||
|
|
e3219ba452 | ||
|
|
7079c17a0f | ||
|
|
3b8450036a | ||
|
|
defdf34e60 | ||
|
|
6827368e60 | ||
|
|
a409167f64 | ||
|
|
a076b9257d | ||
|
|
7e772b4ee9 | ||
|
|
955a86803f | ||
|
|
d5bacaba63 | ||
|
|
3069deb019 | ||
|
|
7f4bf71807 | ||
|
|
f3e00b6d62 | ||
|
|
feef0f484d | ||
|
|
9adbd4031f | ||
|
|
e0d3101066 | ||
|
|
485387ebbe | ||
|
|
9ac628f020 | ||
|
|
07664c4d54 | ||
|
|
d3dbdb4491 | ||
|
|
90ee9ffe36 | ||
|
|
15e6383aad | ||
|
|
2a0d0b4224 | ||
|
|
02fd75c018 | ||
|
|
a87fec0cbd | ||
|
|
11842d396a | ||
|
|
72854cde44 | ||
|
|
b71c8005e7 | ||
|
|
0994afa25b | ||
|
|
7d5693e0fb | ||
|
|
feaed7ea00 | ||
|
|
8340371f9c | ||
|
|
824caaffd0 | ||
|
|
c0b3fa9ff7 | ||
|
|
e809b9328b | ||
|
|
53defd3108 | ||
|
|
a166dc77bc | ||
|
|
68ed208613 | ||
|
|
8f71dac417 | ||
|
|
5f24da7e18 | ||
|
|
4e61f1f23c | ||
|
|
cbfcaeba8b | ||
|
|
9a8abeac28 | ||
|
|
b0f42466f0 | ||
|
|
bcd7d62d0b | ||
|
|
703f5a22fd | ||
|
|
1ee8e339af | ||
|
|
dffde64079 | ||
|
|
17bc184e28 | ||
|
|
0ba9ee0695 | ||
|
|
c40214e20f | ||
|
|
a3aac3d737 | ||
|
|
7bbe62019a | ||
|
|
b898b9d9e6 | ||
|
|
b217372fea | ||
|
|
b2d2c8e596 | ||
|
|
68e37b8f9a | ||
|
|
fa2d7797f4 | ||
|
|
1885dab066 | ||
|
|
9425f5b772 | ||
|
|
83ed3c8b50 | ||
|
|
f4690e296d | ||
|
|
68c350b4c0 | ||
|
|
da0207f5cb | ||
|
|
2455f1158f | ||
|
|
1031fc4923 | ||
|
|
6beaacb905 | ||
|
|
c46ee7c420 | ||
|
|
227f0bce3d | ||
|
|
611e1c2b19 | ||
|
|
5f974b7457 | ||
|
|
3ef35105c8 | ||
|
|
ec768a2e89 | ||
|
|
b580d3c25a | ||
|
|
ce14f190fb | ||
|
|
4e3da005d4 | ||
|
|
0d9967e8d8 | ||
|
|
2624a0a7ea | ||
|
|
8755d5cbc0 | ||
|
|
abb6d7fbdb | ||
|
|
fc04192c99 | ||
|
|
d4110d3b2a | ||
|
|
05c1751d29 | ||
|
|
6ad042b349 | ||
|
|
e52d8b4dbd | ||
|
|
f288e3469c | ||
|
|
5bb87c6da5 | ||
|
|
03768a5f90 | ||
|
|
a84366368f | ||
|
|
29e6a10e42 | ||
|
|
febd280fba | ||
|
|
73964b374c | ||
|
|
bad6a4b211 | ||
|
|
57d3c52df9 | ||
|
|
d309de2557 | ||
|
|
d5d56ede8b | ||
|
|
6613c29652 | ||
|
|
1a6de25ab6 | ||
|
|
b62c1364a9 | ||
|
|
b59162737d | ||
|
|
543dcb27d8 | ||
|
|
22941168cd | ||
|
|
33dc845de8 | ||
|
|
be0f23beb3 | ||
|
|
b76f2163a4 | ||
|
|
04aa471526 | ||
|
|
b756a67c2a | ||
|
|
a76ee010eb | ||
|
|
eb1fef1f92 | ||
|
|
e498cc7d48 | ||
|
|
a26abe079e | ||
|
|
199b6bdabb | ||
|
|
e4bc7bd1cd | ||
|
|
20651df307 | ||
|
|
f857933748 | ||
|
|
efe2b7c539 | ||
|
|
e090153d93 | ||
|
|
5088b02bfe | ||
|
|
57a716b57a | ||
|
|
1b51714f3b | ||
|
|
cb3d35faf9 | ||
|
|
a0c83b4854 | ||
|
|
1b3ee0e94f | ||
|
|
552a6e7f1c | ||
|
|
38bfb1087b | ||
|
|
2dc55873f0 | ||
|
|
4b1898bfaf | ||
|
|
125bf6f270 | ||
|
|
1873c52aa6 | ||
|
|
ec1e113b4c | ||
|
|
347efac0cd | ||
|
|
b7b5bf58aa | ||
|
|
a324c97815 | ||
|
|
f263a0bc91 | ||
|
|
6a9299018c | ||
|
|
ee471a48bd | ||
|
|
879d7c23b7 | ||
|
|
934b09238e | ||
|
|
1fd8e4435e | ||
|
|
50fd42d0c2 | ||
|
|
399958c881 | ||
|
|
78c93d7e39 | ||
|
|
e3b8a60584 | ||
|
|
b7263edfd0 | ||
|
|
1ee749b352 | ||
|
|
f93734f9e3 | ||
|
|
e211dfa1c2 | ||
|
|
0f7deb1d2a | ||
|
|
f2cb16a5be | ||
|
|
98477e27aa | ||
|
|
4149db1a01 | ||
|
|
9ac921380f | ||
|
|
286e24629f | ||
|
|
ab2efc0c5c | ||
|
|
60d6078e1f | ||
|
|
f94492b2d3 | ||
|
|
f03bb61747 | ||
|
|
dc4e8bae98 | ||
|
|
ac26f8be8b | ||
|
|
8c79499573 | ||
|
|
63fbcc5fc8 | ||
|
|
cad217af19 | ||
|
|
a6ad4a8293 | ||
|
|
503999cb32 | ||
|
|
c2d8f2443e | ||
|
|
4571ed7e2f | ||
|
|
ef5cbd3ba3 | ||
|
|
5c162bd7ce | ||
|
|
7bdaaa25c1 | ||
|
|
9a5a02b654 | ||
|
|
4fea6b6e9b | ||
|
|
bd8b8822ac | ||
|
|
0a44c3ec49 | ||
|
|
3262984386 | ||
|
|
180265c8f4 | ||
|
|
a9b4d33cd2 | ||
|
|
5dfb9b28f7 | ||
|
|
ec75793ac3 | ||
|
|
cd4da36863 | ||
|
|
1749e22569 | ||
|
|
0cce88cfbc | ||
|
|
61e83a300b | ||
|
|
136a13aac7 | ||
|
|
2c90db9ae7 | ||
|
|
507e051a5a | ||
|
|
b5bf9ed1d7 | ||
|
|
215eb7e473 | ||
|
|
f42233699a | ||
|
|
1bec68df4d | ||
|
|
d8576e72eb | ||
|
|
7265468e8d | ||
|
|
d07f36dedd | ||
|
|
364a1b71ec | ||
|
|
daee6d210f | ||
|
|
96be0071e6 | ||
|
|
ff8e1dfb47 | ||
|
|
d26db6f213 | ||
|
|
bb6c753583 | ||
|
|
ca08e4b950 | ||
|
|
5a6b02dbd3 | ||
|
|
14416b1050 | ||
|
|
da4e6fc532 | ||
|
|
57d8b69a6d | ||
|
|
c9d8a8661c | ||
|
|
4a3d23e0e6 | ||
|
|
a3666f2ae5 | ||
|
|
c3e000e574 | ||
|
|
dd5481930a | ||
|
|
842328c661 | ||
|
|
8f75384e2e | ||
|
|
193faa00ce | ||
|
|
5e5383b399 | ||
|
|
cb6b29dbe3 | ||
|
|
82b0819051 | ||
|
|
e12ab4afa4 | ||
|
|
1416f631cc | ||
|
|
dbaac47d1e | ||
|
|
cf0ae5e31b | ||
|
|
8891f07362 | ||
|
|
d78974ec59 | ||
|
|
32be26c4d7 | ||
|
|
9de49aa419 | ||
|
|
294a67a4b4 | ||
|
|
0e99888926 | ||
|
|
74cbf10930 | ||
|
|
08d2909b0e | ||
|
|
0949b11436 | ||
|
|
9cdffe7f63 | ||
|
|
8b2a883669 | ||
|
|
b7fc96100c | ||
|
|
63cbc00a40 | ||
|
|
57b94dba6f | ||
|
|
0dd188e108 | ||
|
|
bf8c840293 | ||
|
|
c0244f3018 | ||
|
|
8af8502202 | ||
|
|
42eaeb92f0 | ||
|
|
7f35eb8867 | ||
|
|
785569c40c | ||
|
|
a9eb70a881 | ||
|
|
5d3d0c8625 | ||
|
|
7e32feeea3 | ||
|
|
0d1935e757 | ||
|
|
9b3ee018e9 | ||
|
|
1de411ec89 | ||
|
|
3192799bbf | ||
|
|
2c8dded52f | ||
|
|
06111ac6cf | ||
|
|
d83294efa7 | ||
|
|
be550ff6fb | ||
|
|
dd55409209 | ||
|
|
e267714d44 | ||
|
|
7c30c4a169 | ||
|
|
4882366ffc | ||
|
|
5f73c245fc | ||
|
|
21ffc0fc54 | ||
|
|
e95a41cf93 | ||
|
|
04771fa4f0 | ||
|
|
2639796255 | ||
|
|
4ebabc1208 | ||
|
|
ce34b60712 | ||
|
|
54094c6331 | ||
|
|
3986f6f11a | ||
|
|
5662da6f7d | ||
|
|
33a75fb2cb | ||
|
|
ee9bcb84b7 | ||
|
|
b5269e9aa4 | ||
|
|
00a6ac3a52 | ||
|
|
ea8a14b003 | ||
|
|
414ab86422 | ||
|
|
d4e2698ae0 | ||
|
|
3f8e3082c0 | ||
|
|
0f738935ee | ||
|
|
9c57976252 | ||
|
|
3e08acf381 | ||
|
|
113259bc15 | ||
|
|
61afe76eae | ||
|
|
08b3b3ecf5 | ||
|
|
bc61221ec6 | ||
|
|
2f0b81e12c | ||
|
|
bb9a6bcd2e | ||
|
|
c8b7ef1016 | ||
|
|
e00467c2a2 | ||
|
|
0eb6150e95 | ||
|
|
91d977479d | ||
|
|
cd761db170 | ||
|
|
026011323e | ||
|
|
adc5f3a07d | ||
|
|
69940374e1 | ||
|
|
6dc461609b | ||
|
|
58d460678e | ||
|
|
0f7fd48cdd | ||
|
|
18de035b4d | ||
|
|
11fa43f0a4 | ||
|
|
91a8fc91d6 | ||
|
|
15bde56551 | ||
|
|
d744e086ef | ||
|
|
378fa5d5c4 | ||
|
|
8349774c5c | ||
|
|
34795b598a | ||
|
|
efd5004330 | ||
|
|
c799531105 | ||
|
|
5c1ded1fe9 | ||
|
|
b2162bb8e6 | ||
|
|
f1769a8d00 | ||
|
|
f520c1d9f2 | ||
|
|
910369a7f8 | ||
|
|
dbf6b6f935 | ||
|
|
e9c463c897 | ||
|
|
f4e43ca9e0 | ||
|
|
a298be9c41 | ||
|
|
18bcaa85a2 | ||
|
|
359f45d50f | ||
|
|
f5c574c37a | ||
|
|
f75a1ae117 | ||
|
|
768ccffe72 | ||
|
|
f6668997e6 | ||
|
|
db11c620a7 | ||
|
|
da48af60dc | ||
|
|
19faaa4104 | ||
|
|
628252896e | ||
|
|
f28aff6f9a | ||
|
|
894732be47 | ||
|
|
051518e72a | ||
|
|
b7b78dead3 | ||
|
|
d1167027f4 | ||
|
|
445c9b22d6 | ||
|
|
67e8877143 | ||
|
|
1fe8024b43 | ||
|
|
8e14e463e4 | ||
|
|
b8666b2562 | ||
|
|
57afdfda6f | ||
|
|
738c21c625 | ||
|
|
41898ed640 | ||
|
|
1ebc9e2ec0 | ||
|
|
9466d5274e | ||
|
|
a53bcb4697 | ||
|
|
8c5592e406 | ||
|
|
41055cd963 | ||
|
|
43874b1d28 | ||
|
|
b570aa2ec6 | ||
|
|
c43233120a | ||
|
|
57a571cc11 | ||
|
|
8622cb6204 | ||
|
|
90417e0022 | ||
|
|
96b941ed35 | ||
|
|
1832bac1a3 | ||
|
|
86641223c1 | ||
|
|
cc770418f2 | ||
|
|
513e361764 | ||
|
|
ddf7fdccc7 | ||
|
|
3df2dbe051 | ||
|
|
3d1d6908c8 | ||
|
|
7474c27372 | ||
|
|
bb0948154d | ||
|
|
fa2816822b | ||
|
|
5a42c70675 | ||
|
|
949527f9cb | ||
|
|
1a1b7e9cf4 | ||
|
|
edacb17171 | ||
|
|
33fd9de281 | ||
|
|
a126dee068 | ||
|
|
e2b942139a | ||
|
|
823b17c386 | ||
|
|
05d1b2129a | ||
|
|
436c0a4104 | ||
|
|
96f469c737 | ||
|
|
4f77abac4f | ||
|
|
d5cd95c7fb | ||
|
|
a2fbf856ff | ||
|
|
4fa8c43266 | ||
|
|
992841a951 | ||
|
|
eb3c3d6bf2 | ||
|
|
39847c5502 | ||
|
|
130232b457 | ||
|
|
ca8ffe583d | ||
|
|
563794ab83 | ||
|
|
9443861849 | ||
|
|
cbf4bbbca8 | ||
|
|
9e353ebb8e | ||
|
|
9183e8f9c9 | ||
|
|
0bb657d2c8 | ||
|
|
992f192529 | ||
|
|
1c9409cac9 | ||
|
|
005a143e3e | ||
|
|
8732974857 | ||
|
|
1ac8349bd4 | ||
|
|
2b9fa89050 | ||
|
|
23ea3c0efc | ||
|
|
698d27aada | ||
|
|
3a46c9fd3e | ||
|
|
9507300939 | ||
|
|
0d6db291de | ||
|
|
d218dec826 | ||
|
|
3d5c277c31 | ||
|
|
a9435dc6bb | ||
|
|
8f307c226b | ||
|
|
4b8f990960 | ||
|
|
3a5a4b89ee | ||
|
|
1485882642 | ||
|
|
2e4f5a64b3 | ||
|
|
90f80ce1c1 | ||
|
|
78904151b0 | ||
|
|
9d4bd6eebd | ||
|
|
5c56dc0357 | ||
|
|
c7810823e8 | ||
|
|
902d03d447 | ||
|
|
b7621a0923 | ||
|
|
b7baaed391 | ||
|
|
9dac7d9cc3 | ||
|
|
1eefe23f11 | ||
|
|
207a76d1b5 | ||
|
|
01df35f215 | ||
|
|
bedf746f1d | ||
|
|
b91a7ac6fb | ||
|
|
79e6beeec3 | ||
|
|
dae9d4c575 | ||
|
|
04928bd83d | ||
|
|
0f3818e711 | ||
|
|
0f1dc6e19c | ||
|
|
ffd0c8b341 | ||
|
|
6220963195 | ||
|
|
20119e3162 | ||
|
|
4cb8fa3cdd | ||
|
|
93e8613da7 | ||
|
|
f9cc19e150 | ||
|
|
0f1c119c76 | ||
|
|
4c734b467f | ||
|
|
1f966ee705 | ||
|
|
172ad4e57d | ||
|
|
3f935aac13 | ||
|
|
9928639ce2 | ||
|
|
0fc722cb28 | ||
|
|
4edca0ce54 | ||
|
|
70942eda8c | ||
|
|
adcb2f59ca | ||
|
|
29b34ca9fd | ||
|
|
d97ee5d209 | ||
|
|
c2bd9df0f7 | ||
|
|
112bfe0933 | ||
|
|
96b500679d | ||
|
|
258ea10c52 | ||
|
|
043ba418ec | ||
|
|
894a8571ee | ||
|
|
874197d940 | ||
|
|
d3ed40cd4d | ||
|
|
a29ba4a6c4 | ||
|
|
fe06fe075e | ||
|
|
de58cb03da | ||
|
|
3204680662 | ||
|
|
07e896508c | ||
|
|
2d3faea713 | ||
|
|
7c89a83d19 | ||
|
|
16f8b41cb9 | ||
|
|
7d506990f5 | ||
|
|
aadcb4c903 | ||
|
|
daf94fcdb2 | ||
|
|
1cef659b78 | ||
|
|
25381ef2c2 | ||
|
|
5927926314 | ||
|
|
2a11d9fec3 | ||
|
|
82c44aaa22 | ||
|
|
a7b483e4b7 | ||
|
|
917335ec54 | ||
|
|
6e59ee2926 | ||
|
|
3c9270d802 | ||
|
|
c4bbcf9890 | ||
|
|
8dbecf3d57 | ||
|
|
0de1369ec5 | ||
|
|
fa95ae4b24 | ||
|
|
2065246186 | ||
|
|
ca1b3df45b | ||
|
|
3bcc86f539 | ||
|
|
218f28912e | ||
|
|
b9642a482f | ||
|
|
33ae68c756 | ||
|
|
62942704bd | ||
|
|
fe81053521 | ||
|
|
222c8aa0ae | ||
|
|
845000d10f | ||
|
|
b05f81b4b4 | ||
|
|
6c1dc5f645 | ||
|
|
5578ccd578 | ||
|
|
78637c96a7 | ||
|
|
f3ec82962e | ||
|
|
4f590cdf7b | ||
|
|
46613adceb | ||
|
|
e1a1cd1067 | ||
|
|
7c8d102c17 | ||
|
|
35d30442f7 | ||
|
|
4f71073d17 | ||
|
|
e142283e64 | ||
|
|
de3707af4a | ||
|
|
2e0769c90e | ||
|
|
1ded7b2fd4 | ||
|
|
cacab68b77 | ||
|
|
728d249202 | ||
|
|
d1823a21ea | ||
|
|
6282efb13c | ||
|
|
0fdc14bc42 | ||
|
|
0370e669e5 | ||
|
|
ccea6bcf51 | ||
|
|
8d9454ea3b | ||
|
|
1ca8d3e4a8 | ||
|
|
9815306875 | ||
|
|
d7736950cd | ||
|
|
f5e3677ef1 | ||
|
|
144d612c52 | ||
|
|
3acbe9ece1 | ||
|
|
7d0b701a2d | ||
|
|
f91537fb48 | ||
|
|
3c5ec49dbe | ||
|
|
9a37a136a1 | ||
|
|
54a0a5ac00 | ||
|
|
704f14ffcd | ||
|
|
925fb967d3 | ||
|
|
5dd19fccd0 | ||
|
|
781100a571 | ||
|
|
3fb0b57d19 | ||
|
|
f79657b41a | ||
|
|
4a5ba756b6 | ||
|
|
0b3d34ab24 | ||
|
|
aa22b62b41 | ||
|
|
51c4fe8f67 | ||
|
|
26f9720e69 | ||
|
|
1f712d9a87 | ||
|
|
5b4d7c7526 | ||
|
|
a948697f3a | ||
|
|
e3b5451672 | ||
|
|
6c69f590cf | ||
|
|
c9625e1b35 | ||
|
|
ced93022b6 | ||
|
|
f4b926ebbe | ||
|
|
203d89d1d3 | ||
|
|
4d42814f5d | ||
|
|
d80069385d | ||
|
|
85a0d59f73 | ||
|
|
58f2205304 | ||
|
|
769fbc55a9 | ||
|
|
f43fa612d5 | ||
|
|
5b0de6b6c7 | ||
|
|
ac8a206d46 | ||
|
|
6896d631db | ||
|
|
6f2e1c2a7e | ||
|
|
ffe0221deb | ||
|
|
18e8d50768 | ||
|
|
81b9a53a37 | ||
|
|
b6ab91fe4b | ||
|
|
f26cda07db | ||
|
|
ecc3094c70 | ||
|
|
17b3ee6eaf | ||
|
|
284e7797c5 | ||
|
|
62ce42440b | ||
|
|
7b755408fa | ||
|
|
ed721dd0c1 | ||
|
|
1a5d22ca78 | ||
|
|
21dbfd2472 | ||
|
|
472d2d5406 | ||
|
|
3af2b1dc66 | ||
|
|
6cfc3a4667 | ||
|
|
992657750c | ||
|
|
a67688749f | ||
|
|
f735416bda | ||
|
|
e5374eb8b8 | ||
|
|
b83b48629d | ||
|
|
ca6792a8a7 | ||
|
|
7cbd50a2e6 | ||
|
|
d6da3bc899 | ||
|
|
9eaca95277 | ||
|
|
c1b27f79ac | ||
|
|
0705f6e6c0 | ||
|
|
a537d8eb65 | ||
|
|
845a604955 | ||
|
|
7adb673a80 | ||
|
|
72e88bb493 | ||
|
|
089b3f17a7 | ||
|
|
ad30e3264a | ||
|
|
e262c8be9c | ||
|
|
492e3a355e | ||
|
|
1487d323cd | ||
|
|
dd88b2c658 | ||
|
|
46dfc4d4fc | ||
|
|
b0a61be9df | ||
|
|
7c00c9a49d | ||
|
|
1365bd7a0a | ||
|
|
6e5adc7abd | ||
|
|
c97e4866dd | ||
|
|
8444ffa0c7 | ||
|
|
2fb59d39c9 | ||
|
|
b5343a36ff | ||
|
|
d7a0f4cb4c | ||
|
|
77d35b95e2 | ||
|
|
b605fb1032 | ||
|
|
a5231a27cc | ||
|
|
1454bacfdd | ||
|
|
ed4e44b994 | ||
|
|
d36c983461 | ||
|
|
05aa96a335 | ||
|
|
6f2464d4ad | ||
|
|
91185f4f7c | ||
|
|
1371c63a8d | ||
|
|
30b414429f | ||
|
|
ce210cd4ee | ||
|
|
8923b06a49 | ||
|
|
b783eab1e8 | ||
|
|
b972e8c071 | ||
|
|
faeb54224e | ||
|
|
1ba7700283 | ||
|
|
710cf4ebba | ||
|
|
82260d728f | ||
|
|
62e4285924 | ||
|
|
ce78c75999 | ||
|
|
c022c742b5 | ||
|
|
3cb5219e09 | ||
|
|
5d30d16e09 | ||
|
|
4780fd9974 | ||
|
|
3ba0576cf6 | ||
|
|
283d1ab7e8 | ||
|
|
78bc7b8156 | ||
|
|
a07ddb4371 | ||
|
|
4395c608e8 | ||
|
|
f4322242a1 | ||
|
|
a3711eb463 | ||
|
|
6656528d78 | ||
|
|
e1f16c6721 | ||
|
|
334781e976 | ||
|
|
6c939d2d59 | ||
|
|
e882c68277 | ||
|
|
dbf284d4b2 | ||
|
|
75624042f7 | ||
|
|
0dade05133 | ||
|
|
fcaba14b62 | ||
|
|
6073d5e37e | ||
|
|
41a7d7eeee | ||
|
|
d3a3c29bc9 | ||
|
|
0ad5b0ade8 | ||
|
|
e6e31a27e6 |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +1,2 @@
|
|||||||
worlds/blasphemous/region_data.py linguist-generated=true
|
worlds/blasphemous/region_data.py linguist-generated=true
|
||||||
|
worlds/yachtdice/YachtWeights.py linguist-generated=true
|
||||||
|
|||||||
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
@@ -21,7 +21,6 @@
|
|||||||
- '!data/**'
|
- '!data/**'
|
||||||
- '!.run/**'
|
- '!.run/**'
|
||||||
- '!.github/**'
|
- '!.github/**'
|
||||||
- '!worlds_disabled/**'
|
|
||||||
- '!worlds/**'
|
- '!worlds/**'
|
||||||
- '!WebHost.py'
|
- '!WebHost.py'
|
||||||
- '!WebHostLib/**'
|
- '!WebHostLib/**'
|
||||||
|
|||||||
19
.github/pyright-config.json
vendored
19
.github/pyright-config.json
vendored
@@ -1,8 +1,21 @@
|
|||||||
{
|
{
|
||||||
"include": [
|
"include": [
|
||||||
"type_check.py",
|
"../BizHawkClient.py",
|
||||||
|
"../Patch.py",
|
||||||
|
"../test/param.py",
|
||||||
|
"../test/general/test_groups.py",
|
||||||
|
"../test/general/test_helpers.py",
|
||||||
|
"../test/general/test_memory.py",
|
||||||
|
"../test/general/test_names.py",
|
||||||
|
"../test/multiworld/__init__.py",
|
||||||
|
"../test/multiworld/test_multiworlds.py",
|
||||||
|
"../test/netutils/__init__.py",
|
||||||
|
"../test/programs/__init__.py",
|
||||||
|
"../test/programs/test_multi_server.py",
|
||||||
|
"../test/utils/__init__.py",
|
||||||
|
"../test/webhost/test_descriptions.py",
|
||||||
"../worlds/AutoSNIClient.py",
|
"../worlds/AutoSNIClient.py",
|
||||||
"../Patch.py"
|
"type_check.py"
|
||||||
],
|
],
|
||||||
|
|
||||||
"exclude": [
|
"exclude": [
|
||||||
@@ -16,7 +29,7 @@
|
|||||||
"reportMissingImports": true,
|
"reportMissingImports": true,
|
||||||
"reportMissingTypeStubs": true,
|
"reportMissingTypeStubs": true,
|
||||||
|
|
||||||
"pythonVersion": "3.8",
|
"pythonVersion": "3.10",
|
||||||
"pythonPlatform": "Windows",
|
"pythonPlatform": "Windows",
|
||||||
|
|
||||||
"executionEnvironments": [
|
"executionEnvironments": [
|
||||||
|
|||||||
4
.github/workflows/analyze-modified-files.yml
vendored
4
.github/workflows/analyze-modified-files.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: '3.10'
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
if: env.diff != '' && matrix.task == 'flake8'
|
if: env.diff != '' && matrix.task == 'flake8'
|
||||||
run: |
|
run: |
|
||||||
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
|
flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
|
||||||
|
|
||||||
- name: "flake8: Lint modified files"
|
- name: "flake8: Lint modified files"
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
43
.github/workflows/build.yml
vendored
43
.github/workflows/build.yml
vendored
@@ -21,17 +21,23 @@ env:
|
|||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
APPIMAGETOOL_VERSION: 13
|
APPIMAGETOOL_VERSION: 13
|
||||||
|
|
||||||
|
permissions: # permissions required for attestation
|
||||||
|
id-token: 'write'
|
||||||
|
attestations: 'write'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-win-py38: # RCs will still be built and signed by hand
|
build-win: # RCs and releases may still be built and signed by hand
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.8'
|
python-version: '~3.12.7'
|
||||||
|
check-latest: true
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||||
@@ -64,6 +70,18 @@ jobs:
|
|||||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
$SETUP_NAME=$contents[0].Name
|
$SETUP_NAME=$contents[0].Name
|
||||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
# - copy code above to release.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher.exe
|
||||||
|
build/exe.*/ArchipelagoLauncherDebug.exe
|
||||||
|
build/exe.*/ArchipelagoGenerate.exe
|
||||||
|
build/exe.*/ArchipelagoServer.exe
|
||||||
|
dist/${{ env.ZIP_NAME }}
|
||||||
|
setups/${{ env.SETUP_NAME }}
|
||||||
- name: Check build loads expected worlds
|
- name: Check build loads expected worlds
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -98,8 +116,8 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
retention-days: 7 # keep for 7 days, should be enough
|
||||||
|
|
||||||
build-ubuntu2004:
|
build-ubuntu2204:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
# - copy code below to release.yml -
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -111,10 +129,11 @@ jobs:
|
|||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '~3.12.7'
|
||||||
|
check-latest: true
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
@@ -130,7 +149,7 @@ jobs:
|
|||||||
# charset-normalizer was somehow incomplete in the github runner
|
# charset-normalizer was somehow incomplete in the github runner
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build_exe --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
@@ -140,6 +159,16 @@ jobs:
|
|||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - copy code above to release.yml -
|
# - copy code above to release.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher
|
||||||
|
build/exe.*/ArchipelagoGenerate
|
||||||
|
build/exe.*/ArchipelagoServer
|
||||||
|
dist/${{ env.APPIMAGE_NAME }}*
|
||||||
|
dist/${{ env.TAR_NAME }}
|
||||||
- name: Build Again
|
- name: Build Again
|
||||||
run: |
|
run: |
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
|
|||||||
8
.github/workflows/ctest.yml
vendored
8
.github/workflows/ctest.yml
vendored
@@ -11,7 +11,7 @@ on:
|
|||||||
- '**.hh?'
|
- '**.hh?'
|
||||||
- '**.hpp'
|
- '**.hpp'
|
||||||
- '**.hxx'
|
- '**.hxx'
|
||||||
- '**.CMakeLists'
|
- '**/CMakeLists.txt'
|
||||||
- '.github/workflows/ctest.yml'
|
- '.github/workflows/ctest.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
@@ -21,7 +21,7 @@ on:
|
|||||||
- '**.hh?'
|
- '**.hh?'
|
||||||
- '**.hpp'
|
- '**.hpp'
|
||||||
- '**.hxx'
|
- '**.hxx'
|
||||||
- '**.CMakeLists'
|
- '**/CMakeLists.txt'
|
||||||
- '.github/workflows/ctest.yml'
|
- '.github/workflows/ctest.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -36,9 +36,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: ilammy/msvc-dev-cmd@v1
|
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||||
if: startsWith(matrix.os,'windows')
|
if: startsWith(matrix.os,'windows')
|
||||||
- uses: Bacondish2023/setup-googletest@v1
|
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
|
||||||
with:
|
with:
|
||||||
build-type: 'Release'
|
build-type: 'Release'
|
||||||
- name: Build tests
|
- name: Build tests
|
||||||
|
|||||||
2
.github/workflows/label-pull-requests.yml
vendored
2
.github/workflows/label-pull-requests.yml
vendored
@@ -6,6 +6,8 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
env:
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
labeler:
|
labeler:
|
||||||
|
|||||||
94
.github/workflows/release.yml
vendored
94
.github/workflows/release.yml
vendored
@@ -11,6 +11,11 @@ env:
|
|||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
APPIMAGETOOL_VERSION: 13
|
APPIMAGETOOL_VERSION: 13
|
||||||
|
|
||||||
|
permissions: # permissions required for attestation
|
||||||
|
id-token: 'write'
|
||||||
|
attestations: 'write'
|
||||||
|
contents: 'write' # additionally required for release
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -26,11 +31,79 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# build-release-windows: # this is done by hand because of signing
|
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-release-ubuntu2004:
|
build-release-win:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: windows-latest
|
||||||
|
if: ${{ true }} # change to false to skip if release is built by hand
|
||||||
|
needs: create-release
|
||||||
|
steps:
|
||||||
|
- name: Set env
|
||||||
|
shell: bash
|
||||||
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
|
# - code below copied from build.yml -
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '~3.12.7'
|
||||||
|
check-latest: true
|
||||||
|
- name: Download run-time dependencies
|
||||||
|
run: |
|
||||||
|
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||||
|
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||||
|
choco install innosetup --version=6.2.2 --allow-downgrade
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python setup.py build_exe --yes
|
||||||
|
if ( $? -eq $false ) {
|
||||||
|
Write-Error "setup.py failed!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
||||||
|
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||||
|
echo "$NAME -> $ZIP_NAME"
|
||||||
|
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
New-Item -Path dist -ItemType Directory -Force
|
||||||
|
cd build
|
||||||
|
Rename-Item "exe.$NAME" Archipelago
|
||||||
|
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||||
|
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||||
|
- name: Build Setup
|
||||||
|
run: |
|
||||||
|
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||||
|
if ( $? -eq $false ) {
|
||||||
|
Write-Error "Building setup failed!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
|
$SETUP_NAME=$contents[0].Name
|
||||||
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
# - code above copied from build.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher.exe
|
||||||
|
build/exe.*/ArchipelagoLauncherDebug.exe
|
||||||
|
build/exe.*/ArchipelagoGenerate.exe
|
||||||
|
build/exe.*/ArchipelagoServer.exe
|
||||||
|
setups/*
|
||||||
|
- name: Add to Release
|
||||||
|
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||||
|
with:
|
||||||
|
draft: true # see above
|
||||||
|
prerelease: false
|
||||||
|
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||||
|
files: |
|
||||||
|
setups/*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
build-release-ubuntu2204:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: create-release
|
||||||
steps:
|
steps:
|
||||||
- name: Set env
|
- name: Set env
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
@@ -44,10 +117,11 @@ jobs:
|
|||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '~3.12.7'
|
||||||
|
check-latest: true
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
@@ -63,7 +137,7 @@ jobs:
|
|||||||
# charset-normalizer was somehow incomplete in the github runner
|
# charset-normalizer was somehow incomplete in the github runner
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build_exe --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
@@ -73,6 +147,14 @@ jobs:
|
|||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - code above copied from build.yml -
|
# - code above copied from build.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher
|
||||||
|
build/exe.*/ArchipelagoGenerate
|
||||||
|
build/exe.*/ArchipelagoServer
|
||||||
|
dist/*
|
||||||
- name: Add to Release
|
- name: Add to Release
|
||||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||||
with:
|
with:
|
||||||
|
|||||||
6
.github/workflows/scan-build.yml
vendored
6
.github/workflows/scan-build.yml
vendored
@@ -40,10 +40,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
wget https://apt.llvm.org/llvm.sh
|
wget https://apt.llvm.org/llvm.sh
|
||||||
chmod +x ./llvm.sh
|
chmod +x ./llvm.sh
|
||||||
sudo ./llvm.sh 17
|
sudo ./llvm.sh 19
|
||||||
- name: Install scan-build command
|
- name: Install scan-build command
|
||||||
run: |
|
run: |
|
||||||
sudo apt install clang-tools-17
|
sudo apt install clang-tools-19
|
||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
- name: scan-build
|
- name: scan-build
|
||||||
run: |
|
run: |
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
|
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
|
||||||
- name: Store report
|
- name: Store report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
2
.github/workflows/strict-type-check.yml
vendored
2
.github/workflows/strict-type-check.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip pyright==1.1.358
|
python -m pip install --upgrade pip pyright==1.1.392.post0
|
||||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
||||||
|
|
||||||
- name: "pyright: strict check on specific files"
|
- name: "pyright: strict check on specific files"
|
||||||
|
|||||||
4
.github/workflows/unittests.yml
vendored
4
.github/workflows/unittests.yml
vendored
@@ -33,13 +33,11 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest]
|
os: [ubuntu-latest]
|
||||||
python:
|
python:
|
||||||
- {version: '3.8'}
|
|
||||||
- {version: '3.9'}
|
|
||||||
- {version: '3.10'}
|
- {version: '3.10'}
|
||||||
- {version: '3.11'}
|
- {version: '3.11'}
|
||||||
- {version: '3.12'}
|
- {version: '3.12'}
|
||||||
include:
|
include:
|
||||||
- python: {version: '3.8'} # win7 compat
|
- python: {version: '3.10'} # old compat
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.12'} # current
|
- python: {version: '3.12'} # current
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -4,11 +4,13 @@
|
|||||||
*_Spoiler.txt
|
*_Spoiler.txt
|
||||||
*.bmbp
|
*.bmbp
|
||||||
*.apbp
|
*.apbp
|
||||||
|
*.apcivvi
|
||||||
*.apl2ac
|
*.apl2ac
|
||||||
*.apm3
|
*.apm3
|
||||||
*.apmc
|
*.apmc
|
||||||
*.apz5
|
*.apz5
|
||||||
*.aptloz
|
*.aptloz
|
||||||
|
*.aptww
|
||||||
*.apemerald
|
*.apemerald
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
@@ -54,7 +56,6 @@ success.txt
|
|||||||
output/
|
output/
|
||||||
Output Logs/
|
Output Logs/
|
||||||
/factorio/
|
/factorio/
|
||||||
/Minecraft Forge Server/
|
|
||||||
/WebHostLib/static/generated
|
/WebHostLib/static/generated
|
||||||
/freeze_requirements.txt
|
/freeze_requirements.txt
|
||||||
/Archipelago.zip
|
/Archipelago.zip
|
||||||
@@ -182,12 +183,6 @@ _speedups.c
|
|||||||
_speedups.cpp
|
_speedups.cpp
|
||||||
_speedups.html
|
_speedups.html
|
||||||
|
|
||||||
# minecraft server stuff
|
|
||||||
jdk*/
|
|
||||||
minecraft*/
|
|
||||||
minecraft_versions.json
|
|
||||||
!worlds/minecraft/
|
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import sys
|
||||||
from worlds.ahit.Client import launch
|
from worlds.ahit.Client import launch
|
||||||
import Utils
|
import Utils
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
@@ -5,4 +6,4 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("AHITClient", exception_logger="Client")
|
Utils.init_logging("AHITClient", exception_logger="Client")
|
||||||
launch()
|
launch(*sys.argv[1:])
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import List
|
|||||||
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
from settings import get_settings
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import ClientStatus
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||||
@@ -80,8 +81,8 @@ class AdventureContext(CommonContext):
|
|||||||
self.local_item_locations = {}
|
self.local_item_locations = {}
|
||||||
self.dragon_speed_info = {}
|
self.dragon_speed_info = {}
|
||||||
|
|
||||||
options = Utils.get_settings()
|
options = get_settings().adventure_options
|
||||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
self.display_msgs = options.display_msgs
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -102,7 +103,7 @@ class AdventureContext(CommonContext):
|
|||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == 'Connected':
|
if cmd == 'Connected':
|
||||||
self.locations_array = None
|
self.locations_array = None
|
||||||
if Utils.get_settings()["adventure_options"].get("death_link", False):
|
if get_settings().adventure_options.as_dict().get("death_link", False):
|
||||||
self.set_deathlink = True
|
self.set_deathlink = True
|
||||||
async_start(self.get_freeincarnates_used())
|
async_start(self.get_freeincarnates_used())
|
||||||
elif cmd == "RoomInfo":
|
elif cmd == "RoomInfo":
|
||||||
@@ -415,8 +416,9 @@ async def atari_sync_task(ctx: AdventureContext):
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
|
options = get_settings().adventure_options
|
||||||
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
|
auto_start = options.rom_start
|
||||||
|
rom_args = options.rom_args
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -511,7 +513,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
396
BaseClasses.py
396
BaseClasses.py
@@ -1,18 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import itertools
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import typing # this can go away when Python 3.8 support is dropped
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
from collections.abc import Collection, MutableSequence
|
from collections.abc import Collection, MutableSequence
|
||||||
from enum import IntEnum, IntFlag
|
from enum import IntEnum, IntFlag
|
||||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
|
||||||
Optional, Protocol, Set, Tuple, Union, Type)
|
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
from typing_extensions import NotRequired, TypedDict
|
from typing_extensions import NotRequired, TypedDict
|
||||||
|
|
||||||
@@ -20,7 +19,8 @@ import NetUtils
|
|||||||
import Options
|
import Options
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from entrance_rando import ERPlacementState
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
|
|
||||||
@@ -55,12 +55,21 @@ class HasNameAndPlayer(Protocol):
|
|||||||
player: int
|
player: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class PlandoItemBlock:
|
||||||
|
player: int
|
||||||
|
from_pool: bool
|
||||||
|
force: bool | Literal["silent"]
|
||||||
|
worlds: set[int] = dataclasses.field(default_factory=set)
|
||||||
|
items: list[str] = dataclasses.field(default_factory=list)
|
||||||
|
locations: list[str] = dataclasses.field(default_factory=list)
|
||||||
|
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
|
||||||
|
count: dict[str, int] = dataclasses.field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class MultiWorld():
|
class MultiWorld():
|
||||||
debug_types = False
|
debug_types = False
|
||||||
player_name: Dict[int, str]
|
player_name: Dict[int, str]
|
||||||
plando_texts: List[Dict[str, str]]
|
|
||||||
plando_items: List[List[Dict[str, Any]]]
|
|
||||||
plando_connections: List
|
|
||||||
worlds: Dict[int, "AutoWorld.World"]
|
worlds: Dict[int, "AutoWorld.World"]
|
||||||
groups: Dict[int, Group]
|
groups: Dict[int, Group]
|
||||||
regions: RegionManager
|
regions: RegionManager
|
||||||
@@ -84,6 +93,8 @@ class MultiWorld():
|
|||||||
start_location_hints: Dict[int, Options.StartLocationHints]
|
start_location_hints: Dict[int, Options.StartLocationHints]
|
||||||
item_links: Dict[int, Options.ItemLinks]
|
item_links: Dict[int, Options.ItemLinks]
|
||||||
|
|
||||||
|
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
|
||||||
|
|
||||||
game: Dict[int, str]
|
game: Dict[int, str]
|
||||||
|
|
||||||
random: random.Random
|
random: random.Random
|
||||||
@@ -161,13 +172,12 @@ class MultiWorld():
|
|||||||
self.local_early_items = {player: {} for player in self.player_ids}
|
self.local_early_items = {player: {} for player in self.player_ids}
|
||||||
self.indirect_connections = {}
|
self.indirect_connections = {}
|
||||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||||
|
self.plando_item_blocks = {}
|
||||||
|
|
||||||
for player in range(1, players + 1):
|
for player in range(1, players + 1):
|
||||||
def set_player_attr(attr: str, val) -> None:
|
def set_player_attr(attr: str, val) -> None:
|
||||||
self.__dict__.setdefault(attr, {})[player] = val
|
self.__dict__.setdefault(attr, {})[player] = val
|
||||||
set_player_attr('plando_items', [])
|
set_player_attr('plando_item_blocks', [])
|
||||||
set_player_attr('plando_texts', {})
|
|
||||||
set_player_attr('plando_connections', [])
|
|
||||||
set_player_attr('game', "Archipelago")
|
set_player_attr('game', "Archipelago")
|
||||||
set_player_attr('completion_condition', lambda state: True)
|
set_player_attr('completion_condition', lambda state: True)
|
||||||
self.worlds = {}
|
self.worlds = {}
|
||||||
@@ -224,14 +234,14 @@ class MultiWorld():
|
|||||||
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
|
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
|
||||||
for option_key in all_keys:
|
for option_key in all_keys:
|
||||||
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
|
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
|
||||||
f"Please use `self.options.{option_key}` instead.")
|
f"Please use `self.options.{option_key}` instead.", True)
|
||||||
option.update(getattr(args, option_key, {}))
|
option.update(getattr(args, option_key, {}))
|
||||||
setattr(self, option_key, option)
|
setattr(self, option_key, option)
|
||||||
|
|
||||||
for player in self.player_ids:
|
for player in self.player_ids:
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||||
self.worlds[player] = world_type(self, player)
|
self.worlds[player] = world_type(self, player)
|
||||||
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
|
options_dataclass: type[Options.PerGameCommonOptions] = world_type.options_dataclass
|
||||||
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
|
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
|
||||||
for option_key in options_dataclass.type_hints})
|
for option_key in options_dataclass.type_hints})
|
||||||
|
|
||||||
@@ -428,20 +438,23 @@ class MultiWorld():
|
|||||||
def get_location(self, location_name: str, player: int) -> Location:
|
def get_location(self, location_name: str, player: int) -> Location:
|
||||||
return self.regions.location_cache[player][location_name]
|
return self.regions.location_cache[player][location_name]
|
||||||
|
|
||||||
def get_all_state(self, use_cache: bool) -> CollectionState:
|
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
|
||||||
|
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
|
||||||
cached = getattr(self, "_all_state", None)
|
cached = getattr(self, "_all_state", None)
|
||||||
if use_cache and cached:
|
if use_cache and cached:
|
||||||
return cached.copy()
|
return cached.copy()
|
||||||
|
|
||||||
ret = CollectionState(self)
|
ret = CollectionState(self, allow_partial_entrances)
|
||||||
|
|
||||||
for item in self.itempool:
|
for item in self.itempool:
|
||||||
self.worlds[item.player].collect(ret, item)
|
self.worlds[item.player].collect(ret, item)
|
||||||
for player in self.player_ids:
|
if collect_pre_fill_items:
|
||||||
subworld = self.worlds[player]
|
for player in self.player_ids:
|
||||||
for item in subworld.get_pre_fill_items():
|
subworld = self.worlds[player]
|
||||||
subworld.collect(ret, item)
|
for item in subworld.get_pre_fill_items():
|
||||||
ret.sweep_for_advancements()
|
subworld.collect(ret, item)
|
||||||
|
if perform_sweep:
|
||||||
|
ret.sweep_for_advancements()
|
||||||
|
|
||||||
if use_cache:
|
if use_cache:
|
||||||
self._all_state = ret
|
self._all_state = ret
|
||||||
@@ -546,7 +559,9 @@ class MultiWorld():
|
|||||||
else:
|
else:
|
||||||
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
||||||
|
|
||||||
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
|
def can_beat_game(self,
|
||||||
|
starting_state: Optional[CollectionState] = None,
|
||||||
|
locations: Optional[Iterable[Location]] = None) -> bool:
|
||||||
if starting_state:
|
if starting_state:
|
||||||
if self.has_beaten_game(starting_state):
|
if self.has_beaten_game(starting_state):
|
||||||
return True
|
return True
|
||||||
@@ -555,7 +570,9 @@ class MultiWorld():
|
|||||||
state = CollectionState(self)
|
state = CollectionState(self)
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
return True
|
return True
|
||||||
prog_locations = {location for location in self.get_locations() if location.item
|
|
||||||
|
base_locations = self.get_locations() if locations is None else locations
|
||||||
|
prog_locations = {location for location in base_locations if location.item
|
||||||
and location.item.advancement and location not in state.locations_checked}
|
and location.item.advancement and location not in state.locations_checked}
|
||||||
|
|
||||||
while prog_locations:
|
while prog_locations:
|
||||||
@@ -606,6 +623,49 @@ class MultiWorld():
|
|||||||
state.collect(location.item, True, location)
|
state.collect(location.item, True, location)
|
||||||
locations -= sphere
|
locations -= sphere
|
||||||
|
|
||||||
|
def get_sendable_spheres(self) -> Iterator[Set[Location]]:
|
||||||
|
"""
|
||||||
|
yields a set of multiserver sendable locations (location.item.code: int) for each logical sphere
|
||||||
|
|
||||||
|
If there are unreachable locations, the last sphere of reachable locations is followed by an empty set,
|
||||||
|
and then a set of all of the unreachable locations.
|
||||||
|
"""
|
||||||
|
state = CollectionState(self)
|
||||||
|
locations: Set[Location] = set()
|
||||||
|
events: Set[Location] = set()
|
||||||
|
for location in self.get_filled_locations():
|
||||||
|
if type(location.item.code) is int and type(location.address) is int:
|
||||||
|
locations.add(location)
|
||||||
|
else:
|
||||||
|
events.add(location)
|
||||||
|
|
||||||
|
while locations:
|
||||||
|
sphere: Set[Location] = set()
|
||||||
|
|
||||||
|
# cull events out
|
||||||
|
done_events: Set[Union[Location, None]] = {None}
|
||||||
|
while done_events:
|
||||||
|
done_events = set()
|
||||||
|
for event in events:
|
||||||
|
if event.can_reach(state):
|
||||||
|
state.collect(event.item, True, event)
|
||||||
|
done_events.add(event)
|
||||||
|
events -= done_events
|
||||||
|
|
||||||
|
for location in locations:
|
||||||
|
if location.can_reach(state):
|
||||||
|
sphere.add(location)
|
||||||
|
|
||||||
|
yield sphere
|
||||||
|
if not sphere:
|
||||||
|
if locations:
|
||||||
|
yield locations # unreachable locations
|
||||||
|
break
|
||||||
|
|
||||||
|
for location in sphere:
|
||||||
|
state.collect(location.item, True, location)
|
||||||
|
locations -= sphere
|
||||||
|
|
||||||
def fulfills_accessibility(self, state: Optional[CollectionState] = None):
|
def fulfills_accessibility(self, state: Optional[CollectionState] = None):
|
||||||
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
||||||
if not state:
|
if not state:
|
||||||
@@ -676,10 +736,12 @@ class CollectionState():
|
|||||||
path: Dict[Union[Region, Entrance], PathValue]
|
path: Dict[Union[Region, Entrance], PathValue]
|
||||||
locations_checked: Set[Location]
|
locations_checked: Set[Location]
|
||||||
stale: Dict[int, bool]
|
stale: Dict[int, bool]
|
||||||
|
allow_partial_entrances: bool
|
||||||
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
||||||
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
||||||
|
|
||||||
def __init__(self, parent: MultiWorld):
|
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
|
||||||
|
assert parent.worlds, "CollectionState created without worlds initialized in parent"
|
||||||
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
||||||
self.multiworld = parent
|
self.multiworld = parent
|
||||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||||
@@ -688,6 +750,7 @@ class CollectionState():
|
|||||||
self.path = {}
|
self.path = {}
|
||||||
self.locations_checked = set()
|
self.locations_checked = set()
|
||||||
self.stale = {player: True for player in parent.get_all_ids()}
|
self.stale = {player: True for player in parent.get_all_ids()}
|
||||||
|
self.allow_partial_entrances = allow_partial_entrances
|
||||||
for function in self.additional_init_functions:
|
for function in self.additional_init_functions:
|
||||||
function(self, parent)
|
function(self, parent)
|
||||||
for items in parent.precollected_items.values():
|
for items in parent.precollected_items.values():
|
||||||
@@ -722,6 +785,8 @@ class CollectionState():
|
|||||||
if new_region in reachable_regions:
|
if new_region in reachable_regions:
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
elif connection.can_reach(self):
|
elif connection.can_reach(self):
|
||||||
|
if self.allow_partial_entrances and not new_region:
|
||||||
|
continue
|
||||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
||||||
reachable_regions.add(new_region)
|
reachable_regions.add(new_region)
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
@@ -747,7 +812,9 @@ class CollectionState():
|
|||||||
if new_region in reachable_regions:
|
if new_region in reachable_regions:
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
elif connection.can_reach(self):
|
elif connection.can_reach(self):
|
||||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
if self.allow_partial_entrances and not new_region:
|
||||||
|
continue
|
||||||
|
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
||||||
reachable_regions.add(new_region)
|
reachable_regions.add(new_region)
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
blocked_connections.update(new_region.exits)
|
blocked_connections.update(new_region.exits)
|
||||||
@@ -767,6 +834,7 @@ class CollectionState():
|
|||||||
ret.advancements = self.advancements.copy()
|
ret.advancements = self.advancements.copy()
|
||||||
ret.path = self.path.copy()
|
ret.path = self.path.copy()
|
||||||
ret.locations_checked = self.locations_checked.copy()
|
ret.locations_checked = self.locations_checked.copy()
|
||||||
|
ret.allow_partial_entrances = self.allow_partial_entrances
|
||||||
for function in self.additional_copy_functions:
|
for function in self.additional_copy_functions:
|
||||||
ret = function(self, ret)
|
ret = function(self, ret)
|
||||||
return ret
|
return ret
|
||||||
@@ -820,21 +888,40 @@ class CollectionState():
|
|||||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||||
return self.prog_items[player][item] >= count
|
return self.prog_items[player][item] >= count
|
||||||
|
|
||||||
|
# for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of
|
||||||
|
# creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the
|
||||||
|
# argument to all() would be a new generator instance, for example.
|
||||||
def has_all(self, items: Iterable[str], player: int) -> bool:
|
def has_all(self, items: Iterable[str], player: int) -> bool:
|
||||||
"""Returns True if each item name of items is in state at least once."""
|
"""Returns True if each item name of items is in state at least once."""
|
||||||
return all(self.prog_items[player][item] for item in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item in items:
|
||||||
|
if not player_prog_items[item]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def has_any(self, items: Iterable[str], player: int) -> bool:
|
def has_any(self, items: Iterable[str], player: int) -> bool:
|
||||||
"""Returns True if at least one item name of items is in state at least once."""
|
"""Returns True if at least one item name of items is in state at least once."""
|
||||||
return any(self.prog_items[player][item] for item in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item in items:
|
||||||
|
if player_prog_items[item]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||||
"""Returns True if each item name is in the state at least as many times as specified."""
|
"""Returns True if each item name is in the state at least as many times as specified."""
|
||||||
return all(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item, count in item_counts.items():
|
||||||
|
if player_prog_items[item] < count:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||||
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
||||||
return any(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item, count in item_counts.items():
|
||||||
|
if player_prog_items[item] >= count:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def count(self, item: str, player: int) -> int:
|
def count(self, item: str, player: int) -> int:
|
||||||
return self.prog_items[player][item]
|
return self.prog_items[player][item]
|
||||||
@@ -862,11 +949,20 @@ class CollectionState():
|
|||||||
|
|
||||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||||
"""Returns the cumulative count of items from a list present in state."""
|
"""Returns the cumulative count of items from a list present in state."""
|
||||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
total = 0
|
||||||
|
for item_name in items:
|
||||||
|
total += player_prog_items[item_name]
|
||||||
|
return total
|
||||||
|
|
||||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
||||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
total = 0
|
||||||
|
for item_name in items:
|
||||||
|
if player_prog_items[item_name] > 0:
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
# item name group related
|
# item name group related
|
||||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||||
@@ -922,6 +1018,17 @@ class CollectionState():
|
|||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
def add_item(self, item: str, player: int, count: int = 1) -> None:
|
||||||
|
"""
|
||||||
|
Adds the item to state.
|
||||||
|
|
||||||
|
:param item: The item to be added.
|
||||||
|
:param player: The player the item is for.
|
||||||
|
:param count: How many of the item to add.
|
||||||
|
"""
|
||||||
|
assert count > 0
|
||||||
|
self.prog_items[player][item] += count
|
||||||
|
|
||||||
def remove(self, item: Item):
|
def remove(self, item: Item):
|
||||||
changed = self.multiworld.worlds[item.player].remove(self, item)
|
changed = self.multiworld.worlds[item.player].remove(self, item)
|
||||||
if changed:
|
if changed:
|
||||||
@@ -930,6 +1037,38 @@ class CollectionState():
|
|||||||
self.blocked_connections[item.player] = set()
|
self.blocked_connections[item.player] = set()
|
||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
|
|
||||||
|
def remove_item(self, item: str, player: int, count: int = 1) -> None:
|
||||||
|
"""
|
||||||
|
Removes the item from state.
|
||||||
|
|
||||||
|
:param item: The item to be removed.
|
||||||
|
:param player: The player the item is for.
|
||||||
|
:param count: How many of the item to remove.
|
||||||
|
"""
|
||||||
|
assert count > 0
|
||||||
|
self.prog_items[player][item] -= count
|
||||||
|
if self.prog_items[player][item] < 1:
|
||||||
|
del (self.prog_items[player][item])
|
||||||
|
|
||||||
|
def set_item(self, item: str, player: int, count: int) -> None:
|
||||||
|
"""
|
||||||
|
Sets the item in state equal to the provided count.
|
||||||
|
|
||||||
|
:param item: The item to modify.
|
||||||
|
:param player: The player the item is for.
|
||||||
|
:param count: How many of the item to now have.
|
||||||
|
"""
|
||||||
|
assert count >= 0
|
||||||
|
if count == 0:
|
||||||
|
del (self.prog_items[player][item])
|
||||||
|
else:
|
||||||
|
self.prog_items[player][item] = count
|
||||||
|
|
||||||
|
|
||||||
|
class EntranceType(IntEnum):
|
||||||
|
ONE_WAY = 1
|
||||||
|
TWO_WAY = 2
|
||||||
|
|
||||||
|
|
||||||
class Entrance:
|
class Entrance:
|
||||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||||
@@ -938,30 +1077,56 @@ class Entrance:
|
|||||||
name: str
|
name: str
|
||||||
parent_region: Optional[Region]
|
parent_region: Optional[Region]
|
||||||
connected_region: Optional[Region] = None
|
connected_region: Optional[Region] = None
|
||||||
# LttP specific, TODO: should make a LttPEntrance
|
randomization_group: int
|
||||||
addresses = None
|
randomization_type: EntranceType
|
||||||
target = None
|
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
|
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
|
||||||
|
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.parent_region = parent
|
self.parent_region = parent
|
||||||
self.player = player
|
self.player = player
|
||||||
|
self.randomization_group = randomization_group
|
||||||
|
self.randomization_type = randomization_type
|
||||||
|
|
||||||
def can_reach(self, state: CollectionState) -> bool:
|
def can_reach(self, state: CollectionState) -> bool:
|
||||||
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
|
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
|
||||||
if self.parent_region.can_reach(state) and self.access_rule(state):
|
if self.parent_region.can_reach(state) and self.access_rule(state):
|
||||||
if not self.hide_path and not self in state.path:
|
if not self.hide_path and self not in state.path:
|
||||||
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
|
def connect(self, region: Region) -> None:
|
||||||
self.connected_region = region
|
self.connected_region = region
|
||||||
self.target = target
|
|
||||||
self.addresses = addresses
|
|
||||||
region.entrances.append(self)
|
region.entrances.append(self)
|
||||||
|
|
||||||
|
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
|
||||||
|
"""
|
||||||
|
Determines whether this is a valid source transition, that is, whether the entrance
|
||||||
|
randomizer is allowed to pair it to place any other regions. By default, this is the
|
||||||
|
same as a reachability check, but can be modified by Entrance implementations to add
|
||||||
|
other restrictions based on the placement state.
|
||||||
|
|
||||||
|
:param er_state: The current (partial) state of the ongoing entrance randomization
|
||||||
|
"""
|
||||||
|
return self.can_reach(er_state.collection_state)
|
||||||
|
|
||||||
|
def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool:
|
||||||
|
"""
|
||||||
|
Determines whether a given Entrance is a valid target transition, that is, whether
|
||||||
|
the entrance randomizer is allowed to pair this Entrance to that Entrance. By default,
|
||||||
|
only allows connection between entrances of the same type (one ways only go to one ways,
|
||||||
|
two ways always go to two ways) and prevents connecting an exit to itself in coupled mode.
|
||||||
|
|
||||||
|
:param other: The proposed Entrance to connect to
|
||||||
|
:param dead_end: Whether the other entrance considered a dead end by Entrance randomization
|
||||||
|
:param er_state: The current (partial) state of the ongoing entrance randomization
|
||||||
|
"""
|
||||||
|
# the implementation of coupled causes issues for self-loops since the reverse entrance will be the
|
||||||
|
# same as the forward entrance. In uncoupled they are ok.
|
||||||
|
return self.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
multiworld = self.parent_region.multiworld if self.parent_region else None
|
multiworld = self.parent_region.multiworld if self.parent_region else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
@@ -975,7 +1140,7 @@ class Region:
|
|||||||
entrances: List[Entrance]
|
entrances: List[Entrance]
|
||||||
exits: List[Entrance]
|
exits: List[Entrance]
|
||||||
locations: List[Location]
|
locations: List[Location]
|
||||||
entrance_type: ClassVar[Type[Entrance]] = Entrance
|
entrance_type: ClassVar[type[Entrance]] = Entrance
|
||||||
|
|
||||||
class Register(MutableSequence):
|
class Register(MutableSequence):
|
||||||
region_manager: MultiWorld.RegionManager
|
region_manager: MultiWorld.RegionManager
|
||||||
@@ -993,6 +1158,9 @@ class Region:
|
|||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self._list.__len__()
|
return self._list.__len__()
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._list)
|
||||||
|
|
||||||
# This seems to not be needed, but that's a bit suspicious.
|
# This seems to not be needed, but that's a bit suspicious.
|
||||||
# def __del__(self):
|
# def __del__(self):
|
||||||
# self.clear()
|
# self.clear()
|
||||||
@@ -1075,7 +1243,7 @@ class Region:
|
|||||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||||
|
|
||||||
def add_locations(self, locations: Dict[str, Optional[int]],
|
def add_locations(self, locations: Dict[str, Optional[int]],
|
||||||
location_type: Optional[Type[Location]] = None) -> None:
|
location_type: Optional[type[Location]] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
||||||
location names to address.
|
location names to address.
|
||||||
@@ -1087,6 +1255,48 @@ class Region:
|
|||||||
for location, address in locations.items():
|
for location, address in locations.items():
|
||||||
self.locations.append(location_type(self.player, location, address, self))
|
self.locations.append(location_type(self.player, location, address, self))
|
||||||
|
|
||||||
|
def add_event(
|
||||||
|
self,
|
||||||
|
location_name: str,
|
||||||
|
item_name: str | None = None,
|
||||||
|
rule: Callable[[CollectionState], bool] | None = None,
|
||||||
|
location_type: type[Location] | None = None,
|
||||||
|
item_type: type[Item] | None = None,
|
||||||
|
show_in_spoiler: bool = True,
|
||||||
|
) -> Item:
|
||||||
|
"""
|
||||||
|
Adds an event location/item pair to the region.
|
||||||
|
|
||||||
|
:param location_name: Name for the event location.
|
||||||
|
:param item_name: Name for the event item. If not provided, defaults to location_name.
|
||||||
|
:param rule: Callable to determine access for this event location within its region.
|
||||||
|
:param location_type: Location class to create the event location with. Defaults to BaseClasses.Location.
|
||||||
|
:param item_type: Item class to create the event item with. Defaults to BaseClasses.Item.
|
||||||
|
:param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute.
|
||||||
|
:return: The created Event Item
|
||||||
|
"""
|
||||||
|
if location_type is None:
|
||||||
|
location_type = Location
|
||||||
|
|
||||||
|
if item_name is None:
|
||||||
|
item_name = location_name
|
||||||
|
|
||||||
|
if item_type is None:
|
||||||
|
item_type = Item
|
||||||
|
|
||||||
|
event_location = location_type(self.player, location_name, None, self)
|
||||||
|
event_location.show_in_spoiler = show_in_spoiler
|
||||||
|
if rule is not None:
|
||||||
|
event_location.access_rule = rule
|
||||||
|
|
||||||
|
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
|
||||||
|
|
||||||
|
event_location.place_locked_item(event_item)
|
||||||
|
|
||||||
|
self.locations.append(event_location)
|
||||||
|
|
||||||
|
return event_item
|
||||||
|
|
||||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||||
"""
|
"""
|
||||||
@@ -1111,21 +1321,35 @@ class Region:
|
|||||||
self.exits.append(exit_)
|
self.exits.append(exit_)
|
||||||
return exit_
|
return exit_
|
||||||
|
|
||||||
|
def create_er_target(self, name: str) -> Entrance:
|
||||||
|
"""
|
||||||
|
Creates and returns an Entrance object as an entrance to this region
|
||||||
|
|
||||||
|
:param name: name of the Entrance being created
|
||||||
|
"""
|
||||||
|
entrance = self.entrance_type(self.player, name)
|
||||||
|
entrance.connect(self)
|
||||||
|
return entrance
|
||||||
|
|
||||||
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
||||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
|
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
|
||||||
"""
|
"""
|
||||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||||
|
|
||||||
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
|
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
|
||||||
created entrances will be named "self.name -> connecting_region"
|
created entrances will be named "self.name -> connecting_region"
|
||||||
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
|
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
|
||||||
"""
|
"""
|
||||||
if not isinstance(exits, Dict):
|
if not isinstance(exits, Dict):
|
||||||
exits = dict.fromkeys(exits)
|
exits = dict.fromkeys(exits)
|
||||||
for connecting_region, name in exits.items():
|
return [
|
||||||
self.connect(self.multiworld.get_region(connecting_region, self.player),
|
self.connect(
|
||||||
name,
|
self.multiworld.get_region(connecting_region, self.player),
|
||||||
rules[connecting_region] if rules and connecting_region in rules else None)
|
name,
|
||||||
|
rules[connecting_region] if rules and connecting_region in rules else None,
|
||||||
|
)
|
||||||
|
for connecting_region, name in exits.items()
|
||||||
|
]
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||||
@@ -1183,9 +1407,6 @@ class Location:
|
|||||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
def __hash__(self):
|
|
||||||
return hash((self.name, self.player))
|
|
||||||
|
|
||||||
def __lt__(self, other: Location):
|
def __lt__(self, other: Location):
|
||||||
return (self.player, self.name) < (other.player, other.name)
|
return (self.player, self.name) < (other.player, other.name)
|
||||||
|
|
||||||
@@ -1209,13 +1430,26 @@ class Location:
|
|||||||
|
|
||||||
|
|
||||||
class ItemClassification(IntFlag):
|
class ItemClassification(IntFlag):
|
||||||
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
|
filler = 0b0000
|
||||||
progression = 0b0001 # Item that is logically relevant
|
""" aka trash, as in filler items like ammo, currency etc """
|
||||||
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
|
|
||||||
trap = 0b0100 # detrimental item
|
progression = 0b0001
|
||||||
skip_balancing = 0b1000 # should technically never occur on its own
|
""" Item that is logically relevant.
|
||||||
# Item that is logically relevant, but progression balancing should not touch.
|
Protects this item from being placed on excluded or unreachable locations. """
|
||||||
# Typically currency or other counted items.
|
|
||||||
|
useful = 0b0010
|
||||||
|
""" Item that is especially useful.
|
||||||
|
Protects this item from being placed on excluded or unreachable locations.
|
||||||
|
When combined with another flag like "progression", it means "an especially useful progression item". """
|
||||||
|
|
||||||
|
trap = 0b0100
|
||||||
|
""" Item that is detrimental in some way. """
|
||||||
|
|
||||||
|
skip_balancing = 0b1000
|
||||||
|
""" should technically never occur on its own
|
||||||
|
Item that is logically relevant, but progression balancing should not touch.
|
||||||
|
Typically currency or other counted items. """
|
||||||
|
|
||||||
progression_skip_balancing = 0b1001 # only progression gets balanced
|
progression_skip_balancing = 0b1001 # only progression gets balanced
|
||||||
|
|
||||||
def as_flag(self) -> int:
|
def as_flag(self) -> int:
|
||||||
@@ -1264,6 +1498,10 @@ class Item:
|
|||||||
def trap(self) -> bool:
|
def trap(self) -> bool:
|
||||||
return ItemClassification.trap in self.classification
|
return ItemClassification.trap in self.classification
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filler(self) -> bool:
|
||||||
|
return not (self.advancement or self.useful or self.trap)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def excludable(self) -> bool:
|
def excludable(self) -> bool:
|
||||||
return not (self.advancement or self.useful)
|
return not (self.advancement or self.useful)
|
||||||
@@ -1272,6 +1510,10 @@ class Item:
|
|||||||
def flags(self) -> int:
|
def flags(self) -> int:
|
||||||
return self.classification.as_flag()
|
return self.classification.as_flag()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_event(self) -> bool:
|
||||||
|
return self.code is None
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
if not isinstance(other, Item):
|
if not isinstance(other, Item):
|
||||||
return NotImplemented
|
return NotImplemented
|
||||||
@@ -1365,35 +1607,40 @@ class Spoiler:
|
|||||||
|
|
||||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||||
# reducing each range of influence to the bare minimum required inside it
|
# reducing each range of influence to the bare minimum required inside it
|
||||||
restore_later: Dict[Location, Item] = {}
|
required_locations = {location for sphere in collection_spheres for location in sphere}
|
||||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||||
to_delete: Set[Location] = set()
|
to_delete: Set[Location] = set()
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
# we remove the item at location and check if game is still beatable
|
# we remove the location from required_locations to sweep from, and check if the game is still beatable
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||||
location.item.player)
|
location.item.player)
|
||||||
old_item = location.item
|
required_locations.remove(location)
|
||||||
location.item = None
|
if multiworld.can_beat_game(state_cache[num], required_locations):
|
||||||
if multiworld.can_beat_game(state_cache[num]):
|
|
||||||
to_delete.add(location)
|
to_delete.add(location)
|
||||||
restore_later[location] = old_item
|
|
||||||
else:
|
else:
|
||||||
# still required, got to keep it around
|
# still required, got to keep it around
|
||||||
location.item = old_item
|
required_locations.add(location)
|
||||||
|
|
||||||
# cull entries in spheres for spoiler walkthrough at end
|
# cull entries in spheres for spoiler walkthrough at end
|
||||||
sphere -= to_delete
|
sphere -= to_delete
|
||||||
|
|
||||||
# second phase, sphere 0
|
# second phase, sphere 0
|
||||||
removed_precollected: List[Item] = []
|
removed_precollected: List[Item] = []
|
||||||
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
for precollected_items in multiworld.precollected_items.values():
|
||||||
multiworld.precollected_items[item.player].remove(item)
|
# The list of items is mutated by removing one item at a time to determine if each item is required to beat
|
||||||
multiworld.state.remove(item)
|
# the game, and re-adding that item if it was required, so a copy needs to be made before iterating.
|
||||||
if not multiworld.can_beat_game():
|
for item in precollected_items.copy():
|
||||||
multiworld.push_precollected(item)
|
if not item.advancement:
|
||||||
else:
|
continue
|
||||||
removed_precollected.append(item)
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||||
|
precollected_items.remove(item)
|
||||||
|
multiworld.state.remove(item)
|
||||||
|
if not multiworld.can_beat_game(multiworld.state, required_locations):
|
||||||
|
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
|
||||||
|
multiworld.push_precollected(item)
|
||||||
|
else:
|
||||||
|
removed_precollected.append(item)
|
||||||
|
|
||||||
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
||||||
# the previous pruning stage could potentially have made certain items dependant on others
|
# the previous pruning stage could potentially have made certain items dependant on others
|
||||||
@@ -1431,9 +1678,6 @@ class Spoiler:
|
|||||||
self.create_paths(state, collection_spheres)
|
self.create_paths(state, collection_spheres)
|
||||||
|
|
||||||
# repair the multiworld again
|
# repair the multiworld again
|
||||||
for location, item in restore_later.items():
|
|
||||||
location.item = item
|
|
||||||
|
|
||||||
for item in removed_precollected:
|
for item in removed_precollected:
|
||||||
multiworld.push_precollected(item)
|
multiworld.push_precollected(item)
|
||||||
|
|
||||||
@@ -1532,7 +1776,7 @@ class Spoiler:
|
|||||||
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
|
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
|
||||||
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
|
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
|
||||||
if self.unreachables:
|
if self.unreachables:
|
||||||
outfile.write('\n\nUnreachable Items:\n\n')
|
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
||||||
outfile.write(
|
outfile.write(
|
||||||
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
|
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
|
||||||
|
|
||||||
|
|||||||
192
CommonClient.py
192
CommonClient.py
@@ -23,7 +23,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
from MultiServer import CommandProcessor
|
from MultiServer import CommandProcessor
|
||||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType)
|
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||||
from Utils import Version, stream_input, async_start
|
from Utils import Version, stream_input, async_start
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
@@ -31,6 +31,7 @@ import ssl
|
|||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import kvui
|
import kvui
|
||||||
|
import argparse
|
||||||
|
|
||||||
logger = logging.getLogger("Client")
|
logger = logging.getLogger("Client")
|
||||||
|
|
||||||
@@ -195,25 +196,11 @@ class CommonContext:
|
|||||||
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
||||||
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
||||||
self._archipelago_lookup: typing.Dict[int, str] = {}
|
self._archipelago_lookup: typing.Dict[int, str] = {}
|
||||||
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
|
|
||||||
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
||||||
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
||||||
self.warned: bool = False
|
|
||||||
|
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
||||||
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
|
|
||||||
if isinstance(key, int):
|
|
||||||
if not self.warned:
|
|
||||||
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
|
|
||||||
self.warned = True
|
|
||||||
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
|
|
||||||
f"backwards compatibility for now. If multiple games share the same id for a "
|
|
||||||
f"{self.lookup_type}, name could be incorrect. Please use "
|
|
||||||
f"`{self.lookup_type}_names.lookup_in_game()` or "
|
|
||||||
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
|
|
||||||
return self._flat_store[key] # type: ignore
|
|
||||||
|
|
||||||
return self._game_store[key]
|
return self._game_store[key]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
@@ -253,7 +240,6 @@ class CommonContext:
|
|||||||
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
||||||
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
||||||
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
||||||
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
|
|
||||||
if game == "Archipelago":
|
if game == "Archipelago":
|
||||||
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
||||||
# it updates in all chain maps automatically.
|
# it updates in all chain maps automatically.
|
||||||
@@ -280,38 +266,71 @@ class CommonContext:
|
|||||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||||
|
|
||||||
# remaining type info
|
# remaining type info
|
||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: dict[int, NetworkSlot]
|
||||||
server_address: typing.Optional[str]
|
"""Slot Info from the server for the current connection"""
|
||||||
password: typing.Optional[str]
|
server_address: str | None
|
||||||
hint_cost: typing.Optional[int]
|
"""Autoconnect address provided by the ctx constructor"""
|
||||||
hint_points: typing.Optional[int]
|
password: str | None
|
||||||
player_names: typing.Dict[int, str]
|
"""Password used for Connecting, expected by server_auth"""
|
||||||
|
hint_cost: int | None
|
||||||
|
"""Current Hint Cost per Hint from the server"""
|
||||||
|
hint_points: int | None
|
||||||
|
"""Current avaliable Hint Points from the server"""
|
||||||
|
player_names: dict[int, str]
|
||||||
|
"""Current lookup of slot number to player display name from server (includes aliases)"""
|
||||||
|
|
||||||
finished_game: bool
|
finished_game: bool
|
||||||
|
"""
|
||||||
|
Bool to signal that status should be updated to Goal after reconnecting
|
||||||
|
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
|
||||||
|
"""
|
||||||
ready: bool
|
ready: bool
|
||||||
team: typing.Optional[int]
|
"""Bool to keep track of state for the /ready command"""
|
||||||
slot: typing.Optional[int]
|
team: int | None
|
||||||
auth: typing.Optional[str]
|
"""Team number of currently connected slot"""
|
||||||
seed_name: typing.Optional[str]
|
slot: int | None
|
||||||
|
"""Slot number of currently connected slot"""
|
||||||
|
auth: str | None
|
||||||
|
"""Name used in Connect packet"""
|
||||||
|
seed_name: str | None
|
||||||
|
"""Seed name that will be validated on opening a socket if present"""
|
||||||
|
|
||||||
# locations
|
# locations
|
||||||
locations_checked: typing.Set[int] # local state
|
locations_checked: set[int]
|
||||||
locations_scouted: typing.Set[int]
|
"""
|
||||||
items_received: typing.List[NetworkItem]
|
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
|
||||||
missing_locations: typing.Set[int] # server state
|
to be used to ensure that a LocationChecks packet does not get lost when disconnected
|
||||||
checked_locations: typing.Set[int] # server state
|
"""
|
||||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
locations_scouted: set[int]
|
||||||
locations_info: typing.Dict[int, NetworkItem]
|
"""
|
||||||
|
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
|
||||||
|
to be used to ensure that a LocationScouts packet does not get lost when disconnected
|
||||||
|
"""
|
||||||
|
items_received: list[NetworkItem]
|
||||||
|
"""List of NetworkItems recieved from the server"""
|
||||||
|
missing_locations: set[int]
|
||||||
|
"""Container of Locations that are unchecked per server state"""
|
||||||
|
checked_locations: set[int]
|
||||||
|
"""Container of Locations that are checked per server state"""
|
||||||
|
server_locations: set[int]
|
||||||
|
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
|
||||||
|
locations_info: dict[int, NetworkItem]
|
||||||
|
"""Dict of location id: NetworkItem info from LocationScouts request"""
|
||||||
|
|
||||||
# data storage
|
# data storage
|
||||||
stored_data: typing.Dict[str, typing.Any]
|
stored_data: dict[str, typing.Any]
|
||||||
stored_data_notification_keys: typing.Set[str]
|
"""
|
||||||
|
Data Storage values by key that were retrieved from the server
|
||||||
|
any keys subscribed to with SetNotify will be kept up to date
|
||||||
|
"""
|
||||||
|
stored_data_notification_keys: set[str]
|
||||||
|
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
|
||||||
|
|
||||||
# internals
|
# internals
|
||||||
# current message box through kvui
|
|
||||||
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
||||||
# message box reporting a loss of connection
|
"""Current message box through kvui"""
|
||||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
||||||
|
"""Message box reporting a loss of connection"""
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
||||||
# server state
|
# server state
|
||||||
@@ -355,7 +374,6 @@ class CommonContext:
|
|||||||
|
|
||||||
self.item_names = self.NameLookupDict(self, "item")
|
self.item_names = self.NameLookupDict(self, "item")
|
||||||
self.location_names = self.NameLookupDict(self, "location")
|
self.location_names = self.NameLookupDict(self, "location")
|
||||||
self.versions = {}
|
|
||||||
self.checksums = {}
|
self.checksums = {}
|
||||||
|
|
||||||
self.jsontotextparser = JSONtoTextParser(self)
|
self.jsontotextparser = JSONtoTextParser(self)
|
||||||
@@ -412,6 +430,8 @@ class CommonContext:
|
|||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task is not None:
|
if self.server_task is not None:
|
||||||
await self.server_task
|
await self.server_task
|
||||||
|
if self.ui:
|
||||||
|
self.ui.update_hints()
|
||||||
|
|
||||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||||
""" `msgs` JSON serializable """
|
""" `msgs` JSON serializable """
|
||||||
@@ -458,6 +478,13 @@ class CommonContext:
|
|||||||
await self.send_msgs([payload])
|
await self.send_msgs([payload])
|
||||||
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
|
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
|
||||||
|
|
||||||
|
async def check_locations(self, locations: typing.Collection[int]) -> set[int]:
|
||||||
|
"""Send new location checks to the server. Returns the set of actually new locations that were sent."""
|
||||||
|
locations = set(locations) & self.missing_locations
|
||||||
|
if locations:
|
||||||
|
await self.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(locations)}])
|
||||||
|
return locations
|
||||||
|
|
||||||
async def console_input(self) -> str:
|
async def console_input(self) -> str:
|
||||||
if self.ui:
|
if self.ui:
|
||||||
self.ui.focus_textinput()
|
self.ui.focus_textinput()
|
||||||
@@ -551,10 +578,16 @@ class CommonContext:
|
|||||||
await self.ui_task
|
await self.ui_task
|
||||||
if self.input_task:
|
if self.input_task:
|
||||||
self.input_task.cancel()
|
self.input_task.cancel()
|
||||||
|
|
||||||
|
# Hints
|
||||||
|
def update_hint(self, location: int, finding_player: int, status: typing.Optional[HintStatus]) -> None:
|
||||||
|
msg = {"cmd": "UpdateHint", "location": location, "player": finding_player}
|
||||||
|
if status is not None:
|
||||||
|
msg["status"] = status
|
||||||
|
async_start(self.send_msgs([msg]), name="update_hint")
|
||||||
|
|
||||||
# DataPackage
|
# DataPackage
|
||||||
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
||||||
remote_date_package_versions: typing.Dict[str, int],
|
|
||||||
remote_data_package_checksums: typing.Dict[str, str]):
|
remote_data_package_checksums: typing.Dict[str, str]):
|
||||||
"""Validate that all data is present for the current multiworld.
|
"""Validate that all data is present for the current multiworld.
|
||||||
Download, assimilate and cache missing data from the server."""
|
Download, assimilate and cache missing data from the server."""
|
||||||
@@ -563,33 +596,26 @@ class CommonContext:
|
|||||||
|
|
||||||
needed_updates: typing.Set[str] = set()
|
needed_updates: typing.Set[str] = set()
|
||||||
for game in relevant_games:
|
for game in relevant_games:
|
||||||
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
|
if game not in remote_data_package_checksums:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
remote_version: int = remote_date_package_versions.get(game, 0)
|
|
||||||
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
||||||
|
|
||||||
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
|
if not remote_checksum: # custom data package and no checksum for this game
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cached_version: int = self.versions.get(game, 0)
|
|
||||||
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
||||||
# no action required if cached version is new enough
|
# no action required if cached version is new enough
|
||||||
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
|
if remote_checksum != cached_checksum:
|
||||||
or remote_checksum != cached_checksum:
|
|
||||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
|
||||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||||
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
|
if remote_checksum == local_checksum:
|
||||||
and remote_checksum == local_checksum):
|
|
||||||
self.update_game(network_data_package["games"][game], game)
|
self.update_game(network_data_package["games"][game], game)
|
||||||
else:
|
else:
|
||||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||||
cache_version: int = cached_game.get("version", 0)
|
|
||||||
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
||||||
# download remote version if cache is not new enough
|
# download remote version if cache is not new enough
|
||||||
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
if remote_checksum != cache_checksum:
|
||||||
or remote_checksum != cache_checksum:
|
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
else:
|
else:
|
||||||
self.update_game(cached_game, game)
|
self.update_game(cached_game, game)
|
||||||
@@ -599,7 +625,6 @@ class CommonContext:
|
|||||||
def update_game(self, game_package: dict, game: str):
|
def update_game(self, game_package: dict, game: str):
|
||||||
self.item_names.update_game(game, game_package["item_name_to_id"])
|
self.item_names.update_game(game, game_package["item_name_to_id"])
|
||||||
self.location_names.update_game(game, game_package["location_name_to_id"])
|
self.location_names.update_game(game, game_package["location_name_to_id"])
|
||||||
self.versions[game] = game_package.get("version", 0)
|
|
||||||
self.checksums[game] = game_package.get("checksum")
|
self.checksums[game] = game_package.get("checksum")
|
||||||
|
|
||||||
def update_data_package(self, data_package: dict):
|
def update_data_package(self, data_package: dict):
|
||||||
@@ -608,9 +633,6 @@ class CommonContext:
|
|||||||
|
|
||||||
def consume_network_data_package(self, data_package: dict):
|
def consume_network_data_package(self, data_package: dict):
|
||||||
self.update_data_package(data_package)
|
self.update_data_package(data_package)
|
||||||
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
|
||||||
current_cache.update(data_package["games"])
|
|
||||||
Utils.persistent_store("datapackage", "games", current_cache)
|
|
||||||
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
||||||
for game, game_data in data_package["games"].items():
|
for game, game_data in data_package["games"].items():
|
||||||
Utils.store_data_package_for_checksum(game, game_data)
|
Utils.store_data_package_for_checksum(game, game_data)
|
||||||
@@ -693,8 +715,16 @@ class CommonContext:
|
|||||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||||
|
|
||||||
def make_gui(self) -> typing.Type["kvui.GameManager"]:
|
def make_gui(self) -> "type[kvui.GameManager]":
|
||||||
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
|
"""
|
||||||
|
To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built
|
||||||
|
|
||||||
|
Common changes are changing `base_title` to update the window title of the client and
|
||||||
|
updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger.
|
||||||
|
|
||||||
|
ex. `logging_pairs.append(("Foo", "Bar"))`
|
||||||
|
will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")`
|
||||||
|
"""
|
||||||
from kvui import GameManager
|
from kvui import GameManager
|
||||||
|
|
||||||
class TextManager(GameManager):
|
class TextManager(GameManager):
|
||||||
@@ -865,9 +895,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||||
|
|
||||||
# update data package
|
# update data package
|
||||||
data_package_versions = args.get("datapackage_versions", {})
|
|
||||||
data_package_checksums = args.get("datapackage_checksums", {})
|
data_package_checksums = args.get("datapackage_checksums", {})
|
||||||
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
|
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
|
||||||
|
|
||||||
await ctx.server_auth(args['password'])
|
await ctx.server_auth(args['password'])
|
||||||
|
|
||||||
@@ -883,6 +912,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.disconnected_intentionally = True
|
ctx.disconnected_intentionally = True
|
||||||
ctx.event_invalid_game()
|
ctx.event_invalid_game()
|
||||||
elif 'IncompatibleVersion' in errors:
|
elif 'IncompatibleVersion' in errors:
|
||||||
|
ctx.disconnected_intentionally = True
|
||||||
raise Exception('Server reported your client version as incompatible. '
|
raise Exception('Server reported your client version as incompatible. '
|
||||||
'This probably means you have to update.')
|
'This probably means you have to update.')
|
||||||
elif 'InvalidItemsHandling' in errors:
|
elif 'InvalidItemsHandling' in errors:
|
||||||
@@ -1033,6 +1063,32 @@ def get_base_parser(description: typing.Optional[str] = None):
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def handle_url_arg(args: "argparse.Namespace",
|
||||||
|
parser: "typing.Optional[argparse.ArgumentParser]" = None) -> "argparse.Namespace":
|
||||||
|
"""
|
||||||
|
Parse the url arg "archipelago://name:pass@host:port" from launcher into correct launch args for CommonClient
|
||||||
|
If alternate data is required the urlparse response is saved back to args.url if valid
|
||||||
|
"""
|
||||||
|
if not args.url:
|
||||||
|
return args
|
||||||
|
|
||||||
|
url = urllib.parse.urlparse(args.url)
|
||||||
|
if url.scheme != "archipelago":
|
||||||
|
if not parser:
|
||||||
|
parser = get_base_parser()
|
||||||
|
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
||||||
|
return args
|
||||||
|
|
||||||
|
args.url = url
|
||||||
|
args.connect = url.netloc
|
||||||
|
if url.username:
|
||||||
|
args.name = urllib.parse.unquote(url.username)
|
||||||
|
if url.password:
|
||||||
|
args.password = urllib.parse.unquote(url.password)
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
def run_as_textclient(*args):
|
def run_as_textclient(*args):
|
||||||
class TextContext(CommonContext):
|
class TextContext(CommonContext):
|
||||||
# Text Mode to use !hint and such with games that have no text entry
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
@@ -1045,7 +1101,7 @@ def run_as_textclient(*args):
|
|||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
await super(TextContext, self).server_auth(password_requested)
|
await super(TextContext, self).server_auth(password_requested)
|
||||||
await self.get_username()
|
await self.get_username()
|
||||||
await self.send_connect()
|
await self.send_connect(game="")
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
@@ -1074,20 +1130,10 @@ def run_as_textclient(*args):
|
|||||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||||
args = parser.parse_args(args)
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
|
args = handle_url_arg(args, parser=parser)
|
||||||
if args.url:
|
|
||||||
url = urllib.parse.urlparse(args.url)
|
|
||||||
if url.scheme == "archipelago":
|
|
||||||
args.connect = url.netloc
|
|
||||||
if url.username:
|
|
||||||
args.name = urllib.parse.unquote(url.username)
|
|
||||||
if url.password:
|
|
||||||
args.password = urllib.parse.unquote(url.password)
|
|
||||||
else:
|
|
||||||
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
|
||||||
|
|
||||||
# use colorama to display colored text highlighting on windows
|
# use colorama to display colored text highlighting on windows
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
267
FF1Client.py
267
FF1Client.py
@@ -1,267 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from asyncio import StreamReader, StreamWriter
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
from Utils import async_start
|
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
|
||||||
get_base_parser
|
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
|
|
||||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
|
|
||||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
|
|
||||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
|
||||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
|
||||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
|
||||||
|
|
||||||
DISPLAY_MSGS = True
|
|
||||||
|
|
||||||
|
|
||||||
class FF1CommandProcessor(ClientCommandProcessor):
|
|
||||||
def __init__(self, ctx: CommonContext):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def _cmd_nes(self):
|
|
||||||
"""Check NES Connection State"""
|
|
||||||
if isinstance(self.ctx, FF1Context):
|
|
||||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
|
||||||
|
|
||||||
def _cmd_toggle_msgs(self):
|
|
||||||
"""Toggle displaying messages in EmuHawk"""
|
|
||||||
global DISPLAY_MSGS
|
|
||||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
|
||||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
|
||||||
|
|
||||||
|
|
||||||
class FF1Context(CommonContext):
|
|
||||||
command_processor = FF1CommandProcessor
|
|
||||||
game = 'Final Fantasy'
|
|
||||||
items_handling = 0b111 # full remote
|
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
|
||||||
super().__init__(server_address, password)
|
|
||||||
self.nes_streams: (StreamReader, StreamWriter) = None
|
|
||||||
self.nes_sync_task = None
|
|
||||||
self.messages = {}
|
|
||||||
self.locations_array = None
|
|
||||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
|
||||||
self.awaiting_rom = False
|
|
||||||
self.display_msgs = True
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
|
||||||
if password_requested and not self.password:
|
|
||||||
await super(FF1Context, self).server_auth(password_requested)
|
|
||||||
if not self.auth:
|
|
||||||
self.awaiting_rom = True
|
|
||||||
logger.info('Awaiting connection to NES to get Player information')
|
|
||||||
return
|
|
||||||
|
|
||||||
await self.send_connect()
|
|
||||||
|
|
||||||
def _set_message(self, msg: str, msg_id: int):
|
|
||||||
if DISPLAY_MSGS:
|
|
||||||
self.messages[time.time(), msg_id] = msg
|
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
|
||||||
if cmd == 'Connected':
|
|
||||||
async_start(parse_locations(self.locations_array, self, True))
|
|
||||||
elif cmd == 'Print':
|
|
||||||
msg = args['text']
|
|
||||||
if ': !' not in msg:
|
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
|
||||||
|
|
||||||
def on_print_json(self, args: dict):
|
|
||||||
if self.ui:
|
|
||||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
|
||||||
else:
|
|
||||||
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
|
||||||
logger.info(text)
|
|
||||||
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
|
||||||
if relevant:
|
|
||||||
item = args["item"]
|
|
||||||
# goes to this world
|
|
||||||
if self.slot_concerns_self(args["receiving"]):
|
|
||||||
relevant = True
|
|
||||||
# found in this world
|
|
||||||
elif self.slot_concerns_self(item.player):
|
|
||||||
relevant = True
|
|
||||||
# not related
|
|
||||||
else:
|
|
||||||
relevant = False
|
|
||||||
if relevant:
|
|
||||||
item = args["item"]
|
|
||||||
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
|
||||||
self._set_message(msg, item.item)
|
|
||||||
|
|
||||||
def run_gui(self):
|
|
||||||
from kvui import GameManager
|
|
||||||
|
|
||||||
class FF1Manager(GameManager):
|
|
||||||
logging_pairs = [
|
|
||||||
("Client", "Archipelago")
|
|
||||||
]
|
|
||||||
base_title = "Archipelago Final Fantasy 1 Client"
|
|
||||||
|
|
||||||
self.ui = FF1Manager(self)
|
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
|
||||||
|
|
||||||
|
|
||||||
def get_payload(ctx: FF1Context):
|
|
||||||
current_time = time.time()
|
|
||||||
return json.dumps(
|
|
||||||
{
|
|
||||||
"items": [item.item for item in ctx.items_received],
|
|
||||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
|
||||||
if key[0] > current_time - 10}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
|
|
||||||
if locations_array == ctx.locations_array and not force:
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# print("New values")
|
|
||||||
ctx.locations_array = locations_array
|
|
||||||
locations_checked = []
|
|
||||||
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
|
|
||||||
await ctx.send_msgs([
|
|
||||||
{"cmd": "StatusUpdate",
|
|
||||||
"status": 30}
|
|
||||||
])
|
|
||||||
ctx.finished_game = True
|
|
||||||
for location in ctx.missing_locations:
|
|
||||||
# index will be - 0x100 or 0x200
|
|
||||||
index = location
|
|
||||||
if location < 0x200:
|
|
||||||
# Location is a chest
|
|
||||||
index -= 0x100
|
|
||||||
flag = 0x04
|
|
||||||
else:
|
|
||||||
# Location is an NPC
|
|
||||||
index -= 0x200
|
|
||||||
flag = 0x02
|
|
||||||
|
|
||||||
# print(f"Location: {ctx.location_names[location]}")
|
|
||||||
# print(f"Index: {str(hex(index))}")
|
|
||||||
# print(f"value: {locations_array[index] & flag != 0}")
|
|
||||||
if locations_array[index] & flag != 0:
|
|
||||||
locations_checked.append(location)
|
|
||||||
if locations_checked:
|
|
||||||
# print([ctx.location_names[location] for location in locations_checked])
|
|
||||||
await ctx.send_msgs([
|
|
||||||
{"cmd": "LocationChecks",
|
|
||||||
"locations": locations_checked}
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
async def nes_sync_task(ctx: FF1Context):
|
|
||||||
logger.info("Starting nes connector. Use /nes for status information")
|
|
||||||
while not ctx.exit_event.is_set():
|
|
||||||
error_status = None
|
|
||||||
if ctx.nes_streams:
|
|
||||||
(reader, writer) = ctx.nes_streams
|
|
||||||
msg = get_payload(ctx).encode()
|
|
||||||
writer.write(msg)
|
|
||||||
writer.write(b'\n')
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
|
||||||
try:
|
|
||||||
# Data will return a dict with up to two fields:
|
|
||||||
# 1. A keepalive response of the Players Name (always)
|
|
||||||
# 2. An array representing the memory values of the locations area (if in game)
|
|
||||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
|
||||||
data_decoded = json.loads(data.decode())
|
|
||||||
# print(data_decoded)
|
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
|
||||||
# Not just a keep alive ping, parse
|
|
||||||
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
|
||||||
if not ctx.auth:
|
|
||||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
|
||||||
if ctx.auth == '':
|
|
||||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
|
||||||
"the ROM using the same link but adding your slot name")
|
|
||||||
if ctx.awaiting_rom:
|
|
||||||
await ctx.server_auth(False)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.debug("Read Timed Out, Reconnecting")
|
|
||||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
except ConnectionResetError as e:
|
|
||||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
except TimeoutError:
|
|
||||||
logger.debug("Connection Timed Out, Reconnecting")
|
|
||||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
except ConnectionResetError:
|
|
||||||
logger.debug("Connection Lost, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
|
|
||||||
if not error_status:
|
|
||||||
logger.info("Successfully Connected to NES")
|
|
||||||
ctx.nes_status = CONNECTION_CONNECTED_STATUS
|
|
||||||
else:
|
|
||||||
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
|
|
||||||
elif error_status:
|
|
||||||
ctx.nes_status = error_status
|
|
||||||
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
logger.debug("Attempting to connect to NES")
|
|
||||||
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
|
|
||||||
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
|
|
||||||
except TimeoutError:
|
|
||||||
logger.debug("Connection Timed Out, Trying Again")
|
|
||||||
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
continue
|
|
||||||
except ConnectionRefusedError:
|
|
||||||
logger.debug("Connection Refused, Trying Again")
|
|
||||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Text Mode to use !hint and such with games that have no text entry
|
|
||||||
Utils.init_logging("FF1Client")
|
|
||||||
|
|
||||||
options = Utils.get_options()
|
|
||||||
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
|
|
||||||
|
|
||||||
async def main(args):
|
|
||||||
ctx = FF1Context(args.connect, args.password)
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
|
||||||
if gui_enabled:
|
|
||||||
ctx.run_gui()
|
|
||||||
ctx.run_cli()
|
|
||||||
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
|
||||||
ctx.server_address = None
|
|
||||||
|
|
||||||
await ctx.shutdown()
|
|
||||||
|
|
||||||
if ctx.nes_sync_task:
|
|
||||||
await ctx.nes_sync_task
|
|
||||||
|
|
||||||
|
|
||||||
import colorama
|
|
||||||
|
|
||||||
parser = get_base_parser()
|
|
||||||
args = parser.parse_args()
|
|
||||||
colorama.init()
|
|
||||||
|
|
||||||
asyncio.run(main(args))
|
|
||||||
colorama.deinit()
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from worlds.factorio.Client import check_stdin, launch
|
|
||||||
import Utils
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
|
||||||
check_stdin()
|
|
||||||
launch()
|
|
||||||
458
Fill.py
458
Fill.py
@@ -4,7 +4,7 @@ import logging
|
|||||||
import typing
|
import typing
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
|
||||||
from Options import Accessibility
|
from Options import Accessibility
|
||||||
|
|
||||||
from worlds.AutoWorld import call_all
|
from worlds.AutoWorld import call_all
|
||||||
@@ -36,7 +36,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
|||||||
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||||
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||||
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
|
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
|
||||||
|
name: str = "Unknown") -> None:
|
||||||
"""
|
"""
|
||||||
:param multiworld: Multiworld to be filled.
|
:param multiworld: Multiworld to be filled.
|
||||||
:param base_state: State assumed before fill.
|
:param base_state: State assumed before fill.
|
||||||
@@ -63,14 +64,24 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
placed = 0
|
placed = 0
|
||||||
|
|
||||||
while any(reachable_items.values()) and locations:
|
while any(reachable_items.values()) and locations:
|
||||||
# grab one item per player
|
if one_item_per_player:
|
||||||
items_to_place = [items.pop()
|
# grab one item per player
|
||||||
for items in reachable_items.values() if items]
|
items_to_place = [items.pop()
|
||||||
|
for items in reachable_items.values() if items]
|
||||||
|
else:
|
||||||
|
next_player = multiworld.random.choice([player for player, items in reachable_items.items() if items])
|
||||||
|
items_to_place = []
|
||||||
|
if item_pool:
|
||||||
|
items_to_place.append(reachable_items[next_player].pop())
|
||||||
|
|
||||||
for item in items_to_place:
|
for item in items_to_place:
|
||||||
for p, pool_item in enumerate(item_pool):
|
# The items added into `reachable_items` are placed starting from the end of each deque in
|
||||||
|
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
|
||||||
|
for p, pool_item in enumerate(reversed(item_pool), start=1):
|
||||||
if pool_item is item:
|
if pool_item is item:
|
||||||
item_pool.pop(p)
|
del item_pool[-p]
|
||||||
break
|
break
|
||||||
|
|
||||||
maximum_exploration_state = sweep_from_pool(
|
maximum_exploration_state = sweep_from_pool(
|
||||||
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
|
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
|
||||||
if single_player_placement else None)
|
if single_player_placement else None)
|
||||||
@@ -89,7 +100,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
# if minimal accessibility, only check whether location is reachable if game not beatable
|
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||||
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
||||||
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
||||||
item_to_place.player) \
|
item_to_place.player) \
|
||||||
if single_player_placement else not has_beaten_game
|
if single_player_placement else not has_beaten_game
|
||||||
else:
|
else:
|
||||||
perform_access_check = True
|
perform_access_check = True
|
||||||
@@ -127,32 +138,21 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
# to clean that up later, so there is a chance generation fails.
|
# to clean that up later, so there is a chance generation fails.
|
||||||
if (not single_player_placement or location.player == item_to_place.player) \
|
if (not single_player_placement or location.player == item_to_place.player) \
|
||||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||||
|
# Add this item to the existing placement, and
|
||||||
|
# add the old item to the back of the queue
|
||||||
|
spot_to_fill = placements.pop(i)
|
||||||
|
|
||||||
# Verify placing this item won't reduce available locations, which would be a useless swap.
|
swap_count += 1
|
||||||
prev_state = swap_state.copy()
|
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
||||||
prev_loc_count = len(
|
|
||||||
multiworld.get_reachable_locations(prev_state))
|
|
||||||
|
|
||||||
swap_state.collect(item_to_place, True)
|
reachable_items[placed_item.player].appendleft(
|
||||||
new_loc_count = len(
|
placed_item)
|
||||||
multiworld.get_reachable_locations(swap_state))
|
item_pool.append(placed_item)
|
||||||
|
|
||||||
if new_loc_count >= prev_loc_count:
|
# cleanup at the end to hopefully get better errors
|
||||||
# Add this item to the existing placement, and
|
cleanup_required = True
|
||||||
# add the old item to the back of the queue
|
|
||||||
spot_to_fill = placements.pop(i)
|
|
||||||
|
|
||||||
swap_count += 1
|
break
|
||||||
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
|
||||||
|
|
||||||
reachable_items[placed_item.player].appendleft(
|
|
||||||
placed_item)
|
|
||||||
item_pool.append(placed_item)
|
|
||||||
|
|
||||||
# cleanup at the end to hopefully get better errors
|
|
||||||
cleanup_required = True
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
# Item can't be placed here, restore original item
|
# Item can't be placed here, restore original item
|
||||||
location.item = placed_item
|
location.item = placed_item
|
||||||
@@ -226,18 +226,30 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
locations: typing.List[Location],
|
locations: typing.List[Location],
|
||||||
itempool: typing.List[Item],
|
itempool: typing.List[Item],
|
||||||
name: str = "Remaining",
|
name: str = "Remaining",
|
||||||
move_unplaceable_to_start_inventory: bool = False) -> None:
|
move_unplaceable_to_start_inventory: bool = False,
|
||||||
|
check_location_can_fill: bool = False) -> None:
|
||||||
unplaced_items: typing.List[Item] = []
|
unplaced_items: typing.List[Item] = []
|
||||||
placements: typing.List[Location] = []
|
placements: typing.List[Location] = []
|
||||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||||
total = min(len(itempool), len(locations))
|
total = min(len(itempool), len(locations))
|
||||||
placed = 0
|
placed = 0
|
||||||
|
|
||||||
|
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
||||||
|
if check_location_can_fill:
|
||||||
|
state = CollectionState(multiworld)
|
||||||
|
|
||||||
|
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
|
||||||
|
return location_to_fill.can_fill(state, item_to_fill, check_access=False)
|
||||||
|
else:
|
||||||
|
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
|
||||||
|
return location_to_fill.item_rule(item_to_fill)
|
||||||
|
|
||||||
while locations and itempool:
|
while locations and itempool:
|
||||||
item_to_place = itempool.pop()
|
item_to_place = itempool.pop()
|
||||||
spot_to_fill: typing.Optional[Location] = None
|
spot_to_fill: typing.Optional[Location] = None
|
||||||
|
|
||||||
for i, location in enumerate(locations):
|
for i, location in enumerate(locations):
|
||||||
if location.item_rule(item_to_place):
|
if location_can_fill_item(location, item_to_place):
|
||||||
# popping by index is faster than removing by content,
|
# popping by index is faster than removing by content,
|
||||||
spot_to_fill = locations.pop(i)
|
spot_to_fill = locations.pop(i)
|
||||||
# skipping a scan for the element
|
# skipping a scan for the element
|
||||||
@@ -258,7 +270,7 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
|
|
||||||
location.item = None
|
location.item = None
|
||||||
placed_item.location = None
|
placed_item.location = None
|
||||||
if location.item_rule(item_to_place):
|
if location_can_fill_item(location, item_to_place):
|
||||||
# Add this item to the existing placement, and
|
# Add this item to the existing placement, and
|
||||||
# add the old item to the back of the queue
|
# add the old item to the back of the queue
|
||||||
spot_to_fill = placements.pop(i)
|
spot_to_fill = placements.pop(i)
|
||||||
@@ -320,17 +332,19 @@ def fast_fill(multiworld: MultiWorld,
|
|||||||
|
|
||||||
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
||||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||||
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
|
minimal_players = {player for player in multiworld.player_ids if
|
||||||
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
|
multiworld.worlds[player].options.accessibility == "minimal"}
|
||||||
|
unreachable_locations = [location for location in multiworld.get_locations() if
|
||||||
|
location.player in minimal_players and
|
||||||
not location.can_reach(maximum_exploration_state)]
|
not location.can_reach(maximum_exploration_state)]
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||||
location.locked and location.item.player not in minimal_players):
|
location.locked and location.item.player not in minimal_players):
|
||||||
pool.append(location.item)
|
pool.append(location.item)
|
||||||
state.remove(location.item)
|
|
||||||
location.item = None
|
location.item = None
|
||||||
if location in state.advancements:
|
if location in state.advancements:
|
||||||
state.advancements.remove(location)
|
state.advancements.remove(location)
|
||||||
|
state.remove(location.item)
|
||||||
locations.append(location)
|
locations.append(location)
|
||||||
if pool and locations:
|
if pool and locations:
|
||||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||||
@@ -342,7 +356,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
|
|||||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||||
if unreachable_locations:
|
if unreachable_locations:
|
||||||
def forbid_important_item_rule(item: Item):
|
def forbid_important_item_rule(item: Item):
|
||||||
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
|
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
|
||||||
|
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
add_item_rule(location, forbid_important_item_rule)
|
add_item_rule(location, forbid_important_item_rule)
|
||||||
@@ -479,21 +493,31 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
# "priority fill"
|
# "priority fill"
|
||||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
|
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority", one_item_per_player=True, allow_partial=True)
|
||||||
|
|
||||||
|
if prioritylocations:
|
||||||
|
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||||
|
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
||||||
|
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry", one_item_per_player=False)
|
||||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
# "advancement/progression fill"
|
# "advancement/progression fill"
|
||||||
|
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
||||||
if panic_method == "swap":
|
if panic_method == "swap":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
|
||||||
name="Progression", single_player_placement=single_player)
|
name="Progression", single_player_placement=single_player)
|
||||||
elif panic_method == "raise":
|
elif panic_method == "raise":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
||||||
name="Progression", single_player_placement=single_player)
|
name="Progression", single_player_placement=single_player)
|
||||||
elif panic_method == "start_inventory":
|
elif panic_method == "start_inventory":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
||||||
allow_partial=True, name="Progression", single_player_placement=single_player)
|
allow_partial=True, name="Progression", single_player_placement=single_player)
|
||||||
if progitempool:
|
if progitempool:
|
||||||
for item in progitempool:
|
for item in progitempool:
|
||||||
@@ -509,7 +533,8 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
if progitempool:
|
if progitempool:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f"Not enough locations for progression items. "
|
f"Not enough locations for progression items. "
|
||||||
f"There are {len(progitempool)} more progression items than there are available locations.",
|
f"There are {len(progitempool)} more progression items than there are available locations.\n"
|
||||||
|
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
|
||||||
multiworld=multiworld,
|
multiworld=multiworld,
|
||||||
)
|
)
|
||||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
||||||
@@ -527,7 +552,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
if excludedlocations:
|
if excludedlocations:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f"Not enough filler items for excluded locations. "
|
f"Not enough filler items for excluded locations. "
|
||||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
|
f"There are {len(excludedlocations)} more excluded locations than excludable items.",
|
||||||
multiworld=multiworld,
|
multiworld=multiworld,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -548,6 +573,26 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
print_data = {"items": items_counter, "locations": locations_counter}
|
print_data = {"items": items_counter, "locations": locations_counter}
|
||||||
logging.info(f"Per-Player counts: {print_data})")
|
logging.info(f"Per-Player counts: {print_data})")
|
||||||
|
|
||||||
|
more_locations = locations_counter - items_counter
|
||||||
|
more_items = items_counter - locations_counter
|
||||||
|
for player in multiworld.player_ids:
|
||||||
|
if more_locations[player]:
|
||||||
|
logging.error(
|
||||||
|
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
|
||||||
|
elif more_items[player]:
|
||||||
|
logging.warning(
|
||||||
|
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
|
||||||
|
if unfilled:
|
||||||
|
raise FillError(
|
||||||
|
f"Unable to fill all locations.\n" +
|
||||||
|
f"Unfilled locations({len(unfilled)}): {unfilled}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
f"Unable to place all items.\n" +
|
||||||
|
f"Unplaced items({len(unplaced)}): {unplaced}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def flood_items(multiworld: MultiWorld) -> None:
|
def flood_items(multiworld: MultiWorld) -> None:
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
@@ -623,9 +668,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if multiworld.worlds[player].options.progression_balancing > 0
|
if multiworld.worlds[player].options.progression_balancing > 0
|
||||||
}
|
}
|
||||||
if not balanceable_players:
|
if not balanceable_players:
|
||||||
logging.info('Skipping multiworld progression balancing.')
|
logging.info("Skipping multiworld progression balancing.")
|
||||||
else:
|
else:
|
||||||
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
|
||||||
logging.debug(balanceable_players)
|
logging.debug(balanceable_players)
|
||||||
state: CollectionState = CollectionState(multiworld)
|
state: CollectionState = CollectionState(multiworld)
|
||||||
checked_locations: typing.Set[Location] = set()
|
checked_locations: typing.Set[Location] = set()
|
||||||
@@ -723,7 +768,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if player in threshold_percentages):
|
if player in threshold_percentages):
|
||||||
break
|
break
|
||||||
elif not balancing_sphere:
|
elif not balancing_sphere:
|
||||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
|
||||||
# Gather a set of locations which we can swap items into
|
# Gather a set of locations which we can swap items into
|
||||||
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||||
for l in unchecked_locations:
|
for l in unchecked_locations:
|
||||||
@@ -739,8 +784,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
testing = items_to_test.pop()
|
testing = items_to_test.pop()
|
||||||
reducing_state = state.copy()
|
reducing_state = state.copy()
|
||||||
for location in itertools.chain((
|
for location in itertools.chain((
|
||||||
l for l in items_to_replace
|
l for l in items_to_replace
|
||||||
if l.item.player == player
|
if l.item.player == player
|
||||||
), items_to_test):
|
), items_to_test):
|
||||||
reducing_state.collect(location.item, True, location)
|
reducing_state.collect(location.item, True, location)
|
||||||
|
|
||||||
@@ -813,52 +858,30 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
|
|||||||
location_2.item.location = location_2
|
location_2.item.location = location_2
|
||||||
|
|
||||||
|
|
||||||
def distribute_planned(multiworld: MultiWorld) -> None:
|
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
|
||||||
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
def warn(warning: str, force: bool | str) -> None:
|
||||||
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
if isinstance(force, bool):
|
||||||
logging.warning(f'{warning}')
|
logging.warning(f"{warning}")
|
||||||
else:
|
else:
|
||||||
logging.debug(f'{warning}')
|
logging.debug(f"{warning}")
|
||||||
|
|
||||||
def failed(warning: str, force: typing.Union[bool, str]) -> None:
|
def failed(warning: str, force: bool | str) -> None:
|
||||||
if force in [True, 'fail', 'failure']:
|
if force is True:
|
||||||
raise Exception(warning)
|
raise Exception(warning)
|
||||||
else:
|
else:
|
||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
swept_state = multiworld.state.copy()
|
|
||||||
swept_state.sweep_for_advancements()
|
|
||||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
|
||||||
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
|
||||||
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
|
||||||
for loc in multiworld.get_unfilled_locations():
|
|
||||||
if loc in reachable:
|
|
||||||
early_locations[loc.player].append(loc.name)
|
|
||||||
else: # not reachable with swept state
|
|
||||||
non_early_locations[loc.player].append(loc.name)
|
|
||||||
|
|
||||||
world_name_lookup = multiworld.world_name_lookup
|
world_name_lookup = multiworld.world_name_lookup
|
||||||
|
|
||||||
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
|
||||||
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
player_ids: set[int] = set(multiworld.player_ids)
|
||||||
player_ids = set(multiworld.player_ids)
|
|
||||||
for player in player_ids:
|
for player in player_ids:
|
||||||
for block in multiworld.plando_items[player]:
|
plando_blocks[player] = []
|
||||||
block['player'] = player
|
for block in multiworld.worlds[player].options.plando_items:
|
||||||
if 'force' not in block:
|
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
|
||||||
block['force'] = 'silent'
|
target_world = block.world
|
||||||
if 'from_pool' not in block:
|
|
||||||
block['from_pool'] = True
|
|
||||||
elif not isinstance(block['from_pool'], bool):
|
|
||||||
from_pool_type = type(block['from_pool'])
|
|
||||||
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
|
|
||||||
if 'world' not in block:
|
|
||||||
target_world = False
|
|
||||||
else:
|
|
||||||
target_world = block['world']
|
|
||||||
|
|
||||||
if target_world is False or multiworld.players == 1: # target own world
|
if target_world is False or multiworld.players == 1: # target own world
|
||||||
worlds: typing.Set[int] = {player}
|
worlds: set[int] = {player}
|
||||||
elif target_world is True: # target any worlds besides own
|
elif target_world is True: # target any worlds besides own
|
||||||
worlds = set(multiworld.player_ids) - {player}
|
worlds = set(multiworld.player_ids) - {player}
|
||||||
elif target_world is None: # target all worlds
|
elif target_world is None: # target all worlds
|
||||||
@@ -867,156 +890,201 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
worlds = set()
|
worlds = set()
|
||||||
for listed_world in target_world:
|
for listed_world in target_world:
|
||||||
if listed_world not in world_name_lookup:
|
if listed_world not in world_name_lookup:
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
|
||||||
block['force'])
|
block.force)
|
||||||
continue
|
continue
|
||||||
worlds.add(world_name_lookup[listed_world])
|
worlds.add(world_name_lookup[listed_world])
|
||||||
elif type(target_world) == int: # target world by slot number
|
elif type(target_world) == int: # target world by slot number
|
||||||
if target_world not in range(1, multiworld.players + 1):
|
if target_world not in range(1, multiworld.players + 1):
|
||||||
failed(
|
failed(
|
||||||
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
||||||
block['force'])
|
block.force)
|
||||||
continue
|
continue
|
||||||
worlds = {target_world}
|
worlds = {target_world}
|
||||||
else: # target world by slot name
|
else: # target world by slot name
|
||||||
if target_world not in world_name_lookup:
|
if target_world not in world_name_lookup:
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
block['force'])
|
block.force)
|
||||||
continue
|
continue
|
||||||
worlds = {world_name_lookup[target_world]}
|
worlds = {world_name_lookup[target_world]}
|
||||||
block['world'] = worlds
|
new_block.worlds = worlds
|
||||||
|
|
||||||
items: block_value = []
|
items: list[str] | dict[str, typing.Any] = block.items
|
||||||
if "items" in block:
|
|
||||||
items = block["items"]
|
|
||||||
if 'count' not in block:
|
|
||||||
block['count'] = False
|
|
||||||
elif "item" in block:
|
|
||||||
items = block["item"]
|
|
||||||
if 'count' not in block:
|
|
||||||
block['count'] = 1
|
|
||||||
else:
|
|
||||||
failed("You must specify at least one item to place items with plando.", block['force'])
|
|
||||||
continue
|
|
||||||
if isinstance(items, dict):
|
if isinstance(items, dict):
|
||||||
item_list: typing.List[str] = []
|
item_list: list[str] = []
|
||||||
for key, value in items.items():
|
for key, value in items.items():
|
||||||
if value is True:
|
if value is True:
|
||||||
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
||||||
item_list += [key] * value
|
item_list += [key] * value
|
||||||
items = item_list
|
items = item_list
|
||||||
if isinstance(items, str):
|
new_block.items = items
|
||||||
items = [items]
|
|
||||||
block['items'] = items
|
|
||||||
|
|
||||||
locations: block_value = []
|
locations: list[str] = block.locations
|
||||||
if 'location' in block:
|
|
||||||
locations = block['location'] # just allow 'location' to keep old yamls compatible
|
|
||||||
elif 'locations' in block:
|
|
||||||
locations = block['locations']
|
|
||||||
if isinstance(locations, str):
|
if isinstance(locations, str):
|
||||||
locations = [locations]
|
locations = [locations]
|
||||||
|
|
||||||
if isinstance(locations, dict):
|
resolved_locations: list[Location] = []
|
||||||
location_list = []
|
for target_player in worlds:
|
||||||
for key, value in locations.items():
|
locations_from_groups: list[str] = []
|
||||||
location_list += [key] * value
|
world_locations = multiworld.get_unfilled_locations(target_player)
|
||||||
locations = location_list
|
for group in multiworld.worlds[target_player].location_name_groups:
|
||||||
|
if group in locations:
|
||||||
|
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
|
||||||
|
resolved_locations.extend(location for location in world_locations
|
||||||
|
if location.name in [*locations, *locations_from_groups])
|
||||||
|
new_block.locations = sorted(dict.fromkeys(locations))
|
||||||
|
new_block.resolved_locations = sorted(set(resolved_locations))
|
||||||
|
|
||||||
|
count = block.count
|
||||||
|
if not count:
|
||||||
|
count = (min(len(new_block.items), len(new_block.resolved_locations))
|
||||||
|
if new_block.resolved_locations else len(new_block.items))
|
||||||
|
if isinstance(count, int):
|
||||||
|
count = {"min": count, "max": count}
|
||||||
|
if "min" not in count:
|
||||||
|
count["min"] = 0
|
||||||
|
if "max" not in count:
|
||||||
|
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
|
||||||
|
if new_block.resolved_locations else len(new_block.items))
|
||||||
|
|
||||||
|
|
||||||
|
new_block.count = count
|
||||||
|
plando_blocks[player].append(new_block)
|
||||||
|
|
||||||
|
return plando_blocks
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_early_locations_for_planned(multiworld: MultiWorld):
|
||||||
|
def warn(warning: str, force: bool | str) -> None:
|
||||||
|
if isinstance(force, bool):
|
||||||
|
logging.warning(f"{warning}")
|
||||||
|
else:
|
||||||
|
logging.debug(f"{warning}")
|
||||||
|
|
||||||
|
def failed(warning: str, force: bool | str) -> None:
|
||||||
|
if force is True:
|
||||||
|
raise Exception(warning)
|
||||||
|
else:
|
||||||
|
warn(warning, force)
|
||||||
|
|
||||||
|
swept_state = multiworld.state.copy()
|
||||||
|
swept_state.sweep_for_advancements()
|
||||||
|
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||||
|
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
||||||
|
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
||||||
|
for loc in multiworld.get_unfilled_locations():
|
||||||
|
if loc in reachable:
|
||||||
|
early_locations[loc.player].append(loc)
|
||||||
|
else: # not reachable with swept state
|
||||||
|
non_early_locations[loc.player].append(loc)
|
||||||
|
|
||||||
|
for player in multiworld.plando_item_blocks:
|
||||||
|
removed = []
|
||||||
|
for block in multiworld.plando_item_blocks[player]:
|
||||||
|
locations = block.locations
|
||||||
|
resolved_locations = block.resolved_locations
|
||||||
|
worlds = block.worlds
|
||||||
if "early_locations" in locations:
|
if "early_locations" in locations:
|
||||||
locations.remove("early_locations")
|
|
||||||
for target_player in worlds:
|
for target_player in worlds:
|
||||||
locations += early_locations[target_player]
|
resolved_locations += early_locations[target_player]
|
||||||
if "non_early_locations" in locations:
|
if "non_early_locations" in locations:
|
||||||
locations.remove("non_early_locations")
|
|
||||||
for target_player in worlds:
|
for target_player in worlds:
|
||||||
locations += non_early_locations[target_player]
|
resolved_locations += non_early_locations[target_player]
|
||||||
|
|
||||||
block['locations'] = list(dict.fromkeys(locations))
|
if block.count["max"] > len(block.items):
|
||||||
|
count = block.count["max"]
|
||||||
|
failed(f"Plando count {count} greater than items specified", block.force)
|
||||||
|
block.count["max"] = len(block.items)
|
||||||
|
if block.count["min"] > len(block.items):
|
||||||
|
block.count["min"] = len(block.items)
|
||||||
|
if block.count["max"] > len(block.resolved_locations) > 0:
|
||||||
|
count = block.count["max"]
|
||||||
|
failed(f"Plando count {count} greater than locations specified", block.force)
|
||||||
|
block.count["max"] = len(block.resolved_locations)
|
||||||
|
if block.count["min"] > len(block.resolved_locations):
|
||||||
|
block.count["min"] = len(block.resolved_locations)
|
||||||
|
block.count["target"] = multiworld.random.randint(block.count["min"],
|
||||||
|
block.count["max"])
|
||||||
|
|
||||||
if not block['count']:
|
if not block.count["target"]:
|
||||||
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
removed.append(block)
|
||||||
len(block['locations']) > 0 else len(block['items']))
|
|
||||||
if isinstance(block['count'], int):
|
|
||||||
block['count'] = {'min': block['count'], 'max': block['count']}
|
|
||||||
if 'min' not in block['count']:
|
|
||||||
block['count']['min'] = 0
|
|
||||||
if 'max' not in block['count']:
|
|
||||||
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
|
|
||||||
len(block['locations']) > 0 else len(block['items']))
|
|
||||||
if block['count']['max'] > len(block['items']):
|
|
||||||
count = block['count']
|
|
||||||
failed(f"Plando count {count} greater than items specified", block['force'])
|
|
||||||
block['count'] = len(block['items'])
|
|
||||||
if block['count']['max'] > len(block['locations']) > 0:
|
|
||||||
count = block['count']
|
|
||||||
failed(f"Plando count {count} greater than locations specified", block['force'])
|
|
||||||
block['count'] = len(block['locations'])
|
|
||||||
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
|
|
||||||
|
|
||||||
if block['count']['target'] > 0:
|
for block in removed:
|
||||||
plando_blocks.append(block)
|
multiworld.plando_item_blocks[player].remove(block)
|
||||||
|
|
||||||
|
|
||||||
|
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
|
||||||
|
def warn(warning: str, force: bool | str) -> None:
|
||||||
|
if isinstance(force, bool):
|
||||||
|
logging.warning(f"{warning}")
|
||||||
|
else:
|
||||||
|
logging.debug(f"{warning}")
|
||||||
|
|
||||||
|
def failed(warning: str, force: bool | str) -> None:
|
||||||
|
if force is True:
|
||||||
|
raise Exception(warning)
|
||||||
|
else:
|
||||||
|
warn(warning, force)
|
||||||
|
|
||||||
# shuffle, but then sort blocks by number of locations minus number of items,
|
# shuffle, but then sort blocks by number of locations minus number of items,
|
||||||
# so less-flexible blocks get priority
|
# so less-flexible blocks get priority
|
||||||
multiworld.random.shuffle(plando_blocks)
|
multiworld.random.shuffle(plando_blocks)
|
||||||
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
|
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
|
||||||
if len(block['locations']) > 0
|
if len(block.resolved_locations) > 0
|
||||||
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
|
else len(multiworld.get_unfilled_locations(block.player)) -
|
||||||
|
block.count["target"]))
|
||||||
for placement in plando_blocks:
|
for placement in plando_blocks:
|
||||||
player = placement['player']
|
player = placement.player
|
||||||
try:
|
try:
|
||||||
worlds = placement['world']
|
worlds = placement.worlds
|
||||||
locations = placement['locations']
|
locations = placement.resolved_locations
|
||||||
items = placement['items']
|
items = placement.items
|
||||||
maxcount = placement['count']['target']
|
maxcount = placement.count["target"]
|
||||||
from_pool = placement['from_pool']
|
from_pool = placement.from_pool
|
||||||
|
|
||||||
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
item_candidates = []
|
||||||
multiworld.random.shuffle(candidates)
|
if from_pool:
|
||||||
multiworld.random.shuffle(items)
|
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
|
||||||
count = 0
|
for item in multiworld.random.sample(items, maxcount):
|
||||||
err: typing.List[str] = []
|
candidate = next((i for i in instances if i.name == item), None)
|
||||||
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
|
if candidate is None:
|
||||||
for item_name in items:
|
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
|
||||||
item = multiworld.worlds[player].create_item(item_name)
|
f"it's already missing from it", placement.force)
|
||||||
for location in reversed(candidates):
|
candidate = multiworld.worlds[player].create_item(item)
|
||||||
if (location.address is None) == (item.code is None): # either both None or both not None
|
|
||||||
if not location.item:
|
|
||||||
if location.item_rule(item):
|
|
||||||
if location.can_fill(multiworld.state, item, False):
|
|
||||||
successful_pairs.append((item, location))
|
|
||||||
candidates.remove(location)
|
|
||||||
count = count + 1
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
err.append(f"Can't place item at {location} due to fill condition not met.")
|
|
||||||
else:
|
|
||||||
err.append(f"{item_name} not allowed at {location}.")
|
|
||||||
else:
|
|
||||||
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
|
||||||
else:
|
else:
|
||||||
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
|
multiworld.itempool.remove(candidate)
|
||||||
if count == maxcount:
|
instances.remove(candidate)
|
||||||
break
|
item_candidates.append(candidate)
|
||||||
if count < placement['count']['min']:
|
else:
|
||||||
m = placement['count']['min']
|
item_candidates = [multiworld.worlds[player].create_item(item)
|
||||||
failed(
|
for item in multiworld.random.sample(items, maxcount)]
|
||||||
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
if any(item.code is None for item in item_candidates) \
|
||||||
placement['force'])
|
and not all(item.code is None for item in item_candidates):
|
||||||
for (item, location) in successful_pairs:
|
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
|
||||||
multiworld.push_item(location, item, collect=False)
|
f"event items and non-event items. "
|
||||||
location.locked = True
|
f"Event items: {[item for item in item_candidates if item.code is None]}, "
|
||||||
logging.debug(f"Plando placed {item} at {location}")
|
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
|
||||||
if from_pool:
|
placement.force)
|
||||||
try:
|
continue
|
||||||
multiworld.itempool.remove(item)
|
else:
|
||||||
except ValueError:
|
is_real = item_candidates[0].code is not None
|
||||||
warn(
|
candidates = [candidate for candidate in locations if candidate.item is None
|
||||||
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
and bool(candidate.address) == is_real]
|
||||||
placement['force'])
|
multiworld.random.shuffle(candidates)
|
||||||
|
allstate = multiworld.get_all_state(False)
|
||||||
|
mincount = placement.count["min"]
|
||||||
|
allowed_margin = len(item_candidates) - mincount
|
||||||
|
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
|
||||||
|
allow_partial=True, name="Plando Main Fill")
|
||||||
|
|
||||||
|
if len(item_candidates) > allowed_margin:
|
||||||
|
failed(f"Could not place {len(item_candidates)} "
|
||||||
|
f"of {mincount + allowed_margin} item(s) "
|
||||||
|
f"for {multiworld.player_name[player]}, "
|
||||||
|
f"remaining items: {item_candidates}",
|
||||||
|
placement.force)
|
||||||
|
if from_pool:
|
||||||
|
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
||||||
|
|||||||
127
Generate.py
127
Generate.py
@@ -10,8 +10,8 @@ import sys
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Any, Dict, Tuple, Union
|
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
@@ -42,7 +42,9 @@ def mystery_argparse():
|
|||||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level')
|
||||||
|
parser.add_argument('--log_time', help="Add timestamps to STDOUT",
|
||||||
|
default=defaults.logtime, action='store_true')
|
||||||
parser.add_argument("--csv_output", action="store_true",
|
parser.add_argument("--csv_output", action="store_true",
|
||||||
help="Output rolled player options to csv (made for async multiworld).")
|
help="Output rolled player options to csv (made for async multiworld).")
|
||||||
parser.add_argument("--plando", default=defaults.plando_options,
|
parser.add_argument("--plando", default=defaults.plando_options,
|
||||||
@@ -52,12 +54,22 @@ def mystery_argparse():
|
|||||||
parser.add_argument("--skip_output", action="store_true",
|
parser.add_argument("--skip_output", action="store_true",
|
||||||
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
||||||
"Intended for debugging and testing purposes.")
|
"Intended for debugging and testing purposes.")
|
||||||
|
parser.add_argument("--spoiler_only", action="store_true",
|
||||||
|
help="Skips generation assertion and multidata, outputting only a spoiler log. "
|
||||||
|
"Intended for debugging and testing purposes.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.skip_output and args.spoiler_only:
|
||||||
|
parser.error("Cannot mix --skip_output and --spoiler_only")
|
||||||
|
elif args.spoiler == 0 and args.spoiler_only:
|
||||||
|
parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value")
|
||||||
|
|
||||||
if not os.path.isabs(args.weights_file_path):
|
if not os.path.isabs(args.weights_file_path):
|
||||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||||
if not os.path.isabs(args.meta_file_path):
|
if not os.path.isabs(args.meta_file_path):
|
||||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +77,7 @@ def get_seed_name(random_source) -> str:
|
|||||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||||
|
|
||||||
|
|
||||||
def main(args=None) -> Tuple[argparse.Namespace, int]:
|
def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||||
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
||||||
if __name__ == "__main__" and "worlds" in sys.modules:
|
if __name__ == "__main__" and "worlds" in sys.modules:
|
||||||
raise Exception("Worlds system should not be loaded before logging init.")
|
raise Exception("Worlds system should not be loaded before logging init.")
|
||||||
@@ -75,7 +87,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
|
|
||||||
seed = get_seed(args.seed)
|
seed = get_seed(args.seed)
|
||||||
|
|
||||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
|
||||||
random.seed(seed)
|
random.seed(seed)
|
||||||
seed_name = get_seed_name(random)
|
seed_name = get_seed_name(random)
|
||||||
|
|
||||||
@@ -83,7 +95,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||||
random.seed() # reset to time-based random source
|
random.seed() # reset to time-based random source
|
||||||
|
|
||||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
weights_cache: dict[str, tuple[Any, ...]] = {}
|
||||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||||
try:
|
try:
|
||||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||||
@@ -106,6 +118,8 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
raise Exception("Cannot mix --sameoptions with --meta")
|
raise Exception("Cannot mix --sameoptions with --meta")
|
||||||
else:
|
else:
|
||||||
meta_weights = None
|
meta_weights = None
|
||||||
|
|
||||||
|
|
||||||
player_id = 1
|
player_id = 1
|
||||||
player_files = {}
|
player_files = {}
|
||||||
for file in os.scandir(args.player_files_path):
|
for file in os.scandir(args.player_files_path):
|
||||||
@@ -114,7 +128,14 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||||
path = os.path.join(args.player_files_path, fname)
|
path = os.path.join(args.player_files_path, fname)
|
||||||
try:
|
try:
|
||||||
weights_cache[fname] = read_weights_yamls(path)
|
weights_for_file = []
|
||||||
|
for doc_idx, yaml in enumerate(read_weights_yamls(path)):
|
||||||
|
if yaml is None:
|
||||||
|
logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}")
|
||||||
|
else:
|
||||||
|
weights_for_file.append(yaml)
|
||||||
|
weights_cache[fname] = tuple(weights_for_file)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||||
|
|
||||||
@@ -155,10 +176,11 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
erargs.outputpath = args.outputpath
|
erargs.outputpath = args.outputpath
|
||||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||||
erargs.skip_output = args.skip_output
|
erargs.skip_output = args.skip_output
|
||||||
|
erargs.spoiler_only = args.spoiler_only
|
||||||
erargs.name = {}
|
erargs.name = {}
|
||||||
erargs.csv_output = args.csv_output
|
erargs.csv_output = args.csv_output
|
||||||
|
|
||||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
||||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||||
for fname, yamls in weights_cache.items()}
|
for fname, yamls in weights_cache.items()}
|
||||||
|
|
||||||
@@ -190,7 +212,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
path = player_path_cache[player]
|
path = player_path_cache[player]
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||||
for settingsObject in settings:
|
for settingsObject in settings:
|
||||||
for k, v in vars(settingsObject).items():
|
for k, v in vars(settingsObject).items():
|
||||||
@@ -202,10 +224,14 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||||
|
|
||||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
# name was not specified
|
||||||
erargs.name[player] = f"Player{player}"
|
if player not in erargs.name:
|
||||||
elif player not in erargs.name: # if name was not specified, generate it from filename
|
if path == args.weights_file_path:
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
# weights file, so we need to make the name unique
|
||||||
|
erargs.name[player] = f"Player{player}"
|
||||||
|
else:
|
||||||
|
# use the filename
|
||||||
|
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||||
|
|
||||||
player += 1
|
player += 1
|
||||||
@@ -220,7 +246,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
return erargs, seed
|
return erargs, seed
|
||||||
|
|
||||||
|
|
||||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
def read_weights_yamls(path) -> tuple[Any, ...]:
|
||||||
try:
|
try:
|
||||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||||
@@ -230,7 +256,20 @@ def read_weights_yamls(path) -> Tuple[Any, ...]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Failed to read weights ({path})") from e
|
raise Exception(f"Failed to read weights ({path})") from e
|
||||||
|
|
||||||
return tuple(parse_yamls(yaml))
|
from yaml.error import MarkedYAMLError
|
||||||
|
try:
|
||||||
|
return tuple(parse_yamls(yaml))
|
||||||
|
except MarkedYAMLError as ex:
|
||||||
|
if ex.problem_mark:
|
||||||
|
lines = yaml.splitlines()
|
||||||
|
if ex.context_mark:
|
||||||
|
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
|
||||||
|
else:
|
||||||
|
relevant_lines = lines[ex.problem_mark.line]
|
||||||
|
error_line = " " * ex.problem_mark.column + "^"
|
||||||
|
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
|
||||||
|
f"\n{relevant_lines}\n{error_line}")
|
||||||
|
raise ex
|
||||||
|
|
||||||
|
|
||||||
def interpret_on_off(value) -> bool:
|
def interpret_on_off(value) -> bool:
|
||||||
@@ -270,33 +309,35 @@ def get_choice(option, root, value=None) -> Any:
|
|||||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||||
|
|
||||||
|
|
||||||
class SafeDict(dict):
|
class SafeFormatter(string.Formatter):
|
||||||
def __missing__(self, key):
|
def get_value(self, key, args, kwargs):
|
||||||
return '{' + key + '}'
|
if isinstance(key, int):
|
||||||
|
if key < len(args):
|
||||||
|
return args[key]
|
||||||
|
else:
|
||||||
|
return "{" + str(key) + "}"
|
||||||
|
else:
|
||||||
|
return kwargs.get(key, "{" + key + "}")
|
||||||
|
|
||||||
|
|
||||||
def handle_name(name: str, player: int, name_counter: Counter):
|
def handle_name(name: str, player: int, name_counter: Counter):
|
||||||
name_counter[name.lower()] += 1
|
name_counter[name.lower()] += 1
|
||||||
number = name_counter[name.lower()]
|
number = name_counter[name.lower()]
|
||||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
|
||||||
NUMBER=(number if number > 1 else ''),
|
new_name = SafeFormatter().vformat(new_name, (), {"number": number,
|
||||||
player=player,
|
"NUMBER": (number if number > 1 else ''),
|
||||||
PLAYER=(player if player > 1 else '')))
|
"player": player,
|
||||||
|
"PLAYER": (player if player > 1 else '')})
|
||||||
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
||||||
# Could cause issues for some clients that cannot handle the additional whitespace.
|
# Could cause issues for some clients that cannot handle the additional whitespace.
|
||||||
new_name = new_name.strip()[:16].strip()
|
new_name = new_name.strip()[:16].strip()
|
||||||
|
|
||||||
if new_name == "Archipelago":
|
if new_name == "Archipelago":
|
||||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||||
return new_name
|
return new_name
|
||||||
|
|
||||||
|
|
||||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
|
||||||
"""Roll a percentage chance.
|
|
||||||
percentage is expected to be in range [0, 100]"""
|
|
||||||
return random.random() < (float(percentage) / 100)
|
|
||||||
|
|
||||||
|
|
||||||
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
||||||
logging.debug(f'Applying {new_weights}')
|
logging.debug(f'Applying {new_weights}')
|
||||||
cleaned_weights = {}
|
cleaned_weights = {}
|
||||||
@@ -341,7 +382,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
if not game:
|
if not game:
|
||||||
@@ -362,7 +403,7 @@ def roll_linked_options(weights: dict) -> dict:
|
|||||||
if "name" not in option_set:
|
if "name" not in option_set:
|
||||||
raise ValueError("One of your linked options does not have a name.")
|
raise ValueError("One of your linked options does not have a name.")
|
||||||
try:
|
try:
|
||||||
if roll_percentage(option_set["percentage"]):
|
if Options.roll_percentage(option_set["percentage"]):
|
||||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||||
new_options = option_set["options"]
|
new_options = option_set["options"]
|
||||||
for category_name, category_options in new_options.items():
|
for category_name, category_options in new_options.items():
|
||||||
@@ -395,7 +436,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
|||||||
trigger_result = get_choice("option_result", option_set)
|
trigger_result = get_choice("option_result", option_set)
|
||||||
result = get_choice(key, currently_targeted_weights)
|
result = get_choice(key, currently_targeted_weights)
|
||||||
currently_targeted_weights[key] = result
|
currently_targeted_weights[key] = result
|
||||||
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
|
||||||
for category_name, category_options in option_set["options"].items():
|
for category_name, category_options in option_set["options"].items():
|
||||||
currently_targeted_weights = weights
|
currently_targeted_weights = weights
|
||||||
if category_name:
|
if category_name:
|
||||||
@@ -426,12 +467,20 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||||
|
"""
|
||||||
|
Roll options from specified weights, usually originating from a .yaml options file.
|
||||||
|
|
||||||
|
Important note:
|
||||||
|
The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots).
|
||||||
|
This means it should never be modified without making a deepcopy first.
|
||||||
|
"""
|
||||||
|
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
weights = roll_linked_options(weights)
|
weights = roll_linked_options(weights)
|
||||||
|
|
||||||
valid_keys = set()
|
valid_keys = {"triggers"}
|
||||||
if "triggers" in weights:
|
if "triggers" in weights:
|
||||||
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
||||||
|
|
||||||
@@ -490,15 +539,19 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
valid_keys.add(option_key)
|
valid_keys.add(option_key)
|
||||||
for option_key in game_weights:
|
|
||||||
if option_key in {"triggers", *valid_keys}:
|
|
||||||
continue
|
|
||||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
|
||||||
if PlandoOptions.items in plando_options:
|
|
||||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
|
||||||
if ret.game == "A Link to the Past":
|
if ret.game == "A Link to the Past":
|
||||||
|
# TODO there are still more LTTP options not on the options system
|
||||||
|
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
||||||
roll_alttp_settings(ret, game_weights)
|
roll_alttp_settings(ret, game_weights)
|
||||||
|
|
||||||
|
# log a warning for options within a game section that aren't determined as valid
|
||||||
|
for option_key in game_weights:
|
||||||
|
if option_key in valid_keys:
|
||||||
|
continue
|
||||||
|
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
|
||||||
|
f"for player {ret.name}.")
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,7 +1,7 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2017 LLCoolDave
|
Copyright (c) 2017 LLCoolDave
|
||||||
Copyright (c) 2022 Berserker66
|
Copyright (c) 2025 Berserker66
|
||||||
Copyright (c) 2022 CaitSith2
|
Copyright (c) 2022 CaitSith2
|
||||||
Copyright (c) 2021 LegendaryLinux
|
Copyright (c) 2021 LegendaryLinux
|
||||||
|
|
||||||
|
|||||||
415
Launcher.py
415
Launcher.py
@@ -1,29 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
Archipelago launcher for bundled app.
|
Archipelago Launcher
|
||||||
|
|
||||||
* if run with APBP as argument, launch corresponding client.
|
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
|
||||||
* if run with executable as argument, run it passing argv[2:] as arguments
|
* If run with component name as argument, run it passing argv[2:] as arguments.
|
||||||
* if run without arguments, open launcher GUI
|
* If run without arguments or unknown arguments, open launcher GUI.
|
||||||
|
|
||||||
Scroll down to components= to add components to the launcher as well as setup.py
|
Additional components can be added to worlds.LauncherComponents.components.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import itertools
|
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Callable, Optional, Sequence, Tuple, Union
|
from typing import Any
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
@@ -41,13 +42,17 @@ def open_host_yaml():
|
|||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('sensible-editor') or which('gedit') or \
|
exe = which('sensible-editor') or which('gedit') or \
|
||||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
subprocess.Popen([exe, file])
|
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, file])
|
|
||||||
else:
|
else:
|
||||||
webbrowser.open(file)
|
webbrowser.open(file)
|
||||||
|
return
|
||||||
|
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
subprocess.Popen([exe, file], env=env)
|
||||||
|
|
||||||
def open_patch():
|
def open_patch():
|
||||||
suffixes = []
|
suffixes = []
|
||||||
@@ -85,12 +90,20 @@ def browse_files():
|
|||||||
def open_folder(folder_path):
|
def open_folder(folder_path):
|
||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
subprocess.Popen([exe, folder_path])
|
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, folder_path])
|
|
||||||
else:
|
else:
|
||||||
webbrowser.open(folder_path)
|
webbrowser.open(folder_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
if exe:
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
subprocess.Popen([exe, folder_path], env=env)
|
||||||
|
else:
|
||||||
|
logging.warning(f"No file browser available to open {folder_path}")
|
||||||
|
|
||||||
|
|
||||||
def update_settings():
|
def update_settings():
|
||||||
@@ -100,96 +113,51 @@ def update_settings():
|
|||||||
|
|
||||||
components.extend([
|
components.extend([
|
||||||
# Functions
|
# Functions
|
||||||
Component("Open host.yaml", func=open_host_yaml),
|
Component("Open host.yaml", func=open_host_yaml,
|
||||||
Component("Open Patch", func=open_patch),
|
description="Open the host.yaml file to change settings for generation, games, and more."),
|
||||||
Component("Generate Template Options", func=generate_yamls),
|
Component("Open Patch", func=open_patch,
|
||||||
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
|
description="Open a patch file, downloaded from the room page or provided by the host."),
|
||||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
Component("Generate Template Options", func=generate_yamls,
|
||||||
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
description="Generate template YAMLs for currently installed games."),
|
||||||
Component("Browse Files", func=browse_files),
|
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
|
||||||
|
description="Open archipelago.gg in your browser."),
|
||||||
|
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
|
||||||
|
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
|
||||||
|
Component("Unrated/18+ Discord Server", icon="discord",
|
||||||
|
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
|
||||||
|
description="Find unrated and 18+ games in the After Dark Discord server."),
|
||||||
|
Component("Browse Files", func=browse_files,
|
||||||
|
description="Open the Archipelago installation folder in your file browser."),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
def handle_uri(path: str) -> tuple[list[Component], Component]:
|
||||||
url = urllib.parse.urlparse(path)
|
url = urllib.parse.urlparse(path)
|
||||||
queries = urllib.parse.parse_qs(url.query)
|
queries = urllib.parse.parse_qs(url.query)
|
||||||
launch_args = (path, *launch_args)
|
client_components = []
|
||||||
client_component = None
|
|
||||||
text_client_component = None
|
text_client_component = None
|
||||||
if "game" in queries:
|
game = queries["game"][0]
|
||||||
game = queries["game"][0]
|
|
||||||
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
|
|
||||||
game = "Archipelago"
|
|
||||||
for component in components:
|
for component in components:
|
||||||
if component.supports_uri and component.game_name == game:
|
if component.supports_uri and component.game_name == game:
|
||||||
client_component = component
|
client_components.append(component)
|
||||||
elif component.display_name == "Text Client":
|
elif component.display_name == "Text Client":
|
||||||
text_client_component = component
|
text_client_component = component
|
||||||
|
return client_components, text_client_component
|
||||||
from kvui import App, Button, BoxLayout, Label, Clock, Window
|
|
||||||
|
|
||||||
class Popup(App):
|
|
||||||
timer_label: Label
|
|
||||||
remaining_time: Optional[int]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.title = "Connect to Multiworld"
|
|
||||||
self.icon = r"data/icon.png"
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def build(self):
|
|
||||||
layout = BoxLayout(orientation="vertical")
|
|
||||||
|
|
||||||
if client_component is None:
|
|
||||||
self.remaining_time = 7
|
|
||||||
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
|
|
||||||
f"Launching Text Client in 7 seconds...")
|
|
||||||
self.timer_label = Label(text=label_text)
|
|
||||||
layout.add_widget(self.timer_label)
|
|
||||||
Clock.schedule_interval(self.update_label, 1)
|
|
||||||
else:
|
|
||||||
layout.add_widget(Label(text="Select client to open and connect with."))
|
|
||||||
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
|
||||||
|
|
||||||
text_client_button = Button(
|
|
||||||
text=text_client_component.display_name,
|
|
||||||
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
|
||||||
)
|
|
||||||
button_row.add_widget(text_client_button)
|
|
||||||
|
|
||||||
game_client_button = Button(
|
|
||||||
text=client_component.display_name,
|
|
||||||
on_release=lambda *args: run_component(client_component, *launch_args)
|
|
||||||
)
|
|
||||||
button_row.add_widget(game_client_button)
|
|
||||||
|
|
||||||
layout.add_widget(button_row)
|
|
||||||
|
|
||||||
return layout
|
|
||||||
|
|
||||||
def update_label(self, dt):
|
|
||||||
if self.remaining_time > 1:
|
|
||||||
# countdown the timer and string replace the number
|
|
||||||
self.remaining_time -= 1
|
|
||||||
self.timer_label.text = self.timer_label.text.replace(
|
|
||||||
str(self.remaining_time + 1), str(self.remaining_time)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# our timer is finished so launch text client and close down
|
|
||||||
run_component(text_client_component, *launch_args)
|
|
||||||
Clock.unschedule(self.update_label)
|
|
||||||
App.get_running_app().stop()
|
|
||||||
Window.close()
|
|
||||||
|
|
||||||
def _stop(self, *largs):
|
|
||||||
# see run_gui Launcher _stop comment for details
|
|
||||||
self.root_window.close()
|
|
||||||
super()._stop(*largs)
|
|
||||||
|
|
||||||
Popup().run()
|
|
||||||
|
|
||||||
|
|
||||||
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
|
||||||
|
from kvui import ButtonsPrompt
|
||||||
|
component_options = {
|
||||||
|
component.display_name: component for component in component_list
|
||||||
|
}
|
||||||
|
popup = ButtonsPrompt("Connect to Multiworld",
|
||||||
|
"Select client to open and connect with.",
|
||||||
|
lambda component_name: run_component(component_options[component_name], *launch_args),
|
||||||
|
*component_options.keys())
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
|
||||||
|
def identify(path: None | str) -> tuple[None | str, None | Component]:
|
||||||
if path is None:
|
if path is None:
|
||||||
return None, None
|
return None, None
|
||||||
for component in components:
|
for component in components:
|
||||||
@@ -200,7 +168,7 @@ def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Comp
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
def get_exe(component: str | Component) -> Sequence[str] | None:
|
||||||
if isinstance(component, str):
|
if isinstance(component, str):
|
||||||
name = component
|
name = component
|
||||||
component = None
|
component = None
|
||||||
@@ -228,7 +196,8 @@ def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
|||||||
def launch(exe, in_terminal=False):
|
def launch(exe, in_terminal=False):
|
||||||
if in_terminal:
|
if in_terminal:
|
||||||
if is_windows:
|
if is_windows:
|
||||||
subprocess.Popen(['start', *exe], shell=True)
|
# intentionally using a window title with a space so it gets quoted and treated as a title
|
||||||
|
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
|
||||||
return
|
return
|
||||||
elif is_linux:
|
elif is_linux:
|
||||||
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
||||||
@@ -242,101 +211,189 @@ def launch(exe, in_terminal=False):
|
|||||||
subprocess.Popen(exe)
|
subprocess.Popen(exe)
|
||||||
|
|
||||||
|
|
||||||
refresh_components: Optional[Callable[[], None]] = None
|
def create_shortcut(button: Any, component: Component) -> None:
|
||||||
|
from pyshortcuts import make_shortcut
|
||||||
|
script = sys.argv[0]
|
||||||
|
wkdir = Utils.local_path()
|
||||||
|
|
||||||
|
script = f"{script} \"{component.display_name}\""
|
||||||
|
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
|
||||||
|
startmenu=False, terminal=False, working_dir=wkdir)
|
||||||
|
button.menu.dismiss()
|
||||||
|
|
||||||
|
|
||||||
def run_gui():
|
refresh_components: Callable[[], None] | None = None
|
||||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
|
|
||||||
|
|
||||||
|
def run_gui(launch_components: list[Component], args: Any) -> None:
|
||||||
|
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
|
||||||
|
from kivy.properties import ObjectProperty
|
||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
from kivy.uix.image import AsyncImage
|
from kivy.metrics import dp
|
||||||
from kivy.uix.relativelayout import RelativeLayout
|
from kivymd.uix.button import MDIconButton, MDButton
|
||||||
|
from kivymd.uix.card import MDCard
|
||||||
|
from kivymd.uix.menu import MDDropdownMenu
|
||||||
|
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
|
||||||
|
from kivymd.uix.textfield import MDTextField
|
||||||
|
|
||||||
class Launcher(App):
|
from kivy.lang.builder import Builder
|
||||||
|
|
||||||
|
class LauncherCard(MDCard):
|
||||||
|
component: Component | None
|
||||||
|
image: str
|
||||||
|
context_button: MDIconButton = ObjectProperty(None)
|
||||||
|
|
||||||
|
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
|
||||||
|
self.component = component
|
||||||
|
self.image = image_path
|
||||||
|
super().__init__(args, kwargs)
|
||||||
|
|
||||||
|
class Launcher(ThemedApp):
|
||||||
base_title: str = "Archipelago Launcher"
|
base_title: str = "Archipelago Launcher"
|
||||||
container: ContainerLayout
|
top_screen: MDFloatLayout = ObjectProperty(None)
|
||||||
grid: GridLayout
|
navigation: MDGridLayout = ObjectProperty(None)
|
||||||
_tool_layout: Optional[ScrollBox] = None
|
grid: MDGridLayout = ObjectProperty(None)
|
||||||
_client_layout: Optional[ScrollBox] = None
|
button_layout: ScrollBox = ObjectProperty(None)
|
||||||
|
search_box: MDTextField = ObjectProperty(None)
|
||||||
|
cards: list[LauncherCard]
|
||||||
|
current_filter: Sequence[str | Type] | None
|
||||||
|
|
||||||
def __init__(self, ctx=None):
|
def __init__(self, ctx=None, components=None, args=None):
|
||||||
self.title = self.base_title + " " + Utils.__version__
|
self.title = self.base_title + " " + Utils.__version__
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self.icon = r"data/icon.png"
|
self.icon = r"data/icon.png"
|
||||||
|
self.favorites = []
|
||||||
|
self.launch_components = components
|
||||||
|
self.launch_args = args
|
||||||
|
self.cards = []
|
||||||
|
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||||
|
persistent = Utils.persistent_load()
|
||||||
|
if "launcher" in persistent:
|
||||||
|
if "favorites" in persistent["launcher"]:
|
||||||
|
self.favorites.extend(persistent["launcher"]["favorites"])
|
||||||
|
if "filter" in persistent["launcher"]:
|
||||||
|
if persistent["launcher"]["filter"]:
|
||||||
|
filters = []
|
||||||
|
for filter in persistent["launcher"]["filter"].split(", "):
|
||||||
|
if filter == "favorites":
|
||||||
|
filters.append(filter)
|
||||||
|
else:
|
||||||
|
filters.append(Type[filter])
|
||||||
|
self.current_filter = filters
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def _refresh_components(self) -> None:
|
def set_favorite(self, caller):
|
||||||
|
if caller.component.display_name in self.favorites:
|
||||||
|
self.favorites.remove(caller.component.display_name)
|
||||||
|
caller.icon = "star-outline"
|
||||||
|
else:
|
||||||
|
self.favorites.append(caller.component.display_name)
|
||||||
|
caller.icon = "star"
|
||||||
|
|
||||||
def build_button(component: Component) -> Widget:
|
def build_card(self, component: Component) -> LauncherCard:
|
||||||
|
"""
|
||||||
|
Builds a card widget for a given component.
|
||||||
|
|
||||||
|
:param component: The component associated with the button.
|
||||||
|
|
||||||
|
:return: The created Card Widget.
|
||||||
"""
|
"""
|
||||||
Builds a button widget for a given component.
|
button_card = LauncherCard(component=component,
|
||||||
|
image_path=icon_paths[component.icon])
|
||||||
|
|
||||||
Args:
|
def open_menu(caller):
|
||||||
component (Component): The component associated with the button.
|
caller.menu.open()
|
||||||
|
|
||||||
Returns:
|
menu_items = [
|
||||||
None. The button is added to the parent grid layout.
|
{
|
||||||
|
"text": "Add shortcut on desktop",
|
||||||
|
"leading_icon": "laptop",
|
||||||
|
"on_release": lambda: create_shortcut(button_card.context_button, component)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
|
||||||
|
button_card.context_button.bind(on_release=open_menu)
|
||||||
|
|
||||||
"""
|
return button_card
|
||||||
button = Button(text=component.display_name, size_hint_y=None, height=40)
|
|
||||||
button.component = component
|
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
|
||||||
button.bind(on_release=self.component_action)
|
if not type_filter:
|
||||||
if component.icon != "icon":
|
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
|
||||||
image = AsyncImage(source=icon_paths[component.icon],
|
favorites = "favorites" in type_filter
|
||||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
|
||||||
box_layout = RelativeLayout(size_hint_y=None, height=40)
|
|
||||||
box_layout.add_widget(button)
|
|
||||||
box_layout.add_widget(image)
|
|
||||||
return box_layout
|
|
||||||
return button
|
|
||||||
|
|
||||||
# clear before repopulating
|
# clear before repopulating
|
||||||
assert self._tool_layout and self._client_layout, "must call `build` first"
|
assert self.button_layout, "must call `build` first"
|
||||||
tool_children = reversed(self._tool_layout.layout.children)
|
tool_children = reversed(self.button_layout.layout.children)
|
||||||
for child in tool_children:
|
for child in tool_children:
|
||||||
self._tool_layout.layout.remove_widget(child)
|
self.button_layout.layout.remove_widget(child)
|
||||||
client_children = reversed(self._client_layout.layout.children)
|
|
||||||
for child in client_children:
|
|
||||||
self._client_layout.layout.remove_widget(child)
|
|
||||||
|
|
||||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
cards = [card for card in self.cards if card.component.type in type_filter
|
||||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
or favorites and card.component.display_name in self.favorites]
|
||||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
|
||||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
|
||||||
|
|
||||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
self.current_filter = type_filter
|
||||||
_tools.items(), _miscs.items(), _adjusters.items()
|
|
||||||
), _clients.items()):
|
for card in cards:
|
||||||
# column 1
|
self.button_layout.layout.add_widget(card)
|
||||||
if tool:
|
|
||||||
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
|
||||||
# column 2
|
- self.button_layout.height
|
||||||
if client:
|
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
|
||||||
self._client_layout.layout.add_widget(build_button(client[1]))
|
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
|
||||||
|
|
||||||
|
def filter_clients_by_type(self, caller: MDButton):
|
||||||
|
self._refresh_components(caller.type)
|
||||||
|
self.search_box.text = ""
|
||||||
|
|
||||||
|
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
|
||||||
|
if len(name) == 0:
|
||||||
|
self._refresh_components(self.current_filter)
|
||||||
|
return
|
||||||
|
|
||||||
|
sub_matches = [
|
||||||
|
card for card in self.cards
|
||||||
|
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
|
||||||
|
]
|
||||||
|
self.button_layout.layout.clear_widgets()
|
||||||
|
for card in sub_matches:
|
||||||
|
self.button_layout.layout.add_widget(card)
|
||||||
|
|
||||||
def build(self):
|
def build(self):
|
||||||
self.container = ContainerLayout()
|
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
|
||||||
self.grid = GridLayout(cols=2)
|
self.grid = self.top_screen.ids.grid
|
||||||
self.container.add_widget(self.grid)
|
self.navigation = self.top_screen.ids.navigation
|
||||||
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
self.button_layout = self.top_screen.ids.button_layout
|
||||||
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
self.search_box = self.top_screen.ids.search_box
|
||||||
self._tool_layout = ScrollBox()
|
self.set_colors()
|
||||||
self._tool_layout.layout.orientation = "vertical"
|
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
|
||||||
self.grid.add_widget(self._tool_layout)
|
|
||||||
self._client_layout = ScrollBox()
|
|
||||||
self._client_layout.layout.orientation = "vertical"
|
|
||||||
self.grid.add_widget(self._client_layout)
|
|
||||||
|
|
||||||
self._refresh_components()
|
|
||||||
|
|
||||||
global refresh_components
|
global refresh_components
|
||||||
refresh_components = self._refresh_components
|
refresh_components = self._refresh_components
|
||||||
|
|
||||||
Window.bind(on_drop_file=self._on_drop_file)
|
Window.bind(on_drop_file=self._on_drop_file)
|
||||||
|
Window.bind(on_keyboard=self._on_keyboard)
|
||||||
|
|
||||||
return self.container
|
for component in components:
|
||||||
|
self.cards.append(self.build_card(component))
|
||||||
|
|
||||||
|
self._refresh_components(self.current_filter)
|
||||||
|
|
||||||
|
# Uncomment to re-enable the Kivy console/live editor
|
||||||
|
# Ctrl-E to enable it, make sure numlock/capslock is disabled
|
||||||
|
# from kivy.modules.console import create_console
|
||||||
|
# create_console(Window, self.top_screen)
|
||||||
|
|
||||||
|
return self.top_screen
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
if self.launch_components:
|
||||||
|
build_uri_popup(self.launch_components, self.launch_args)
|
||||||
|
self.launch_components = None
|
||||||
|
self.launch_args = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def component_action(button):
|
def component_action(button):
|
||||||
|
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
if button.component.func:
|
if button.component.func:
|
||||||
button.component.func()
|
button.component.func()
|
||||||
else:
|
else:
|
||||||
@@ -348,7 +405,16 @@ def run_gui():
|
|||||||
if file and component:
|
if file and component:
|
||||||
run_component(component, file)
|
run_component(component, file)
|
||||||
else:
|
else:
|
||||||
logging.warning(f"unable to identify component for {file}")
|
logging.warning(f"unable to identify component for {filename}")
|
||||||
|
|
||||||
|
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
|
||||||
|
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
|
||||||
|
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
|
||||||
|
# Limit text input to ASCII non-control characters (space bar to tilde).
|
||||||
|
if not self.search_box.focus:
|
||||||
|
self.search_box.focus = True
|
||||||
|
if key in range(32, 126):
|
||||||
|
self.search_box.text += codepoint
|
||||||
|
|
||||||
def _stop(self, *largs):
|
def _stop(self, *largs):
|
||||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||||
@@ -356,7 +422,13 @@ def run_gui():
|
|||||||
self.root_window.close()
|
self.root_window.close()
|
||||||
super()._stop(*largs)
|
super()._stop(*largs)
|
||||||
|
|
||||||
Launcher().run()
|
def on_stop(self):
|
||||||
|
Utils.persistent_store("launcher", "favorites", self.favorites)
|
||||||
|
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
|
||||||
|
for filter in self.current_filter))
|
||||||
|
super().on_stop()
|
||||||
|
|
||||||
|
Launcher(components=launch_components, args=args).run()
|
||||||
|
|
||||||
# avoiding Launcher reference leak
|
# avoiding Launcher reference leak
|
||||||
# and don't try to do something with widgets after window closed
|
# and don't try to do something with widgets after window closed
|
||||||
@@ -375,7 +447,7 @@ def run_component(component: Component, *args):
|
|||||||
logging.warning(f"Component {component} does not appear to be executable.")
|
logging.warning(f"Component {component} does not appear to be executable.")
|
||||||
|
|
||||||
|
|
||||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
def main(args: argparse.Namespace | dict | None = None):
|
||||||
if isinstance(args, argparse.Namespace):
|
if isinstance(args, argparse.Namespace):
|
||||||
args = {k: v for k, v in args._get_kwargs()}
|
args = {k: v for k, v in args._get_kwargs()}
|
||||||
elif not args:
|
elif not args:
|
||||||
@@ -384,15 +456,21 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
path = args.get("Patch|Game|Component|url", None)
|
path = args.get("Patch|Game|Component|url", None)
|
||||||
if path is not None:
|
if path is not None:
|
||||||
if path.startswith("archipelago://"):
|
if path.startswith("archipelago://"):
|
||||||
handle_uri(path, args.get("args", ()))
|
args["args"] = (path, *args.get("args", ()))
|
||||||
return
|
# add the url arg to the passthrough args
|
||||||
file, component = identify(path)
|
components, text_client_component = handle_uri(path)
|
||||||
if file:
|
if not components:
|
||||||
args['file'] = file
|
args["component"] = text_client_component
|
||||||
if component:
|
else:
|
||||||
args['component'] = component
|
args['launch_components'] = [text_client_component, *components]
|
||||||
if not component:
|
else:
|
||||||
logging.warning(f"Could not identify Component responsible for {path}")
|
file, component = identify(path)
|
||||||
|
if file:
|
||||||
|
args['file'] = file
|
||||||
|
if component:
|
||||||
|
args['component'] = component
|
||||||
|
if not component:
|
||||||
|
logging.warning(f"Could not identify Component responsible for {path}")
|
||||||
|
|
||||||
if args["update_settings"]:
|
if args["update_settings"]:
|
||||||
update_settings()
|
update_settings()
|
||||||
@@ -401,7 +479,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
elif "component" in args:
|
elif "component" in args:
|
||||||
run_component(args["component"], *args["args"])
|
run_component(args["component"], *args["args"])
|
||||||
elif not args["update_settings"]:
|
elif not args["update_settings"]:
|
||||||
run_gui()
|
run_gui(args.get("launch_components", None), args.get("args", ()))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
@@ -423,6 +501,7 @@ if __name__ == '__main__':
|
|||||||
main(parser.parse_args())
|
main(parser.parse_args())
|
||||||
|
|
||||||
from worlds.LauncherComponents import processes
|
from worlds.LauncherComponents import processes
|
||||||
|
|
||||||
for process in processes:
|
for process in processes:
|
||||||
# we await all child processes to close before we tear down the process host
|
# we await all child processes to close before we tear down the process host
|
||||||
# this makes it feel like each one is its own program, as the Launcher is closed now
|
# this makes it feel like each one is its own program, as the Launcher is closed now
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ import typing
|
|||||||
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||||
server_loop)
|
server_loop)
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import ClientStatus
|
||||||
|
from worlds.ladx import LinksAwakeningWorld
|
||||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||||
from worlds.ladx.GpsTracker import GpsTracker
|
from worlds.ladx.GpsTracker import GpsTracker
|
||||||
|
from worlds.ladx.TrackerConsts import storage_key
|
||||||
from worlds.ladx.ItemTracker import ItemTracker
|
from worlds.ladx.ItemTracker import ItemTracker
|
||||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||||
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
|
from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check
|
||||||
|
|
||||||
|
|
||||||
class GameboyException(Exception):
|
class GameboyException(Exception):
|
||||||
@@ -50,22 +52,6 @@ class BadRetroArchResponse(GameboyException):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def magpie_logo():
|
|
||||||
from kivy.uix.image import CoreImage
|
|
||||||
binary_data = """
|
|
||||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
|
|
||||||
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
|
|
||||||
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
|
|
||||||
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
|
|
||||||
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
|
|
||||||
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
|
|
||||||
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
|
|
||||||
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
|
|
||||||
binary_data = base64.b64decode(binary_data)
|
|
||||||
data = io.BytesIO(binary_data)
|
|
||||||
return CoreImage(data, ext="png").texture
|
|
||||||
|
|
||||||
|
|
||||||
class LAClientConstants:
|
class LAClientConstants:
|
||||||
# Connector version
|
# Connector version
|
||||||
VERSION = 0x01
|
VERSION = 0x01
|
||||||
@@ -100,19 +86,23 @@ class LAClientConstants:
|
|||||||
WRamCheckSize = 0x4
|
WRamCheckSize = 0x4
|
||||||
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
||||||
|
|
||||||
|
wRamStart = 0xC000
|
||||||
|
hRamStart = 0xFF80
|
||||||
|
hRamSize = 0x80
|
||||||
|
|
||||||
MinGameplayValue = 0x06
|
MinGameplayValue = 0x06
|
||||||
MaxGameplayValue = 0x1A
|
MaxGameplayValue = 0x1A
|
||||||
VictoryGameplayAndSub = 0x0102
|
VictoryGameplayAndSub = 0x0102
|
||||||
|
|
||||||
|
|
||||||
class RAGameboy():
|
class RAGameboy():
|
||||||
cache = []
|
cache = []
|
||||||
cache_start = 0
|
|
||||||
cache_size = 0
|
|
||||||
last_cache_read = None
|
last_cache_read = None
|
||||||
socket = None
|
socket = None
|
||||||
|
|
||||||
def __init__(self, address, port) -> None:
|
def __init__(self, address, port) -> None:
|
||||||
|
self.cache_start = LAClientConstants.wRamStart
|
||||||
|
self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart
|
||||||
|
|
||||||
self.address = address
|
self.address = address
|
||||||
self.port = port
|
self.port = port
|
||||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
@@ -131,9 +121,14 @@ class RAGameboy():
|
|||||||
async def get_retroarch_status(self):
|
async def get_retroarch_status(self):
|
||||||
return await self.send_command("GET_STATUS")
|
return await self.send_command("GET_STATUS")
|
||||||
|
|
||||||
def set_cache_limits(self, cache_start, cache_size):
|
def set_checks_range(self, checks_start, checks_size):
|
||||||
self.cache_start = cache_start
|
self.checks_start = checks_start
|
||||||
self.cache_size = cache_size
|
self.checks_size = checks_size
|
||||||
|
|
||||||
|
def set_location_range(self, location_start, location_size, critical_addresses):
|
||||||
|
self.location_start = location_start
|
||||||
|
self.location_size = location_size
|
||||||
|
self.critical_location_addresses = critical_addresses
|
||||||
|
|
||||||
def send(self, b):
|
def send(self, b):
|
||||||
if type(b) is str:
|
if type(b) is str:
|
||||||
@@ -188,21 +183,57 @@ class RAGameboy():
|
|||||||
if not await self.check_safe_gameplay():
|
if not await self.check_safe_gameplay():
|
||||||
return
|
return
|
||||||
|
|
||||||
cache = []
|
attempts = 0
|
||||||
remaining_size = self.cache_size
|
while True:
|
||||||
while remaining_size:
|
# RA doesn't let us do an atomic read of a large enough block of RAM
|
||||||
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
|
# Some bytes can't change in between reading location_block and hram_block
|
||||||
remaining_size -= len(block)
|
location_block = await self.read_memory_block(self.location_start, self.location_size)
|
||||||
cache += block
|
hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize)
|
||||||
|
verification_block = await self.read_memory_block(self.location_start, self.location_size)
|
||||||
|
|
||||||
|
valid = True
|
||||||
|
for address in self.critical_location_addresses:
|
||||||
|
if location_block[address - self.location_start] != verification_block[address - self.location_start]:
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
if valid:
|
||||||
|
break
|
||||||
|
|
||||||
|
attempts += 1
|
||||||
|
|
||||||
|
# Shouldn't really happen, but keep it from choking
|
||||||
|
if attempts > 5:
|
||||||
|
return
|
||||||
|
|
||||||
|
checks_block = await self.read_memory_block(self.checks_start, self.checks_size)
|
||||||
|
|
||||||
if not await self.check_safe_gameplay():
|
if not await self.check_safe_gameplay():
|
||||||
return
|
return
|
||||||
|
|
||||||
self.cache = cache
|
self.cache = bytearray(self.cache_size)
|
||||||
|
|
||||||
|
start = self.checks_start - self.cache_start
|
||||||
|
self.cache[start:start + len(checks_block)] = checks_block
|
||||||
|
|
||||||
|
start = self.location_start - self.cache_start
|
||||||
|
self.cache[start:start + len(location_block)] = location_block
|
||||||
|
|
||||||
|
start = LAClientConstants.hRamStart - self.cache_start
|
||||||
|
self.cache[start:start + len(hram_block)] = hram_block
|
||||||
|
|
||||||
self.last_cache_read = time.time()
|
self.last_cache_read = time.time()
|
||||||
|
|
||||||
|
async def read_memory_block(self, address: int, size: int):
|
||||||
|
block = bytearray()
|
||||||
|
remaining_size = size
|
||||||
|
while remaining_size:
|
||||||
|
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
||||||
|
remaining_size -= len(chunk)
|
||||||
|
block += chunk
|
||||||
|
|
||||||
|
return block
|
||||||
|
|
||||||
async def read_memory_cache(self, addresses):
|
async def read_memory_cache(self, addresses):
|
||||||
# TODO: can we just update once per frame?
|
|
||||||
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
||||||
await self.update_cache()
|
await self.update_cache()
|
||||||
if not self.cache:
|
if not self.cache:
|
||||||
@@ -235,7 +266,7 @@ class RAGameboy():
|
|||||||
|
|
||||||
def check_command_response(self, command: str, response: bytes):
|
def check_command_response(self, command: str, response: bytes):
|
||||||
if command == "VERSION":
|
if command == "VERSION":
|
||||||
ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
|
ok = re.match(r"\d+\.\d+\.\d+", response.decode('ascii')) is not None
|
||||||
else:
|
else:
|
||||||
ok = response.startswith(command.encode())
|
ok = response.startswith(command.encode())
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -359,11 +390,12 @@ class LinksAwakeningClient():
|
|||||||
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
||||||
self.auth = auth
|
self.auth = auth
|
||||||
|
|
||||||
async def wait_and_init_tracker(self):
|
async def wait_and_init_tracker(self, magpie: MagpieBridge):
|
||||||
await self.wait_for_game_ready()
|
await self.wait_for_game_ready()
|
||||||
self.tracker = LocationTracker(self.gameboy)
|
self.tracker = LocationTracker(self.gameboy)
|
||||||
self.item_tracker = ItemTracker(self.gameboy)
|
self.item_tracker = ItemTracker(self.gameboy)
|
||||||
self.gps_tracker = GpsTracker(self.gameboy)
|
self.gps_tracker = GpsTracker(self.gameboy)
|
||||||
|
magpie.gps_tracker = self.gps_tracker
|
||||||
|
|
||||||
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
||||||
# Don't allow getting an item until you've got your first check
|
# Don't allow getting an item until you've got your first check
|
||||||
@@ -405,9 +437,11 @@ class LinksAwakeningClient():
|
|||||||
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
||||||
|
|
||||||
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
||||||
|
await self.gameboy.update_cache()
|
||||||
await self.tracker.readChecks(item_get_cb)
|
await self.tracker.readChecks(item_get_cb)
|
||||||
await self.item_tracker.readItems()
|
await self.item_tracker.readItems()
|
||||||
await self.gps_tracker.read_location()
|
await self.gps_tracker.read_location()
|
||||||
|
await self.gps_tracker.read_entrances()
|
||||||
|
|
||||||
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
||||||
if self.deathlink_debounce and current_health != 0:
|
if self.deathlink_debounce and current_health != 0:
|
||||||
@@ -457,7 +491,7 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
la_task = None
|
la_task = None
|
||||||
client = None
|
client = None
|
||||||
# TODO: does this need to re-read on reset?
|
# TODO: does this need to re-read on reset?
|
||||||
found_checks = []
|
found_checks = set()
|
||||||
last_resend = time.time()
|
last_resend = time.time()
|
||||||
|
|
||||||
magpie_enabled = False
|
magpie_enabled = False
|
||||||
@@ -465,6 +499,10 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
magpie_task = None
|
magpie_task = None
|
||||||
won = False
|
won = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slot_storage_key(self):
|
||||||
|
return f"{self.slot_info[self.slot].name}_{storage_key}"
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||||
self.client = LinksAwakeningClient()
|
self.client = LinksAwakeningClient()
|
||||||
self.slot_data = {}
|
self.slot_data = {}
|
||||||
@@ -476,9 +514,9 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
def run_gui(self) -> None:
|
def run_gui(self) -> None:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import kvui
|
from kvui import GameManager
|
||||||
from kvui import Button, GameManager
|
from kivy.metrics import dp
|
||||||
from kivy.uix.image import Image
|
from kivymd.uix.button import MDButton, MDButtonText
|
||||||
|
|
||||||
class LADXManager(GameManager):
|
class LADXManager(GameManager):
|
||||||
logging_pairs = [
|
logging_pairs = [
|
||||||
@@ -491,23 +529,27 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
b = super().build()
|
b = super().build()
|
||||||
|
|
||||||
if self.ctx.magpie_enabled:
|
if self.ctx.magpie_enabled:
|
||||||
button = Button(text="", size=(30, 30), size_hint_x=None,
|
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
|
||||||
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
|
||||||
image = Image(size=(16, 16), texture=magpie_logo())
|
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
||||||
button.add_widget(image)
|
button.height = self.server_connect_bar.height
|
||||||
|
|
||||||
def set_center(_, center):
|
|
||||||
image.center = center
|
|
||||||
button.bind(center=set_center)
|
|
||||||
|
|
||||||
self.connect_layout.add_widget(button)
|
self.connect_layout.add_widget(button)
|
||||||
|
|
||||||
return b
|
return b
|
||||||
|
|
||||||
self.ui = LADXManager(self)
|
self.ui = LADXManager(self)
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
async def send_checks(self):
|
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
|
||||||
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
|
# Store the entrances we find on the server for future sessions
|
||||||
|
message = [{
|
||||||
|
"cmd": "Set",
|
||||||
|
"key": self.slot_storage_key,
|
||||||
|
"default": {},
|
||||||
|
"want_reply": False,
|
||||||
|
"operations": [{"operation": "update", "value": entrances}],
|
||||||
|
}]
|
||||||
|
|
||||||
await self.send_msgs(message)
|
await self.send_msgs(message)
|
||||||
|
|
||||||
had_invalid_slot_data = None
|
had_invalid_slot_data = None
|
||||||
@@ -537,13 +579,19 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
await self.send_msgs(message)
|
await self.send_msgs(message)
|
||||||
self.won = True
|
self.won = True
|
||||||
|
|
||||||
|
async def request_found_entrances(self):
|
||||||
|
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
||||||
|
|
||||||
|
# Ask for updates so that players can co-op entrances in a seed
|
||||||
|
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
|
||||||
|
|
||||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||||
if self.ENABLE_DEATHLINK:
|
if self.ENABLE_DEATHLINK:
|
||||||
self.client.pending_deathlink = True
|
self.client.pending_deathlink = True
|
||||||
|
|
||||||
def new_checks(self, item_ids, ladxr_ids):
|
def new_checks(self, item_ids, ladxr_ids):
|
||||||
self.found_checks += item_ids
|
self.found_checks.update(item_ids)
|
||||||
create_task_log_exception(self.send_checks())
|
create_task_log_exception(self.check_locations(self.found_checks))
|
||||||
if self.magpie_enabled:
|
if self.magpie_enabled:
|
||||||
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
|
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
|
||||||
|
|
||||||
@@ -560,6 +608,10 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
while self.client.auth == None:
|
while self.client.auth == None:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Just return if we're closing
|
||||||
|
if self.exit_event.is_set():
|
||||||
|
return
|
||||||
self.auth = self.client.auth
|
self.auth = self.client.auth
|
||||||
await self.send_connect()
|
await self.send_connect()
|
||||||
|
|
||||||
@@ -567,16 +619,40 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
self.game = self.slot_info[self.slot].game
|
self.game = self.slot_info[self.slot].game
|
||||||
self.slot_data = args.get("slot_data", {})
|
self.slot_data = args.get("slot_data", {})
|
||||||
|
# This is sent to magpie over local websocket to make its own connection
|
||||||
|
self.slot_data.update({
|
||||||
|
"server_address": self.server_address,
|
||||||
|
"slot_name": self.player_names[self.slot],
|
||||||
|
"password": self.password,
|
||||||
|
})
|
||||||
|
|
||||||
|
# We can process linked items on already-checked checks now that we have slot_data
|
||||||
|
if self.client.tracker:
|
||||||
|
checked_checks = set(self.client.tracker.all_checks) - set(self.client.tracker.remaining_checks)
|
||||||
|
self.add_linked_items(checked_checks)
|
||||||
|
|
||||||
# TODO - use watcher_event
|
# TODO - use watcher_event
|
||||||
if cmd == "ReceivedItems":
|
if cmd == "ReceivedItems":
|
||||||
for index, item in enumerate(args["items"], start=args["index"]):
|
for index, item in enumerate(args["items"], start=args["index"]):
|
||||||
self.client.recvd_checks[index] = item
|
self.client.recvd_checks[index] = item
|
||||||
|
|
||||||
|
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
|
||||||
|
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
|
||||||
|
|
||||||
|
if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key:
|
||||||
|
self.client.gps_tracker.receive_found_entrances(args["value"])
|
||||||
|
|
||||||
async def sync(self):
|
async def sync(self):
|
||||||
sync_msg = [{'cmd': 'Sync'}]
|
sync_msg = [{'cmd': 'Sync'}]
|
||||||
await self.send_msgs(sync_msg)
|
await self.send_msgs(sync_msg)
|
||||||
|
|
||||||
|
def add_linked_items(self, checks: typing.List[Check]):
|
||||||
|
for check in checks:
|
||||||
|
if check.value and check.linkedItem:
|
||||||
|
linkedItem = check.linkedItem
|
||||||
|
if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)):
|
||||||
|
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
|
||||||
|
|
||||||
item_id_lookup = get_locations_to_id()
|
item_id_lookup = get_locations_to_id()
|
||||||
|
|
||||||
async def run_game_loop(self):
|
async def run_game_loop(self):
|
||||||
@@ -585,6 +661,8 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
checkMetadataTable[check.id])] for check in ladxr_checks]
|
||||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
self.new_checks(checks, [check.id for check in ladxr_checks])
|
||||||
|
|
||||||
|
self.add_linked_items(ladxr_checks)
|
||||||
|
|
||||||
async def victory():
|
async def victory():
|
||||||
await self.send_victory()
|
await self.send_victory()
|
||||||
|
|
||||||
@@ -618,21 +696,38 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
if not self.client.recvd_checks:
|
if not self.client.recvd_checks:
|
||||||
await self.sync()
|
await self.sync()
|
||||||
|
|
||||||
await self.client.wait_and_init_tracker()
|
await self.client.wait_and_init_tracker(self.magpie)
|
||||||
|
|
||||||
|
min_tick_duration = 0.1
|
||||||
|
last_tick = time.time()
|
||||||
while True:
|
while True:
|
||||||
await self.client.main_tick(on_item_get, victory, deathlink)
|
await self.client.main_tick(on_item_get, victory, deathlink)
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
tick_duration = now - last_tick
|
||||||
|
sleep_duration = max(min_tick_duration - tick_duration, 0)
|
||||||
|
await asyncio.sleep(sleep_duration)
|
||||||
|
|
||||||
|
last_tick = now
|
||||||
|
|
||||||
if self.last_resend + 5.0 < now:
|
if self.last_resend + 5.0 < now:
|
||||||
self.last_resend = now
|
self.last_resend = now
|
||||||
await self.send_checks()
|
await self.check_locations(self.found_checks)
|
||||||
if self.magpie_enabled:
|
if self.magpie_enabled:
|
||||||
try:
|
try:
|
||||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||||
await self.magpie.send_gps(self.client.gps_tracker)
|
if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data:
|
||||||
self.magpie.slot_data = self.slot_data
|
self.magpie.slot_data = self.slot_data
|
||||||
|
await self.magpie.send_slot_data()
|
||||||
|
|
||||||
|
if self.client.gps_tracker.needs_found_entrances:
|
||||||
|
await self.request_found_entrances()
|
||||||
|
self.client.gps_tracker.needs_found_entrances = False
|
||||||
|
|
||||||
|
new_entrances = await self.magpie.send_gps(self.client.gps_tracker)
|
||||||
|
if new_entrances:
|
||||||
|
await self.send_new_entrances(new_entrances)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Don't let magpie errors take out the client
|
# Don't let magpie errors take out the client
|
||||||
pass
|
pass
|
||||||
@@ -643,8 +738,8 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
def run_game(romfile: str) -> None:
|
def run_game(romfile: str) -> None:
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
auto_start = LinksAwakeningWorld.settings.rom_start
|
||||||
Utils.get_options()["ladx_options"].get("rom_start", True))
|
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -701,6 +796,6 @@ async def main():
|
|||||||
await ctx.shutdown()
|
await ctx.shutdown()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -33,10 +33,15 @@ WINDOW_MIN_HEIGHT = 525
|
|||||||
WINDOW_MIN_WIDTH = 425
|
WINDOW_MIN_WIDTH = 425
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
|
class AdjusterSubWorld(object):
|
||||||
|
def __init__(self, random):
|
||||||
|
self.random = random
|
||||||
|
|
||||||
def __init__(self, sprite_pool):
|
def __init__(self, sprite_pool):
|
||||||
import random
|
import random
|
||||||
self.sprite_pool = {1: sprite_pool}
|
self.sprite_pool = {1: sprite_pool}
|
||||||
self.per_slot_randoms = {1: random}
|
self.per_slot_randoms = {1: random}
|
||||||
|
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||||
|
|
||||||
|
|
||||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||||
|
|||||||
@@ -290,12 +290,9 @@ async def gba_sync_task(ctx: MMBN3Context):
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
options = Utils.get_options().get("mmbn3_options", None)
|
from worlds.mmbn3 import MMBN3World
|
||||||
if options is None:
|
auto_start = MMBN3World.settings.rom_start
|
||||||
auto_start = True
|
if auto_start is True:
|
||||||
else:
|
|
||||||
auto_start = options.get("rom_start", True)
|
|
||||||
if auto_start:
|
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
elif os.path.isfile(auto_start):
|
elif os.path.isfile(auto_start):
|
||||||
@@ -370,7 +367,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
156
Main.py
156
Main.py
@@ -7,14 +7,13 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
import zlib
|
import zlib
|
||||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
|
||||||
|
|
||||||
import worlds
|
import worlds
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
|
||||||
flood_items
|
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
|
||||||
from Options import StartInventoryPool
|
from Options import StartInventoryPool
|
||||||
from Utils import __version__, output_path, version_tuple, get_settings
|
from Utils import __version__, output_path, version_tuple
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||||
@@ -22,7 +21,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
|
|||||||
__all__ = ["main"]
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
|
||||||
if not baked_server_options:
|
if not baked_server_options:
|
||||||
baked_server_options = get_settings().server_options.as_dict()
|
baked_server_options = get_settings().server_options.as_dict()
|
||||||
assert isinstance(baked_server_options, dict)
|
assert isinstance(baked_server_options, dict)
|
||||||
@@ -37,9 +36,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||||
multiworld.plando_options = args.plando_options
|
multiworld.plando_options = args.plando_options
|
||||||
multiworld.plando_items = args.plando_items.copy()
|
|
||||||
multiworld.plando_texts = args.plando_texts.copy()
|
|
||||||
multiworld.plando_connections = args.plando_connections.copy()
|
|
||||||
multiworld.game = args.game.copy()
|
multiworld.game = args.game.copy()
|
||||||
multiworld.player_name = args.name.copy()
|
multiworld.player_name = args.name.copy()
|
||||||
multiworld.sprite = args.sprite.copy()
|
multiworld.sprite = args.sprite.copy()
|
||||||
@@ -56,32 +52,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
||||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||||
|
|
||||||
max_item = 0
|
|
||||||
max_location = 0
|
|
||||||
for cls in AutoWorld.AutoWorldRegister.world_types.values():
|
|
||||||
if cls.item_id_to_name:
|
|
||||||
max_item = max(max_item, max(cls.item_id_to_name))
|
|
||||||
max_location = max(max_location, max(cls.location_id_to_name))
|
|
||||||
|
|
||||||
item_digits = len(str(max_item))
|
|
||||||
location_digits = len(str(max_location))
|
|
||||||
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||||
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||||
del max_item, max_location
|
|
||||||
|
|
||||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
if not cls.hidden and len(cls.item_names) > 0:
|
if not cls.hidden and len(cls.item_names) > 0:
|
||||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
|
||||||
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
f"Locations: {len(cls.location_names):{location_count}}")
|
||||||
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
|
||||||
f"{len(cls.location_names):{location_count}} "
|
|
||||||
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
|
||||||
f"{max(cls.location_id_to_name):{location_digits}})")
|
|
||||||
|
|
||||||
del item_digits, location_digits, item_count, location_count
|
del item_count, location_count
|
||||||
|
|
||||||
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
||||||
if not args.skip_output:
|
if not args.skip_output and not args.spoiler_only:
|
||||||
AutoWorld.call_stage(multiworld, "assert_generate")
|
AutoWorld.call_stage(multiworld, "assert_generate")
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "generate_early")
|
AutoWorld.call_all(multiworld, "generate_early")
|
||||||
@@ -148,50 +130,46 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
else:
|
else:
|
||||||
multiworld.worlds[1].options.non_local_items.value = set()
|
multiworld.worlds[1].options.non_local_items.value = set()
|
||||||
multiworld.worlds[1].options.local_items.value = set()
|
multiworld.worlds[1].options.local_items.value = set()
|
||||||
|
|
||||||
|
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
|
||||||
|
|
||||||
|
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||||
AutoWorld.call_all(multiworld, "generate_basic")
|
AutoWorld.call_all(multiworld, "generate_basic")
|
||||||
|
|
||||||
# remove starting inventory from pool items.
|
# remove starting inventory from pool items.
|
||||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||||
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
|
fallback_inventory = StartInventoryPool({})
|
||||||
new_items: List[Item] = []
|
depletion_pool: dict[int, dict[str, int]] = {
|
||||||
old_items: List[Item] = []
|
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
||||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
for player in multiworld.player_ids
|
||||||
player: getattr(multiworld.worlds[player].options,
|
}
|
||||||
"start_inventory_from_pool",
|
target_per_player = {
|
||||||
StartInventoryPool({})).value.copy()
|
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items
|
||||||
for player in multiworld.player_ids
|
}
|
||||||
}
|
|
||||||
for player, items in depletion_pool.items():
|
|
||||||
player_world: AutoWorld.World = multiworld.worlds[player]
|
|
||||||
for count in items.values():
|
|
||||||
for _ in range(count):
|
|
||||||
new_items.append(player_world.create_filler())
|
|
||||||
target: int = sum(sum(items.values()) for items in depletion_pool.values())
|
|
||||||
for i, item in enumerate(multiworld.itempool):
|
|
||||||
if depletion_pool[item.player].get(item.name, 0):
|
|
||||||
target -= 1
|
|
||||||
depletion_pool[item.player][item.name] -= 1
|
|
||||||
# quick abort if we have found all items
|
|
||||||
if not target:
|
|
||||||
old_items.extend(multiworld.itempool[i+1:])
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
old_items.append(item)
|
|
||||||
|
|
||||||
# leftovers?
|
if target_per_player:
|
||||||
if target:
|
new_itempool: list[Item] = []
|
||||||
for player, remaining_items in depletion_pool.items():
|
|
||||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
# Make new itempool with start_inventory_from_pool items removed
|
||||||
if remaining_items:
|
for item in multiworld.itempool:
|
||||||
logger.warning(f"{multiworld.get_player_name(player)}"
|
if depletion_pool[item.player].get(item.name, 0):
|
||||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
depletion_pool[item.player][item.name] -= 1
|
||||||
# find all filler we generated for the current player and remove until it matches
|
else:
|
||||||
removables = [item for item in new_items if item.player == player]
|
new_itempool.append(item)
|
||||||
for _ in range(sum(remaining_items.values())):
|
|
||||||
new_items.remove(removables.pop())
|
# Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool
|
||||||
assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
|
for player, target in target_per_player.items():
|
||||||
multiworld.itempool[:] = new_items + old_items
|
unfound_items = {item: count for item, count in depletion_pool[player].items() if count}
|
||||||
|
|
||||||
|
if unfound_items:
|
||||||
|
player_name = multiworld.get_player_name(player)
|
||||||
|
logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}")
|
||||||
|
|
||||||
|
needed_items = target_per_player[player] - sum(unfound_items.values())
|
||||||
|
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
|
||||||
|
|
||||||
|
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
|
||||||
|
multiworld.itempool[:] = new_itempool
|
||||||
|
|
||||||
multiworld.link_items()
|
multiworld.link_items()
|
||||||
|
|
||||||
@@ -199,8 +177,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
multiworld._all_state = None
|
multiworld._all_state = None
|
||||||
|
|
||||||
logger.info("Running Item Plando.")
|
logger.info("Running Item Plando.")
|
||||||
|
resolve_early_locations_for_planned(multiworld)
|
||||||
distribute_planned(multiworld)
|
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
|
||||||
|
for x in multiworld.plando_item_blocks[player]])
|
||||||
|
|
||||||
logger.info('Running Pre Main Fill.')
|
logger.info('Running Pre Main Fill.')
|
||||||
|
|
||||||
@@ -230,6 +209,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
logger.info(f'Beginning output...')
|
logger.info(f'Beginning output...')
|
||||||
outfilebase = 'AP_' + multiworld.seed_name
|
outfilebase = 'AP_' + multiworld.seed_name
|
||||||
|
|
||||||
|
if args.spoiler_only:
|
||||||
|
if args.spoiler > 1:
|
||||||
|
logger.info('Calculating playthrough.')
|
||||||
|
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||||
|
|
||||||
|
multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
|
||||||
|
logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
|
||||||
|
return multiworld
|
||||||
|
|
||||||
output = tempfile.TemporaryDirectory()
|
output = tempfile.TemporaryDirectory()
|
||||||
with output as temp_dir:
|
with output as temp_dir:
|
||||||
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
||||||
@@ -244,11 +232,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
er_hint_data: dict[int, dict[int, str]] = {}
|
||||||
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
||||||
|
|
||||||
def write_multidata():
|
def write_multidata():
|
||||||
import NetUtils
|
import NetUtils
|
||||||
|
from NetUtils import HintStatus
|
||||||
slot_data = {}
|
slot_data = {}
|
||||||
client_versions = {}
|
client_versions = {}
|
||||||
games = {}
|
games = {}
|
||||||
@@ -273,10 +262,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for slot in multiworld.player_ids:
|
for slot in multiworld.player_ids:
|
||||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
||||||
|
|
||||||
def precollect_hint(location):
|
def precollect_hint(location: Location, auto_status: HintStatus):
|
||||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||||
location.item.code, False, entrance, location.item.flags)
|
location.item.code, False, entrance, location.item.flags, auto_status)
|
||||||
precollected_hints[location.player].add(hint)
|
precollected_hints[location.player].add(hint)
|
||||||
if location.item.player not in multiworld.groups:
|
if location.item.player not in multiworld.groups:
|
||||||
precollected_hints[location.item.player].add(hint)
|
precollected_hints[location.item.player].add(hint)
|
||||||
@@ -284,40 +273,43 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player in multiworld.groups[location.item.player]["players"]:
|
for player in multiworld.groups[location.item.player]["players"]:
|
||||||
precollected_hints[player].add(hint)
|
precollected_hints[player].add(hint)
|
||||||
|
|
||||||
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
||||||
for location in multiworld.get_filled_locations():
|
for location in multiworld.get_filled_locations():
|
||||||
if type(location.address) == int:
|
if type(location.address) == int:
|
||||||
assert location.item.code is not None, "item code None should be event, " \
|
assert location.item.code is not None, "item code None should be event, " \
|
||||||
"location.address should then also be None. Location: " \
|
"location.address should then also be None. Location: " \
|
||||||
f" {location}"
|
f" {location}, Item: {location.item}"
|
||||||
assert location.address not in locations_data[location.player], (
|
assert location.address not in locations_data[location.player], (
|
||||||
f"Locations with duplicate address. {location} and "
|
f"Locations with duplicate address. {location} and "
|
||||||
f"{locations_data[location.player][location.address]}")
|
f"{locations_data[location.player][location.address]}")
|
||||||
locations_data[location.player][location.address] = \
|
locations_data[location.player][location.address] = \
|
||||||
location.item.code, location.item.player, location.item.flags
|
location.item.code, location.item.player, location.item.flags
|
||||||
|
auto_status = HintStatus.HINT_AVOID if location.item.trap else HintStatus.HINT_PRIORITY
|
||||||
if location.name in multiworld.worlds[location.player].options.start_location_hints:
|
if location.name in multiworld.worlds[location.player].options.start_location_hints:
|
||||||
precollect_hint(location)
|
if not location.item.trap: # Unspecified status for location hints, except traps
|
||||||
|
auto_status = HintStatus.HINT_UNSPECIFIED
|
||||||
|
precollect_hint(location, auto_status)
|
||||||
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
|
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
|
||||||
precollect_hint(location)
|
precollect_hint(location, auto_status)
|
||||||
elif any([location.item.name in multiworld.worlds[player].options.start_hints
|
elif any([location.item.name in multiworld.worlds[player].options.start_hints
|
||||||
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
|
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
|
||||||
precollect_hint(location)
|
precollect_hint(location, auto_status)
|
||||||
|
|
||||||
# embedded data package
|
# embedded data package
|
||||||
data_package = {
|
data_package = {
|
||||||
game_world.game: worlds.network_data_package["games"][game_world.game]
|
game_world.game: worlds.network_data_package["games"][game_world.game]
|
||||||
for game_world in multiworld.worlds.values()
|
for game_world in multiworld.worlds.values()
|
||||||
}
|
}
|
||||||
|
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
|
||||||
|
|
||||||
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
checks_in_area: dict[int, dict[str, int | list[int]]] = {}
|
||||||
|
|
||||||
# get spheres -> filter address==None -> skip empty
|
# get spheres -> filter address==None -> skip empty
|
||||||
spheres: List[Dict[int, Set[int]]] = []
|
spheres: list[dict[int, set[int]]] = []
|
||||||
for sphere in multiworld.get_spheres():
|
for sphere in multiworld.get_sendable_spheres():
|
||||||
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
|
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
|
||||||
for sphere_location in sphere:
|
for sphere_location in sphere:
|
||||||
if type(sphere_location.address) is int:
|
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
|
||||||
|
|
||||||
if current_sphere:
|
if current_sphere:
|
||||||
spheres.append(dict(current_sphere))
|
spheres.append(dict(current_sphere))
|
||||||
|
|||||||
@@ -1,344 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
import atexit
|
|
||||||
import shutil
|
|
||||||
from subprocess import Popen
|
|
||||||
from shutil import copyfile
|
|
||||||
from time import strftime
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
from Utils import is_windows
|
|
||||||
|
|
||||||
atexit.register(input, "Press enter to exit.")
|
|
||||||
|
|
||||||
# 1 or more digits followed by m or g, then optional b
|
|
||||||
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_yes_no(prompt):
|
|
||||||
yes_inputs = {'yes', 'ye', 'y'}
|
|
||||||
no_inputs = {'no', 'n'}
|
|
||||||
while True:
|
|
||||||
choice = input(prompt + " [y/n] ").lower()
|
|
||||||
if choice in yes_inputs:
|
|
||||||
return True
|
|
||||||
elif choice in no_inputs:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
print('Please respond with "y" or "n".')
|
|
||||||
|
|
||||||
|
|
||||||
def find_ap_randomizer_jar(forge_dir):
|
|
||||||
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
|
|
||||||
mods_dir = os.path.join(forge_dir, 'mods')
|
|
||||||
if os.path.isdir(mods_dir):
|
|
||||||
for entry in os.scandir(mods_dir):
|
|
||||||
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
|
|
||||||
logging.info(f"Found AP randomizer mod: {entry.name}")
|
|
||||||
return entry.name
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
os.mkdir(mods_dir)
|
|
||||||
logging.info(f"Created mods folder in {forge_dir}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def replace_apmc_files(forge_dir, apmc_file):
|
|
||||||
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
|
|
||||||
if apmc_file is None:
|
|
||||||
return
|
|
||||||
apdata_dir = os.path.join(forge_dir, 'APData')
|
|
||||||
copy_apmc = True
|
|
||||||
if not os.path.isdir(apdata_dir):
|
|
||||||
os.mkdir(apdata_dir)
|
|
||||||
logging.info(f"Created APData folder in {forge_dir}")
|
|
||||||
for entry in os.scandir(apdata_dir):
|
|
||||||
if entry.name.endswith(".apmc") and entry.is_file():
|
|
||||||
if not os.path.samefile(apmc_file, entry.path):
|
|
||||||
os.remove(entry.path)
|
|
||||||
logging.info(f"Removed {entry.name} in {apdata_dir}")
|
|
||||||
else: # apmc already in apdata
|
|
||||||
copy_apmc = False
|
|
||||||
if copy_apmc:
|
|
||||||
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
|
|
||||||
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
|
|
||||||
|
|
||||||
|
|
||||||
def read_apmc_file(apmc_file):
|
|
||||||
from base64 import b64decode
|
|
||||||
|
|
||||||
with open(apmc_file, 'r') as f:
|
|
||||||
return json.loads(b64decode(f.read()))
|
|
||||||
|
|
||||||
|
|
||||||
def update_mod(forge_dir, url: str):
|
|
||||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
|
||||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
|
||||||
os.path.basename(url)
|
|
||||||
if ap_randomizer is not None:
|
|
||||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
|
||||||
else:
|
|
||||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
|
||||||
|
|
||||||
if ap_randomizer != os.path.basename(url):
|
|
||||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
|
||||||
f"{os.path.basename(url)}")
|
|
||||||
if prompt_yes_no("Would you like to update?"):
|
|
||||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
|
||||||
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
|
|
||||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
|
||||||
apmod_resp = requests.get(url)
|
|
||||||
if apmod_resp.status_code == 200:
|
|
||||||
with open(new_ap_mod, 'wb') as f:
|
|
||||||
f.write(apmod_resp.content)
|
|
||||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
|
||||||
if old_ap_mod is not None:
|
|
||||||
os.remove(old_ap_mod)
|
|
||||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
|
||||||
else:
|
|
||||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
|
||||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def check_eula(forge_dir):
|
|
||||||
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
|
|
||||||
eula_path = os.path.join(forge_dir, "eula.txt")
|
|
||||||
if not os.path.isfile(eula_path):
|
|
||||||
# Create eula.txt
|
|
||||||
with open(eula_path, 'w') as f:
|
|
||||||
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
|
|
||||||
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
|
|
||||||
f.write("eula=false\n")
|
|
||||||
with open(eula_path, 'r+') as f:
|
|
||||||
text = f.read()
|
|
||||||
if 'false' in text:
|
|
||||||
# Prompt user to agree to the EULA
|
|
||||||
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
|
|
||||||
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
|
|
||||||
if prompt_yes_no("Do you agree to the EULA?"):
|
|
||||||
f.seek(0)
|
|
||||||
f.write(text.replace('false', 'true'))
|
|
||||||
f.truncate()
|
|
||||||
logging.info(f"Set {eula_path} to true")
|
|
||||||
else:
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def find_jdk_dir(version: str) -> str:
|
|
||||||
"""get the specified versions jdk directory"""
|
|
||||||
for entry in os.listdir():
|
|
||||||
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
|
|
||||||
return os.path.abspath(entry)
|
|
||||||
|
|
||||||
|
|
||||||
def find_jdk(version: str) -> str:
|
|
||||||
"""get the java exe location"""
|
|
||||||
|
|
||||||
if is_windows:
|
|
||||||
jdk = find_jdk_dir(version)
|
|
||||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
|
||||||
if os.path.isfile(jdk_exe):
|
|
||||||
return jdk_exe
|
|
||||||
else:
|
|
||||||
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
|
|
||||||
if not jdk_exe:
|
|
||||||
raise Exception("Could not find Java. Is Java installed on the system?")
|
|
||||||
return jdk_exe
|
|
||||||
|
|
||||||
|
|
||||||
def download_java(java: str):
|
|
||||||
"""Download Corretto (Amazon JDK)"""
|
|
||||||
|
|
||||||
jdk = find_jdk_dir(java)
|
|
||||||
if jdk is not None:
|
|
||||||
print(f"Removing old JDK...")
|
|
||||||
from shutil import rmtree
|
|
||||||
rmtree(jdk)
|
|
||||||
|
|
||||||
print(f"Downloading Java...")
|
|
||||||
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
|
|
||||||
resp = requests.get(jdk_url)
|
|
||||||
if resp.status_code == 200: # OK
|
|
||||||
print(f"Extracting...")
|
|
||||||
import zipfile
|
|
||||||
from io import BytesIO
|
|
||||||
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
|
|
||||||
zf.extractall()
|
|
||||||
else:
|
|
||||||
print(f"Error downloading Java (status code {resp.status_code}).")
|
|
||||||
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
|
||||||
if not prompt_yes_no("Continue anyways?"):
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def install_forge(directory: str, forge_version: str, java_version: str):
|
|
||||||
"""download and install forge"""
|
|
||||||
|
|
||||||
java_exe = find_jdk(java_version)
|
|
||||||
if java_exe is not None:
|
|
||||||
print(f"Downloading Forge {forge_version}...")
|
|
||||||
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
|
|
||||||
resp = requests.get(forge_url)
|
|
||||||
if resp.status_code == 200: # OK
|
|
||||||
forge_install_jar = os.path.join(directory, "forge_install.jar")
|
|
||||||
if not os.path.exists(directory):
|
|
||||||
os.mkdir(directory)
|
|
||||||
with open(forge_install_jar, 'wb') as f:
|
|
||||||
f.write(resp.content)
|
|
||||||
print(f"Installing Forge...")
|
|
||||||
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
|
|
||||||
install_process.wait()
|
|
||||||
os.remove(forge_install_jar)
|
|
||||||
|
|
||||||
|
|
||||||
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
|
|
||||||
"""Run the Forge server."""
|
|
||||||
|
|
||||||
java_exe = find_jdk(java_version)
|
|
||||||
if not os.path.isfile(java_exe):
|
|
||||||
java_exe = "java" # try to fall back on java in the PATH
|
|
||||||
|
|
||||||
heap_arg = max_heap_re.match(heap_arg).group()
|
|
||||||
if heap_arg[-1] in ['b', 'B']:
|
|
||||||
heap_arg = heap_arg[:-1]
|
|
||||||
heap_arg = "-Xmx" + heap_arg
|
|
||||||
|
|
||||||
os_args = "win_args.txt" if is_windows else "unix_args.txt"
|
|
||||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
|
|
||||||
forge_args = []
|
|
||||||
with open(args_file) as argfile:
|
|
||||||
for line in argfile:
|
|
||||||
forge_args.extend(line.strip().split(" "))
|
|
||||||
|
|
||||||
args = [java_exe, heap_arg, *forge_args, "-nogui"]
|
|
||||||
logging.info(f"Running Forge server: {args}")
|
|
||||||
os.chdir(forge_dir)
|
|
||||||
return Popen(args)
|
|
||||||
|
|
||||||
|
|
||||||
def get_minecraft_versions(version, release_channel="release"):
|
|
||||||
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
|
|
||||||
resp = requests.get(version_file_endpoint)
|
|
||||||
local = False
|
|
||||||
if resp.status_code == 200: # OK
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
except requests.exceptions.JSONDecodeError:
|
|
||||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
|
||||||
local = True
|
|
||||||
else:
|
|
||||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
|
||||||
local = True
|
|
||||||
|
|
||||||
if local:
|
|
||||||
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
else:
|
|
||||||
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if version:
|
|
||||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
|
||||||
else:
|
|
||||||
return resp.json()[release_channel][0]
|
|
||||||
except (StopIteration, KeyError):
|
|
||||||
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
|
|
||||||
if release_channel != "release":
|
|
||||||
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
|
|
||||||
else:
|
|
||||||
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def is_correct_forge(forge_dir) -> bool:
|
|
||||||
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
Utils.init_logging("MinecraftClient")
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
|
|
||||||
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
|
|
||||||
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
|
|
||||||
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
|
|
||||||
help="Specify release channel to use.")
|
|
||||||
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
|
|
||||||
help="specify java version.")
|
|
||||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
|
||||||
help="specify forge version. (Minecraft Version-Forge Version)")
|
|
||||||
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
|
|
||||||
help="specify Mod data version to download.")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
|
||||||
|
|
||||||
# Change to executable's working directory
|
|
||||||
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
|
|
||||||
|
|
||||||
options = Utils.get_options()
|
|
||||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
|
||||||
apmc_data = None
|
|
||||||
data_version = args.data_version or None
|
|
||||||
|
|
||||||
if apmc_file is None and not args.install:
|
|
||||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
|
||||||
|
|
||||||
if apmc_file is not None and data_version is None:
|
|
||||||
apmc_data = read_apmc_file(apmc_file)
|
|
||||||
data_version = apmc_data.get('client_version', '')
|
|
||||||
|
|
||||||
versions = get_minecraft_versions(data_version, channel)
|
|
||||||
|
|
||||||
forge_dir = options["minecraft_options"]["forge_directory"]
|
|
||||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
|
||||||
forge_version = args.forge or versions["forge"]
|
|
||||||
java_version = args.java or versions["java"]
|
|
||||||
mod_url = versions["url"]
|
|
||||||
java_dir = find_jdk_dir(java_version)
|
|
||||||
|
|
||||||
if args.install:
|
|
||||||
if is_windows:
|
|
||||||
print("Installing Java")
|
|
||||||
download_java(java_version)
|
|
||||||
if not is_correct_forge(forge_dir):
|
|
||||||
print("Installing Minecraft Forge")
|
|
||||||
install_forge(forge_dir, forge_version, java_version)
|
|
||||||
else:
|
|
||||||
print("Correct Forge version already found, skipping install.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
if apmc_data is None:
|
|
||||||
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
|
|
||||||
|
|
||||||
if is_windows:
|
|
||||||
if java_dir is None or not os.path.isdir(java_dir):
|
|
||||||
if prompt_yes_no("Did not find java directory. Download and install java now?"):
|
|
||||||
download_java(java_version)
|
|
||||||
java_dir = find_jdk_dir(java_version)
|
|
||||||
if java_dir is None or not os.path.isdir(java_dir):
|
|
||||||
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
|
|
||||||
|
|
||||||
if not is_correct_forge(forge_dir):
|
|
||||||
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
|
|
||||||
install_forge(forge_dir, forge_version, java_version)
|
|
||||||
if not os.path.isdir(forge_dir):
|
|
||||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
|
||||||
|
|
||||||
if not max_heap_re.match(max_heap):
|
|
||||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
|
||||||
|
|
||||||
update_mod(forge_dir, mod_url)
|
|
||||||
replace_apmc_files(forge_dir, apmc_file)
|
|
||||||
check_eula(forge_dir)
|
|
||||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
|
||||||
server_process.wait()
|
|
||||||
@@ -5,8 +5,15 @@ import multiprocessing
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info < (3, 8, 6):
|
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
|
||||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
# Official micro version updates. This should match the number in docs/running from source.md.
|
||||||
|
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.")
|
||||||
|
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
|
||||||
|
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
|
||||||
|
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
|
||||||
|
elif sys.version_info < (3, 10, 1):
|
||||||
|
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
|
||||||
|
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
|
||||||
|
|
||||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||||
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
||||||
|
|||||||
331
MultiServer.py
331
MultiServer.py
@@ -28,9 +28,11 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import ssl
|
import ssl
|
||||||
|
from NetUtils import ServerConnection
|
||||||
|
|
||||||
import websockets
|
|
||||||
import colorama
|
import colorama
|
||||||
|
import websockets
|
||||||
|
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||||
try:
|
try:
|
||||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||||
from pony.orm.dbapiprovider import OperationalError
|
from pony.orm.dbapiprovider import OperationalError
|
||||||
@@ -41,10 +43,12 @@ import NetUtils
|
|||||||
import Utils
|
import Utils
|
||||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
||||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||||
SlotType, LocationStore
|
SlotType, LocationStore, Hint, HintStatus
|
||||||
|
from BaseClasses import ItemClassification
|
||||||
|
|
||||||
min_client_version = Version(0, 1, 6)
|
|
||||||
colorama.init()
|
min_client_version = Version(0, 5, 0)
|
||||||
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
|
|
||||||
def remove_from_list(container, value):
|
def remove_from_list(container, value):
|
||||||
@@ -63,9 +67,13 @@ def pop_from_container(container, value):
|
|||||||
return container
|
return container
|
||||||
|
|
||||||
|
|
||||||
def update_dict(dictionary, entries):
|
def update_container_unique(container, entries):
|
||||||
dictionary.update(entries)
|
if isinstance(container, list):
|
||||||
return dictionary
|
existing_container_as_set = set(container)
|
||||||
|
container.extend([entry for entry in entries if entry not in existing_container_as_set])
|
||||||
|
else:
|
||||||
|
container.update(entries)
|
||||||
|
return container
|
||||||
|
|
||||||
|
|
||||||
def queue_gc():
|
def queue_gc():
|
||||||
@@ -106,7 +114,7 @@ modify_functions = {
|
|||||||
# lists/dicts:
|
# lists/dicts:
|
||||||
"remove": remove_from_list,
|
"remove": remove_from_list,
|
||||||
"pop": pop_from_container,
|
"pop": pop_from_container,
|
||||||
"update": update_dict,
|
"update": update_container_unique,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -118,13 +126,14 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
|||||||
|
|
||||||
class Client(Endpoint):
|
class Client(Endpoint):
|
||||||
version = Version(0, 0, 0)
|
version = Version(0, 0, 0)
|
||||||
tags: typing.List[str] = []
|
tags: typing.List[str]
|
||||||
remote_items: bool
|
remote_items: bool
|
||||||
remote_start_inventory: bool
|
remote_start_inventory: bool
|
||||||
no_items: bool
|
no_items: bool
|
||||||
no_locations: bool
|
no_locations: bool
|
||||||
|
no_text: bool
|
||||||
|
|
||||||
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
|
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
||||||
super().__init__(socket)
|
super().__init__(socket)
|
||||||
self.auth = False
|
self.auth = False
|
||||||
self.team = None
|
self.team = None
|
||||||
@@ -174,6 +183,7 @@ class Context:
|
|||||||
"compatibility": int}
|
"compatibility": int}
|
||||||
# team -> slot id -> list of clients authenticated to slot.
|
# team -> slot id -> list of clients authenticated to slot.
|
||||||
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
||||||
|
endpoints: list[Client]
|
||||||
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||||
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||||
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
||||||
@@ -228,7 +238,7 @@ class Context:
|
|||||||
self.hint_cost = hint_cost
|
self.hint_cost = hint_cost
|
||||||
self.location_check_points = location_check_points
|
self.location_check_points = location_check_points
|
||||||
self.hints_used = collections.defaultdict(int)
|
self.hints_used = collections.defaultdict(int)
|
||||||
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
|
self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set)
|
||||||
self.release_mode: str = release_mode
|
self.release_mode: str = release_mode
|
||||||
self.remaining_mode: str = remaining_mode
|
self.remaining_mode: str = remaining_mode
|
||||||
self.collect_mode: str = collect_mode
|
self.collect_mode: str = collect_mode
|
||||||
@@ -363,18 +373,28 @@ class Context:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def broadcast_all(self, msgs: typing.List[dict]):
|
def broadcast_all(self, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||||
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
data = self.dumper(msgs)
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
endpoints = (
|
||||||
|
endpoint
|
||||||
|
for endpoint in self.endpoints
|
||||||
|
if endpoint.auth and not (msg_is_text and endpoint.no_text)
|
||||||
|
)
|
||||||
|
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||||
|
|
||||||
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
||||||
self.logger.info("Notice (all): %s" % text)
|
self.logger.info("Notice (all): %s" % text)
|
||||||
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
||||||
|
|
||||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||||
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
data = self.dumper(msgs)
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
endpoints = (
|
||||||
|
endpoint
|
||||||
|
for endpoint in itertools.chain.from_iterable(self.clients[team].values())
|
||||||
|
if not (msg_is_text and endpoint.no_text)
|
||||||
|
)
|
||||||
|
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||||
|
|
||||||
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msgs = self.dumper(msgs)
|
||||||
@@ -388,13 +408,13 @@ class Context:
|
|||||||
await on_client_disconnected(self, endpoint)
|
await on_client_disconnected(self, endpoint)
|
||||||
|
|
||||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||||
if not client.auth:
|
if not client.auth or client.no_text:
|
||||||
return
|
return
|
||||||
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
||||||
|
|
||||||
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
||||||
if not client.auth:
|
if not client.auth or client.no_text:
|
||||||
return
|
return
|
||||||
async_start(self.send_msgs(client,
|
async_start(self.send_msgs(client,
|
||||||
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
||||||
@@ -438,12 +458,16 @@ class Context:
|
|||||||
self.generator_version = Version(*decoded_obj["version"])
|
self.generator_version = Version(*decoded_obj["version"])
|
||||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||||
self.minimum_client_versions = {}
|
self.minimum_client_versions = {}
|
||||||
|
if self.generator_version < Version(0, 6, 2):
|
||||||
|
min_version = Version(0, 1, 6)
|
||||||
|
else:
|
||||||
|
min_version = min_client_version
|
||||||
for player, version in clients_ver.items():
|
for player, version in clients_ver.items():
|
||||||
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
|
self.minimum_client_versions[player] = max(Version(*version), min_version)
|
||||||
|
|
||||||
self.slot_info = decoded_obj["slot_info"]
|
self.slot_info = decoded_obj["slot_info"]
|
||||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||||
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
|
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
|
||||||
if slot_info.type == SlotType.group}
|
if slot_info.type == SlotType.group}
|
||||||
|
|
||||||
self.clients = {0: {}}
|
self.clients = {0: {}}
|
||||||
@@ -656,13 +680,29 @@ class Context:
|
|||||||
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
|
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None,
|
||||||
|
changed: typing.Optional[typing.Set[team_slot]] = None) -> None:
|
||||||
|
"""Refreshes the hints for the specified team/slot. Providing 'None' for either team or slot
|
||||||
|
will refresh all teams or all slots respectively. If a set is passed for 'changed', each (team,slot)
|
||||||
|
pair that has at least one hint modified will be added to the set.
|
||||||
|
"""
|
||||||
for hint_team, hint_slot in self.hints:
|
for hint_team, hint_slot in self.hints:
|
||||||
if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
|
if team != hint_team and team is not None:
|
||||||
self.hints[hint_team, hint_slot] = {
|
continue # Check specified team only, all if team is None
|
||||||
hint.re_check(self, hint_team) for hint in
|
if slot != hint_slot and slot is not None:
|
||||||
self.hints[hint_team, hint_slot]
|
continue # Check specified slot only, all if slot is None
|
||||||
}
|
new_hints: typing.Set[Hint] = set()
|
||||||
|
for hint in self.hints[hint_team, hint_slot]:
|
||||||
|
new_hint = hint.re_check(self, hint_team)
|
||||||
|
new_hints.add(new_hint)
|
||||||
|
if hint == new_hint:
|
||||||
|
continue
|
||||||
|
for player in self.slot_set(hint.receiving_player) | {hint.finding_player}:
|
||||||
|
if changed is not None:
|
||||||
|
changed.add((hint_team,player))
|
||||||
|
if slot is not None and slot != player:
|
||||||
|
self.replace_hint(hint_team, player, hint, new_hint)
|
||||||
|
self.hints[hint_team, hint_slot] = new_hints
|
||||||
|
|
||||||
def get_rechecked_hints(self, team: int, slot: int):
|
def get_rechecked_hints(self, team: int, slot: int):
|
||||||
self.recheck_hints(team, slot)
|
self.recheck_hints(team, slot)
|
||||||
@@ -711,7 +751,7 @@ class Context:
|
|||||||
else:
|
else:
|
||||||
return self.player_names[team, slot]
|
return self.player_names[team, slot]
|
||||||
|
|
||||||
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
|
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
||||||
recipients: typing.Sequence[int] = None):
|
recipients: typing.Sequence[int] = None):
|
||||||
"""Send and remember hints."""
|
"""Send and remember hints."""
|
||||||
if only_new:
|
if only_new:
|
||||||
@@ -726,29 +766,41 @@ class Context:
|
|||||||
concerns[player].append(data)
|
concerns[player].append(data)
|
||||||
if not hint.local and data not in concerns[hint.finding_player]:
|
if not hint.local and data not in concerns[hint.finding_player]:
|
||||||
concerns[hint.finding_player].append(data)
|
concerns[hint.finding_player].append(data)
|
||||||
# remember hints in all cases
|
|
||||||
|
|
||||||
# since hints are bidirectional, finding player and receiving player,
|
# only remember hints that were not already found at the time of creation
|
||||||
# we can check once if hint already exists
|
if not hint.found:
|
||||||
if hint not in self.hints[team, hint.finding_player]:
|
# since hints are bidirectional, finding player and receiving player,
|
||||||
self.hints[team, hint.finding_player].add(hint)
|
# we can check once if hint already exists
|
||||||
new_hint_events.add(hint.finding_player)
|
if hint not in self.hints[team, hint.finding_player]:
|
||||||
for player in self.slot_set(hint.receiving_player):
|
self.hints[team, hint.finding_player].add(hint)
|
||||||
self.hints[team, player].add(hint)
|
new_hint_events.add(hint.finding_player)
|
||||||
new_hint_events.add(player)
|
for player in self.slot_set(hint.receiving_player):
|
||||||
|
self.hints[team, player].add(hint)
|
||||||
|
new_hint_events.add(player)
|
||||||
|
|
||||||
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
||||||
for slot in new_hint_events:
|
for slot in new_hint_events:
|
||||||
self.on_new_hint(team, slot)
|
self.on_new_hint(team, slot)
|
||||||
for slot, hint_data in concerns.items():
|
for slot, hint_data in concerns.items():
|
||||||
if recipients is None or slot in recipients:
|
if recipients is None or slot in recipients:
|
||||||
clients = self.clients[team].get(slot)
|
clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, []))
|
||||||
if not clients:
|
if not clients:
|
||||||
continue
|
continue
|
||||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
||||||
for client in clients:
|
for client in clients:
|
||||||
async_start(self.send_msgs(client, client_hints))
|
async_start(self.send_msgs(client, client_hints))
|
||||||
|
|
||||||
|
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
|
||||||
|
for hint in self.hints[team, finding_player]:
|
||||||
|
if hint.location == seeked_location and hint.finding_player == finding_player:
|
||||||
|
return hint
|
||||||
|
return None
|
||||||
|
|
||||||
|
def replace_hint(self, team: int, slot: int, old_hint: Hint, new_hint: Hint) -> None:
|
||||||
|
if old_hint in self.hints[team, slot]:
|
||||||
|
self.hints[team, slot].remove(old_hint)
|
||||||
|
self.hints[team, slot].add(new_hint)
|
||||||
|
|
||||||
# "events"
|
# "events"
|
||||||
|
|
||||||
def on_goal_achieved(self, client: Client):
|
def on_goal_achieved(self, client: Client):
|
||||||
@@ -790,7 +842,7 @@ def update_aliases(ctx: Context, team: int):
|
|||||||
async_start(ctx.send_encoded_msgs(client, cmd))
|
async_start(ctx.send_encoded_msgs(client, cmd))
|
||||||
|
|
||||||
|
|
||||||
async def server(websocket, path: str = "/", ctx: Context = None):
|
async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None:
|
||||||
client = Client(websocket, ctx)
|
client = Client(websocket, ctx)
|
||||||
ctx.endpoints.append(client)
|
ctx.endpoints.append(client)
|
||||||
|
|
||||||
@@ -881,6 +933,10 @@ async def on_client_joined(ctx: Context, client: Client):
|
|||||||
"If your client supports it, "
|
"If your client supports it, "
|
||||||
"you may have additional local commands you can list with /help.",
|
"you may have additional local commands you can list with /help.",
|
||||||
{"type": "Tutorial"})
|
{"type": "Tutorial"})
|
||||||
|
if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
|
||||||
|
ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
|
||||||
|
"It may stop working in the future. If you are a player, please report this to the "
|
||||||
|
"client's developer.")
|
||||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
@@ -947,9 +1003,13 @@ def get_status_string(ctx: Context, team: int, tag: str):
|
|||||||
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
|
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
|
||||||
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
|
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
|
||||||
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
|
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
|
||||||
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
|
status_text = (
|
||||||
|
" and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else
|
||||||
|
" and is ready." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_READY else
|
||||||
|
"."
|
||||||
|
)
|
||||||
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
|
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
|
||||||
f"{tag_text}{goal_text} {completion_text}"
|
f"{tag_text}{status_text} {completion_text}"
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -1027,21 +1087,37 @@ def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem
|
|||||||
|
|
||||||
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
||||||
count_activity: bool = True):
|
count_activity: bool = True):
|
||||||
|
slot_locations = ctx.locations[slot]
|
||||||
new_locations = set(locations) - ctx.location_checks[team, slot]
|
new_locations = set(locations) - ctx.location_checks[team, slot]
|
||||||
new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
|
new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata
|
||||||
if new_locations:
|
if new_locations:
|
||||||
if count_activity:
|
if count_activity:
|
||||||
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
sortable: list[tuple[int, int, int, int]] = []
|
||||||
for location in new_locations:
|
for location in new_locations:
|
||||||
item_id, target_player, flags = ctx.locations[slot][location]
|
# extract all fields to avoid runtime overhead in LocationStore
|
||||||
|
item_id, target_player, flags = slot_locations[location]
|
||||||
|
# sort/group by receiver and item
|
||||||
|
sortable.append((target_player, item_id, location, flags))
|
||||||
|
|
||||||
|
info_texts: list[dict[str, typing.Any]] = []
|
||||||
|
for target_player, item_id, location, flags in sorted(sortable):
|
||||||
new_item = NetworkItem(item_id, location, slot, flags)
|
new_item = NetworkItem(item_id, location, slot, flags)
|
||||||
send_items_to(ctx, team, target_player, new_item)
|
send_items_to(ctx, team, target_player, new_item)
|
||||||
|
|
||||||
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
||||||
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
||||||
info_text = json_format_send_event(new_item, target_player)
|
if len(info_texts) >= 140:
|
||||||
ctx.broadcast_team(team, [info_text])
|
# split into chunks that are close to compression window of 64K but not too big on the wire
|
||||||
|
# (roughly 1300-2600 bytes after compression depending on repetitiveness)
|
||||||
|
ctx.broadcast_team(team, info_texts)
|
||||||
|
info_texts.clear()
|
||||||
|
info_texts.append(json_format_send_event(new_item, target_player))
|
||||||
|
ctx.broadcast_team(team, info_texts)
|
||||||
|
del info_texts
|
||||||
|
del sortable
|
||||||
|
|
||||||
ctx.location_checks[team, slot] |= new_locations
|
ctx.location_checks[team, slot] |= new_locations
|
||||||
send_new_items(ctx)
|
send_new_items(ctx)
|
||||||
@@ -1050,14 +1126,15 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
"hint_points": get_slot_points(ctx, team, slot),
|
"hint_points": get_slot_points(ctx, team, slot),
|
||||||
"checked_locations": new_locations, # send back new checks only
|
"checked_locations": new_locations, # send back new checks only
|
||||||
}])
|
}])
|
||||||
old_hints = ctx.hints[team, slot].copy()
|
updated_slots: typing.Set[tuple[int, int]] = set()
|
||||||
ctx.recheck_hints(team, slot)
|
ctx.recheck_hints(team, slot, updated_slots)
|
||||||
if old_hints != ctx.hints[team, slot]:
|
for hint_team, hint_slot in updated_slots:
|
||||||
ctx.on_changed_hints(team, slot)
|
ctx.on_changed_hints(hint_team, hint_slot)
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
|
||||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
|
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
|
||||||
|
-> typing.List[Hint]:
|
||||||
hints = []
|
hints = []
|
||||||
slots: typing.Set[int] = {slot}
|
slots: typing.Set[int] = {slot}
|
||||||
for group_id, group in ctx.groups.items():
|
for group_id, group in ctx.groups.items():
|
||||||
@@ -1067,31 +1144,58 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
|||||||
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
||||||
for finding_player, location_id, item_id, receiving_player, item_flags \
|
for finding_player, location_id, item_id, receiving_player, item_flags \
|
||||||
in ctx.locations.find_item(slots, seeked_item_id):
|
in ctx.locations.find_item(slots, seeked_item_id):
|
||||||
found = location_id in ctx.location_checks[team, finding_player]
|
prev_hint = ctx.get_hint(team, finding_player, location_id)
|
||||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
if prev_hint:
|
||||||
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
hints.append(prev_hint)
|
||||||
item_flags))
|
else:
|
||||||
|
found = location_id in ctx.location_checks[team, finding_player]
|
||||||
|
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||||
|
new_status = auto_status
|
||||||
|
if found:
|
||||||
|
new_status = HintStatus.HINT_FOUND
|
||||||
|
elif item_flags & ItemClassification.trap:
|
||||||
|
new_status = HintStatus.HINT_AVOID
|
||||||
|
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||||
|
item_flags, new_status))
|
||||||
|
|
||||||
return hints
|
return hints
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
|
||||||
|
-> typing.List[Hint]:
|
||||||
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
||||||
return collect_hint_location_id(ctx, team, slot, seeked_location)
|
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
|
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
|
||||||
|
-> typing.List[Hint]:
|
||||||
|
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
||||||
|
if prev_hint:
|
||||||
|
return [prev_hint]
|
||||||
result = ctx.locations[slot].get(seeked_location, (None, None, None))
|
result = ctx.locations[slot].get(seeked_location, (None, None, None))
|
||||||
if any(result):
|
if any(result):
|
||||||
item_id, receiving_player, item_flags = result
|
item_id, receiving_player, item_flags = result
|
||||||
|
|
||||||
found = seeked_location in ctx.location_checks[team, slot]
|
found = seeked_location in ctx.location_checks[team, slot]
|
||||||
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
||||||
return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)]
|
new_status = auto_status
|
||||||
|
if found:
|
||||||
|
new_status = HintStatus.HINT_FOUND
|
||||||
|
elif item_flags & ItemClassification.trap:
|
||||||
|
new_status = HintStatus.HINT_AVOID
|
||||||
|
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
|
||||||
|
new_status)]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
status_names: typing.Dict[HintStatus, str] = {
|
||||||
|
HintStatus.HINT_FOUND: "(found)",
|
||||||
|
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
|
||||||
|
HintStatus.HINT_NO_PRIORITY: "(no priority)",
|
||||||
|
HintStatus.HINT_AVOID: "(avoid)",
|
||||||
|
HintStatus.HINT_PRIORITY: "(priority)",
|
||||||
|
}
|
||||||
|
def format_hint(ctx: Context, team: int, hint: Hint) -> str:
|
||||||
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
||||||
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
|
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
|
||||||
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
|
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
|
||||||
@@ -1099,7 +1203,8 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
|||||||
|
|
||||||
if hint.entrance:
|
if hint.entrance:
|
||||||
text += f" at {hint.entrance}"
|
text += f" at {hint.entrance}"
|
||||||
return text + (". (found)" if hint.found else ".")
|
|
||||||
|
return text + ". " + status_names.get(hint.status, "(unknown)")
|
||||||
|
|
||||||
|
|
||||||
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
||||||
@@ -1503,7 +1608,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||||
|
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
|
||||||
if not input_text:
|
if not input_text:
|
||||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||||
self.ctx.hints[self.client.team, self.client.slot]}
|
self.ctx.hints[self.client.team, self.client.slot]}
|
||||||
@@ -1529,9 +1634,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||||
hints = []
|
hints = []
|
||||||
elif not for_location:
|
elif not for_location:
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
game = self.ctx.games[self.client.slot]
|
game = self.ctx.games[self.client.slot]
|
||||||
@@ -1551,16 +1656,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
||||||
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
|
||||||
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||||
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
||||||
hints = []
|
hints = []
|
||||||
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
||||||
if loc_name in self.ctx.location_names_for_game(game):
|
if loc_name in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
|
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
@@ -1725,7 +1830,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
ctx.clients[team][slot].append(client)
|
ctx.clients[team][slot].append(client)
|
||||||
client.version = args['version']
|
client.version = args['version']
|
||||||
client.tags = args['tags']
|
client.tags = args['tags']
|
||||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
||||||
|
# set NoText for old PopTracker clients that predate the tag to save traffic
|
||||||
|
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
||||||
connected_packet = {
|
connected_packet = {
|
||||||
"cmd": "Connected",
|
"cmd": "Connected",
|
||||||
"team": client.team, "slot": client.slot,
|
"team": client.team, "slot": client.slot,
|
||||||
@@ -1797,7 +1904,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
old_tags = client.tags
|
old_tags = client.tags
|
||||||
client.tags = args["tags"]
|
client.tags = args["tags"]
|
||||||
if set(old_tags) != set(client.tags):
|
if set(old_tags) != set(client.tags):
|
||||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
||||||
|
client.no_text = "NoText" in client.tags or (
|
||||||
|
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
||||||
|
)
|
||||||
ctx.broadcast_text_all(
|
ctx.broadcast_text_all(
|
||||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
||||||
f"from {old_tags} to {client.tags}.",
|
f"from {old_tags} to {client.tags}.",
|
||||||
@@ -1826,21 +1936,72 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
for location in args["locations"]:
|
for location in args["locations"]:
|
||||||
if type(location) is not int:
|
if type(location) is not int:
|
||||||
await ctx.send_msgs(client,
|
await ctx.send_msgs(client,
|
||||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
|
[{'cmd': 'InvalidPacket', "type": "arguments",
|
||||||
|
"text": 'Locations has to be a list of integers',
|
||||||
"original_cmd": cmd}])
|
"original_cmd": cmd}])
|
||||||
return
|
return
|
||||||
|
|
||||||
target_item, target_player, flags = ctx.locations[client.slot][location]
|
target_item, target_player, flags = ctx.locations[client.slot][location]
|
||||||
if create_as_hint:
|
if create_as_hint:
|
||||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
||||||
|
HintStatus.HINT_UNSPECIFIED))
|
||||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
||||||
if locs and create_as_hint:
|
if locs and create_as_hint:
|
||||||
ctx.save()
|
ctx.save()
|
||||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||||
|
|
||||||
|
elif cmd == 'UpdateHint':
|
||||||
|
location = args["location"]
|
||||||
|
player = args["player"]
|
||||||
|
status = args["status"]
|
||||||
|
if not isinstance(player, int) or not isinstance(location, int) \
|
||||||
|
or (status is not None and not isinstance(status, int)):
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint',
|
||||||
|
"original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
hint = ctx.get_hint(client.team, player, location)
|
||||||
|
if not hint:
|
||||||
|
return # Ignored safely
|
||||||
|
if client.slot not in ctx.slot_set(hint.receiving_player):
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission',
|
||||||
|
"original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
new_hint = hint
|
||||||
|
if status is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
status = HintStatus(status)
|
||||||
|
except ValueError:
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{'cmd': 'InvalidPacket', "type": "arguments",
|
||||||
|
"text": 'UpdateHint: Invalid Status', "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
if status == HintStatus.HINT_FOUND:
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{'cmd': 'InvalidPacket', "type": "arguments",
|
||||||
|
"text": 'UpdateHint: Cannot manually update status to "HINT_FOUND"', "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
new_hint = new_hint.re_prioritize(ctx, status)
|
||||||
|
if hint == new_hint:
|
||||||
|
return
|
||||||
|
|
||||||
|
concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
|
||||||
|
for slot in concerning_slots:
|
||||||
|
ctx.replace_hint(client.team, slot, hint, new_hint)
|
||||||
|
ctx.save()
|
||||||
|
for slot in concerning_slots:
|
||||||
|
ctx.on_changed_hints(client.team, slot)
|
||||||
|
|
||||||
elif cmd == 'StatusUpdate':
|
elif cmd == 'StatusUpdate':
|
||||||
update_client_status(ctx, client, args["status"])
|
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
|
||||||
|
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
|
||||||
|
"text": "Trackers can't register Goal Complete",
|
||||||
|
"original_cmd": cmd}])
|
||||||
|
else:
|
||||||
|
update_client_status(ctx, client, args["status"])
|
||||||
|
|
||||||
elif cmd == 'Say':
|
elif cmd == 'Say':
|
||||||
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
||||||
@@ -1886,12 +2047,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
args["cmd"] = "SetReply"
|
args["cmd"] = "SetReply"
|
||||||
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
||||||
args["original_value"] = copy.copy(value)
|
args["original_value"] = copy.copy(value)
|
||||||
|
args["slot"] = client.slot
|
||||||
for operation in args["operations"]:
|
for operation in args["operations"]:
|
||||||
func = modify_functions[operation["operation"]]
|
func = modify_functions[operation["operation"]]
|
||||||
value = func(value, operation["value"])
|
value = func(value, operation["value"])
|
||||||
ctx.stored_data[args["key"]] = args["value"] = value
|
ctx.stored_data[args["key"]] = args["value"] = value
|
||||||
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
||||||
if args.get("want_reply", True):
|
if args.get("want_reply", False):
|
||||||
targets.add(client)
|
targets.add(client)
|
||||||
if targets:
|
if targets:
|
||||||
ctx.broadcast(targets, [args])
|
ctx.broadcast(targets, [args])
|
||||||
@@ -2143,9 +2305,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
||||||
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
|
||||||
else: # item name or id
|
else: # item name or id
|
||||||
hints = collect_hints(self.ctx, team, slot, item)
|
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
|
||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
self.ctx.notify_hints(team, hints)
|
self.ctx.notify_hints(team, hints)
|
||||||
@@ -2179,14 +2341,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
if isinstance(location, int):
|
if isinstance(location, int):
|
||||||
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
hints = collect_hint_location_id(self.ctx, team, slot, location,
|
||||||
|
HintStatus.HINT_UNSPECIFIED)
|
||||||
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
||||||
hints = []
|
hints = []
|
||||||
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
||||||
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
|
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
|
||||||
|
HintStatus.HINT_UNSPECIFIED))
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
hints = collect_hint_location_name(self.ctx, team, slot, location,
|
||||||
|
HintStatus.HINT_UNSPECIFIED)
|
||||||
if hints:
|
if hints:
|
||||||
self.ctx.notify_hints(team, hints)
|
self.ctx.notify_hints(team, hints)
|
||||||
else:
|
else:
|
||||||
@@ -2263,8 +2428,10 @@ async def console(ctx: Context):
|
|||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
|
from settings import get_settings
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
defaults = Utils.get_settings()["server_options"].as_dict()
|
defaults = get_settings().server_options.as_dict()
|
||||||
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
||||||
parser.add_argument('--host', default=defaults["host"])
|
parser.add_argument('--host', default=defaults["host"])
|
||||||
parser.add_argument('--port', default=defaults["port"], type=int)
|
parser.add_argument('--port', default=defaults["port"], type=int)
|
||||||
@@ -2276,6 +2443,8 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
|
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
|
||||||
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
||||||
choices=['debug', 'info', 'warning', 'error', 'critical'])
|
choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||||
|
parser.add_argument('--logtime', help="Add timestamps to STDOUT",
|
||||||
|
default=defaults["logtime"], action='store_true')
|
||||||
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
|
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
|
||||||
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
|
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
|
||||||
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
|
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
|
||||||
@@ -2356,7 +2525,9 @@ def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLConte
|
|||||||
|
|
||||||
|
|
||||||
async def main(args: argparse.Namespace):
|
async def main(args: argparse.Namespace):
|
||||||
Utils.init_logging("Server", loglevel=args.loglevel.lower())
|
Utils.init_logging(name="Server",
|
||||||
|
loglevel=args.loglevel.lower(),
|
||||||
|
add_timestamp=args.logtime)
|
||||||
|
|
||||||
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
||||||
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
||||||
|
|||||||
63
NetUtils.py
63
NetUtils.py
@@ -5,11 +5,20 @@ import enum
|
|||||||
import warnings
|
import warnings
|
||||||
from json import JSONEncoder, JSONDecoder
|
from json import JSONEncoder, JSONDecoder
|
||||||
|
|
||||||
import websockets
|
if typing.TYPE_CHECKING:
|
||||||
|
from websockets import WebSocketServerProtocol as ServerConnection
|
||||||
|
|
||||||
from Utils import ByValue, Version
|
from Utils import ByValue, Version
|
||||||
|
|
||||||
|
|
||||||
|
class HintStatus(ByValue, enum.IntEnum):
|
||||||
|
HINT_UNSPECIFIED = 0
|
||||||
|
HINT_NO_PRIORITY = 10
|
||||||
|
HINT_AVOID = 20
|
||||||
|
HINT_PRIORITY = 30
|
||||||
|
HINT_FOUND = 40
|
||||||
|
|
||||||
|
|
||||||
class JSONMessagePart(typing.TypedDict, total=False):
|
class JSONMessagePart(typing.TypedDict, total=False):
|
||||||
text: str
|
text: str
|
||||||
# optional
|
# optional
|
||||||
@@ -19,6 +28,8 @@ class JSONMessagePart(typing.TypedDict, total=False):
|
|||||||
player: int
|
player: int
|
||||||
# if type == item indicates item flags
|
# if type == item indicates item flags
|
||||||
flags: int
|
flags: int
|
||||||
|
# if type == hint_status
|
||||||
|
hint_status: HintStatus
|
||||||
|
|
||||||
|
|
||||||
class ClientStatus(ByValue, enum.IntEnum):
|
class ClientStatus(ByValue, enum.IntEnum):
|
||||||
@@ -141,7 +152,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
|||||||
|
|
||||||
|
|
||||||
class Endpoint:
|
class Endpoint:
|
||||||
socket: websockets.WebSocketServerProtocol
|
socket: "ServerConnection"
|
||||||
|
|
||||||
def __init__(self, socket):
|
def __init__(self, socket):
|
||||||
self.socket = socket
|
self.socket = socket
|
||||||
@@ -184,6 +195,7 @@ class JSONTypes(str, enum.Enum):
|
|||||||
location_name = "location_name"
|
location_name = "location_name"
|
||||||
location_id = "location_id"
|
location_id = "location_id"
|
||||||
entrance_name = "entrance_name"
|
entrance_name = "entrance_name"
|
||||||
|
hint_status = "hint_status"
|
||||||
|
|
||||||
|
|
||||||
class JSONtoTextParser(metaclass=HandlerMeta):
|
class JSONtoTextParser(metaclass=HandlerMeta):
|
||||||
@@ -224,7 +236,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
|||||||
|
|
||||||
def _handle_player_id(self, node: JSONMessagePart):
|
def _handle_player_id(self, node: JSONMessagePart):
|
||||||
player = int(node["text"])
|
player = int(node["text"])
|
||||||
node["color"] = 'magenta' if player == self.ctx.slot else 'yellow'
|
node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow'
|
||||||
node["text"] = self.ctx.player_names[player]
|
node["text"] = self.ctx.player_names[player]
|
||||||
return self._handle_color(node)
|
return self._handle_color(node)
|
||||||
|
|
||||||
@@ -265,6 +277,10 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
|||||||
node["color"] = 'blue'
|
node["color"] = 'blue'
|
||||||
return self._handle_color(node)
|
return self._handle_color(node)
|
||||||
|
|
||||||
|
def _handle_hint_status(self, node: JSONMessagePart):
|
||||||
|
node["color"] = status_colors.get(node["hint_status"], "red")
|
||||||
|
return self._handle_color(node)
|
||||||
|
|
||||||
|
|
||||||
class RawJSONtoTextParser(JSONtoTextParser):
|
class RawJSONtoTextParser(JSONtoTextParser):
|
||||||
def _handle_color(self, node: JSONMessagePart):
|
def _handle_color(self, node: JSONMessagePart):
|
||||||
@@ -297,6 +313,27 @@ def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs)
|
|||||||
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
||||||
|
|
||||||
|
|
||||||
|
status_names: typing.Dict[HintStatus, str] = {
|
||||||
|
HintStatus.HINT_FOUND: "(found)",
|
||||||
|
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
|
||||||
|
HintStatus.HINT_NO_PRIORITY: "(no priority)",
|
||||||
|
HintStatus.HINT_AVOID: "(avoid)",
|
||||||
|
HintStatus.HINT_PRIORITY: "(priority)",
|
||||||
|
}
|
||||||
|
status_colors: typing.Dict[HintStatus, str] = {
|
||||||
|
HintStatus.HINT_FOUND: "green",
|
||||||
|
HintStatus.HINT_UNSPECIFIED: "white",
|
||||||
|
HintStatus.HINT_NO_PRIORITY: "slateblue",
|
||||||
|
HintStatus.HINT_AVOID: "salmon",
|
||||||
|
HintStatus.HINT_PRIORITY: "plum",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_json_hint_status(parts: list, hint_status: HintStatus, text: typing.Optional[str] = None, **kwargs):
|
||||||
|
parts.append({"text": text if text != None else status_names.get(hint_status, "(unknown)"),
|
||||||
|
"hint_status": hint_status, "type": JSONTypes.hint_status, **kwargs})
|
||||||
|
|
||||||
|
|
||||||
class Hint(typing.NamedTuple):
|
class Hint(typing.NamedTuple):
|
||||||
receiving_player: int
|
receiving_player: int
|
||||||
finding_player: int
|
finding_player: int
|
||||||
@@ -305,14 +342,21 @@ class Hint(typing.NamedTuple):
|
|||||||
found: bool
|
found: bool
|
||||||
entrance: str = ""
|
entrance: str = ""
|
||||||
item_flags: int = 0
|
item_flags: int = 0
|
||||||
|
status: HintStatus = HintStatus.HINT_UNSPECIFIED
|
||||||
|
|
||||||
def re_check(self, ctx, team) -> Hint:
|
def re_check(self, ctx, team) -> Hint:
|
||||||
if self.found:
|
if self.found and self.status == HintStatus.HINT_FOUND:
|
||||||
return self
|
return self
|
||||||
found = self.location in ctx.location_checks[team, self.finding_player]
|
found = self.location in ctx.location_checks[team, self.finding_player]
|
||||||
if found:
|
if found:
|
||||||
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
|
return self._replace(found=found, status=HintStatus.HINT_FOUND)
|
||||||
self.item_flags)
|
return self
|
||||||
|
|
||||||
|
def re_prioritize(self, ctx, status: HintStatus) -> Hint:
|
||||||
|
if self.found and status != HintStatus.HINT_FOUND:
|
||||||
|
status = HintStatus.HINT_FOUND
|
||||||
|
if status != self.status:
|
||||||
|
return self._replace(status=status)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
@@ -334,10 +378,7 @@ class Hint(typing.NamedTuple):
|
|||||||
else:
|
else:
|
||||||
add_json_text(parts, "'s World")
|
add_json_text(parts, "'s World")
|
||||||
add_json_text(parts, ". ")
|
add_json_text(parts, ". ")
|
||||||
if self.found:
|
add_json_hint_status(parts, self.status)
|
||||||
add_json_text(parts, "(found)", type="color", color="green")
|
|
||||||
else:
|
|
||||||
add_json_text(parts, "(not found)", type="color", color="red")
|
|
||||||
|
|
||||||
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
||||||
"receiving": self.receiving_player,
|
"receiving": self.receiving_player,
|
||||||
@@ -383,6 +424,8 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
|||||||
checked = state[team, slot]
|
checked = state[team, slot]
|
||||||
if not checked:
|
if not checked:
|
||||||
# This optimizes the case where everyone connects to a fresh game at the same time.
|
# This optimizes the case where everyone connects to a fresh game at the same time.
|
||||||
|
if slot not in self:
|
||||||
|
raise KeyError(slot)
|
||||||
return []
|
return []
|
||||||
return [location_id for
|
return [location_id for
|
||||||
location_id in self[slot] if
|
location_id in self[slot] if
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
@@ -197,7 +196,6 @@ def set_icon(window):
|
|||||||
def adjust(args):
|
def adjust(args):
|
||||||
# Create a fake multiworld and OOTWorld to use as a base
|
# Create a fake multiworld and OOTWorld to use as a base
|
||||||
multiworld = MultiWorld(1)
|
multiworld = MultiWorld(1)
|
||||||
multiworld.per_slot_randoms = {1: random}
|
|
||||||
ootworld = OOTWorld(multiworld, 1)
|
ootworld = OOTWorld(multiworld, 1)
|
||||||
# Set options in the fake OOTWorld
|
# Set options in the fake OOTWorld
|
||||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
|
|||||||
import Utils
|
import Utils
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
|
from worlds.oot import OOTWorld
|
||||||
from worlds.oot.Rom import Rom, compress_rom_file
|
from worlds.oot.Rom import Rom, compress_rom_file
|
||||||
from worlds.oot.N64Patch import apply_patch_file
|
from worlds.oot.N64Patch import apply_patch_file
|
||||||
from worlds.oot.Utils import data_path
|
from worlds.oot.Utils import data_path
|
||||||
@@ -280,7 +281,7 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
|
auto_start = OOTWorld.settings.rom_start
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -295,7 +296,7 @@ async def patch_and_run_game(apz5_file):
|
|||||||
decomp_path = base_name + '-decomp.z64'
|
decomp_path = base_name + '-decomp.z64'
|
||||||
comp_path = base_name + '.z64'
|
comp_path = base_name + '.z64'
|
||||||
# Load vanilla ROM, patch file, compress ROM
|
# Load vanilla ROM, patch file, compress ROM
|
||||||
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
rom_file_name = OOTWorld.settings.rom_file
|
||||||
rom = Rom(rom_file_name)
|
rom = Rom(rom_file_name)
|
||||||
|
|
||||||
sub_file = None
|
sub_file = None
|
||||||
@@ -346,7 +347,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
354
Options.py
354
Options.py
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
|
import collections
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
@@ -23,6 +24,12 @@ if typing.TYPE_CHECKING:
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
|
def roll_percentage(percentage: int | float) -> bool:
|
||||||
|
"""Roll a percentage chance.
|
||||||
|
percentage is expected to be in range [0, 100]"""
|
||||||
|
return random.random() < (float(percentage) / 100)
|
||||||
|
|
||||||
|
|
||||||
class OptionError(ValueError):
|
class OptionError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -137,7 +144,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
If this is False, the docstring is instead interpreted as plain text, and
|
If this is False, the docstring is instead interpreted as plain text, and
|
||||||
displayed as-is on the WebHost with whitespace preserved.
|
displayed as-is on the WebHost with whitespace preserved.
|
||||||
|
|
||||||
If this is None, it inherits the value of `World.rich_text_options_doc`. For
|
If this is None, it inherits the value of `WebWorld.rich_text_options_doc`. For
|
||||||
backwards compatibility, this defaults to False, but worlds are encouraged to
|
backwards compatibility, this defaults to False, but worlds are encouraged to
|
||||||
set it to True and use reStructuredText for their Option documentation.
|
set it to True and use reStructuredText for their Option documentation.
|
||||||
|
|
||||||
@@ -496,7 +503,7 @@ class TextChoice(Choice):
|
|||||||
|
|
||||||
def __init__(self, value: typing.Union[str, int]):
|
def __init__(self, value: typing.Union[str, int]):
|
||||||
assert isinstance(value, str) or isinstance(value, int), \
|
assert isinstance(value, str) or isinstance(value, int), \
|
||||||
f"{value} is not a valid option for {self.__class__.__name__}"
|
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -617,17 +624,17 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
|
|||||||
used_locations.append(location)
|
used_locations.append(location)
|
||||||
used_bosses.append(boss)
|
used_bosses.append(boss)
|
||||||
if not cls.valid_boss_name(boss):
|
if not cls.valid_boss_name(boss):
|
||||||
raise ValueError(f"{boss.title()} is not a valid boss name.")
|
raise ValueError(f"'{boss.title()}' is not a valid boss name.")
|
||||||
if not cls.valid_location_name(location):
|
if not cls.valid_location_name(location):
|
||||||
raise ValueError(f"{location.title()} is not a valid boss location name.")
|
raise ValueError(f"'{location.title()}' is not a valid boss location name.")
|
||||||
if not cls.can_place_boss(boss, location):
|
if not cls.can_place_boss(boss, location):
|
||||||
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
|
raise ValueError(f"'{location.title()}' is not a valid location for {boss.title()} to be placed.")
|
||||||
else:
|
else:
|
||||||
if cls.duplicate_bosses:
|
if cls.duplicate_bosses:
|
||||||
if not cls.valid_boss_name(option):
|
if not cls.valid_boss_name(option):
|
||||||
raise ValueError(f"{option} is not a valid boss name.")
|
raise ValueError(f"'{option}' is not a valid boss name.")
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"{option.title()} is not formatted correctly.")
|
raise ValueError(f"'{option.title()}' is not formatted correctly.")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def can_place_boss(cls, boss: str, location: str) -> bool:
|
def can_place_boss(cls, boss: str, location: str) -> bool:
|
||||||
@@ -689,9 +696,9 @@ class Range(NumericOption):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def weighted_range(cls, text) -> Range:
|
def weighted_range(cls, text) -> Range:
|
||||||
if text == "random-low":
|
if text == "random-low":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
|
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
|
||||||
elif text == "random-high":
|
elif text == "random-high":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
|
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
|
||||||
elif text == "random-middle":
|
elif text == "random-middle":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||||
elif text.startswith("random-range-"):
|
elif text.startswith("random-range-"):
|
||||||
@@ -717,11 +724,11 @@ class Range(NumericOption):
|
|||||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||||
if text.startswith("random-range-low"):
|
if text.startswith("random-range-low"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
|
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
|
||||||
elif text.startswith("random-range-middle"):
|
elif text.startswith("random-range-middle"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||||
elif text.startswith("random-range-high"):
|
elif text.startswith("random-range-high"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
|
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
|
||||||
else:
|
else:
|
||||||
return cls(random.randint(random_range[0], random_range[1]))
|
return cls(random.randint(random_range[0], random_range[1]))
|
||||||
|
|
||||||
@@ -739,8 +746,16 @@ class Range(NumericOption):
|
|||||||
return str(self.value)
|
return str(self.value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
|
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
||||||
return int(round(random.triangular(lower, end, tri), 0))
|
"""
|
||||||
|
Integer triangular distribution for `lower` inclusive to `end` inclusive.
|
||||||
|
|
||||||
|
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
|
||||||
|
"""
|
||||||
|
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
|
||||||
|
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
|
||||||
|
# when a != b, so ensure the result is never more than `end`.
|
||||||
|
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
|
||||||
|
|
||||||
|
|
||||||
class NamedRange(Range):
|
class NamedRange(Range):
|
||||||
@@ -754,7 +769,7 @@ class NamedRange(Range):
|
|||||||
elif value > self.range_end and value not in self.special_range_names.values():
|
elif value > self.range_end and value not in self.special_range_names.values():
|
||||||
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
||||||
f"and is also not one of the supported named special values: {self.special_range_names}")
|
f"and is also not one of the supported named special values: {self.special_range_names}")
|
||||||
|
|
||||||
# See docstring
|
# See docstring
|
||||||
for key in self.special_range_names:
|
for key in self.special_range_names:
|
||||||
if key != key.lower():
|
if key != key.lower():
|
||||||
@@ -817,18 +832,21 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
|||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
if item_name not in world.item_names:
|
if item_name not in world.item_names:
|
||||||
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
||||||
raise Exception(f"Item {item_name} from option {self} "
|
raise Exception(f"Item '{item_name}' from option '{self}' "
|
||||||
f"is not a valid item name from {world.game}. "
|
f"is not a valid item name from '{world.game}'. "
|
||||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||||
elif self.verify_location_name:
|
elif self.verify_location_name:
|
||||||
for location_name in self.value:
|
for location_name in self.value:
|
||||||
if location_name not in world.location_names:
|
if location_name not in world.location_names:
|
||||||
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
|
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
|
||||||
raise Exception(f"Location {location_name} from option {self} "
|
raise Exception(f"Location '{location_name}' from option '{self}' "
|
||||||
f"is not a valid location name from {world.game}. "
|
f"is not a valid location name from '{world.game}'. "
|
||||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||||
|
|
||||||
|
def __iter__(self) -> typing.Iterator[typing.Any]:
|
||||||
|
return self.value.__iter__()
|
||||||
|
|
||||||
|
|
||||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
||||||
default = {}
|
default = {}
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
@@ -855,13 +873,49 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
|||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.value.__len__()
|
return self.value.__len__()
|
||||||
|
|
||||||
|
# __getitem__ fallback fails for Counters, so we define this explicitly
|
||||||
|
def __contains__(self, item) -> bool:
|
||||||
|
return item in self.value
|
||||||
|
|
||||||
class ItemDict(OptionDict):
|
|
||||||
|
class OptionCounter(OptionDict):
|
||||||
|
min: int | None = None
|
||||||
|
max: int | None = None
|
||||||
|
|
||||||
|
def __init__(self, value: dict[str, int]) -> None:
|
||||||
|
super(OptionCounter, self).__init__(collections.Counter(value))
|
||||||
|
|
||||||
|
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
||||||
|
super(OptionCounter, self).verify(world, player_name, plando_options)
|
||||||
|
|
||||||
|
range_errors = []
|
||||||
|
|
||||||
|
if self.max is not None:
|
||||||
|
range_errors += [
|
||||||
|
f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
|
||||||
|
for key, value in self.value.items() if value > self.max
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.min is not None:
|
||||||
|
range_errors += [
|
||||||
|
f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
|
||||||
|
for key, value in self.value.items() if value < self.min
|
||||||
|
]
|
||||||
|
|
||||||
|
if range_errors:
|
||||||
|
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
|
||||||
|
raise OptionError("\n".join(range_errors))
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDict(OptionCounter):
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, int]):
|
min = 0
|
||||||
if any(item_count < 1 for item_count in value.values()):
|
|
||||||
raise Exception("Cannot have non-positive item counts.")
|
def __init__(self, value: dict[str, int]) -> None:
|
||||||
|
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
|
||||||
|
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
|
||||||
|
|
||||||
super(ItemDict, self).__init__(value)
|
super(ItemDict, self).__init__(value)
|
||||||
|
|
||||||
|
|
||||||
@@ -971,7 +1025,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
if isinstance(data, typing.Iterable):
|
if isinstance(data, typing.Iterable):
|
||||||
for text in data:
|
for text in data:
|
||||||
if isinstance(text, typing.Mapping):
|
if isinstance(text, typing.Mapping):
|
||||||
if random.random() < float(text.get("percentage", 100)/100):
|
if roll_percentage(text.get("percentage", 100)):
|
||||||
at = text.get("at", None)
|
at = text.get("at", None)
|
||||||
if at is not None:
|
if at is not None:
|
||||||
if isinstance(at, dict):
|
if isinstance(at, dict):
|
||||||
@@ -997,7 +1051,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
else:
|
else:
|
||||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||||
elif isinstance(text, PlandoText):
|
elif isinstance(text, PlandoText):
|
||||||
if random.random() < float(text.percentage/100):
|
if roll_percentage(text.percentage):
|
||||||
texts.append(text)
|
texts.append(text)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
||||||
@@ -1106,11 +1160,11 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
used_entrances.append(entrance)
|
used_entrances.append(entrance)
|
||||||
used_exits.append(exit)
|
used_exits.append(exit)
|
||||||
if not cls.validate_entrance_name(entrance):
|
if not cls.validate_entrance_name(entrance):
|
||||||
raise ValueError(f"{entrance.title()} is not a valid entrance.")
|
raise ValueError(f"'{entrance.title()}' is not a valid entrance.")
|
||||||
if not cls.validate_exit_name(exit):
|
if not cls.validate_exit_name(exit):
|
||||||
raise ValueError(f"{exit.title()} is not a valid exit.")
|
raise ValueError(f"'{exit.title()}' is not a valid exit.")
|
||||||
if not cls.can_connect(entrance, exit):
|
if not cls.can_connect(entrance, exit):
|
||||||
raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
|
raise ValueError(f"Connection between '{entrance.title()}' and '{exit.title()}' is invalid.")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: PlandoConFromAnyType) -> Self:
|
def from_any(cls, data: PlandoConFromAnyType) -> Self:
|
||||||
@@ -1121,7 +1175,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
for connection in data:
|
for connection in data:
|
||||||
if isinstance(connection, typing.Mapping):
|
if isinstance(connection, typing.Mapping):
|
||||||
percentage = connection.get("percentage", 100)
|
percentage = connection.get("percentage", 100)
|
||||||
if random.random() < float(percentage / 100):
|
if roll_percentage(percentage):
|
||||||
entrance = connection.get("entrance", None)
|
entrance = connection.get("entrance", None)
|
||||||
if is_iterable_except_str(entrance):
|
if is_iterable_except_str(entrance):
|
||||||
entrance = random.choice(sorted(entrance))
|
entrance = random.choice(sorted(entrance))
|
||||||
@@ -1139,7 +1193,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
percentage
|
percentage
|
||||||
))
|
))
|
||||||
elif isinstance(connection, PlandoConnection):
|
elif isinstance(connection, PlandoConnection):
|
||||||
if random.random() < float(connection.percentage / 100):
|
if roll_percentage(connection.percentage):
|
||||||
value.append(connection)
|
value.append(connection)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
||||||
@@ -1175,7 +1229,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
class Accessibility(Choice):
|
class Accessibility(Choice):
|
||||||
"""
|
"""
|
||||||
Set rules for reachability of your items/locations.
|
Set rules for reachability of your items/locations.
|
||||||
|
|
||||||
**Full:** ensure everything can be reached and acquired.
|
**Full:** ensure everything can be reached and acquired.
|
||||||
|
|
||||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||||
@@ -1193,7 +1247,7 @@ class Accessibility(Choice):
|
|||||||
class ItemsAccessibility(Accessibility):
|
class ItemsAccessibility(Accessibility):
|
||||||
"""
|
"""
|
||||||
Set rules for reachability of your items/locations.
|
Set rules for reachability of your items/locations.
|
||||||
|
|
||||||
**Full:** ensure everything can be reached and acquired.
|
**Full:** ensure everything can be reached and acquired.
|
||||||
|
|
||||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||||
@@ -1244,36 +1298,47 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
progression_balancing: ProgressionBalancing
|
progression_balancing: ProgressionBalancing
|
||||||
accessibility: Accessibility
|
accessibility: Accessibility
|
||||||
|
|
||||||
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
|
def as_dict(
|
||||||
|
self,
|
||||||
|
*option_names: str,
|
||||||
|
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
||||||
|
toggles_as_bools: bool = False,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
"""
|
"""
|
||||||
Returns a dictionary of [str, Option.value]
|
Returns a dictionary of [str, Option.value]
|
||||||
|
|
||||||
:param option_names: names of the options to return
|
:param option_names: Names of the options to get the values of.
|
||||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
|
||||||
|
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
|
||||||
|
|
||||||
|
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
|
||||||
|
will be returned as a sorted list.
|
||||||
"""
|
"""
|
||||||
assert option_names, "options.as_dict() was used without any option names."
|
assert option_names, "options.as_dict() was used without any option names."
|
||||||
option_results = {}
|
option_results = {}
|
||||||
for option_name in option_names:
|
for option_name in option_names:
|
||||||
if option_name in type(self).type_hints:
|
if option_name not in type(self).type_hints:
|
||||||
if casing == "snake":
|
|
||||||
display_name = option_name
|
|
||||||
elif casing == "camel":
|
|
||||||
split_name = [name.title() for name in option_name.split("_")]
|
|
||||||
split_name[0] = split_name[0].lower()
|
|
||||||
display_name = "".join(split_name)
|
|
||||||
elif casing == "pascal":
|
|
||||||
display_name = "".join([name.title() for name in option_name.split("_")])
|
|
||||||
elif casing == "kebab":
|
|
||||||
display_name = option_name.replace("_", "-")
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
|
||||||
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
|
|
||||||
value = getattr(self, option_name).value
|
|
||||||
if isinstance(value, set):
|
|
||||||
value = sorted(value)
|
|
||||||
option_results[display_name] = value
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
||||||
|
|
||||||
|
if casing == "snake":
|
||||||
|
display_name = option_name
|
||||||
|
elif casing == "camel":
|
||||||
|
split_name = [name.title() for name in option_name.split("_")]
|
||||||
|
split_name[0] = split_name[0].lower()
|
||||||
|
display_name = "".join(split_name)
|
||||||
|
elif casing == "pascal":
|
||||||
|
display_name = "".join([name.title() for name in option_name.split("_")])
|
||||||
|
elif casing == "kebab":
|
||||||
|
display_name = option_name.replace("_", "-")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
||||||
|
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
|
||||||
|
value = getattr(self, option_name).value
|
||||||
|
if isinstance(value, set):
|
||||||
|
value = sorted(value)
|
||||||
|
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
|
||||||
|
value = bool(value)
|
||||||
|
option_results[display_name] = value
|
||||||
return option_results
|
return option_results
|
||||||
|
|
||||||
|
|
||||||
@@ -1294,6 +1359,7 @@ class StartInventory(ItemDict):
|
|||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
display_name = "Start Inventory"
|
display_name = "Start Inventory"
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
|
max = 10000
|
||||||
|
|
||||||
|
|
||||||
class StartInventoryPool(StartInventory):
|
class StartInventoryPool(StartInventory):
|
||||||
@@ -1368,8 +1434,8 @@ class ItemLinks(OptionList):
|
|||||||
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
|
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
|
||||||
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
|
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
|
||||||
|
|
||||||
raise Exception(f"Item {item_name} from item link {item_link} "
|
raise Exception(f"Item '{item_name}' from item link '{item_link}' "
|
||||||
f"is not a valid item from {world.game} for {pool_name}. "
|
f"is not a valid item from '{world.game}' for '{pool_name}'. "
|
||||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
|
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
|
||||||
if allow_item_groups:
|
if allow_item_groups:
|
||||||
pool |= world.item_name_groups.get(item_name, {item_name})
|
pool |= world.item_name_groups.get(item_name, {item_name})
|
||||||
@@ -1409,6 +1475,133 @@ class ItemLinks(OptionList):
|
|||||||
link["item_pool"] = list(pool)
|
link["item_pool"] = list(pool)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlandoItem:
|
||||||
|
items: list[str] | dict[str, typing.Any]
|
||||||
|
locations: list[str]
|
||||||
|
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
|
||||||
|
from_pool: bool = True
|
||||||
|
force: bool | typing.Literal["silent"] = "silent"
|
||||||
|
count: int | bool | dict[str, int] = False
|
||||||
|
percentage: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class PlandoItems(Option[typing.List[PlandoItem]]):
|
||||||
|
"""Generic items plando."""
|
||||||
|
default = ()
|
||||||
|
supports_weighting = False
|
||||||
|
display_name = "Plando Items"
|
||||||
|
|
||||||
|
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
|
||||||
|
self.value = list(deepcopy(value))
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
|
||||||
|
if not isinstance(data, typing.Iterable):
|
||||||
|
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
|
||||||
|
|
||||||
|
value: typing.List[PlandoItem] = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, typing.Mapping):
|
||||||
|
percentage = item.get("percentage", 100)
|
||||||
|
if not isinstance(percentage, int):
|
||||||
|
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
|
||||||
|
if not (0 <= percentage <= 100):
|
||||||
|
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
|
||||||
|
if roll_percentage(percentage):
|
||||||
|
count = item.get("count", False)
|
||||||
|
items = item.get("items", [])
|
||||||
|
if not items:
|
||||||
|
items = item.get("item", None) # explicitly throw an error here if not present
|
||||||
|
if not items:
|
||||||
|
raise OptionError("You must specify at least one item to place items with plando.")
|
||||||
|
count = 1
|
||||||
|
if isinstance(items, str):
|
||||||
|
items = [items]
|
||||||
|
elif not isinstance(items, (dict, list)):
|
||||||
|
raise OptionError(f"Plando 'items' has to be string, list, or "
|
||||||
|
f"dictionary, not {type(items)}")
|
||||||
|
locations = item.get("locations", [])
|
||||||
|
if not locations:
|
||||||
|
locations = item.get("location", [])
|
||||||
|
if locations:
|
||||||
|
count = 1
|
||||||
|
else:
|
||||||
|
locations = ["Everywhere"]
|
||||||
|
if isinstance(locations, str):
|
||||||
|
locations = [locations]
|
||||||
|
if not isinstance(locations, list):
|
||||||
|
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
|
||||||
|
world = item.get("world", False)
|
||||||
|
from_pool = item.get("from_pool", True)
|
||||||
|
force = item.get("force", "silent")
|
||||||
|
if not isinstance(from_pool, bool):
|
||||||
|
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
|
||||||
|
if not (isinstance(force, bool) or force == "silent"):
|
||||||
|
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
|
||||||
|
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
|
||||||
|
elif isinstance(item, PlandoItem):
|
||||||
|
if roll_percentage(item.percentage):
|
||||||
|
value.append(item)
|
||||||
|
else:
|
||||||
|
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
|
||||||
|
return cls(value)
|
||||||
|
|
||||||
|
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||||
|
if not self.value:
|
||||||
|
return
|
||||||
|
from BaseClasses import PlandoOptions
|
||||||
|
if not (PlandoOptions.items & plando_options):
|
||||||
|
# plando is disabled but plando options were given so overwrite the options
|
||||||
|
self.value = []
|
||||||
|
logging.warning(f"The plando items module is turned off, "
|
||||||
|
f"so items for {player_name} will be ignored.")
|
||||||
|
else:
|
||||||
|
# filter down item groups
|
||||||
|
for plando in self.value:
|
||||||
|
# confirm a valid count
|
||||||
|
if isinstance(plando.count, dict):
|
||||||
|
if "min" in plando.count and "max" in plando.count:
|
||||||
|
if plando.count["min"] > plando.count["max"]:
|
||||||
|
raise OptionError("Plando cannot have count `min` greater than `max`.")
|
||||||
|
items_copy = plando.items.copy()
|
||||||
|
if isinstance(plando.items, dict):
|
||||||
|
for item in items_copy:
|
||||||
|
if item in world.item_name_groups:
|
||||||
|
value = plando.items.pop(item)
|
||||||
|
group = world.item_name_groups[item]
|
||||||
|
filtered_items = sorted(group.difference(list(plando.items.keys())))
|
||||||
|
if not filtered_items:
|
||||||
|
raise OptionError(f"Plando `items` contains the group \"{item}\" "
|
||||||
|
f"and every item in it. This is not allowed.")
|
||||||
|
if value is True:
|
||||||
|
for key in filtered_items:
|
||||||
|
plando.items[key] = True
|
||||||
|
else:
|
||||||
|
for key in random.choices(filtered_items, k=value):
|
||||||
|
plando.items[key] = plando.items.get(key, 0) + 1
|
||||||
|
else:
|
||||||
|
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
|
||||||
|
for item in items_copy:
|
||||||
|
if item in world.item_name_groups:
|
||||||
|
plando.items.remove(item)
|
||||||
|
plando.items.extend(sorted(world.item_name_groups[item]))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: list[PlandoItem]) -> str:
|
||||||
|
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
|
||||||
|
|
||||||
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
|
||||||
|
return self.value.__getitem__(index)
|
||||||
|
|
||||||
|
def __iter__(self) -> typing.Iterator[PlandoItem]:
|
||||||
|
yield from self.value
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.value)
|
||||||
|
|
||||||
|
|
||||||
class Removed(FreeText):
|
class Removed(FreeText):
|
||||||
"""This Option has been Removed."""
|
"""This Option has been Removed."""
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
@@ -1431,6 +1624,7 @@ class PerGameCommonOptions(CommonOptions):
|
|||||||
exclude_locations: ExcludeLocations
|
exclude_locations: ExcludeLocations
|
||||||
priority_locations: PriorityLocations
|
priority_locations: PriorityLocations
|
||||||
item_links: ItemLinks
|
item_links: ItemLinks
|
||||||
|
plando_items: PlandoItems
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -1460,26 +1654,31 @@ it.
|
|||||||
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
|
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
|
||||||
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
|
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
|
||||||
"""Generates and returns a dictionary for the option groups of a specified world."""
|
"""Generates and returns a dictionary for the option groups of a specified world."""
|
||||||
option_groups = {option: option_group.name
|
option_to_name = {option: option_name for option_name, option in world.options_dataclass.type_hints.items()}
|
||||||
for option_group in world.web.option_groups
|
|
||||||
for option in option_group.options}
|
ordered_groups = {group.name: group.options for group in world.web.option_groups}
|
||||||
|
|
||||||
# add a default option group for uncategorized options to get thrown into
|
# add a default option group for uncategorized options to get thrown into
|
||||||
ordered_groups = ["Game Options"]
|
if "Game Options" not in ordered_groups:
|
||||||
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
|
grouped_options = set(option for group in ordered_groups.values() for option in group)
|
||||||
grouped_options = {group: {} for group in ordered_groups}
|
ungrouped_options = [option for option in option_to_name if option not in grouped_options]
|
||||||
for option_name, option in world.options_dataclass.type_hints.items():
|
# only add the game options group if we have ungrouped options
|
||||||
if visibility_level & option.visibility:
|
if ungrouped_options:
|
||||||
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
ordered_groups = {**{"Game Options": ungrouped_options}, **ordered_groups}
|
||||||
|
|
||||||
# if the world doesn't have any ungrouped options, this group will be empty so just remove it
|
return {
|
||||||
if not grouped_options["Game Options"]:
|
group: {
|
||||||
del grouped_options["Game Options"]
|
option_to_name[option]: option
|
||||||
|
for option in group_options
|
||||||
return grouped_options
|
if (visibility_level in option.visibility and option in option_to_name)
|
||||||
|
}
|
||||||
|
for group, group_options in ordered_groups.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
||||||
import os
|
import os
|
||||||
|
from inspect import cleandoc
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
@@ -1518,19 +1717,21 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
# yaml dump may add end of document marker and newlines.
|
# yaml dump may add end of document marker and newlines.
|
||||||
return yaml.dump(scalar).replace("...\n", "").strip()
|
return yaml.dump(scalar).replace("...\n", "").strip()
|
||||||
|
|
||||||
|
with open(local_path("data", "options.yaml")) as f:
|
||||||
|
file_data = f.read()
|
||||||
|
template = Template(file_data)
|
||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
if not world.hidden or generate_hidden:
|
if not world.hidden or generate_hidden:
|
||||||
option_groups = get_option_groups(world)
|
option_groups = get_option_groups(world)
|
||||||
with open(local_path("data", "options.yaml")) as f:
|
|
||||||
file_data = f.read()
|
res = template.render(
|
||||||
res = Template(file_data).render(
|
|
||||||
option_groups=option_groups,
|
option_groups=option_groups,
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
||||||
dictify_range=dictify_range,
|
dictify_range=dictify_range,
|
||||||
|
cleandoc=cleandoc,
|
||||||
)
|
)
|
||||||
|
|
||||||
del file_data
|
|
||||||
|
|
||||||
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||||
f.write(res)
|
f.write(res)
|
||||||
|
|
||||||
@@ -1556,10 +1757,11 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
|||||||
player_output = {
|
player_output = {
|
||||||
"Game": multiworld.game[player],
|
"Game": multiworld.game[player],
|
||||||
"Name": multiworld.get_player_name(player),
|
"Name": multiworld.get_player_name(player),
|
||||||
|
"ID": player,
|
||||||
}
|
}
|
||||||
output.append(player_output)
|
output.append(player_output)
|
||||||
for option_key, option in world.options_dataclass.type_hints.items():
|
for option_key, option in world.options_dataclass.type_hints.items():
|
||||||
if issubclass(Removed, option):
|
if option.visibility == Visibility.none:
|
||||||
continue
|
continue
|
||||||
display_name = getattr(option, "display_name", option_key)
|
display_name = getattr(option, "display_name", option_key)
|
||||||
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
||||||
@@ -1568,7 +1770,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
|||||||
game_option_names.append(display_name)
|
game_option_names.append(display_name)
|
||||||
|
|
||||||
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
||||||
fields = ["Game", "Name", *all_option_names]
|
fields = ["ID", "Game", "Name", *all_option_names]
|
||||||
writer = DictWriter(file, fields)
|
writer = DictWriter(file, fields)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
writer.writerows(output)
|
writer.writerows(output)
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -7,9 +7,7 @@ Currently, the following games are supported:
|
|||||||
|
|
||||||
* The Legend of Zelda: A Link to the Past
|
* The Legend of Zelda: A Link to the Past
|
||||||
* Factorio
|
* Factorio
|
||||||
* Minecraft
|
|
||||||
* Subnautica
|
* Subnautica
|
||||||
* Slay the Spire
|
|
||||||
* Risk of Rain 2
|
* Risk of Rain 2
|
||||||
* The Legend of Zelda: Ocarina of Time
|
* The Legend of Zelda: Ocarina of Time
|
||||||
* Timespinner
|
* Timespinner
|
||||||
@@ -63,7 +61,6 @@ Currently, the following games are supported:
|
|||||||
* TUNIC
|
* TUNIC
|
||||||
* Kirby's Dream Land 3
|
* Kirby's Dream Land 3
|
||||||
* Celeste 64
|
* Celeste 64
|
||||||
* Zork Grand Inquisitor
|
|
||||||
* Castlevania 64
|
* Castlevania 64
|
||||||
* A Short Hike
|
* A Short Hike
|
||||||
* Yoshi's Island
|
* Yoshi's Island
|
||||||
@@ -76,6 +73,15 @@ Currently, the following games are supported:
|
|||||||
* Kingdom Hearts 1
|
* Kingdom Hearts 1
|
||||||
* Mega Man 2
|
* Mega Man 2
|
||||||
* Yacht Dice
|
* Yacht Dice
|
||||||
|
* Faxanadu
|
||||||
|
* Saving Princess
|
||||||
|
* Castlevania: Circle of the Moon
|
||||||
|
* Inscryption
|
||||||
|
* Civilization VI
|
||||||
|
* The Legend of Zelda: The Wind Waker
|
||||||
|
* Jak and Daxter: The Precursor Legacy
|
||||||
|
* Super Mario Land 2: 6 Golden Coins
|
||||||
|
* shapez
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
|
|||||||
@@ -243,6 +243,9 @@ class SNIContext(CommonContext):
|
|||||||
# Once the games handled by SNIClient gets made to be remote items,
|
# Once the games handled by SNIClient gets made to be remote items,
|
||||||
# this will no longer be needed.
|
# this will no longer be needed.
|
||||||
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
||||||
|
|
||||||
|
if self.client_handler is not None:
|
||||||
|
self.client_handler.on_package(self, cmd, args)
|
||||||
|
|
||||||
def run_gui(self) -> None:
|
def run_gui(self) -> None:
|
||||||
from kvui import GameManager
|
from kvui import GameManager
|
||||||
@@ -732,6 +735,6 @@ async def main() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ def main():
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(_main())
|
asyncio.run(_main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
119
Utils.py
119
Utils.py
@@ -19,8 +19,7 @@ import warnings
|
|||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from settings import Settings, get_settings
|
from settings import Settings, get_settings
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
|
||||||
from typing_extensions import TypeGuard
|
|
||||||
from yaml import load, load_all, dump
|
from yaml import load, load_all, dump
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -48,7 +47,7 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.5.1"
|
__version__ = "0.6.2"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -115,6 +114,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
|
|||||||
cache[arg] = res
|
cache[arg] = res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
wrap.__defaults__ = function.__defaults__
|
||||||
|
|
||||||
return wrap
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
@@ -138,8 +139,11 @@ def local_path(*path: str) -> str:
|
|||||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||||
else:
|
else:
|
||||||
import __main__
|
import __main__
|
||||||
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
if globals().get("__file__") and os.path.isfile(__file__):
|
||||||
# we are running in a normal Python environment
|
# we are running in a normal Python environment
|
||||||
|
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||||
|
# we are running in a normal Python environment, but AP was imported weirdly
|
||||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||||
else:
|
else:
|
||||||
# pray
|
# pray
|
||||||
@@ -153,7 +157,18 @@ def home_path(*path: str) -> str:
|
|||||||
if hasattr(home_path, 'cached_path'):
|
if hasattr(home_path, 'cached_path'):
|
||||||
pass
|
pass
|
||||||
elif sys.platform.startswith('linux'):
|
elif sys.platform.startswith('linux'):
|
||||||
home_path.cached_path = os.path.expanduser('~/Archipelago')
|
xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
||||||
|
home_path.cached_path = xdg_data_home + '/Archipelago'
|
||||||
|
if not os.path.isdir(home_path.cached_path):
|
||||||
|
legacy_home_path = os.path.expanduser('~/Archipelago')
|
||||||
|
if os.path.isdir(legacy_home_path):
|
||||||
|
os.renames(legacy_home_path, home_path.cached_path)
|
||||||
|
os.symlink(home_path.cached_path, legacy_home_path)
|
||||||
|
else:
|
||||||
|
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||||
|
elif sys.platform == 'darwin':
|
||||||
|
import platformdirs
|
||||||
|
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
|
||||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||||
else:
|
else:
|
||||||
# not implemented
|
# not implemented
|
||||||
@@ -166,7 +181,7 @@ def user_path(*path: str) -> str:
|
|||||||
"""Returns either local_path or home_path based on write permissions."""
|
"""Returns either local_path or home_path based on write permissions."""
|
||||||
if hasattr(user_path, "cached_path"):
|
if hasattr(user_path, "cached_path"):
|
||||||
pass
|
pass
|
||||||
elif os.access(local_path(), os.W_OK):
|
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()):
|
||||||
user_path.cached_path = local_path()
|
user_path.cached_path = local_path()
|
||||||
else:
|
else:
|
||||||
user_path.cached_path = home_path()
|
user_path.cached_path = home_path()
|
||||||
@@ -215,7 +230,12 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
||||||
assert open_command, "Didn't find program for open_file! Please report this together with system details."
|
assert open_command, "Didn't find program for open_file! Please report this together with system details."
|
||||||
subprocess.call([open_command, filename])
|
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
subprocess.call([open_command, filename], env=env)
|
||||||
|
|
||||||
|
|
||||||
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
|
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
|
||||||
@@ -421,8 +441,12 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
def find_class(self, module: str, name: str) -> type:
|
def find_class(self, module: str, name: str) -> type:
|
||||||
if module == "builtins" and name in safe_builtins:
|
if module == "builtins" and name in safe_builtins:
|
||||||
return getattr(builtins, name)
|
return getattr(builtins, name)
|
||||||
|
# used by OptionCounter
|
||||||
|
if module == "collections" and name == "Counter":
|
||||||
|
return collections.Counter
|
||||||
# used by MultiServer -> savegame/multidata
|
# used by MultiServer -> savegame/multidata
|
||||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
|
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
||||||
|
"SlotType", "NetworkSlot", "HintStatus"}:
|
||||||
return getattr(self.net_utils_module, name)
|
return getattr(self.net_utils_module, name)
|
||||||
# Options and Plando are unpickled by WebHost -> Generate
|
# Options and Plando are unpickled by WebHost -> Generate
|
||||||
if module == "worlds.generic" and name == "PlandoItem":
|
if module == "worlds.generic" and name == "PlandoItem":
|
||||||
@@ -436,7 +460,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
else:
|
else:
|
||||||
mod = importlib.import_module(module)
|
mod = importlib.import_module(module)
|
||||||
obj = getattr(mod, name)
|
obj = getattr(mod, name)
|
||||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)):
|
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
||||||
|
self.options_module.PlandoText)):
|
||||||
return obj
|
return obj
|
||||||
# Forbid everything else.
|
# Forbid everything else.
|
||||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||||
@@ -485,9 +510,9 @@ def get_text_after(text: str, start: str) -> str:
|
|||||||
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
|
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
|
||||||
|
|
||||||
|
|
||||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
|
||||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
write_mode: str = "w", log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||||
exception_logger: typing.Optional[str] = None):
|
add_timestamp: bool = False, exception_logger: typing.Optional[str] = None):
|
||||||
import datetime
|
import datetime
|
||||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||||
log_folder = user_path("logs")
|
log_folder = user_path("logs")
|
||||||
@@ -514,12 +539,18 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
return self.condition(record)
|
return self.condition(record)
|
||||||
|
|
||||||
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
||||||
|
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage()))
|
||||||
root_logger.addHandler(file_handler)
|
root_logger.addHandler(file_handler)
|
||||||
if sys.stdout:
|
if sys.stdout:
|
||||||
|
formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
||||||
stream_handler = logging.StreamHandler(sys.stdout)
|
stream_handler = logging.StreamHandler(sys.stdout)
|
||||||
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
|
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
|
||||||
|
if add_timestamp:
|
||||||
|
stream_handler.setFormatter(formatter)
|
||||||
root_logger.addHandler(stream_handler)
|
root_logger.addHandler(stream_handler)
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
# Relay unhandled exceptions to logger.
|
# Relay unhandled exceptions to logger.
|
||||||
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
||||||
@@ -530,7 +561,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||||
return
|
return
|
||||||
logging.getLogger(exception_logger).exception("Uncaught exception",
|
logging.getLogger(exception_logger).exception("Uncaught exception",
|
||||||
exc_info=(exc_type, exc_value, exc_traceback))
|
exc_info=(exc_type, exc_value, exc_traceback),
|
||||||
|
extra={"NoStream": exception_logger is None})
|
||||||
return orig_hook(exc_type, exc_value, exc_traceback)
|
return orig_hook(exc_type, exc_value, exc_traceback)
|
||||||
|
|
||||||
handle_exception._wrapped = True
|
handle_exception._wrapped = True
|
||||||
@@ -553,7 +585,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
import platform
|
import platform
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Archipelago ({__version__}) logging initialized"
|
f"Archipelago ({__version__}) logging initialized"
|
||||||
f" on {platform.platform()}"
|
f" on {platform.platform()} process {os.getpid()}"
|
||||||
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||||
f"{' (frozen)' if is_frozen() else ''}"
|
f"{' (frozen)' if is_frozen() else ''}"
|
||||||
)
|
)
|
||||||
@@ -617,6 +649,8 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
|
|||||||
import jellyfish
|
import jellyfish
|
||||||
|
|
||||||
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
||||||
|
if word1 == word2:
|
||||||
|
return 1.01
|
||||||
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
||||||
/ max(len(word1), len(word2)))
|
/ max(len(word1), len(word2)))
|
||||||
|
|
||||||
@@ -637,8 +671,10 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
|
|||||||
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
||||||
if len(picks) > 1:
|
if len(picks) > 1:
|
||||||
dif = picks[0][1] - picks[1][1]
|
dif = picks[0][1] - picks[1][1]
|
||||||
if picks[0][1] == 100:
|
if picks[0][1] == 101:
|
||||||
return picks[0][0], True, "Perfect Match"
|
return picks[0][0], True, "Perfect Match"
|
||||||
|
elif picks[0][1] == 100:
|
||||||
|
return picks[0][0], True, "Case Insensitive Perfect Match"
|
||||||
elif picks[0][1] < 75:
|
elif picks[0][1] < 75:
|
||||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||||
@@ -681,25 +717,30 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
|
|||||||
res.put(open_filename(*args))
|
res.put(open_filename(*args))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_for_stdout(*args: str):
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
|
||||||
|
|
||||||
|
|
||||||
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
||||||
-> typing.Optional[str]:
|
-> typing.Optional[str]:
|
||||||
logging.info(f"Opening file input dialog for {title}.")
|
logging.info(f"Opening file input dialog for {title}.")
|
||||||
|
|
||||||
def run(*args: str):
|
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
|
||||||
|
|
||||||
if is_linux:
|
if is_linux:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||||
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
|
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||||
selection = (f"--filename={suggest}",) if suggest else ()
|
selection = (f"--filename={suggest}",) if suggest else ()
|
||||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -733,21 +774,18 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args
|
|||||||
|
|
||||||
|
|
||||||
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
||||||
def run(*args: str):
|
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
|
||||||
|
|
||||||
if is_linux:
|
if is_linux:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
return run(kdialog, f"--title={title}", "--getexistingdirectory",
|
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory",
|
||||||
os.path.abspath(suggest) if suggest else ".")
|
os.path.abspath(suggest) if suggest else ".")
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = ("--directory",)
|
z_filters = ("--directory",)
|
||||||
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
|
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
|
||||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -774,9 +812,6 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||||
def run(*args: str):
|
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
|
||||||
|
|
||||||
if is_kivy_running():
|
if is_kivy_running():
|
||||||
from kvui import MessageBox
|
from kvui import MessageBox
|
||||||
MessageBox(title, text, error).open()
|
MessageBox(title, text, error).open()
|
||||||
@@ -787,10 +822,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
|
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
|
||||||
|
|
||||||
elif is_windows:
|
elif is_windows:
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -855,11 +890,10 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
|
|||||||
task.add_done_callback(_faf_tasks.discard)
|
task.add_done_callback(_faf_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
def deprecate(message: str):
|
def deprecate(message: str, add_stacklevels: int = 0):
|
||||||
if __debug__:
|
if __debug__:
|
||||||
raise Exception(message)
|
raise Exception(message)
|
||||||
import warnings
|
warnings.warn(message, stacklevel=2 + add_stacklevels)
|
||||||
warnings.warn(message)
|
|
||||||
|
|
||||||
|
|
||||||
class DeprecateDict(dict):
|
class DeprecateDict(dict):
|
||||||
@@ -873,10 +907,9 @@ class DeprecateDict(dict):
|
|||||||
|
|
||||||
def __getitem__(self, item: Any) -> Any:
|
def __getitem__(self, item: Any) -> Any:
|
||||||
if self.should_error:
|
if self.should_error:
|
||||||
deprecate(self.log_message)
|
deprecate(self.log_message, add_stacklevels=1)
|
||||||
elif __debug__:
|
elif __debug__:
|
||||||
import warnings
|
warnings.warn(self.log_message, stacklevel=2)
|
||||||
warnings.warn(self.log_message)
|
|
||||||
return super().__getitem__(item)
|
return super().__getitem__(item)
|
||||||
|
|
||||||
|
|
||||||
@@ -930,7 +963,7 @@ def freeze_support() -> None:
|
|||||||
|
|
||||||
def visualize_regions(root_region: Region, file_name: str, *,
|
def visualize_regions(root_region: Region, file_name: str, *,
|
||||||
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
||||||
linetype_ortho: bool = True) -> None:
|
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
||||||
"""Visualize the layout of a world as a PlantUML diagram.
|
"""Visualize the layout of a world as a PlantUML diagram.
|
||||||
|
|
||||||
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
||||||
@@ -946,16 +979,22 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
|||||||
Items without ID will be shown in italics.
|
Items without ID will be shown in italics.
|
||||||
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
||||||
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
||||||
|
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
|
||||||
|
|
||||||
Example usage in World code:
|
Example usage in World code:
|
||||||
from Utils import visualize_regions
|
from Utils import visualize_regions
|
||||||
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
|
state = self.multiworld.get_all_state(False)
|
||||||
|
state.update_reachable_regions(self.player)
|
||||||
|
visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True,
|
||||||
|
regions_to_highlight=state.reachable_regions[self.player])
|
||||||
|
|
||||||
Example usage in Main code:
|
Example usage in Main code:
|
||||||
from Utils import visualize_regions
|
from Utils import visualize_regions
|
||||||
for player in multiworld.player_ids:
|
for player in multiworld.player_ids:
|
||||||
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
|
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
|
||||||
"""
|
"""
|
||||||
|
if regions_to_highlight is None:
|
||||||
|
regions_to_highlight = set()
|
||||||
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
|
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
|
||||||
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -1008,7 +1047,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
|||||||
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
|
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
|
||||||
|
|
||||||
def visualize_region(region: Region) -> None:
|
def visualize_region(region: Region) -> None:
|
||||||
uml.append(f"class \"{fmt(region)}\"")
|
uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}")
|
||||||
if show_locations:
|
if show_locations:
|
||||||
visualize_locations(region)
|
visualize_locations(region)
|
||||||
visualize_exits(region)
|
visualize_exits(region)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from Utils import get_file_safe_name
|
|||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||||
settings.no_gui = True
|
settings.no_gui = True
|
||||||
configpath = os.path.abspath("config.yaml")
|
configpath = os.path.abspath("config.yaml")
|
||||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||||
@@ -34,7 +34,7 @@ def get_app() -> "Flask":
|
|||||||
app.config.from_file(configpath, yaml.safe_load)
|
app.config.from_file(configpath, yaml.safe_load)
|
||||||
logging.info(f"Updated config from {configpath}")
|
logging.info(f"Updated config from {configpath}")
|
||||||
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||||
parser.add_argument('--config_override', default=None,
|
parser.add_argument('--config_override', default=None,
|
||||||
help="Path to yaml config file that overrules config.yaml.")
|
help="Path to yaml config file that overrules config.yaml.")
|
||||||
args = parser.parse_known_args()[0]
|
args = parser.parse_known_args()[0]
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
|||||||
app.config["JOB_THRESHOLD"] = 1
|
app.config["JOB_THRESHOLD"] = 1
|
||||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||||
app.config["JOB_TIME"] = 600
|
app.config["JOB_TIME"] = 600
|
||||||
|
# memory limit for generator processes in bytes
|
||||||
|
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
|
||||||
app.config['SESSION_PERMANENT'] = True
|
app.config['SESSION_PERMANENT'] = True
|
||||||
|
|
||||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||||
@@ -78,13 +80,11 @@ def register():
|
|||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
# has automatic patch integration
|
# has automatic patch integration
|
||||||
import worlds.AutoWorld
|
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
|
||||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
|
||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
from WebHostLib.customserver import run_server_process
|
||||||
# to trigger app routing picking up on it
|
# to trigger app routing picking up on it
|
||||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
|
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
|
||||||
|
|
||||||
app.register_blueprint(api.api_endpoints)
|
app.register_blueprint(api.api_endpoints)
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ from typing import List, Tuple
|
|||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
from ..models import Seed
|
from ..models import Seed, Slot
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
||||||
|
|
||||||
|
|
||||||
from . import datapackage, generate, room, user # trigger registration
|
from . import datapackage, generate, room, user # trigger registration
|
||||||
|
|||||||
@@ -28,6 +28,6 @@ def get_seeds():
|
|||||||
response.append({
|
response.append({
|
||||||
"seed_id": seed.id,
|
"seed_id": seed.id,
|
||||||
"creation_time": seed.creation_time,
|
"creation_time": seed.creation_time,
|
||||||
"players": get_players(seed.slots),
|
"players": get_players(seed),
|
||||||
})
|
})
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import multiprocessing
|
|||||||
import typing
|
import typing
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pony.orm import db_session, select, commit
|
from pony.orm import db_session, select, commit, PrimaryKey
|
||||||
|
|
||||||
from Utils import restricted_loads
|
from Utils import restricted_loads
|
||||||
from .locker import Locker, AlreadyRunningException
|
from .locker import Locker, AlreadyRunningException
|
||||||
@@ -35,12 +36,21 @@ def handle_generation_failure(result: BaseException):
|
|||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle(f"Generator ({sid})")
|
||||||
|
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
|
||||||
|
setproctitle(f"Generator (idle)")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||||
try:
|
try:
|
||||||
meta = json.loads(generation.meta)
|
meta = json.loads(generation.meta)
|
||||||
options = restricted_loads(generation.options)
|
options = restricted_loads(generation.options)
|
||||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||||
pool.apply_async(gen_game, (options,),
|
pool.apply_async(_mp_gen_game, (options,),
|
||||||
{"meta": meta,
|
{"meta": meta,
|
||||||
"sid": generation.id,
|
"sid": generation.id,
|
||||||
"owner": generation.owner},
|
"owner": generation.owner},
|
||||||
@@ -53,7 +63,25 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
|||||||
generation.state = STATE_STARTED
|
generation.state = STATE_STARTED
|
||||||
|
|
||||||
|
|
||||||
def init_db(pony_config: dict):
|
def init_generator(config: dict[str, Any]) -> None:
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle("Generator (idle)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import resource
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass # unix only module
|
||||||
|
else:
|
||||||
|
# set soft limit for memory to from config (default 4GiB)
|
||||||
|
soft_limit = config["GENERATOR_MEMORY_LIMIT"]
|
||||||
|
old_limit, hard_limit = resource.getrlimit(resource.RLIMIT_AS)
|
||||||
|
if soft_limit != old_limit:
|
||||||
|
resource.setrlimit(resource.RLIMIT_AS, (soft_limit, hard_limit))
|
||||||
|
logging.debug(f"Changed AS mem limit {old_limit} -> {soft_limit}")
|
||||||
|
del resource, soft_limit, hard_limit
|
||||||
|
|
||||||
|
pony_config = config["PONY"]
|
||||||
db.bind(**pony_config)
|
db.bind(**pony_config)
|
||||||
db.generate_mapping()
|
db.generate_mapping()
|
||||||
|
|
||||||
@@ -105,8 +133,8 @@ def autogen(config: dict):
|
|||||||
try:
|
try:
|
||||||
with Locker("autogen"):
|
with Locker("autogen"):
|
||||||
|
|
||||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||||
initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
|
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||||
with db_session:
|
with db_session:
|
||||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||||
|
|
||||||
|
|||||||
@@ -105,8 +105,9 @@ def roll_options(options: Dict[str, Union[dict, str]],
|
|||||||
plando_options=plando_options)
|
plando_options=plando_options)
|
||||||
else:
|
else:
|
||||||
for i, yaml_data in enumerate(yaml_datas):
|
for i, yaml_data in enumerate(yaml_datas):
|
||||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
if yaml_data is not None:
|
||||||
plando_options=plando_options)
|
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||||
|
plando_options=plando_options)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if e.__cause__:
|
if e.__cause__:
|
||||||
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
|
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class WebHostContext(Context):
|
|||||||
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
||||||
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
||||||
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
||||||
|
missing_checksum = False
|
||||||
|
|
||||||
for game in list(multidata.get("datapackage", {})):
|
for game in list(multidata.get("datapackage", {})):
|
||||||
game_data = multidata["datapackage"][game]
|
game_data = multidata["datapackage"][game]
|
||||||
@@ -132,11 +133,13 @@ class WebHostContext(Context):
|
|||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||||
|
else:
|
||||||
|
missing_checksum = True # Game rolled on old AP and will load data package from multidata
|
||||||
self.gamespackage[game] = static_gamespackage.get(game, {})
|
self.gamespackage[game] = static_gamespackage.get(game, {})
|
||||||
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
||||||
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
||||||
|
|
||||||
if not game_data_packages:
|
if not game_data_packages and not missing_checksum:
|
||||||
# all static -> use the static dicts directly
|
# all static -> use the static dicts directly
|
||||||
self.gamespackage = static_gamespackage
|
self.gamespackage = static_gamespackage
|
||||||
self.item_name_groups = static_item_name_groups
|
self.item_name_groups = static_item_name_groups
|
||||||
@@ -224,6 +227,9 @@ def set_up_logging(room_id) -> logging.Logger:
|
|||||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle(name)
|
||||||
Utils.init_logging(name)
|
Utils.init_logging(name)
|
||||||
try:
|
try:
|
||||||
import resource
|
import resource
|
||||||
@@ -244,8 +250,23 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||||
|
|
||||||
import gc
|
import gc
|
||||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
|
||||||
del cert_file, cert_key_file, ponyconfig
|
if not cert_file:
|
||||||
|
def get_ssl_context():
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
load_date = None
|
||||||
|
ssl_context = load_server_cert(cert_file, cert_key_file)
|
||||||
|
|
||||||
|
def get_ssl_context():
|
||||||
|
nonlocal load_date, ssl_context
|
||||||
|
today = datetime.date.today()
|
||||||
|
if load_date != today:
|
||||||
|
ssl_context = load_server_cert(cert_file, cert_key_file)
|
||||||
|
load_date = today
|
||||||
|
return ssl_context
|
||||||
|
|
||||||
|
del ponyconfig
|
||||||
gc.collect() # free intermediate objects used during setup
|
gc.collect() # free intermediate objects used during setup
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -260,12 +281,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
assert ctx.server is None
|
assert ctx.server is None
|
||||||
try:
|
try:
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(
|
||||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
except OSError: # likely port in use
|
except OSError: # likely port in use
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(
|
||||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
port = 0
|
port = 0
|
||||||
|
|||||||
@@ -61,12 +61,7 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
else:
|
else:
|
||||||
import io
|
import io
|
||||||
|
|
||||||
if slot_data.game == "Minecraft":
|
if slot_data.game == "Factorio":
|
||||||
from worlds.minecraft import mc_update_output
|
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
|
||||||
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
|
|
||||||
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
|
||||||
elif slot_data.game == "Factorio":
|
|
||||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||||
for name in zf.namelist():
|
for name in zf.namelist():
|
||||||
if name.endswith("info.json"):
|
if name.endswith("info.json"):
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[s
|
|||||||
|
|
||||||
server_options = {
|
server_options = {
|
||||||
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
|
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
|
||||||
"release_mode": options_source.get("release_mode", ServerOptions.release_mode),
|
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
||||||
"remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode),
|
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
||||||
"collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode),
|
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
|
||||||
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||||
"server_password": options_source.get("server_password", None),
|
"server_password": str(options_source.get("server_password", None)),
|
||||||
}
|
}
|
||||||
generator_options = {
|
generator_options = {
|
||||||
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
|
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
|
||||||
@@ -135,6 +135,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
|||||||
{"bosses", "items", "connections", "texts"}))
|
{"bosses", "items", "connections", "texts"}))
|
||||||
erargs.skip_prog_balancing = False
|
erargs.skip_prog_balancing = False
|
||||||
erargs.skip_output = False
|
erargs.skip_output = False
|
||||||
|
erargs.spoiler_only = False
|
||||||
erargs.csv_output = False
|
erargs.csv_output = False
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
|
|||||||
@@ -18,13 +18,6 @@ def get_world_theme(game_name: str):
|
|||||||
return 'grass'
|
return 'grass'
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
|
||||||
def register_session():
|
|
||||||
session.permanent = True # technically 31 days after the last visit
|
|
||||||
if not session.get("_id", None):
|
|
||||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||||
def page_not_found(err):
|
def page_not_found(err):
|
||||||
@@ -42,6 +35,12 @@ def start_playing():
|
|||||||
@app.route('/games/<string:game>/info/<string:lang>')
|
@app.route('/games/<string:game>/info/<string:lang>')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def game_info(game, lang):
|
def game_info(game, lang):
|
||||||
|
try:
|
||||||
|
world = AutoWorldRegister.world_types[game]
|
||||||
|
if lang not in world.web.game_info_languages:
|
||||||
|
raise KeyError("Sorry, this game's info page is not available in that language yet.")
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +58,12 @@ def games():
|
|||||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def tutorial(game, file, lang):
|
def tutorial(game, file, lang):
|
||||||
|
try:
|
||||||
|
world = AutoWorldRegister.world_types[game]
|
||||||
|
if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]:
|
||||||
|
raise KeyError("Sorry, the tutorial is not available in that language yet.")
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Dict, Union
|
|||||||
from docutils.core import publish_parts
|
from docutils.core import publish_parts
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from flask import redirect, render_template, request, Response
|
from flask import redirect, render_template, request, Response, abort
|
||||||
|
|
||||||
import Options
|
import Options
|
||||||
from Utils import local_path
|
from Utils import local_path
|
||||||
@@ -108,7 +108,7 @@ def option_presets(game: str) -> Response:
|
|||||||
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
||||||
|
|
||||||
presets[preset_name][preset_option_name] = option.value
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
|
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)):
|
||||||
presets[preset_name][preset_option_name] = option.value
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(preset_option, str):
|
elif isinstance(preset_option, str):
|
||||||
# Ensure the option value is valid for Choice and Toggle options
|
# Ensure the option value is valid for Choice and Toggle options
|
||||||
@@ -142,7 +142,10 @@ def weighted_options_old():
|
|||||||
@app.route("/games/<string:game>/weighted-options")
|
@app.route("/games/<string:game>/weighted-options")
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def weighted_options(game: str):
|
def weighted_options(game: str):
|
||||||
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
try:
|
||||||
|
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
||||||
@@ -197,7 +200,10 @@ def generate_weighted_yaml(game: str):
|
|||||||
@app.route("/games/<string:game>/player-options")
|
@app.route("/games/<string:game>/player-options")
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def player_options(game: str):
|
def player_options(game: str):
|
||||||
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
try:
|
||||||
|
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
# YAML generator for player-options
|
# YAML generator for player-options
|
||||||
@@ -216,7 +222,7 @@ def generate_yaml(game: str):
|
|||||||
|
|
||||||
for key, val in options.copy().items():
|
for key, val in options.copy().items():
|
||||||
key_parts = key.rsplit("||", 2)
|
key_parts = key.rsplit("||", 2)
|
||||||
# Detect and build ItemDict options from their name pattern
|
# Detect and build OptionCounter options from their name pattern
|
||||||
if key_parts[-1] == "qty":
|
if key_parts[-1] == "qty":
|
||||||
if key_parts[0] not in options:
|
if key_parts[0] not in options:
|
||||||
options[key_parts[0]] = {}
|
options[key_parts[0]] = {}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
flask>=3.0.3
|
flask>=3.1.1
|
||||||
werkzeug>=3.0.6
|
werkzeug>=3.1.3
|
||||||
pony>=0.7.19
|
pony>=0.7.19
|
||||||
waitress>=3.0.0
|
waitress>=3.0.2
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress>=1.15
|
Flask-Compress>=1.17
|
||||||
Flask-Limiter>=3.8.0
|
Flask-Limiter>=3.12
|
||||||
bokeh>=3.1.1; python_version <= '3.8'
|
bokeh>=3.6.3
|
||||||
bokeh>=3.4.3; python_version == '3.9'
|
markupsafe>=3.0.2
|
||||||
bokeh>=3.5.2; python_version >= '3.10'
|
|
||||||
markupsafe>=2.1.5
|
|
||||||
Markdown>=3.7
|
Markdown>=3.7
|
||||||
mdx-breakless-lists>=1.0.1
|
mdx-breakless-lists>=1.0.1
|
||||||
|
setproctitle>=1.3.5
|
||||||
|
|||||||
31
WebHostLib/session.py
Normal file
31
WebHostLib/session.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from uuid import uuid4, UUID
|
||||||
|
|
||||||
|
from flask import session, render_template
|
||||||
|
|
||||||
|
from WebHostLib import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def register_session():
|
||||||
|
session.permanent = True # technically 31 days after the last visit
|
||||||
|
if not session.get("_id", None):
|
||||||
|
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/session')
|
||||||
|
def show_session():
|
||||||
|
return render_template(
|
||||||
|
"session.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/session/<string:_id>')
|
||||||
|
def set_session(_id: str):
|
||||||
|
new_id: UUID = UUID(_id, version=4)
|
||||||
|
old_id: UUID = session["_id"]
|
||||||
|
if old_id != new_id:
|
||||||
|
session["_id"] = new_id
|
||||||
|
return render_template(
|
||||||
|
"session.html",
|
||||||
|
old_id=old_id,
|
||||||
|
)
|
||||||
@@ -22,7 +22,7 @@ players to rely upon each other to complete their game.
|
|||||||
|
|
||||||
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
||||||
players to randomize any of the supported games, and send items between them. This allows players of different
|
players to randomize any of the supported games, and send items between them. This allows players of different
|
||||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
|
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
|
||||||
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
||||||
|
|
||||||
## Can I generate a single-player game with Archipelago?
|
## Can I generate a single-player game with Archipelago?
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ window.addEventListener('load', () => {
|
|||||||
showdown.setOption('strikethrough', true);
|
showdown.setOption('strikethrough', true);
|
||||||
showdown.setOption('literalMidWordUnderscores', true);
|
showdown.setOption('literalMidWordUnderscores', true);
|
||||||
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
|
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||||
adjustHeaderWidth();
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||||
@@ -42,10 +41,5 @@ window.addEventListener('load', () => {
|
|||||||
scrollTarget?.scrollIntoView();
|
scrollTarget?.scrollIntoView();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
gameInfo.innerHTML =
|
|
||||||
`<h2>This page is out of logic!</h2>
|
|
||||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,4 @@ window.addEventListener('load', () => {
|
|||||||
document.getElementById('file-input').addEventListener('change', () => {
|
document.getElementById('file-input').addEventListener('change', () => {
|
||||||
document.getElementById('host-game-form').submit();
|
document.getElementById('host-game-form').submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
adjustFooterHeight();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
// Reload tracker every 15 seconds
|
|
||||||
const url = window.location;
|
|
||||||
setInterval(() => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
|
|
||||||
// Create a fake DOM using the returned HTML
|
|
||||||
const domParser = new DOMParser();
|
|
||||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
|
||||||
|
|
||||||
// Update item tracker
|
|
||||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
|
||||||
// Update only counters in the location-table
|
|
||||||
let counters = document.getElementsByClassName('counter');
|
|
||||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
|
||||||
for (let i = 0; i < counters.length; i++) {
|
|
||||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ajax.open('GET', url);
|
|
||||||
ajax.send();
|
|
||||||
}, 15000)
|
|
||||||
|
|
||||||
// Collapsible advancement sections
|
|
||||||
const categories = document.getElementsByClassName("location-category");
|
|
||||||
for (let i = 0; i < categories.length; i++) {
|
|
||||||
let hide_id = categories[i].id.split('-')[0];
|
|
||||||
if (hide_id == 'Total') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
categories[i].addEventListener('click', function() {
|
|
||||||
// Toggle the advancement list
|
|
||||||
document.getElementById(hide_id).classList.toggle("hide");
|
|
||||||
// Change text of the header
|
|
||||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
|
||||||
const orig_text = tab_header.innerHTML;
|
|
||||||
let new_text;
|
|
||||||
if (orig_text.includes("▼")) {
|
|
||||||
new_text = orig_text.replace("▼", "▲");
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
new_text = orig_text.replace("▲", "▼");
|
|
||||||
}
|
|
||||||
tab_header.innerHTML = new_text;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
const adjustFooterHeight = () => {
|
|
||||||
// If there is no footer on this page, do nothing
|
|
||||||
const footer = document.getElementById('island-footer');
|
|
||||||
if (!footer) { return; }
|
|
||||||
|
|
||||||
// If the body is taller than the window, also do nothing
|
|
||||||
if (document.body.offsetHeight > window.innerHeight) {
|
|
||||||
footer.style.marginTop = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a margin-top to the footer to position it at the bottom of the screen
|
|
||||||
const sibling = footer.previousElementSibling;
|
|
||||||
const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
|
|
||||||
if (margin < 1) {
|
|
||||||
footer.style.marginTop = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
footer.style.marginTop = `${margin}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const adjustHeaderWidth = () => {
|
|
||||||
// If there is no header, do nothing
|
|
||||||
const header = document.getElementById('base-header');
|
|
||||||
if (!header) { return; }
|
|
||||||
|
|
||||||
const tempDiv = document.createElement('div');
|
|
||||||
tempDiv.style.width = '100px';
|
|
||||||
tempDiv.style.height = '100px';
|
|
||||||
tempDiv.style.overflow = 'scroll';
|
|
||||||
tempDiv.style.position = 'absolute';
|
|
||||||
tempDiv.style.top = '-500px';
|
|
||||||
document.body.appendChild(tempDiv);
|
|
||||||
const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
|
|
||||||
document.body.removeChild(tempDiv);
|
|
||||||
|
|
||||||
const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
|
|
||||||
const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
|
|
||||||
document.getElementById('base-header-right').style.marginRight = `${margin}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
window.addEventListener('resize', adjustFooterHeight);
|
|
||||||
window.addEventListener('resize', adjustHeaderWidth);
|
|
||||||
adjustFooterHeight();
|
|
||||||
adjustHeaderWidth();
|
|
||||||
});
|
|
||||||
@@ -25,7 +25,6 @@ window.addEventListener('load', () => {
|
|||||||
showdown.setOption('literalMidWordUnderscores', true);
|
showdown.setOption('literalMidWordUnderscores', true);
|
||||||
showdown.setOption('disableForced4SpacesIndentedSublists', true);
|
showdown.setOption('disableForced4SpacesIndentedSublists', true);
|
||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||||
adjustHeaderWidth();
|
|
||||||
|
|
||||||
const title = document.querySelector('h1')
|
const title = document.querySelector('h1')
|
||||||
if (title) {
|
if (title) {
|
||||||
@@ -49,10 +48,5 @@ window.addEventListener('load', () => {
|
|||||||
scrollTarget?.scrollIntoView();
|
scrollTarget?.scrollIntoView();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
tutorialWrapper.innerHTML =
|
|
||||||
`<h2>This page is out of logic!</h2>
|
|
||||||
<h3>Click <a href="${window.location.origin}/tutorial">here</a> to return to safety.</h3>`;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ html{
|
|||||||
|
|
||||||
body{
|
body{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100vh - 110px);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
a{
|
a{
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
#player-tracker-wrapper{
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table{
|
|
||||||
border-top: 2px solid #000000;
|
|
||||||
border-left: 2px solid #000000;
|
|
||||||
border-right: 2px solid #000000;
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
padding: 3px 3px 10px;
|
|
||||||
width: 384px;
|
|
||||||
background-color: #42b149;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table td{
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table img{
|
|
||||||
height: 100%;
|
|
||||||
max-width: 40px;
|
|
||||||
max-height: 40px;
|
|
||||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table img.acquired{
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table div.counted-item {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table div.item-count {
|
|
||||||
position: absolute;
|
|
||||||
color: white;
|
|
||||||
font-family: "Minecraftia", monospace;
|
|
||||||
font-weight: bold;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table{
|
|
||||||
width: 384px;
|
|
||||||
border-left: 2px solid #000000;
|
|
||||||
border-right: 2px solid #000000;
|
|
||||||
border-bottom: 2px solid #000000;
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
background-color: #42b149;
|
|
||||||
padding: 0 3px 3px;
|
|
||||||
font-family: "Minecraftia", monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table th{
|
|
||||||
vertical-align: middle;
|
|
||||||
text-align: left;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td{
|
|
||||||
padding-top: 2px;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.counter {
|
|
||||||
text-align: right;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.toggle-arrow {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tr#Total-header {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table img{
|
|
||||||
height: 100%;
|
|
||||||
max-width: 30px;
|
|
||||||
max-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tbody.locations {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.location-name {
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@@ -75,6 +75,27 @@
|
|||||||
#inventory-table img.acquired.green{ /*32CD32*/
|
#inventory-table img.acquired.green{ /*32CD32*/
|
||||||
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
||||||
}
|
}
|
||||||
|
#inventory-table img.acquired.hotpink{ /*FF69B4*/
|
||||||
|
filter: sepia(100%) hue-rotate(300deg) saturate(10);
|
||||||
|
}
|
||||||
|
#inventory-table img.acquired.lightsalmon{ /*FFA07A*/
|
||||||
|
filter: sepia(100%) hue-rotate(347deg) saturate(10);
|
||||||
|
}
|
||||||
|
#inventory-table img.acquired.crimson{ /*DB143B*/
|
||||||
|
filter: sepia(100%) hue-rotate(318deg) saturate(10) brightness(0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table span{
|
||||||
|
color: #B4B4A0;
|
||||||
|
font-size: 40px;
|
||||||
|
max-width: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table span.acquired{
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
#inventory-table div.image-stack{
|
#inventory-table div.image-stack{
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Page Not Found (404)</title>
|
<title>Page Not Found (404)</title>
|
||||||
@@ -13,5 +14,4 @@
|
|||||||
The page you're looking for doesn't exist.<br />
|
The page you're looking for doesn't exist.<br />
|
||||||
<a href="/">Click here to return to safety.</a>
|
<a href="/">Click here to return to safety.</a>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Upload Multidata</title>
|
<title>Upload Multidata</title>
|
||||||
@@ -27,6 +28,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -178,8 +178,15 @@
|
|||||||
})
|
})
|
||||||
.then(text => new DOMParser().parseFromString(text, 'text/html'))
|
.then(text => new DOMParser().parseFromString(text, 'text/html'))
|
||||||
.then(newDocument => {
|
.then(newDocument => {
|
||||||
let el = newDocument.getElementById("host-room-info");
|
["host-room-info", "slots-table"].forEach(function(id) {
|
||||||
document.getElementById("host-room-info").innerHTML = el.innerHTML;
|
const newEl = newDocument.getElementById(id);
|
||||||
|
const oldEl = document.getElementById(id);
|
||||||
|
if (oldEl && newEl) {
|
||||||
|
oldEl.innerHTML = newEl.innerHTML;
|
||||||
|
} else if (newEl) {
|
||||||
|
console.warn(`Did not find element to replace for ${id}`)
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% block footer %}
|
{% block footer %}
|
||||||
<footer id="island-footer">
|
<footer id="island-footer">
|
||||||
<div id="copyright-notice">Copyright 2024 Archipelago</div>
|
<div id="copyright-notice">Copyright 2025 Archipelago</div>
|
||||||
<div id="links">
|
<div id="links">
|
||||||
<a href="/sitemap">Site Map</a>
|
<a href="/sitemap">Site Map</a>
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
@@ -57,5 +58,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
{% macro list_patches_room(room) %}
|
{% macro list_patches_room(room) %}
|
||||||
{% if room.seed.slots %}
|
{% if room.seed.slots %}
|
||||||
<table>
|
<table id="slots-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Id</th>
|
<th>Id</th>
|
||||||
@@ -26,30 +26,15 @@
|
|||||||
<td>{{ patch.game }}</td>
|
<td>{{ patch.game }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if patch.data %}
|
{% if patch.data %}
|
||||||
{% if patch.game == "Minecraft" %}
|
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download APMC File...</a>
|
|
||||||
{% elif patch.game == "Factorio" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download Factorio Mod...</a>
|
|
||||||
{% elif patch.game == "Kingdom Hearts 2" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download Kingdom Hearts 2 Mod...</a>
|
|
||||||
{% elif patch.game == "Ocarina of Time" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download APZ5 File...</a>
|
|
||||||
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APV6 File...</a>
|
Download APV6 File...</a>
|
||||||
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APSM64EX File...</a>
|
Download APSM64EX File...</a>
|
||||||
{% elif patch.game | supports_apdeltapatch %}
|
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
|
||||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||||
Download Patch File...</a>
|
Download Patch File...</a>
|
||||||
{% elif patch.game == "Final Fantasy Mystic Quest" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download APMQ File...</a>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
No file to download for this game.
|
No file to download for this game.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,26 +5,29 @@
|
|||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div>
|
||||||
|
{% for message in messages | unique %}
|
||||||
|
<div class="user-message">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% with messages = get_flashed_messages() %}
|
{% block body %}
|
||||||
{% if messages %}
|
{% endblock %}
|
||||||
<div>
|
</main>
|
||||||
{% for message in messages | unique %}
|
|
||||||
<div class="user-message">{{ message }}</div>
|
{% if show_footer %}
|
||||||
{% endfor %}
|
{% include "islandFooter.html" %}
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -111,10 +111,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemDict(option_name, option) %}
|
{% macro OptionCounter(option_name, option) %}
|
||||||
|
{% set relevant_keys = option.valid_keys %}
|
||||||
|
{% if not relevant_keys %}
|
||||||
|
{% if option.verify_item_name %}
|
||||||
|
{% set relevant_keys = world.item_names %}
|
||||||
|
{% elif option.verify_location_name %}
|
||||||
|
{% set relevant_keys = world.location_names %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||||
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
||||||
@@ -213,7 +222,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro RandomizeButton(option_name, option) %}
|
{% macro RandomizeButton(option_name, option) %}
|
||||||
<div class="randomize-button" data-tooltip="Toggle randomization for this option!">
|
<div class="randomize-button" data-tooltip="Pick a random value for this option.">
|
||||||
<label for="random-{{ option_name }}">
|
<label for="random-{{ option_name }}">
|
||||||
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
|
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
|
||||||
🎲
|
🎲
|
||||||
|
|||||||
@@ -93,8 +93,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
@@ -133,8 +135,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Generation failed, please retry.</title>
|
<title>Generation failed, please retry.</title>
|
||||||
@@ -15,5 +16,4 @@
|
|||||||
{{ seed_error }}
|
{{ seed_error }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
30
WebHostLib/templates/session.html
Normal file
30
WebHostLib/templates/session.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% include 'header/stoneHeader.html' %}
|
||||||
|
<title>Session</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="markdown">
|
||||||
|
{% if old_id is defined %}
|
||||||
|
<p>Your old code was:</p>
|
||||||
|
<code>{{ old_id }}</code>
|
||||||
|
<br>
|
||||||
|
{% endif %}
|
||||||
|
<p>The following code is your unique identifier, it binds your uploaded content, such as rooms and seeds to you.
|
||||||
|
Treat it like a combined login name and password.
|
||||||
|
You should save this securely if you ever need to restore access.
|
||||||
|
You can also paste it into another device to access your content from multiple devices / browsers.
|
||||||
|
Some browsers, such as Brave, will delete your identifier cookie on a timer.</p>
|
||||||
|
<code>{{ session["_id"] }}</code>
|
||||||
|
<br>
|
||||||
|
<p>
|
||||||
|
The following link can be used to set the identifier. Do not share the code or link with others. <br>
|
||||||
|
<a href="{{ url_for('set_session', _id=session['_id']) }}">
|
||||||
|
{{ url_for('set_session', _id=session['_id'], _external=True) }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<li><a href="/user-content">User Content</a></li>
|
<li><a href="/user-content">User Content</a></li>
|
||||||
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
|
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
|
||||||
<li><a href="/glossary/en">Glossary</a></li>
|
<li><a href="/glossary/en">Glossary</a></li>
|
||||||
|
<li><a href="{{url_for("show_session")}}">Session / Login</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Tutorials</h2>
|
<h2>Tutorials</h2>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Start Playing</title>
|
<title>Start Playing</title>
|
||||||
@@ -26,6 +27,4 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
{% include 'header/grassHeader.html' %}
|
{% include 'header/grassHeader.html' %}
|
||||||
<title>Option Templates (YAML)</title>
|
<title>Option Templates (YAML)</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
|
||||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
|
|
||||||
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
|
|
||||||
<div style="margin-bottom: 0.5rem">
|
|
||||||
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
|
||||||
<table id="inventory-table">
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
|
|
||||||
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
|
|
||||||
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
|
|
||||||
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
|
|
||||||
title="Progressive Resource Crafting" /></td>
|
|
||||||
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
|
|
||||||
<td>
|
|
||||||
<div class="counted-item">
|
|
||||||
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
|
|
||||||
<div class="item-count">{{ pearls_count }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
|
|
||||||
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
|
|
||||||
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
|
|
||||||
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
|
|
||||||
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
|
|
||||||
<td>
|
|
||||||
<div class="counted-item">
|
|
||||||
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
|
|
||||||
<div class="item-count">{{ scrap_count }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
|
|
||||||
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
|
|
||||||
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
|
|
||||||
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
|
|
||||||
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
|
|
||||||
<td>
|
|
||||||
<div class="counted-item">
|
|
||||||
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
|
|
||||||
<div class="item-count">{{ shard_count }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
|
|
||||||
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
|
|
||||||
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
|
|
||||||
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
|
|
||||||
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table id="location-table">
|
|
||||||
{% for area in checks_done %}
|
|
||||||
<tr class="location-category" id="{{area}}-header">
|
|
||||||
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
|
|
||||||
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
|
|
||||||
</tr>
|
|
||||||
<tbody class="locations hide" id="{{area}}">
|
|
||||||
{% for location in location_info[area] %}
|
|
||||||
<tr>
|
|
||||||
<td class="location-name">{{ location }}</td>
|
|
||||||
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -99,6 +99,52 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if 'PrismBreak' in options or 'LockKeyAmadeus' in options or 'GateKeep' in options %}
|
||||||
|
<div class="table-row">
|
||||||
|
{% if 'PrismBreak' in options %}
|
||||||
|
<div class="C1">
|
||||||
|
<div class="image-stack">
|
||||||
|
<div class="stack-front">
|
||||||
|
<div class="stack-top-left">
|
||||||
|
<img src="{{ icons['Laser Access'] }}" class="hotpink {{ 'acquired' if 'Laser Access A' in acquired_items }}" title="Laser Access A" />
|
||||||
|
</div>
|
||||||
|
<div class="stack-top-right">
|
||||||
|
<img src="{{ icons['Laser Access'] }}" class="lightsalmon {{ 'acquired' if 'Laser Access I' in acquired_items }}" title="Laser Access I" />
|
||||||
|
</div>
|
||||||
|
<div class="stack-bottum-left">
|
||||||
|
<img src="{{ icons['Laser Access'] }}" class="crimson {{ 'acquired' if 'Laser Access M' in acquired_items }}" title="Laser Access M" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'LockKeyAmadeus' in options %}
|
||||||
|
<div class="C2">
|
||||||
|
<div class="image-stack">
|
||||||
|
<div class="stack-front">
|
||||||
|
<div class="stack-top-left">
|
||||||
|
<img src="{{ icons['Lab Glasses'] }}" class="{{ 'acquired' if 'Lab Access Genza' in acquired_items }}" title="Lab Access Genza" />
|
||||||
|
</div>
|
||||||
|
<div class="stack-top-right">
|
||||||
|
<img src="{{ icons['Eye Orb'] }}" class="{{ 'acquired' if 'Lab Access Dynamo' in acquired_items }}" title="Lab Access Dynamo" />
|
||||||
|
</div>
|
||||||
|
<div class="stack-bottum-left">
|
||||||
|
<img src="{{ icons['Lab Coat'] }}" class="{{ 'acquired' if 'Lab Access Research' in acquired_items }}" title="Lab Access Research" />
|
||||||
|
</div>
|
||||||
|
<div class="stack-bottum-right">
|
||||||
|
<img src="{{ icons['Demon'] }}" class="{{ 'acquired' if 'Lab Access Experiment' in acquired_items }}" title="Lab Access Experiment" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'GateKeep' in options %}
|
||||||
|
<div class="C3">
|
||||||
|
<span class="{{ 'acquired' if 'Drawbridge Key' in acquired_items }}" title="Drawbridge Key">❖</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table id="location-table">
|
<table id="location-table">
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
<div id="user-content-wrapper" class="markdown">
|
<div id="user-content-wrapper" class="markdown">
|
||||||
<div id="user-content" class="grass-island">
|
<div id="user-content" class="grass-island">
|
||||||
<h1>User Content</h1>
|
<h1>User Content</h1>
|
||||||
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
|
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.<br/>
|
||||||
|
Sessions can be saved or synced across devices using the <a href="{{url_for('show_session')}}">Sessions Page.</a>
|
||||||
|
|
||||||
<h2>Your Rooms</h2>
|
<h2>Your Rooms</h2>
|
||||||
{% if rooms %}
|
{% if rooms %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>View Seed {{ seed.id|suuid }}</title>
|
<title>View Seed {{ seed.id|suuid }}</title>
|
||||||
@@ -50,5 +51,4 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Generation in Progress</title>
|
<title>Generation in Progress</title>
|
||||||
<meta http-equiv="refresh" content="1">
|
<noscript>
|
||||||
|
<meta http-equiv="refresh" content="1">
|
||||||
|
</noscript>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -15,5 +18,34 @@
|
|||||||
Waiting for game to generate, this page auto-refreshes to check.
|
Waiting for game to generate, this page auto-refreshes to check.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
<script>
|
||||||
|
const waitSeedDiv = document.getElementById("wait-seed");
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("{{ url_for('api.wait_seed_api', seed=seed_id) }}");
|
||||||
|
if (response.status !== 202) {
|
||||||
|
// Seed is ready; reload page to load seed page.
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
waitSeedDiv.innerHTML = `
|
||||||
|
<h1>Generation in Progress</h1>
|
||||||
|
<p>${data.text}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
setTimeout(checkStatus, 1000); // Continue polling.
|
||||||
|
} catch (error) {
|
||||||
|
waitSeedDiv.innerHTML = `
|
||||||
|
<h1>Progress Unknown</h1>
|
||||||
|
<p>${error.message}<br />(Last checked: ${new Date().toLocaleTimeString()})</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
setTimeout(checkStatus, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(checkStatus, 1000);
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
<table class="range-rows" data-option="{{ option_name }}">
|
<table class="range-rows" data-option="{{ option_name }}">
|
||||||
<tbody>
|
<tbody>
|
||||||
{{ RangeRow(option_name, option, option.range_start, option.range_start, True) }}
|
{{ RangeRow(option_name, option, option.range_start, option.range_start, True) }}
|
||||||
{% if option.range_start < option.default < option.range_end %}
|
{% if option.default is number and option.range_start < option.default < option.range_end %}
|
||||||
{{ RangeRow(option_name, option, option.default, option.default, True) }}
|
{{ RangeRow(option_name, option, option.default, option.default, True) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ RangeRow(option_name, option, option.range_end, option.range_end, True) }}
|
{{ RangeRow(option_name, option, option.range_end, option.range_end, True) }}
|
||||||
@@ -113,9 +113,18 @@
|
|||||||
{{ TextChoice(option_name, option) }}
|
{{ TextChoice(option_name, option) }}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemDict(option_name, option, world) %}
|
{% macro OptionCounter(option_name, option, world) %}
|
||||||
|
{% set relevant_keys = option.valid_keys %}
|
||||||
|
{% if not relevant_keys %}
|
||||||
|
{% if option.verify_item_name %}
|
||||||
|
{% set relevant_keys = world.item_names %}
|
||||||
|
{% elif option.verify_location_name %}
|
||||||
|
{% set relevant_keys = world.location_names %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="dict-container">
|
<div class="dict-container">
|
||||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
|
||||||
<div class="dict-entry">
|
<div class="dict-entry">
|
||||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -83,8 +83,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option, world) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option, world) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="unsupported-option">
|
<div class="unsupported-option">
|
||||||
This option is not supported. Please edit your .yaml file manually.
|
This option cannot be modified here. Please edit your .yaml file manually.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -706,127 +706,6 @@ if "A Link to the Past" in network_data_package["games"]:
|
|||||||
_multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker
|
_multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker
|
||||||
_player_trackers["A Link to the Past"] = render_ALinkToThePast_tracker
|
_player_trackers["A Link to the Past"] = render_ALinkToThePast_tracker
|
||||||
|
|
||||||
if "Minecraft" in network_data_package["games"]:
|
|
||||||
def render_Minecraft_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
|
|
||||||
icons = {
|
|
||||||
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
|
|
||||||
"Stone Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c4/Stone_Pickaxe_JE2_BE2.png",
|
|
||||||
"Iron Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d1/Iron_Pickaxe_JE3_BE2.png",
|
|
||||||
"Diamond Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e7/Diamond_Pickaxe_JE3_BE3.png",
|
|
||||||
"Wooden Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d5/Wooden_Sword_JE2_BE2.png",
|
|
||||||
"Stone Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b1/Stone_Sword_JE2_BE2.png",
|
|
||||||
"Iron Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/8/8e/Iron_Sword_JE2_BE2.png",
|
|
||||||
"Diamond Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/4/44/Diamond_Sword_JE3_BE3.png",
|
|
||||||
"Leather Tunic": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b7/Leather_Tunic_JE4_BE2.png",
|
|
||||||
"Iron Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Iron_Chestplate_JE2_BE2.png",
|
|
||||||
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
|
|
||||||
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
|
|
||||||
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
|
|
||||||
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
|
|
||||||
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
|
|
||||||
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
|
|
||||||
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
|
|
||||||
"Shield": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c6/Shield_JE2_BE1.png",
|
|
||||||
"Red Bed": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/6/6a/Red_Bed_%28N%29.png",
|
|
||||||
"Netherite Scrap": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/33/Netherite_Scrap_JE2_BE1.png",
|
|
||||||
"Flint and Steel": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/94/Flint_and_Steel_JE4_BE2.png",
|
|
||||||
"Enchanting Table": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Enchanting_Table.gif",
|
|
||||||
"Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png",
|
|
||||||
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
|
|
||||||
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
|
|
||||||
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
|
|
||||||
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
|
|
||||||
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
|
|
||||||
"Saddle": "https://i.imgur.com/2QtDyR0.png",
|
|
||||||
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
|
|
||||||
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
|
|
||||||
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
minecraft_location_ids = {
|
|
||||||
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
|
|
||||||
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
|
|
||||||
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
|
|
||||||
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014],
|
|
||||||
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
|
|
||||||
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020,
|
|
||||||
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105,
|
|
||||||
42099, 42103, 42110, 42100],
|
|
||||||
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111,
|
|
||||||
42112,
|
|
||||||
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
|
|
||||||
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
|
|
||||||
}
|
|
||||||
|
|
||||||
display_data = {}
|
|
||||||
|
|
||||||
# Determine display for progressive items
|
|
||||||
progressive_items = {
|
|
||||||
"Progressive Tools": 45013,
|
|
||||||
"Progressive Weapons": 45012,
|
|
||||||
"Progressive Armor": 45014,
|
|
||||||
"Progressive Resource Crafting": 45001
|
|
||||||
}
|
|
||||||
progressive_names = {
|
|
||||||
"Progressive Tools": ["Wooden Pickaxe", "Stone Pickaxe", "Iron Pickaxe", "Diamond Pickaxe"],
|
|
||||||
"Progressive Weapons": ["Wooden Sword", "Stone Sword", "Iron Sword", "Diamond Sword"],
|
|
||||||
"Progressive Armor": ["Leather Tunic", "Iron Chestplate", "Diamond Chestplate"],
|
|
||||||
"Progressive Resource Crafting": ["Iron Ingot", "Iron Ingot", "Block of Iron"]
|
|
||||||
}
|
|
||||||
|
|
||||||
inventory = tracker_data.get_player_inventory_counts(team, player)
|
|
||||||
for item_name, item_id in progressive_items.items():
|
|
||||||
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
|
|
||||||
display_name = progressive_names[item_name][level]
|
|
||||||
base_name = item_name.split(maxsplit=1)[1].lower().replace(" ", "_")
|
|
||||||
display_data[base_name + "_url"] = icons[display_name]
|
|
||||||
|
|
||||||
# Multi-items
|
|
||||||
multi_items = {
|
|
||||||
"3 Ender Pearls": 45029,
|
|
||||||
"8 Netherite Scrap": 45015,
|
|
||||||
"Dragon Egg Shard": 45043
|
|
||||||
}
|
|
||||||
for item_name, item_id in multi_items.items():
|
|
||||||
base_name = item_name.split()[-1].lower()
|
|
||||||
count = inventory[item_id]
|
|
||||||
if count >= 0:
|
|
||||||
display_data[base_name + "_count"] = count
|
|
||||||
|
|
||||||
# Victory condition
|
|
||||||
game_state = tracker_data.get_player_client_status(team, player)
|
|
||||||
display_data["game_finished"] = game_state == 30
|
|
||||||
|
|
||||||
# Turn location IDs into advancement tab counts
|
|
||||||
checked_locations = tracker_data.get_player_checked_locations(team, player)
|
|
||||||
lookup_name = lambda id: tracker_data.location_id_to_name["Minecraft"][id]
|
|
||||||
location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations}
|
|
||||||
for tab_name, tab_locations in minecraft_location_ids.items()}
|
|
||||||
checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations])
|
|
||||||
for tab_name, tab_locations in minecraft_location_ids.items()}
|
|
||||||
checks_done["Total"] = len(checked_locations)
|
|
||||||
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in minecraft_location_ids.items()}
|
|
||||||
checks_in_area["Total"] = sum(checks_in_area.values())
|
|
||||||
|
|
||||||
lookup_any_item_id_to_name = tracker_data.item_id_to_name["Minecraft"]
|
|
||||||
return render_template(
|
|
||||||
"tracker__Minecraft.html",
|
|
||||||
inventory=inventory,
|
|
||||||
icons=icons,
|
|
||||||
acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0},
|
|
||||||
player=player,
|
|
||||||
team=team,
|
|
||||||
room=tracker_data.room,
|
|
||||||
player_name=tracker_data.get_player_name(team, player),
|
|
||||||
saving_second=tracker_data.get_room_saving_second(),
|
|
||||||
checks_done=checks_done,
|
|
||||||
checks_in_area=checks_in_area,
|
|
||||||
location_info=location_info,
|
|
||||||
**display_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
_player_trackers["Minecraft"] = render_Minecraft_tracker
|
|
||||||
|
|
||||||
if "Ocarina of Time" in network_data_package["games"]:
|
if "Ocarina of Time" in network_data_package["games"]:
|
||||||
def render_OcarinaOfTime_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
|
def render_OcarinaOfTime_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
|
||||||
icons = {
|
icons = {
|
||||||
@@ -1071,6 +950,11 @@ if "Timespinner" in network_data_package["games"]:
|
|||||||
"Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png",
|
"Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png",
|
||||||
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png",
|
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png",
|
||||||
"Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png",
|
"Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png",
|
||||||
|
"Laser Access": "https://timespinnerwiki.com/mediawiki/images/9/99/Historical_Documents.png",
|
||||||
|
"Lab Glasses": "https://timespinnerwiki.com/mediawiki/images/4/4a/Lab_Glasses.png",
|
||||||
|
"Eye Orb": "https://timespinnerwiki.com/mediawiki/images/a/a4/Eye_Orb.png",
|
||||||
|
"Lab Coat": "https://timespinnerwiki.com/mediawiki/images/5/51/Lab_Coat.png",
|
||||||
|
"Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
timespinner_location_ids = {
|
timespinner_location_ids = {
|
||||||
@@ -1118,6 +1002,9 @@ if "Timespinner" in network_data_package["games"]:
|
|||||||
timespinner_location_ids["Ancient Pyramid"] += [
|
timespinner_location_ids["Ancient Pyramid"] += [
|
||||||
1337237, 1337238, 1337239,
|
1337237, 1337238, 1337239,
|
||||||
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
|
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
|
||||||
|
if (slot_data["PyramidStart"]):
|
||||||
|
timespinner_location_ids["Ancient Pyramid"] += [
|
||||||
|
1337233, 1337234, 1337235]
|
||||||
|
|
||||||
display_data = {}
|
display_data = {}
|
||||||
|
|
||||||
|
|||||||
@@ -119,9 +119,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
|||||||
# AP Container
|
# AP Container
|
||||||
elif handler:
|
elif handler:
|
||||||
data = zfile.open(file, "r").read()
|
data = zfile.open(file, "r").read()
|
||||||
patch = handler(BytesIO(data))
|
with zipfile.ZipFile(BytesIO(data)) as container:
|
||||||
patch.read()
|
player = json.loads(container.open("archipelago.json").read())["player"]
|
||||||
files[patch.player] = data
|
files[player] = data
|
||||||
|
|
||||||
# Spoiler
|
# Spoiler
|
||||||
elif file.filename.endswith(".txt"):
|
elif file.filename.endswith(".txt"):
|
||||||
@@ -135,11 +135,6 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
|||||||
flash("Could not load multidata. File may be corrupted or incompatible.")
|
flash("Could not load multidata. File may be corrupted or incompatible.")
|
||||||
multidata = None
|
multidata = None
|
||||||
|
|
||||||
# Minecraft
|
|
||||||
elif file.filename.endswith(".apmc"):
|
|
||||||
data = zfile.open(file, "r").read()
|
|
||||||
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
|
|
||||||
files[metadata["player_id"]] = data
|
|
||||||
|
|
||||||
# Factorio
|
# Factorio
|
||||||
elif file.filename.endswith(".zip"):
|
elif file.filename.endswith(".zip"):
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ if __name__ == '__main__':
|
|||||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||||
help='Path to a Archipelago Binary Patch file')
|
help='Path to a Archipelago Binary Patch file')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ cdef struct IndexEntry:
|
|||||||
size_t count
|
size_t count
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
State = Dict[Tuple[int, int], Set[int]]
|
||||||
|
else:
|
||||||
|
State = Union[Tuple[int, int], Set[int], defaultdict]
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
@cython.auto_pickle(False)
|
@cython.auto_pickle(False)
|
||||||
cdef class LocationStore:
|
cdef class LocationStore:
|
||||||
"""Compact store for locations and their items in a MultiServer"""
|
"""Compact store for locations and their items in a MultiServer"""
|
||||||
@@ -137,10 +145,16 @@ cdef class LocationStore:
|
|||||||
warnings.warn("Game has no locations")
|
warnings.warn("Game has no locations")
|
||||||
|
|
||||||
# allocate the arrays and invalidate index (0xff...)
|
# allocate the arrays and invalidate index (0xff...)
|
||||||
self.entries = <LocationEntry*>self._mem.alloc(count, sizeof(LocationEntry))
|
if count:
|
||||||
|
# leaving entries as NULL if there are none, makes potential memory errors more visible
|
||||||
|
self.entries = <LocationEntry*>self._mem.alloc(count, sizeof(LocationEntry))
|
||||||
self.sender_index = <IndexEntry*>self._mem.alloc(max_sender + 1, sizeof(IndexEntry))
|
self.sender_index = <IndexEntry*>self._mem.alloc(max_sender + 1, sizeof(IndexEntry))
|
||||||
self._raw_proxies = <PyObject**>self._mem.alloc(max_sender + 1, sizeof(PyObject*))
|
self._raw_proxies = <PyObject**>self._mem.alloc(max_sender + 1, sizeof(PyObject*))
|
||||||
|
|
||||||
|
assert (not self.entries) == (not count)
|
||||||
|
assert self.sender_index
|
||||||
|
assert self._raw_proxies
|
||||||
|
|
||||||
# build entries and index
|
# build entries and index
|
||||||
cdef size_t i = 0
|
cdef size_t i = 0
|
||||||
for sender, locations in sorted(locations_dict.items()):
|
for sender, locations in sorted(locations_dict.items()):
|
||||||
@@ -190,8 +204,6 @@ cdef class LocationStore:
|
|||||||
raise KeyError(key)
|
raise KeyError(key)
|
||||||
return <object>self._raw_proxies[key]
|
return <object>self._raw_proxies[key]
|
||||||
|
|
||||||
T = TypeVar('T')
|
|
||||||
|
|
||||||
def get(self, key: int, default: T) -> Union[PlayerLocationProxy, T]:
|
def get(self, key: int, default: T) -> Union[PlayerLocationProxy, T]:
|
||||||
# calling into self.__getitem__ here is slow, but this is not used in MultiServer
|
# calling into self.__getitem__ here is slow, but this is not used in MultiServer
|
||||||
try:
|
try:
|
||||||
@@ -246,12 +258,11 @@ cdef class LocationStore:
|
|||||||
all_locations[sender].add(entry.location)
|
all_locations[sender].add(entry.location)
|
||||||
return all_locations
|
return all_locations
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
State = Dict[Tuple[int, int], Set[int]]
|
|
||||||
else:
|
|
||||||
State = Union[Tuple[int, int], Set[int], defaultdict]
|
|
||||||
|
|
||||||
def get_checked(self, state: State, team: int, slot: int) -> List[int]:
|
def get_checked(self, state: State, team: int, slot: int) -> List[int]:
|
||||||
|
cdef ap_player_t sender = slot
|
||||||
|
if sender < 0 or sender >= self.sender_index_size:
|
||||||
|
raise KeyError(slot)
|
||||||
|
|
||||||
# This used to validate checks actually exist. A remnant from the past.
|
# This used to validate checks actually exist. A remnant from the past.
|
||||||
# If the order of locations becomes relevant at some point, we could not do sorted(set), so leaving it.
|
# If the order of locations becomes relevant at some point, we could not do sorted(set), so leaving it.
|
||||||
cdef set checked = state[team, slot]
|
cdef set checked = state[team, slot]
|
||||||
@@ -263,7 +274,6 @@ cdef class LocationStore:
|
|||||||
|
|
||||||
# Unless the set is close to empty, it's cheaper to use the python set directly, so we do that.
|
# Unless the set is close to empty, it's cheaper to use the python set directly, so we do that.
|
||||||
cdef LocationEntry* entry
|
cdef LocationEntry* entry
|
||||||
cdef ap_player_t sender = slot
|
|
||||||
cdef size_t start = self.sender_index[sender].start
|
cdef size_t start = self.sender_index[sender].start
|
||||||
cdef size_t count = self.sender_index[sender].count
|
cdef size_t count = self.sender_index[sender].count
|
||||||
return [entry.location for
|
return [entry.location for
|
||||||
@@ -273,9 +283,11 @@ cdef class LocationStore:
|
|||||||
def get_missing(self, state: State, team: int, slot: int) -> List[int]:
|
def get_missing(self, state: State, team: int, slot: int) -> List[int]:
|
||||||
cdef LocationEntry* entry
|
cdef LocationEntry* entry
|
||||||
cdef ap_player_t sender = slot
|
cdef ap_player_t sender = slot
|
||||||
|
if sender < 0 or sender >= self.sender_index_size:
|
||||||
|
raise KeyError(slot)
|
||||||
|
cdef set checked = state[team, slot]
|
||||||
cdef size_t start = self.sender_index[sender].start
|
cdef size_t start = self.sender_index[sender].start
|
||||||
cdef size_t count = self.sender_index[sender].count
|
cdef size_t count = self.sender_index[sender].count
|
||||||
cdef set checked = state[team, slot]
|
|
||||||
if not len(checked):
|
if not len(checked):
|
||||||
# Skip `in` if none have been checked.
|
# Skip `in` if none have been checked.
|
||||||
# This optimizes the case where everyone connects to a fresh game at the same time.
|
# This optimizes the case where everyone connects to a fresh game at the same time.
|
||||||
@@ -290,9 +302,11 @@ cdef class LocationStore:
|
|||||||
def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]:
|
def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]:
|
||||||
cdef LocationEntry* entry
|
cdef LocationEntry* entry
|
||||||
cdef ap_player_t sender = slot
|
cdef ap_player_t sender = slot
|
||||||
|
if sender < 0 or sender >= self.sender_index_size:
|
||||||
|
raise KeyError(slot)
|
||||||
|
cdef set checked = state[team, slot]
|
||||||
cdef size_t start = self.sender_index[sender].start
|
cdef size_t start = self.sender_index[sender].start
|
||||||
cdef size_t count = self.sender_index[sender].count
|
cdef size_t count = self.sender_index[sender].count
|
||||||
cdef set checked = state[team, slot]
|
|
||||||
return sorted([(entry.receiver, entry.item) for
|
return sorted([(entry.receiver, entry.item) for
|
||||||
entry in self.entries[start:start+count] if
|
entry in self.entries[start:start+count] if
|
||||||
entry.location not in checked])
|
entry.location not in checked])
|
||||||
@@ -328,7 +342,8 @@ cdef class PlayerLocationProxy:
|
|||||||
cdef LocationEntry* entry = NULL
|
cdef LocationEntry* entry = NULL
|
||||||
# binary search
|
# binary search
|
||||||
cdef size_t l = self._store.sender_index[self._player].start
|
cdef size_t l = self._store.sender_index[self._player].start
|
||||||
cdef size_t r = l + self._store.sender_index[self._player].count
|
cdef size_t e = l + self._store.sender_index[self._player].count
|
||||||
|
cdef size_t r = e
|
||||||
cdef size_t m
|
cdef size_t m
|
||||||
while l < r:
|
while l < r:
|
||||||
m = (l + r) // 2
|
m = (l + r) // 2
|
||||||
@@ -337,7 +352,7 @@ cdef class PlayerLocationProxy:
|
|||||||
l = m + 1
|
l = m + 1
|
||||||
else:
|
else:
|
||||||
r = m
|
r = m
|
||||||
if entry: # count != 0
|
if l < e:
|
||||||
entry = self._store.entries + l
|
entry = self._store.entries + l
|
||||||
if entry.location == loc:
|
if entry.location == loc:
|
||||||
return entry
|
return entry
|
||||||
@@ -349,8 +364,6 @@ cdef class PlayerLocationProxy:
|
|||||||
return entry.item, entry.receiver, entry.flags
|
return entry.item, entry.receiver, entry.flags
|
||||||
raise KeyError(f"No location {key} for player {self._player}")
|
raise KeyError(f"No location {key} for player {self._player}")
|
||||||
|
|
||||||
T = TypeVar('T')
|
|
||||||
|
|
||||||
def get(self, key: int, default: T) -> Union[Tuple[int, int, int], T]:
|
def get(self, key: int, default: T) -> Union[Tuple[int, int, int], T]:
|
||||||
cdef LocationEntry* entry = self._get(key)
|
cdef LocationEntry* entry = self._get(key)
|
||||||
if entry:
|
if entry:
|
||||||
|
|||||||
@@ -3,8 +3,16 @@ import os
|
|||||||
|
|
||||||
def make_ext(modname, pyxfilename):
|
def make_ext(modname, pyxfilename):
|
||||||
from distutils.extension import Extension
|
from distutils.extension import Extension
|
||||||
return Extension(name=modname,
|
return Extension(
|
||||||
sources=[pyxfilename],
|
name=modname,
|
||||||
depends=["intset.h"],
|
sources=[pyxfilename],
|
||||||
include_dirs=[os.getcwd()],
|
depends=["intset.h"],
|
||||||
language="c")
|
include_dirs=[os.getcwd()],
|
||||||
|
language="c",
|
||||||
|
# to enable ASAN and debug build:
|
||||||
|
# extra_compile_args=["-fsanitize=address", "-UNDEBUG", "-Og", "-g"],
|
||||||
|
# extra_objects=["-fsanitize=address"],
|
||||||
|
# NOTE: we can not put -lasan at the front of link args, so needs to be run with
|
||||||
|
# LD_PRELOAD=/usr/lib/libasan.so ASAN_OPTIONS=detect_leaks=0 path/to/exe
|
||||||
|
# NOTE: this can't find everything unless libpython and cymem are also built with ASAN
|
||||||
|
)
|
||||||
|
|||||||
119
data/client.kv
119
data/client.kv
@@ -14,23 +14,71 @@
|
|||||||
salmon: "FA8072" # typically trap item
|
salmon: "FA8072" # typically trap item
|
||||||
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
||||||
orange: "FF7700" # Used for command echo
|
orange: "FF7700" # Used for command echo
|
||||||
<Label>:
|
# KivyMD theming parameters
|
||||||
color: "FFFFFF"
|
theme_style: "Dark" # Light/Dark
|
||||||
<TabbedPanel>:
|
primary_palette: "Lightsteelblue" # Many options
|
||||||
tab_width: root.width / app.tab_count
|
dynamic_scheme_name: "VIBRANT"
|
||||||
|
dynamic_scheme_contrast: 0.0
|
||||||
|
<MDLabel>:
|
||||||
|
color: self.theme_cls.primaryColor
|
||||||
|
<BaseButton>:
|
||||||
|
ripple_color: app.theme_cls.primaryColor
|
||||||
|
ripple_duration_in_fast: 0.2
|
||||||
|
<MDNavigationItemBase>:
|
||||||
|
on_release: app.screens.switch_screens(self)
|
||||||
|
|
||||||
|
MDNavigationItemLabel:
|
||||||
|
text: root.text
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color_active: self.theme_cls.primaryColor
|
||||||
|
text_color_normal: 1, 1, 1, 1
|
||||||
|
# indicator is on icon only for some reason
|
||||||
|
canvas.before:
|
||||||
|
Color:
|
||||||
|
rgba: self.theme_cls.secondaryContainerColor if root.active else self.theme_cls.transparentColor
|
||||||
|
Rectangle:
|
||||||
|
size: root.size
|
||||||
<TooltipLabel>:
|
<TooltipLabel>:
|
||||||
text_size: self.width, None
|
adaptive_height: True
|
||||||
size_hint_y: None
|
theme_font_size: "Custom"
|
||||||
height: self.texture_size[1]
|
font_size: "20dp"
|
||||||
font_size: dp(20)
|
|
||||||
markup: True
|
markup: True
|
||||||
|
halign: "left"
|
||||||
<SelectableLabel>:
|
<SelectableLabel>:
|
||||||
|
size_hint: 1, None
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: 1, 1, 1, 1
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1)
|
rgba: (self.theme_cls.primaryColor[0], self.theme_cls.primaryColor[1], self.theme_cls.primaryColor[2], .3) if self.selected else self.theme_cls.surfaceContainerLowestColor
|
||||||
Rectangle:
|
Rectangle:
|
||||||
size: self.size
|
size: self.size
|
||||||
pos: self.pos
|
pos: self.pos
|
||||||
|
<MarkupDropdownItem>
|
||||||
|
orientation: "vertical"
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: root.text
|
||||||
|
valign: "center"
|
||||||
|
padding_x: "12dp"
|
||||||
|
shorten: True
|
||||||
|
shorten_from: "right"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
markup: True
|
||||||
|
text_color:
|
||||||
|
app.theme_cls.onSurfaceVariantColor \
|
||||||
|
if not root.text_color else \
|
||||||
|
root.text_color
|
||||||
|
|
||||||
|
MDDivider:
|
||||||
|
md_bg_color:
|
||||||
|
( \
|
||||||
|
app.theme_cls.outlineVariantColor \
|
||||||
|
if not root.divider_color \
|
||||||
|
else root.divider_color \
|
||||||
|
) \
|
||||||
|
if root.divider else \
|
||||||
|
(0, 0, 0, 0)
|
||||||
<UILog>:
|
<UILog>:
|
||||||
messages: 1000 # amount of messages stored in client logs.
|
messages: 1000 # amount of messages stored in client logs.
|
||||||
cols: 1
|
cols: 1
|
||||||
@@ -49,7 +97,7 @@
|
|||||||
<HintLabel>:
|
<HintLabel>:
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) if self.striped else (0.18, 0.18, 0.18, 1)
|
rgba: (.0, 0.9, .1, .3) if self.selected else self.theme_cls.surfaceContainerHighColor if self.striped else self.theme_cls.surfaceContainerLowColor
|
||||||
Rectangle:
|
Rectangle:
|
||||||
size: self.size
|
size: self.size
|
||||||
pos: self.pos
|
pos: self.pos
|
||||||
@@ -59,7 +107,7 @@
|
|||||||
finding_text: "Finding Player"
|
finding_text: "Finding Player"
|
||||||
location_text: "Location"
|
location_text: "Location"
|
||||||
entrance_text: "Entrance"
|
entrance_text: "Entrance"
|
||||||
found_text: "Found?"
|
status_text: "Status"
|
||||||
TooltipLabel:
|
TooltipLabel:
|
||||||
id: receiving
|
id: receiving
|
||||||
sort_key: 'receiving'
|
sort_key: 'receiving'
|
||||||
@@ -96,9 +144,9 @@
|
|||||||
valign: 'center'
|
valign: 'center'
|
||||||
pos_hint: {"center_y": 0.5}
|
pos_hint: {"center_y": 0.5}
|
||||||
TooltipLabel:
|
TooltipLabel:
|
||||||
id: found
|
id: status
|
||||||
sort_key: 'found'
|
sort_key: 'status'
|
||||||
text: root.found_text
|
text: root.status_text
|
||||||
halign: 'center'
|
halign: 'center'
|
||||||
valign: 'center'
|
valign: 'center'
|
||||||
pos_hint: {"center_y": 0.5}
|
pos_hint: {"center_y": 0.5}
|
||||||
@@ -126,9 +174,12 @@
|
|||||||
<ToolTip>:
|
<ToolTip>:
|
||||||
size: self.texture_size
|
size: self.texture_size
|
||||||
size_hint: None, None
|
size_hint: None, None
|
||||||
|
theme_font_size: "Custom"
|
||||||
font_size: dp(18)
|
font_size: dp(18)
|
||||||
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
||||||
halign: "left"
|
halign: "left"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: (1, 1, 1, 1)
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
rgba: 0.2, 0.2, 0.2, 1
|
rgba: 0.2, 0.2, 0.2, 1
|
||||||
@@ -147,3 +198,43 @@
|
|||||||
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
|
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
|
||||||
<ServerToolTip>:
|
<ServerToolTip>:
|
||||||
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
||||||
|
<AutocompleteHintInput>:
|
||||||
|
size_hint_y: None
|
||||||
|
height: "30dp"
|
||||||
|
multiline: False
|
||||||
|
write_tab: False
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||||
|
<ConnectBarTextInput>:
|
||||||
|
height: "30dp"
|
||||||
|
multiline: False
|
||||||
|
write_tab: False
|
||||||
|
role: "medium"
|
||||||
|
size_hint_y: None
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||||
|
<CommandPromptTextInput>:
|
||||||
|
size_hint_y: None
|
||||||
|
height: "30dp"
|
||||||
|
multiline: False
|
||||||
|
write_tab: False
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||||
|
<MessageBoxLabel>:
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: 1, 1, 1, 1
|
||||||
|
<ScrollBox>:
|
||||||
|
layout: layout
|
||||||
|
bar_width: "12dp"
|
||||||
|
scroll_wheel_distance: 40
|
||||||
|
do_scroll_x: False
|
||||||
|
scroll_type: ['bars', 'content']
|
||||||
|
|
||||||
|
MDBoxLayout:
|
||||||
|
id: layout
|
||||||
|
orientation: "vertical"
|
||||||
|
spacing: 10
|
||||||
|
size_hint_y: None
|
||||||
|
height: self.minimum_height
|
||||||
|
<MessageBoxLabel>:
|
||||||
|
valign: "middle"
|
||||||
|
halign: "center"
|
||||||
|
text_size: self.width, None
|
||||||
|
height: self.texture_size[1]
|
||||||
|
|||||||
161
data/launcher.kv
Normal file
161
data/launcher.kv
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<LauncherCard>:
|
||||||
|
id: main
|
||||||
|
style: "filled"
|
||||||
|
padding: "4dp"
|
||||||
|
size_hint: 1, None
|
||||||
|
height: "75dp"
|
||||||
|
context_button: context
|
||||||
|
focus_behavior: False
|
||||||
|
|
||||||
|
MDRelativeLayout:
|
||||||
|
ApAsyncImage:
|
||||||
|
source: main.image
|
||||||
|
size: (48, 48)
|
||||||
|
size_hint: None, None
|
||||||
|
pos_hint: {"center_x": 0.1, "center_y": 0.5}
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: main.component.display_name
|
||||||
|
pos_hint:{"center_x": 0.5, "center_y": 0.75 if main.component.description else 0.65}
|
||||||
|
halign: "center"
|
||||||
|
font_style: "Title"
|
||||||
|
role: "medium"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: main.component.description
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.35}
|
||||||
|
halign: "center"
|
||||||
|
role: "small"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
|
||||||
|
MDIconButton:
|
||||||
|
component: main.component
|
||||||
|
icon: "star" if self.component.display_name in app.favorites else "star-outline"
|
||||||
|
style: "standard"
|
||||||
|
pos_hint:{"center_x": 0.85, "center_y": 0.8}
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
detect_visible: False
|
||||||
|
on_release: app.set_favorite(self)
|
||||||
|
|
||||||
|
MDIconButton:
|
||||||
|
id: context
|
||||||
|
icon: "menu"
|
||||||
|
style: "standard"
|
||||||
|
pos_hint:{"center_x": 0.95, "center_y": 0.8}
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
detect_visible: False
|
||||||
|
|
||||||
|
MDButton:
|
||||||
|
pos_hint:{"center_x": 0.9, "center_y": 0.25}
|
||||||
|
size_hint_y: None
|
||||||
|
height: "25dp"
|
||||||
|
component: main.component
|
||||||
|
on_release: app.component_action(self)
|
||||||
|
detect_visible: False
|
||||||
|
MDButtonText:
|
||||||
|
text: "Open"
|
||||||
|
|
||||||
|
|
||||||
|
#:import Type worlds.LauncherComponents.Type
|
||||||
|
MDFloatLayout:
|
||||||
|
id: top_screen
|
||||||
|
|
||||||
|
MDGridLayout:
|
||||||
|
id: grid
|
||||||
|
cols: 2
|
||||||
|
spacing: "5dp"
|
||||||
|
padding: "10dp"
|
||||||
|
|
||||||
|
MDGridLayout:
|
||||||
|
id: navigation
|
||||||
|
cols: 1
|
||||||
|
size_hint_x: 0.25
|
||||||
|
|
||||||
|
MDButton:
|
||||||
|
id: all
|
||||||
|
style: "text"
|
||||||
|
type: (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "asterisk"
|
||||||
|
MDButtonText:
|
||||||
|
text: "All"
|
||||||
|
MDButton:
|
||||||
|
id: client
|
||||||
|
style: "text"
|
||||||
|
type: (Type.CLIENT, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "controller"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Client"
|
||||||
|
MDButton:
|
||||||
|
id: Tool
|
||||||
|
style: "text"
|
||||||
|
type: (Type.TOOL, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "desktop-classic"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Tool"
|
||||||
|
MDButton:
|
||||||
|
id: adjuster
|
||||||
|
style: "text"
|
||||||
|
type: (Type.ADJUSTER, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "wrench"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Adjuster"
|
||||||
|
MDButton:
|
||||||
|
id: misc
|
||||||
|
style: "text"
|
||||||
|
type: (Type.MISC, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "dots-horizontal-circle-outline"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Misc"
|
||||||
|
|
||||||
|
MDButton:
|
||||||
|
id: favorites
|
||||||
|
style: "text"
|
||||||
|
type: ("favorites", )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "star"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Favorites"
|
||||||
|
|
||||||
|
MDNavigationDrawerDivider:
|
||||||
|
|
||||||
|
|
||||||
|
MDGridLayout:
|
||||||
|
id: main_layout
|
||||||
|
cols: 1
|
||||||
|
spacing: "10dp"
|
||||||
|
|
||||||
|
MDTextField:
|
||||||
|
id: search_box
|
||||||
|
mode: "outlined"
|
||||||
|
set_text: app.filter_clients_by_name
|
||||||
|
|
||||||
|
MDTextFieldLeadingIcon:
|
||||||
|
icon: "magnify"
|
||||||
|
|
||||||
|
MDTextFieldHintText:
|
||||||
|
text: "Search"
|
||||||
|
|
||||||
|
ScrollBox:
|
||||||
|
id: button_layout
|
||||||
@@ -121,6 +121,14 @@ Response:
|
|||||||
|
|
||||||
Expected Response Type: `HASH_RESPONSE`
|
Expected Response Type: `HASH_RESPONSE`
|
||||||
|
|
||||||
|
- `MEMORY_SIZE`
|
||||||
|
Returns the size in bytes of the specified memory domain.
|
||||||
|
|
||||||
|
Expected Response Type: `MEMORY_SIZE_RESPONSE`
|
||||||
|
|
||||||
|
Additional Fields:
|
||||||
|
- `domain` (`string`): The name of the memory domain to check
|
||||||
|
|
||||||
- `GUARD`
|
- `GUARD`
|
||||||
Checks a section of memory against `expected_data`. If the bytes starting
|
Checks a section of memory against `expected_data`. If the bytes starting
|
||||||
at `address` do not match `expected_data`, the response will have `value`
|
at `address` do not match `expected_data`, the response will have `value`
|
||||||
@@ -216,6 +224,12 @@ Response:
|
|||||||
Additional Fields:
|
Additional Fields:
|
||||||
- `value` (`string`): The returned hash
|
- `value` (`string`): The returned hash
|
||||||
|
|
||||||
|
- `MEMORY_SIZE_RESPONSE`
|
||||||
|
Contains the size in bytes of the specified memory domain.
|
||||||
|
|
||||||
|
Additional Fields:
|
||||||
|
- `value` (`number`): The size of the domain in bytes
|
||||||
|
|
||||||
- `GUARD_RESPONSE`
|
- `GUARD_RESPONSE`
|
||||||
The result of an attempted `GUARD` request.
|
The result of an attempted `GUARD` request.
|
||||||
|
|
||||||
@@ -351,18 +365,14 @@ request_handlers = {
|
|||||||
["PREFERRED_CORES"] = function (req)
|
["PREFERRED_CORES"] = function (req)
|
||||||
local res = {}
|
local res = {}
|
||||||
local preferred_cores = client.getconfig().PreferredCores
|
local preferred_cores = client.getconfig().PreferredCores
|
||||||
|
local systems_enumerator = preferred_cores.Keys:GetEnumerator()
|
||||||
|
|
||||||
res["type"] = "PREFERRED_CORES_RESPONSE"
|
res["type"] = "PREFERRED_CORES_RESPONSE"
|
||||||
res["value"] = {}
|
res["value"] = {}
|
||||||
res["value"]["NES"] = preferred_cores.NES
|
|
||||||
res["value"]["SNES"] = preferred_cores.SNES
|
while systems_enumerator:MoveNext() do
|
||||||
res["value"]["GB"] = preferred_cores.GB
|
res["value"][systems_enumerator.Current] = preferred_cores[systems_enumerator.Current]
|
||||||
res["value"]["GBC"] = preferred_cores.GBC
|
end
|
||||||
res["value"]["DGB"] = preferred_cores.DGB
|
|
||||||
res["value"]["SGB"] = preferred_cores.SGB
|
|
||||||
res["value"]["PCE"] = preferred_cores.PCE
|
|
||||||
res["value"]["PCECD"] = preferred_cores.PCECD
|
|
||||||
res["value"]["SGX"] = preferred_cores.SGX
|
|
||||||
|
|
||||||
return res
|
return res
|
||||||
end,
|
end,
|
||||||
@@ -376,6 +386,15 @@ request_handlers = {
|
|||||||
return res
|
return res
|
||||||
end,
|
end,
|
||||||
|
|
||||||
|
["MEMORY_SIZE"] = function (req)
|
||||||
|
local res = {}
|
||||||
|
|
||||||
|
res["type"] = "MEMORY_SIZE_RESPONSE"
|
||||||
|
res["value"] = memory.getmemorydomainsize(req["domain"])
|
||||||
|
|
||||||
|
return res
|
||||||
|
end,
|
||||||
|
|
||||||
["GUARD"] = function (req)
|
["GUARD"] = function (req)
|
||||||
local res = {}
|
local res = {}
|
||||||
local expected_data = base64.decode(req["expected_data"])
|
local expected_data = base64.decode(req["expected_data"])
|
||||||
@@ -613,9 +632,11 @@ end)
|
|||||||
|
|
||||||
if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then
|
if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then
|
||||||
print("Must use BizHawk 2.7.0 or newer")
|
print("Must use BizHawk 2.7.0 or newer")
|
||||||
elseif bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9) then
|
|
||||||
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.9.")
|
|
||||||
else
|
else
|
||||||
|
if bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 10) then
|
||||||
|
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.10.")
|
||||||
|
end
|
||||||
|
|
||||||
if emu.getsystemid() == "NULL" then
|
if emu.getsystemid() == "NULL" then
|
||||||
print("No ROM is loaded. Please load a ROM.")
|
print("No ROM is loaded. Please load a ROM.")
|
||||||
while emu.getsystemid() == "NULL" do
|
while emu.getsystemid() == "NULL" do
|
||||||
|
|||||||
@@ -1,462 +0,0 @@
|
|||||||
local socket = require("socket")
|
|
||||||
local json = require('json')
|
|
||||||
local math = require('math')
|
|
||||||
require("common")
|
|
||||||
|
|
||||||
local STATE_OK = "Ok"
|
|
||||||
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
|
|
||||||
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
|
|
||||||
local STATE_UNINITIALIZED = "Uninitialized"
|
|
||||||
|
|
||||||
local ITEM_INDEX = 0x03
|
|
||||||
local WEAPON_INDEX = 0x07
|
|
||||||
local ARMOR_INDEX = 0x0B
|
|
||||||
|
|
||||||
local goldLookup = {
|
|
||||||
[0x16C] = 10,
|
|
||||||
[0x16D] = 20,
|
|
||||||
[0x16E] = 25,
|
|
||||||
[0x16F] = 30,
|
|
||||||
[0x170] = 55,
|
|
||||||
[0x171] = 70,
|
|
||||||
[0x172] = 85,
|
|
||||||
[0x173] = 110,
|
|
||||||
[0x174] = 135,
|
|
||||||
[0x175] = 155,
|
|
||||||
[0x176] = 160,
|
|
||||||
[0x177] = 180,
|
|
||||||
[0x178] = 240,
|
|
||||||
[0x179] = 255,
|
|
||||||
[0x17A] = 260,
|
|
||||||
[0x17B] = 295,
|
|
||||||
[0x17C] = 300,
|
|
||||||
[0x17D] = 315,
|
|
||||||
[0x17E] = 330,
|
|
||||||
[0x17F] = 350,
|
|
||||||
[0x180] = 385,
|
|
||||||
[0x181] = 400,
|
|
||||||
[0x182] = 450,
|
|
||||||
[0x183] = 500,
|
|
||||||
[0x184] = 530,
|
|
||||||
[0x185] = 575,
|
|
||||||
[0x186] = 620,
|
|
||||||
[0x187] = 680,
|
|
||||||
[0x188] = 750,
|
|
||||||
[0x189] = 795,
|
|
||||||
[0x18A] = 880,
|
|
||||||
[0x18B] = 1020,
|
|
||||||
[0x18C] = 1250,
|
|
||||||
[0x18D] = 1455,
|
|
||||||
[0x18E] = 1520,
|
|
||||||
[0x18F] = 1760,
|
|
||||||
[0x190] = 1975,
|
|
||||||
[0x191] = 2000,
|
|
||||||
[0x192] = 2750,
|
|
||||||
[0x193] = 3400,
|
|
||||||
[0x194] = 4150,
|
|
||||||
[0x195] = 5000,
|
|
||||||
[0x196] = 5450,
|
|
||||||
[0x197] = 6400,
|
|
||||||
[0x198] = 6720,
|
|
||||||
[0x199] = 7340,
|
|
||||||
[0x19A] = 7690,
|
|
||||||
[0x19B] = 7900,
|
|
||||||
[0x19C] = 8135,
|
|
||||||
[0x19D] = 9000,
|
|
||||||
[0x19E] = 9300,
|
|
||||||
[0x19F] = 9500,
|
|
||||||
[0x1A0] = 9900,
|
|
||||||
[0x1A1] = 10000,
|
|
||||||
[0x1A2] = 12350,
|
|
||||||
[0x1A3] = 13000,
|
|
||||||
[0x1A4] = 13450,
|
|
||||||
[0x1A5] = 14050,
|
|
||||||
[0x1A6] = 14720,
|
|
||||||
[0x1A7] = 15000,
|
|
||||||
[0x1A8] = 17490,
|
|
||||||
[0x1A9] = 18010,
|
|
||||||
[0x1AA] = 19990,
|
|
||||||
[0x1AB] = 20000,
|
|
||||||
[0x1AC] = 20010,
|
|
||||||
[0x1AD] = 26000,
|
|
||||||
[0x1AE] = 45000,
|
|
||||||
[0x1AF] = 65000
|
|
||||||
}
|
|
||||||
|
|
||||||
local extensionConsumableLookup = {
|
|
||||||
[432] = 0x3C,
|
|
||||||
[436] = 0x3C,
|
|
||||||
[440] = 0x3C,
|
|
||||||
[433] = 0x3D,
|
|
||||||
[437] = 0x3D,
|
|
||||||
[441] = 0x3D,
|
|
||||||
[434] = 0x3E,
|
|
||||||
[438] = 0x3E,
|
|
||||||
[442] = 0x3E,
|
|
||||||
[435] = 0x3F,
|
|
||||||
[439] = 0x3F,
|
|
||||||
[443] = 0x3F
|
|
||||||
}
|
|
||||||
|
|
||||||
local noOverworldItemsLookup = {
|
|
||||||
[499] = 0x2B,
|
|
||||||
[500] = 0x12,
|
|
||||||
}
|
|
||||||
|
|
||||||
local consumableStacks = nil
|
|
||||||
local prevstate = ""
|
|
||||||
local curstate = STATE_UNINITIALIZED
|
|
||||||
local ff1Socket = nil
|
|
||||||
local frame = 0
|
|
||||||
|
|
||||||
local isNesHawk = false
|
|
||||||
|
|
||||||
|
|
||||||
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
|
|
||||||
local function defineMemoryFunctions()
|
|
||||||
local memDomain = {}
|
|
||||||
local domains = memory.getmemorydomainlist()
|
|
||||||
if domains[1] == "System Bus" then
|
|
||||||
--NesHawk
|
|
||||||
isNesHawk = true
|
|
||||||
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
|
|
||||||
memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
|
|
||||||
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
|
|
||||||
elseif domains[1] == "WRAM" then
|
|
||||||
--QuickNES
|
|
||||||
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
|
|
||||||
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
|
|
||||||
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
|
|
||||||
end
|
|
||||||
return memDomain
|
|
||||||
end
|
|
||||||
|
|
||||||
local memDomain = defineMemoryFunctions()
|
|
||||||
|
|
||||||
local function StateOKForMainLoop()
|
|
||||||
memDomain.saveram()
|
|
||||||
local A = u8(0x102) -- Party Made
|
|
||||||
local B = u8(0x0FC)
|
|
||||||
local C = u8(0x0A3)
|
|
||||||
return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2)
|
|
||||||
end
|
|
||||||
|
|
||||||
function generateLocationChecked()
|
|
||||||
memDomain.saveram()
|
|
||||||
data = uRange(0x01FF, 0x101)
|
|
||||||
data[0] = nil
|
|
||||||
return data
|
|
||||||
end
|
|
||||||
|
|
||||||
function setConsumableStacks()
|
|
||||||
memDomain.rom()
|
|
||||||
consumableStacks = {}
|
|
||||||
-- In order shards, tent, cabin, house, heal, pure, soft, ext1, ext2, ext3, ex4
|
|
||||||
consumableStacks[0x35] = 1
|
|
||||||
consumableStacks[0x36] = u8(0x47400) + 1
|
|
||||||
consumableStacks[0x37] = u8(0x47401) + 1
|
|
||||||
consumableStacks[0x38] = u8(0x47402) + 1
|
|
||||||
consumableStacks[0x39] = u8(0x47403) + 1
|
|
||||||
consumableStacks[0x3A] = u8(0x47404) + 1
|
|
||||||
consumableStacks[0x3B] = u8(0x47405) + 1
|
|
||||||
consumableStacks[0x3C] = u8(0x47406) + 1
|
|
||||||
consumableStacks[0x3D] = u8(0x47407) + 1
|
|
||||||
consumableStacks[0x3E] = u8(0x47408) + 1
|
|
||||||
consumableStacks[0x3F] = u8(0x47409) + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
function getEmptyWeaponSlots()
|
|
||||||
memDomain.saveram()
|
|
||||||
ret = {}
|
|
||||||
count = 1
|
|
||||||
slot1 = uRange(0x118, 0x4)
|
|
||||||
slot2 = uRange(0x158, 0x4)
|
|
||||||
slot3 = uRange(0x198, 0x4)
|
|
||||||
slot4 = uRange(0x1D8, 0x4)
|
|
||||||
for i,v in pairs(slot1) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x118 + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot2) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x158 + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot3) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x198 + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot4) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x1D8 + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return ret
|
|
||||||
end
|
|
||||||
|
|
||||||
function getEmptyArmorSlots()
|
|
||||||
memDomain.saveram()
|
|
||||||
ret = {}
|
|
||||||
count = 1
|
|
||||||
slot1 = uRange(0x11C, 0x4)
|
|
||||||
slot2 = uRange(0x15C, 0x4)
|
|
||||||
slot3 = uRange(0x19C, 0x4)
|
|
||||||
slot4 = uRange(0x1DC, 0x4)
|
|
||||||
for i,v in pairs(slot1) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x11C + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot2) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x15C + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot3) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x19C + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot4) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x1DC + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return ret
|
|
||||||
end
|
|
||||||
local function slice (tbl, s, e)
|
|
||||||
local pos, new = 1, {}
|
|
||||||
for i = s + 1, e do
|
|
||||||
new[pos] = tbl[i]
|
|
||||||
pos = pos + 1
|
|
||||||
end
|
|
||||||
return new
|
|
||||||
end
|
|
||||||
function processBlock(block)
|
|
||||||
local msgBlock = block['messages']
|
|
||||||
if msgBlock ~= nil then
|
|
||||||
for i, v in pairs(msgBlock) do
|
|
||||||
if itemMessages[i] == nil then
|
|
||||||
local msg = {TTL=450, message=v, color=0xFFFF0000}
|
|
||||||
itemMessages[i] = msg
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local itemsBlock = block["items"]
|
|
||||||
memDomain.saveram()
|
|
||||||
isInGame = u8(0x102)
|
|
||||||
if itemsBlock ~= nil and isInGame ~= 0x00 then
|
|
||||||
if consumableStacks == nil then
|
|
||||||
setConsumableStacks()
|
|
||||||
end
|
|
||||||
memDomain.saveram()
|
|
||||||
-- print('ITEMBLOCK: ')
|
|
||||||
-- print(itemsBlock)
|
|
||||||
itemIndex = u8(ITEM_INDEX)
|
|
||||||
-- print('ITEMINDEX: '..itemIndex)
|
|
||||||
for i, v in pairs(slice(itemsBlock, itemIndex, #itemsBlock)) do
|
|
||||||
-- Minus the offset and add to the correct domain
|
|
||||||
local memoryLocation = v
|
|
||||||
if v >= 0x100 and v <= 0x114 then
|
|
||||||
-- This is a key item
|
|
||||||
memoryLocation = memoryLocation - 0x0E0
|
|
||||||
wU8(memoryLocation, 0x01)
|
|
||||||
elseif v >= 0x1E0 and v <= 0x1F2 then
|
|
||||||
-- This is a movement item
|
|
||||||
-- Minus Offset (0x100) - movement offset (0xE0)
|
|
||||||
memoryLocation = memoryLocation - 0x1E0
|
|
||||||
-- Canal is a flipped bit
|
|
||||||
if memoryLocation == 0x0C then
|
|
||||||
wU8(memoryLocation, 0x00)
|
|
||||||
else
|
|
||||||
wU8(memoryLocation, 0x01)
|
|
||||||
end
|
|
||||||
elseif v >= 0x1F3 and v <= 0x1F4 then
|
|
||||||
-- NoOverworld special items
|
|
||||||
memoryLocation = noOverworldItemsLookup[v]
|
|
||||||
wU8(memoryLocation, 0x01)
|
|
||||||
elseif v >= 0x16C and v <= 0x1AF then
|
|
||||||
-- This is a gold item
|
|
||||||
amountToAdd = goldLookup[v]
|
|
||||||
biggest = u8(0x01E)
|
|
||||||
medium = u8(0x01D)
|
|
||||||
smallest = u8(0x01C)
|
|
||||||
currentValue = 0x10000 * biggest + 0x100 * medium + smallest
|
|
||||||
newValue = currentValue + amountToAdd
|
|
||||||
newBiggest = math.floor(newValue / 0x10000)
|
|
||||||
newMedium = math.floor(math.fmod(newValue, 0x10000) / 0x100)
|
|
||||||
newSmallest = math.floor(math.fmod(newValue, 0x100))
|
|
||||||
wU8(0x01E, newBiggest)
|
|
||||||
wU8(0x01D, newMedium)
|
|
||||||
wU8(0x01C, newSmallest)
|
|
||||||
elseif v >= 0x115 and v <= 0x11B then
|
|
||||||
-- This is a regular consumable OR a shard
|
|
||||||
-- Minus Offset (0x100) + item offset (0x20)
|
|
||||||
memoryLocation = memoryLocation - 0x0E0
|
|
||||||
currentValue = u8(memoryLocation)
|
|
||||||
amountToAdd = consumableStacks[memoryLocation]
|
|
||||||
if currentValue < 99 then
|
|
||||||
wU8(memoryLocation, currentValue + amountToAdd)
|
|
||||||
end
|
|
||||||
elseif v >= 0x1B0 and v <= 0x1BB then
|
|
||||||
-- This is an extension consumable
|
|
||||||
memoryLocation = extensionConsumableLookup[v]
|
|
||||||
currentValue = u8(memoryLocation)
|
|
||||||
amountToAdd = consumableStacks[memoryLocation]
|
|
||||||
if currentValue < 99 then
|
|
||||||
value = currentValue + amountToAdd
|
|
||||||
if value > 99 then
|
|
||||||
value = 99
|
|
||||||
end
|
|
||||||
wU8(memoryLocation, value)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if #itemsBlock > itemIndex then
|
|
||||||
wU8(ITEM_INDEX, #itemsBlock)
|
|
||||||
end
|
|
||||||
|
|
||||||
memDomain.saveram()
|
|
||||||
weaponIndex = u8(WEAPON_INDEX)
|
|
||||||
emptyWeaponSlots = getEmptyWeaponSlots()
|
|
||||||
lastUsedWeaponIndex = weaponIndex
|
|
||||||
-- print('WEAPON_INDEX: '.. weaponIndex)
|
|
||||||
memDomain.saveram()
|
|
||||||
for i, v in pairs(slice(itemsBlock, weaponIndex, #itemsBlock)) do
|
|
||||||
if v >= 0x11C and v <= 0x143 then
|
|
||||||
-- Minus the offset and add to the correct domain
|
|
||||||
local itemValue = v - 0x11B
|
|
||||||
if #emptyWeaponSlots > 0 then
|
|
||||||
slot = table.remove(emptyWeaponSlots, 1)
|
|
||||||
wU8(slot, itemValue)
|
|
||||||
lastUsedWeaponIndex = weaponIndex + i
|
|
||||||
else
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if lastUsedWeaponIndex ~= weaponIndex then
|
|
||||||
wU8(WEAPON_INDEX, lastUsedWeaponIndex)
|
|
||||||
end
|
|
||||||
memDomain.saveram()
|
|
||||||
armorIndex = u8(ARMOR_INDEX)
|
|
||||||
emptyArmorSlots = getEmptyArmorSlots()
|
|
||||||
lastUsedArmorIndex = armorIndex
|
|
||||||
-- print('ARMOR_INDEX: '.. armorIndex)
|
|
||||||
memDomain.saveram()
|
|
||||||
for i, v in pairs(slice(itemsBlock, armorIndex, #itemsBlock)) do
|
|
||||||
if v >= 0x144 and v <= 0x16B then
|
|
||||||
-- Minus the offset and add to the correct domain
|
|
||||||
local itemValue = v - 0x143
|
|
||||||
if #emptyArmorSlots > 0 then
|
|
||||||
slot = table.remove(emptyArmorSlots, 1)
|
|
||||||
wU8(slot, itemValue)
|
|
||||||
lastUsedArmorIndex = armorIndex + i
|
|
||||||
else
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if lastUsedArmorIndex ~= armorIndex then
|
|
||||||
wU8(ARMOR_INDEX, lastUsedArmorIndex)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function receive()
|
|
||||||
l, e = ff1Socket:receive()
|
|
||||||
if e == 'closed' then
|
|
||||||
if curstate == STATE_OK then
|
|
||||||
print("Connection closed")
|
|
||||||
end
|
|
||||||
curstate = STATE_UNINITIALIZED
|
|
||||||
return
|
|
||||||
elseif e == 'timeout' then
|
|
||||||
print("timeout")
|
|
||||||
return
|
|
||||||
elseif e ~= nil then
|
|
||||||
print(e)
|
|
||||||
curstate = STATE_UNINITIALIZED
|
|
||||||
return
|
|
||||||
end
|
|
||||||
processBlock(json.decode(l))
|
|
||||||
|
|
||||||
-- Determine Message to send back
|
|
||||||
memDomain.rom()
|
|
||||||
local playerName = uRange(0x7BCBF, 0x41)
|
|
||||||
playerName[0] = nil
|
|
||||||
local retTable = {}
|
|
||||||
retTable["playerName"] = playerName
|
|
||||||
if StateOKForMainLoop() then
|
|
||||||
retTable["locations"] = generateLocationChecked()
|
|
||||||
end
|
|
||||||
msg = json.encode(retTable).."\n"
|
|
||||||
local ret, error = ff1Socket:send(msg)
|
|
||||||
if ret == nil then
|
|
||||||
print(error)
|
|
||||||
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
|
|
||||||
curstate = STATE_TENTATIVELY_CONNECTED
|
|
||||||
elseif curstate == STATE_TENTATIVELY_CONNECTED then
|
|
||||||
print("Connected!")
|
|
||||||
itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"}
|
|
||||||
curstate = STATE_OK
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function main()
|
|
||||||
if not checkBizHawkVersion() then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
server, error = socket.bind('localhost', 52980)
|
|
||||||
|
|
||||||
while true do
|
|
||||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
|
|
||||||
frame = frame + 1
|
|
||||||
drawMessages()
|
|
||||||
if not (curstate == prevstate) then
|
|
||||||
-- console.log("Current state: "..curstate)
|
|
||||||
prevstate = curstate
|
|
||||||
end
|
|
||||||
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
|
||||||
if (frame % 60 == 0) then
|
|
||||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
|
|
||||||
receive()
|
|
||||||
else
|
|
||||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
|
|
||||||
end
|
|
||||||
elseif (curstate == STATE_UNINITIALIZED) then
|
|
||||||
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
|
|
||||||
if (frame % 60 == 0) then
|
|
||||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
|
|
||||||
|
|
||||||
drawText(5, 8, "Waiting for client", 0xFFFF0000)
|
|
||||||
drawText(5, 32, "Please start FF1Client.exe", 0xFFFF0000)
|
|
||||||
|
|
||||||
-- Advance so the messages are drawn
|
|
||||||
emu.frameadvance()
|
|
||||||
server:settimeout(2)
|
|
||||||
print("Attempting to connect")
|
|
||||||
local client, timeout = server:accept()
|
|
||||||
if timeout == nil then
|
|
||||||
-- print('Initial Connection Made')
|
|
||||||
curstate = STATE_INITIAL_CONNECTION_MADE
|
|
||||||
ff1Socket = client
|
|
||||||
ff1Socket:settimeout(0)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
emu.frameadvance()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -477,7 +477,7 @@ function main()
|
|||||||
elseif (curstate == STATE_UNINITIALIZED) then
|
elseif (curstate == STATE_UNINITIALIZED) then
|
||||||
-- If we're uninitialized, attempt to make the connection.
|
-- If we're uninitialized, attempt to make the connection.
|
||||||
if (frame % 120 == 0) then
|
if (frame % 120 == 0) then
|
||||||
server:settimeout(2)
|
server:settimeout(120)
|
||||||
local client, timeout = server:accept()
|
local client, timeout = server:accept()
|
||||||
if timeout == nil then
|
if timeout == nil then
|
||||||
print('Initial Connection Made')
|
print('Initial Connection Made')
|
||||||
|
|||||||
@@ -1816,7 +1816,7 @@ end
|
|||||||
|
|
||||||
-- Main control handling: main loop and socket receive
|
-- Main control handling: main loop and socket receive
|
||||||
|
|
||||||
function receive()
|
function APreceive()
|
||||||
l, e = ootSocket:receive()
|
l, e = ootSocket:receive()
|
||||||
-- Handle incoming message
|
-- Handle incoming message
|
||||||
if e == 'closed' then
|
if e == 'closed' then
|
||||||
@@ -1874,7 +1874,7 @@ function main()
|
|||||||
end
|
end
|
||||||
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
||||||
if (frame % 30 == 0) then
|
if (frame % 30 == 0) then
|
||||||
receive()
|
APreceive()
|
||||||
end
|
end
|
||||||
elseif (curstate == STATE_UNINITIALIZED) then
|
elseif (curstate == STATE_UNINITIALIZED) then
|
||||||
if (frame % 60 == 0) then
|
if (frame % 60 == 0) then
|
||||||
|
|||||||
BIN
data/mcicon.ico
BIN
data/mcicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -51,10 +51,9 @@ requires:
|
|||||||
{%- for option_key, option in group_options.items() %}
|
{%- for option_key, option in group_options.items() %}
|
||||||
{{ option_key }}:
|
{{ option_key }}:
|
||||||
{%- if option.__doc__ %}
|
{%- if option.__doc__ %}
|
||||||
# {{ option.__doc__
|
# {{ cleandoc(option.__doc__)
|
||||||
| trim
|
| trim
|
||||||
| replace('\n\n', '\n \n')
|
| replace('\n', '\n# ')
|
||||||
| replace('\n ', '\n# ')
|
|
||||||
| indent(4, first=False)
|
| indent(4, first=False)
|
||||||
}}
|
}}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -36,12 +36,18 @@
|
|||||||
# Castlevania 64
|
# Castlevania 64
|
||||||
/worlds/cv64/ @LiquidCat64
|
/worlds/cv64/ @LiquidCat64
|
||||||
|
|
||||||
|
# Castlevania: Circle of the Moon
|
||||||
|
/worlds/cvcotm/ @LiquidCat64
|
||||||
|
|
||||||
# Celeste 64
|
# Celeste 64
|
||||||
/worlds/celeste64/ @PoryGone
|
/worlds/celeste64/ @PoryGone
|
||||||
|
|
||||||
# ChecksFinder
|
# ChecksFinder
|
||||||
/worlds/checksfinder/ @SunCatMC
|
/worlds/checksfinder/ @SunCatMC
|
||||||
|
|
||||||
|
# Civilization VI
|
||||||
|
/worlds/civ6/ @hesto2
|
||||||
|
|
||||||
# Clique
|
# Clique
|
||||||
/worlds/clique/ @ThePhar
|
/worlds/clique/ @ThePhar
|
||||||
|
|
||||||
@@ -55,19 +61,22 @@
|
|||||||
/worlds/dlcquest/ @axe-y @agilbert1412
|
/worlds/dlcquest/ @axe-y @agilbert1412
|
||||||
|
|
||||||
# DOOM 1993
|
# DOOM 1993
|
||||||
/worlds/doom_1993/ @Daivuk
|
/worlds/doom_1993/ @Daivuk @KScl
|
||||||
|
|
||||||
# DOOM II
|
# DOOM II
|
||||||
/worlds/doom_ii/ @Daivuk
|
/worlds/doom_ii/ @Daivuk @KScl
|
||||||
|
|
||||||
# Factorio
|
# Factorio
|
||||||
/worlds/factorio/ @Berserker66
|
/worlds/factorio/ @Berserker66
|
||||||
|
|
||||||
|
# Faxanadu
|
||||||
|
/worlds/faxanadu/ @Daivuk
|
||||||
|
|
||||||
# Final Fantasy Mystic Quest
|
# Final Fantasy Mystic Quest
|
||||||
/worlds/ffmq/ @Alchav @wildham0
|
/worlds/ffmq/ @Alchav @wildham0
|
||||||
|
|
||||||
# Heretic
|
# Heretic
|
||||||
/worlds/heretic/ @Daivuk
|
/worlds/heretic/ @Daivuk @KScl
|
||||||
|
|
||||||
# Hollow Knight
|
# Hollow Knight
|
||||||
/worlds/hk/ @BadMagic100 @qwint
|
/worlds/hk/ @BadMagic100 @qwint
|
||||||
@@ -75,6 +84,12 @@
|
|||||||
# Hylics 2
|
# Hylics 2
|
||||||
/worlds/hylics2/ @TRPG0
|
/worlds/hylics2/ @TRPG0
|
||||||
|
|
||||||
|
# Inscryption
|
||||||
|
/worlds/inscryption/ @DrBibop @Glowbuzz
|
||||||
|
|
||||||
|
# Jak and Daxter: The Precursor Legacy
|
||||||
|
/worlds/jakanddaxter/ @massimilianodelliubaldini
|
||||||
|
|
||||||
# Kirby's Dream Land 3
|
# Kirby's Dream Land 3
|
||||||
/worlds/kdl3/ @Silvris
|
/worlds/kdl3/ @Silvris
|
||||||
|
|
||||||
@@ -90,6 +105,9 @@
|
|||||||
# Lingo
|
# Lingo
|
||||||
/worlds/lingo/ @hatkirby
|
/worlds/lingo/ @hatkirby
|
||||||
|
|
||||||
|
# Links Awakening DX
|
||||||
|
/worlds/ladx/ @threeandthreee
|
||||||
|
|
||||||
# Lufia II Ancient Cave
|
# Lufia II Ancient Cave
|
||||||
/worlds/lufia2ac/ @el-u
|
/worlds/lufia2ac/ @el-u
|
||||||
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
||||||
@@ -103,9 +121,6 @@
|
|||||||
# The Messenger
|
# The Messenger
|
||||||
/worlds/messenger/ @alwaysintreble
|
/worlds/messenger/ @alwaysintreble
|
||||||
|
|
||||||
# Minecraft
|
|
||||||
/worlds/minecraft/ @KonoTyran @espeon65536
|
|
||||||
|
|
||||||
# Mega Man 2
|
# Mega Man 2
|
||||||
/worlds/mm2/ @Silvris
|
/worlds/mm2/ @Silvris
|
||||||
|
|
||||||
@@ -139,8 +154,14 @@
|
|||||||
# Risk of Rain 2
|
# Risk of Rain 2
|
||||||
/worlds/ror2/ @kindasneaki
|
/worlds/ror2/ @kindasneaki
|
||||||
|
|
||||||
|
# Saving Princess
|
||||||
|
/worlds/saving_princess/ @LeonarthCG
|
||||||
|
|
||||||
|
# shapez
|
||||||
|
/worlds/shapez/ @BlastSlimey
|
||||||
|
|
||||||
# Shivers
|
# Shivers
|
||||||
/worlds/shivers/ @GodlFire
|
/worlds/shivers/ @GodlFire @korydondzila
|
||||||
|
|
||||||
# A Short Hike
|
# A Short Hike
|
||||||
/worlds/shorthike/ @chandler05 @BrandenEK
|
/worlds/shorthike/ @chandler05 @BrandenEK
|
||||||
@@ -157,6 +178,9 @@
|
|||||||
# Super Mario 64
|
# Super Mario 64
|
||||||
/worlds/sm64ex/ @N00byKing
|
/worlds/sm64ex/ @N00byKing
|
||||||
|
|
||||||
|
# Super Mario Land 2: 6 Golden Coins
|
||||||
|
/worlds/marioland2/ @Alchav
|
||||||
|
|
||||||
# Super Mario World
|
# Super Mario World
|
||||||
/worlds/smw/ @PoryGone
|
/worlds/smw/ @PoryGone
|
||||||
|
|
||||||
@@ -166,9 +190,6 @@
|
|||||||
# Secret of Evermore
|
# Secret of Evermore
|
||||||
/worlds/soe/ @black-sliver
|
/worlds/soe/ @black-sliver
|
||||||
|
|
||||||
# Slay the Spire
|
|
||||||
/worlds/spire/ @KonoTyran
|
|
||||||
|
|
||||||
# Stardew Valley
|
# Stardew Valley
|
||||||
/worlds/stardew_valley/ @agilbert1412
|
/worlds/stardew_valley/ @agilbert1412
|
||||||
|
|
||||||
@@ -196,6 +217,9 @@
|
|||||||
# Wargroove
|
# Wargroove
|
||||||
/worlds/wargroove/ @FlySniper
|
/worlds/wargroove/ @FlySniper
|
||||||
|
|
||||||
|
# The Wind Waker
|
||||||
|
/worlds/tww/ @tanjo3
|
||||||
|
|
||||||
# The Witness
|
# The Witness
|
||||||
/worlds/witness/ @NewSoupVi @blastron
|
/worlds/witness/ @NewSoupVi @blastron
|
||||||
|
|
||||||
@@ -211,34 +235,18 @@
|
|||||||
# Zillion
|
# Zillion
|
||||||
/worlds/zillion/ @beauxq
|
/worlds/zillion/ @beauxq
|
||||||
|
|
||||||
# Zork Grand Inquisitor
|
|
||||||
/worlds/zork_grand_inquisitor/ @nbrochu
|
|
||||||
|
|
||||||
|
|
||||||
## Active Unmaintained Worlds
|
## Active Unmaintained Worlds
|
||||||
|
|
||||||
# The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks
|
# The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks
|
||||||
# compatibility, these worlds may be moved to `worlds_disabled`. If you are interested in stepping up as maintainer for
|
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
|
||||||
# any of these worlds, please review `/docs/world maintainer.md` documentation.
|
# any of these worlds, please review `/docs/world maintainer.md` documentation.
|
||||||
|
|
||||||
# Final Fantasy (1)
|
# Final Fantasy (1)
|
||||||
# /worlds/ff1/
|
# /worlds/ff1/
|
||||||
|
|
||||||
# Links Awakening DX
|
|
||||||
# /worlds/ladx/
|
|
||||||
|
|
||||||
# Ocarina of Time
|
# Ocarina of Time
|
||||||
# /worlds/oot/
|
# /worlds/oot/
|
||||||
|
|
||||||
## Disabled Unmaintained Worlds
|
|
||||||
|
|
||||||
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
|
|
||||||
# interested in stepping up as maintainer for any of these worlds, please review `/docs/world maintainer.md`
|
|
||||||
# documentation.
|
|
||||||
|
|
||||||
# Ori and the Blind Forest
|
|
||||||
# /worlds_disabled/oribf/
|
|
||||||
|
|
||||||
###################
|
###################
|
||||||
## Documentation ##
|
## Documentation ##
|
||||||
###################
|
###################
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user