mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-23 02:43:20 -07:00
Compare commits
597 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453d89460f | ||
|
|
28889e58aa | ||
|
|
f3c76399e0 | ||
|
|
8384a23fe2 | ||
|
|
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
|
||||||
|
|||||||
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
|
||||||
|
|||||||
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
|
||||||
|
|||||||
2
.gitignore
vendored
2
.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
|
||||||
|
|||||||
@@ -511,7 +511,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()
|
||||||
|
|||||||
292
BaseClasses.py
292
BaseClasses.py
@@ -1,18 +1,16 @@
|
|||||||
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, Mapping, NamedTuple,
|
||||||
Optional, Protocol, Set, Tuple, Union, Type)
|
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
||||||
|
|
||||||
from typing_extensions import NotRequired, TypedDict
|
from typing_extensions import NotRequired, TypedDict
|
||||||
|
|
||||||
@@ -20,7 +18,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
|
||||||
|
|
||||||
|
|
||||||
@@ -224,14 +223,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,12 +427,12 @@ 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) -> 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)
|
||||||
@@ -606,6 +605,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 +718,11 @@ 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):
|
||||||
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 +731,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 +766,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 +793,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 +815,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 +869,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 +930,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:
|
||||||
@@ -931,6 +1008,11 @@ class CollectionState():
|
|||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
hide_path: bool = False
|
hide_path: bool = False
|
||||||
@@ -938,30 +1020,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 +1083,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 +1101,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 +1186,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 +1198,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,8 +1264,18 @@ 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.
|
||||||
|
|
||||||
@@ -1122,10 +1285,14 @@ class Region:
|
|||||||
"""
|
"""
|
||||||
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 +1350,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 +1373,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 +1441,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 +1453,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
|
||||||
@@ -1386,14 +1571,21 @@ class Spoiler:
|
|||||||
|
|
||||||
# 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():
|
||||||
|
# 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
|
||||||
@@ -1532,7 +1724,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]))
|
||||||
|
|
||||||
|
|||||||
117
CommonClient.py
117
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.
|
||||||
@@ -355,7 +341,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 +397,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 +445,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 +545,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 +563,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 +592,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 +600,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 +682,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 +862,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 +879,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 +1030,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 +1068,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 +1097,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()
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
parser = get_base_parser()
|
parser = get_base_parser()
|
||||||
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()
|
||||||
|
|||||||
@@ -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()
|
|
||||||
127
Fill.py
127
Fill.py
@@ -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)
|
||||||
@@ -226,18 +237,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 +281,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)
|
||||||
@@ -327,10 +350,10 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
|
|||||||
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)
|
||||||
@@ -479,21 +502,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 +542,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 +561,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 +582,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
|
||||||
@@ -978,15 +1032,32 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
multiworld.random.shuffle(items)
|
multiworld.random.shuffle(items)
|
||||||
count = 0
|
count = 0
|
||||||
err: typing.List[str] = []
|
err: typing.List[str] = []
|
||||||
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
|
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
|
||||||
|
claimed_indices: typing.Set[typing.Optional[int]] = set()
|
||||||
for item_name in items:
|
for item_name in items:
|
||||||
item = multiworld.worlds[player].create_item(item_name)
|
index_to_delete: typing.Optional[int] = None
|
||||||
|
if from_pool:
|
||||||
|
try:
|
||||||
|
# If from_pool, try to find an existing item with this name & player in the itempool and use it
|
||||||
|
index_to_delete, item = next(
|
||||||
|
(i, item) for i, item in enumerate(multiworld.itempool)
|
||||||
|
if item.player == player and item.name == item_name and i not in claimed_indices
|
||||||
|
)
|
||||||
|
except StopIteration:
|
||||||
|
warn(
|
||||||
|
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
||||||
|
placement['force'])
|
||||||
|
item = multiworld.worlds[player].create_item(item_name)
|
||||||
|
else:
|
||||||
|
item = multiworld.worlds[player].create_item(item_name)
|
||||||
|
|
||||||
for location in reversed(candidates):
|
for location in reversed(candidates):
|
||||||
if (location.address is None) == (item.code is None): # either both None or both not None
|
if (location.address is None) == (item.code is None): # either both None or both not None
|
||||||
if not location.item:
|
if not location.item:
|
||||||
if location.item_rule(item):
|
if location.item_rule(item):
|
||||||
if location.can_fill(multiworld.state, item, False):
|
if location.can_fill(multiworld.state, item, False):
|
||||||
successful_pairs.append((item, location))
|
successful_pairs.append((index_to_delete, item, location))
|
||||||
|
claimed_indices.add(index_to_delete)
|
||||||
candidates.remove(location)
|
candidates.remove(location)
|
||||||
count = count + 1
|
count = count + 1
|
||||||
break
|
break
|
||||||
@@ -998,6 +1069,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
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.")
|
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
|
||||||
|
|
||||||
if count == maxcount:
|
if count == maxcount:
|
||||||
break
|
break
|
||||||
if count < placement['count']['min']:
|
if count < placement['count']['min']:
|
||||||
@@ -1005,17 +1077,16 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
failed(
|
failed(
|
||||||
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
||||||
placement['force'])
|
placement['force'])
|
||||||
for (item, location) in successful_pairs:
|
|
||||||
|
# Sort indices in reverse so we can remove them one by one
|
||||||
|
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
|
||||||
|
|
||||||
|
for (index, item, location) in successful_pairs:
|
||||||
multiworld.push_item(location, item, collect=False)
|
multiworld.push_item(location, item, collect=False)
|
||||||
location.locked = True
|
location.locked = True
|
||||||
logging.debug(f"Plando placed {item} at {location}")
|
logging.debug(f"Plando placed {item} at {location}")
|
||||||
if from_pool:
|
if index is not None: # If this item is from_pool and was found in the pool, remove it.
|
||||||
try:
|
multiworld.itempool.pop(index)
|
||||||
multiworld.itempool.remove(item)
|
|
||||||
except ValueError:
|
|
||||||
warn(
|
|
||||||
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
|
||||||
placement['force'])
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
|
|||||||
91
Generate.py
91
Generate.py
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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,6 +176,7 @@ 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
|
||||||
|
|
||||||
@@ -230,7 +252,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,22 +305,30 @@ 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
|
||||||
@@ -426,12 +469,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 +541,23 @@ 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}:
|
# TODO remove plando_items after moving it to the options system
|
||||||
continue
|
valid_keys.add("plando_items")
|
||||||
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:
|
if PlandoOptions.items in plando_options:
|
||||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
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
|
||||||
|
|
||||||
|
|||||||
365
Launcher.py
365
Launcher.py
@@ -1,16 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
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 shlex
|
import shlex
|
||||||
@@ -20,10 +18,11 @@ import urllib.parse
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
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 Callable, Optional, Sequence, Tuple, Union, Any
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
@@ -85,12 +84,16 @@ 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:
|
||||||
|
subprocess.Popen([exe, folder_path])
|
||||||
|
else:
|
||||||
|
logging.warning(f"No file browser available to open {folder_path}")
|
||||||
|
|
||||||
|
|
||||||
def update_settings():
|
def update_settings():
|
||||||
@@ -105,7 +108,8 @@ components.extend([
|
|||||||
Component("Generate Template Options", func=generate_yamls),
|
Component("Generate Template Options", func=generate_yamls),
|
||||||
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
|
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
|
||||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
||||||
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
Component("Unrated/18+ Discord Server", icon="discord",
|
||||||
|
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||||
Component("Browse Files", func=browse_files),
|
Component("Browse Files", func=browse_files),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -114,7 +118,7 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
|||||||
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)
|
launch_args = (path, *launch_args)
|
||||||
client_component = None
|
client_component = []
|
||||||
text_client_component = None
|
text_client_component = None
|
||||||
if "game" in queries:
|
if "game" in queries:
|
||||||
game = queries["game"][0]
|
game = queries["game"][0]
|
||||||
@@ -122,71 +126,40 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
|||||||
game = "Archipelago"
|
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_component.append(component)
|
||||||
elif component.display_name == "Text Client":
|
elif component.display_name == "Text Client":
|
||||||
text_client_component = component
|
text_client_component = component
|
||||||
|
|
||||||
from kvui import App, Button, BoxLayout, Label, Clock, Window
|
from kvui import MDButton, MDButtonText
|
||||||
|
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText
|
||||||
|
from kivymd.uix.divider import MDDivider
|
||||||
|
|
||||||
class Popup(App):
|
if not client_component:
|
||||||
timer_label: Label
|
run_component(text_client_component, *launch_args)
|
||||||
remaining_time: Optional[int]
|
return
|
||||||
|
else:
|
||||||
|
popup_text = MDDialogSupportingText(text="Select client to open and connect with.")
|
||||||
|
component_buttons = [MDDivider()]
|
||||||
|
for component in [text_client_component, *client_component]:
|
||||||
|
component_buttons.append(MDButton(
|
||||||
|
MDButtonText(text=component.display_name),
|
||||||
|
on_release=lambda *args, comp=component: run_component(comp, *launch_args),
|
||||||
|
style="text"
|
||||||
|
))
|
||||||
|
component_buttons.append(MDDivider())
|
||||||
|
|
||||||
def __init__(self):
|
MDDialog(
|
||||||
self.title = "Connect to Multiworld"
|
# Headline
|
||||||
self.icon = r"data/icon.png"
|
MDDialogHeadlineText(text="Connect to Multiworld"),
|
||||||
super().__init__()
|
# Text
|
||||||
|
popup_text,
|
||||||
|
# Content
|
||||||
|
MDDialogContentContainer(
|
||||||
|
*component_buttons,
|
||||||
|
orientation="vertical"
|
||||||
|
),
|
||||||
|
|
||||||
def build(self):
|
).open()
|
||||||
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 identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
||||||
@@ -242,101 +215,189 @@ def launch(exe, in_terminal=False):
|
|||||||
subprocess.Popen(exe)
|
subprocess.Popen(exe)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
refresh_components: Optional[Callable[[], None]] = None
|
refresh_components: Optional[Callable[[], None]] = None
|
||||||
|
|
||||||
|
|
||||||
def run_gui():
|
def run_gui(path: str, args: Any) -> None:
|
||||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
|
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, path=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_uri = path
|
||||||
|
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_uri:
|
||||||
|
handle_uri(self.launch_uri, self.launch_args)
|
||||||
|
self.launch_uri = 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:
|
||||||
@@ -350,13 +411,28 @@ def run_gui():
|
|||||||
else:
|
else:
|
||||||
logging.warning(f"unable to identify component for {file}")
|
logging.warning(f"unable to identify component for {file}")
|
||||||
|
|
||||||
|
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.
|
||||||
# Closing the window explicitly cleans it up.
|
# Closing the window explicitly cleans it up.
|
||||||
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(path=path, 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
|
||||||
@@ -383,16 +459,14 @@ 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 not path.startswith("archipelago://"):
|
||||||
handle_uri(path, args.get("args", ()))
|
file, component = identify(path)
|
||||||
return
|
if file:
|
||||||
file, component = identify(path)
|
args['file'] = file
|
||||||
if file:
|
if component:
|
||||||
args['file'] = file
|
args['component'] = component
|
||||||
if component:
|
if not component:
|
||||||
args['component'] = component
|
logging.warning(f"Could not identify Component responsible for {path}")
|
||||||
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 +475,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(path, args.get("args", ()))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
@@ -423,6 +497,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):
|
||||||
|
|||||||
@@ -370,7 +370,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()
|
||||||
|
|||||||
125
Main.py
125
Main.py
@@ -56,32 +56,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 +134,44 @@ 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()
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -230,6 +210,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__
|
||||||
@@ -249,6 +238,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
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 +263,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)
|
||||||
@@ -289,35 +279,38 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
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, Union[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))
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
316
MultiServer.py
316
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}
|
||||||
@@ -443,7 +463,7 @@ class Context:
|
|||||||
|
|
||||||
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 +676,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 +747,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 +762,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 +838,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 +929,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 +999,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 +1083,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 +1122,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 +1140,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 +1199,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 +1604,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 +1630,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 +1652,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 +1826,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 = "TextOnly" in client.tags or "Tracker" in client.tags
|
||||||
|
# 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,
|
||||||
@@ -1798,6 +1901,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
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 = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||||
|
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,19 +1932,65 @@ 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"])
|
update_client_status(ctx, client, args["status"])
|
||||||
|
|
||||||
@@ -1886,12 +2038,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 +2296,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 +2332,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 +2419,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 +2434,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 +2516,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()):
|
||||||
|
|||||||
@@ -346,7 +346,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()
|
||||||
|
|||||||
199
Options.py
199
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
|
||||||
@@ -137,7 +138,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 +497,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 +618,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 +690,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 +718,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 +740,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 +763,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 +826,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 +867,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -1106,11 +1154,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:
|
||||||
@@ -1175,7 +1223,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 +1241,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 +1292,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 +1353,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 +1428,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})
|
||||||
@@ -1460,22 +1520,26 @@ 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:
|
||||||
@@ -1556,10 +1620,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 +1633,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)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ Currently, the following games are supported:
|
|||||||
* Factorio
|
* Factorio
|
||||||
* Minecraft
|
* 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 +62,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 +74,12 @@ 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
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
72
Utils.py
72
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
|
||||||
|
|
||||||
|
|
||||||
@@ -153,8 +154,15 @@ 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'))
|
||||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
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)
|
||||||
else:
|
else:
|
||||||
# not implemented
|
# not implemented
|
||||||
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
||||||
@@ -421,8 +429,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 +448,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 +498,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,11 +527,15 @@ 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)
|
||||||
|
|
||||||
# Relay unhandled exceptions to logger.
|
# Relay unhandled exceptions to logger.
|
||||||
@@ -530,7 +547,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 +571,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 +635,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 +657,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)"
|
||||||
@@ -855,11 +877,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 +894,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 +950,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 +966,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 +1034,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)
|
||||||
|
|||||||
@@ -214,17 +214,11 @@ class WargrooveContext(CommonContext):
|
|||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
"""Import kivy UI system and start running it as self.ui_task."""
|
"""Import kivy UI system and start running it as self.ui_task."""
|
||||||
from kvui import GameManager, HoverBehavior, ServerToolTip
|
from kvui import GameManager, HoverBehavior, ServerToolTip
|
||||||
from kivy.uix.tabbedpanel import TabbedPanelItem
|
from kivymd.uix.tab import MDTabsItem, MDTabsItemText
|
||||||
from kivy.lang import Builder
|
from kivy.lang import Builder
|
||||||
from kivy.uix.button import Button
|
|
||||||
from kivy.uix.togglebutton import ToggleButton
|
from kivy.uix.togglebutton import ToggleButton
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
from kivy.uix.gridlayout import GridLayout
|
|
||||||
from kivy.uix.image import AsyncImage, Image
|
|
||||||
from kivy.uix.stacklayout import StackLayout
|
|
||||||
from kivy.uix.label import Label
|
from kivy.uix.label import Label
|
||||||
from kivy.properties import ColorProperty
|
|
||||||
from kivy.uix.image import Image
|
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
|
||||||
class TrackerLayout(BoxLayout):
|
class TrackerLayout(BoxLayout):
|
||||||
@@ -446,6 +440,6 @@ if __name__ == '__main__':
|
|||||||
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
||||||
|
|
||||||
args, rest = parser.parse_known_args()
|
args, rest = parser.parse_known_args()
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -85,6 +87,6 @@ def register():
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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.0
|
||||||
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,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{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -1071,6 +1071,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 +1123,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 = {}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
103
data/client.kv
103
data/client.kv
@@ -14,23 +14,60 @@
|
|||||||
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
|
||||||
|
<MDTabsItemBase>:
|
||||||
|
ripple_color: app.theme_cls.primaryColor
|
||||||
|
ripple_duration_in_fast: 0.2
|
||||||
<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 +86,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 +96,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 +133,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 +163,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 +187,38 @@
|
|||||||
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
|
||||||
|
|||||||
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.
|
||||||
|
|
||||||
@@ -376,6 +390,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 +636,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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,9 @@
|
|||||||
# Hylics 2
|
# Hylics 2
|
||||||
/worlds/hylics2/ @TRPG0
|
/worlds/hylics2/ @TRPG0
|
||||||
|
|
||||||
|
# Inscryption
|
||||||
|
/worlds/inscryption/ @DrBibop @Glowbuzz
|
||||||
|
|
||||||
# Kirby's Dream Land 3
|
# Kirby's Dream Land 3
|
||||||
/worlds/kdl3/ @Silvris
|
/worlds/kdl3/ @Silvris
|
||||||
|
|
||||||
@@ -90,6 +102,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
|
||||||
@@ -139,8 +154,11 @@
|
|||||||
# Risk of Rain 2
|
# Risk of Rain 2
|
||||||
/worlds/ror2/ @kindasneaki
|
/worlds/ror2/ @kindasneaki
|
||||||
|
|
||||||
|
# Saving Princess
|
||||||
|
/worlds/saving_princess/ @LeonarthCG
|
||||||
|
|
||||||
# Shivers
|
# Shivers
|
||||||
/worlds/shivers/ @GodlFire
|
/worlds/shivers/ @GodlFire @korydondzila
|
||||||
|
|
||||||
# A Short Hike
|
# A Short Hike
|
||||||
/worlds/shorthike/ @chandler05 @BrandenEK
|
/worlds/shorthike/ @chandler05 @BrandenEK
|
||||||
@@ -166,9 +184,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 +211,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,10 +229,6 @@
|
|||||||
# 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
|
||||||
@@ -224,9 +238,6 @@
|
|||||||
# 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/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Adding Games
|
# Adding Games
|
||||||
|
|
||||||
|
Like all contributions to Archipelago, New Game implementations should follow the [Contributing](/docs/contributing.md)
|
||||||
|
guide.
|
||||||
|
|
||||||
Adding a new game to Archipelago has two major parts:
|
Adding a new game to Archipelago has two major parts:
|
||||||
|
|
||||||
* Game Modification to communicate with Archipelago server (hereafter referred to as "client")
|
* Game Modification to communicate with Archipelago server (hereafter referred to as "client")
|
||||||
@@ -13,30 +16,51 @@ it will not be detailed here.
|
|||||||
|
|
||||||
The client is an intermediary program between the game and the Archipelago server. This can either be a direct
|
The client is an intermediary program between the game and the Archipelago server. This can either be a direct
|
||||||
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
|
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
|
||||||
must fulfill a few requirements in order to function as expected. The specific requirements the game client must follow
|
must fulfill a few requirements in order to function as expected. Libraries for most modern languages and the spec for
|
||||||
to behave as expected are:
|
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document.
|
||||||
|
|
||||||
|
### Hard Requirements
|
||||||
|
|
||||||
|
In order for the game client to behave as expected, it must be able to perform these functions:
|
||||||
|
|
||||||
* Handle both secure and unsecure websocket connections
|
* Handle both secure and unsecure websocket connections
|
||||||
* Detect and react when a location has been "checked" by the player by sending a network packet to the server
|
* Reconnect if the connection is unstable and lost while playing
|
||||||
* Receive and parse network packets when the player receives an item from the server, and reward it to the player on
|
|
||||||
demand
|
|
||||||
* **Any** of your items can be received any number of times, up to and far surpassing those that the game might
|
|
||||||
normally expect from features such as starting inventory, item link replacement, or item cheating
|
|
||||||
* Players and the admin can cheat items to the player at any time with a server command, and these items may not have
|
|
||||||
a player or location attributed to them
|
|
||||||
* Be able to change the port for saved connection info
|
* Be able to change the port for saved connection info
|
||||||
* Rooms hosted on the website attempt to reserve their port, but since there are a limited number of ports, this
|
* Rooms hosted on the website attempt to reserve their port, but since there are a limited number of ports, this
|
||||||
privilege can be lost, requiring the room to be moved to a new port
|
privilege can be lost, requiring the room to be moved to a new port
|
||||||
* Reconnect if the connection is unstable and lost while playing
|
|
||||||
* Keep an index for items received in order to resync. The ItemsReceived Packets are a single list with guaranteed
|
|
||||||
order.
|
|
||||||
* Receive items that were sent to the player while they were not connected to the server
|
|
||||||
* The player being able to complete checks while offline and sending them when reconnecting is a good bonus, but not
|
|
||||||
strictly required
|
|
||||||
* Send a status update packet alerting the server that the player has completed their goal
|
* Send a status update packet alerting the server that the player has completed their goal
|
||||||
|
|
||||||
Libraries for most modern languages and the spec for various packets can be found in the
|
Regarding items and locations, the game client must be able to handle these tasks:
|
||||||
[network protocol](/docs/network%20protocol.md) API reference document.
|
|
||||||
|
#### Location Handling
|
||||||
|
|
||||||
|
Send a network packet to the server when it detects a location has been "checked" by the player in-game.
|
||||||
|
|
||||||
|
* If actions were taken in game that would usually trigger a location check, and those actions can only ever be taken
|
||||||
|
once, but the client was not connected when they happened: The client must send those location checks on connection
|
||||||
|
so that they are not permanently lost, e.g. by reading flags in the game state or save file.
|
||||||
|
|
||||||
|
#### Item Handling
|
||||||
|
|
||||||
|
Receive and parse network packets from the server when the player receives an item.
|
||||||
|
|
||||||
|
* It must reward items to the player on demand, as items can come from other players at any time.
|
||||||
|
* It must be able to reward copies of an item, up to and beyond the number the game normally expects. This may happen
|
||||||
|
due to features such as starting inventory, item link replacement, admin commands, or item cheating. **Any** of
|
||||||
|
your items can be received **any** number of times.
|
||||||
|
* Admins and players may use server commands to create items without a player or location attributed to them. The
|
||||||
|
client must be able to handle these items.
|
||||||
|
* It must keep an index for items received in order to resync. The ItemsReceived Packets are a single list with a
|
||||||
|
guaranteed order.
|
||||||
|
* It must be able to receive items that were sent to the player while they were not connected to the server.
|
||||||
|
|
||||||
|
### Encouraged Features
|
||||||
|
|
||||||
|
These are "nice to have" features for a client, but they are not strictly required. It is encouraged to add them
|
||||||
|
if possible.
|
||||||
|
|
||||||
|
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
|
||||||
|
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
|
||||||
|
|
||||||
## World
|
## World
|
||||||
|
|
||||||
@@ -44,35 +68,94 @@ The world is your game integration for the Archipelago generator, webhost, and m
|
|||||||
information necessary for creating the items and locations to be randomized, the logic for item placement, the
|
information necessary for creating the items and locations to be randomized, the logic for item placement, the
|
||||||
datapackage information so other game clients can recognize your game data, and documentation. Your world must be
|
datapackage information so other game clients can recognize your game data, and documentation. Your world must be
|
||||||
written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago
|
written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago
|
||||||
repository and creating a new world package in `/worlds/`. A bare minimum world implementation must satisfy the
|
repository and creating a new world package in `/worlds/`.
|
||||||
following requirements:
|
|
||||||
|
|
||||||
* A folder within `/worlds/` that contains an `__init__.py`
|
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call
|
||||||
* A `World` subclass where you create your world and define all of its rules
|
during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
|
||||||
* A unique game name
|
regarding the API can be found in the [world api doc](/docs/world%20api.md). Before publishing, make sure to also
|
||||||
* For webhost documentation and behaviors, a `WebWorld` subclass that must be instantiated in the `World` class
|
check out [world maintainer.md](/docs/world%20maintainer.md).
|
||||||
definition
|
|
||||||
* The game_info doc must follow the format `{language_code}_{game_name}.md`
|
### Hard Requirements
|
||||||
|
|
||||||
|
A bare minimum world implementation must satisfy the following requirements:
|
||||||
|
|
||||||
|
* It has a folder with the name of your game (or an abbreviation) under `/worlds/`
|
||||||
|
* The `/worlds/{game}` folder contains an `__init__.py`
|
||||||
|
* Any subfolders within `/worlds/{game}` that contain `*.py` files also contain an `__init__.py` for frozen build
|
||||||
|
packaging
|
||||||
|
* The game folder has at least one game_info doc named with follow the format `{language_code}_{game_name}.md`
|
||||||
|
* The game folder has at least one setup doc
|
||||||
|
* There must be a `World` subclass in your game folder (typically in `/worlds/{game}/__init__.py`) where you create
|
||||||
|
your world and define all of its rules and features
|
||||||
|
|
||||||
|
Within the `World` subclass you should also have:
|
||||||
|
|
||||||
|
* A [unique game name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L260)
|
||||||
|
* An [instance](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295) of a `WebWorld`
|
||||||
|
subclass for webhost documentation and behaviors
|
||||||
|
* In your `WebWorld`, if you wrote a game_info doc in more than one language, override the list of
|
||||||
|
[game info languages](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L210) with the
|
||||||
|
ones you include.
|
||||||
|
* In your `WebWorld`, override the list of
|
||||||
|
[tutorials](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L213) with each tutorial
|
||||||
|
or setup doc you included in the game folder.
|
||||||
* A mapping for items and locations defining their names and ids for clients to be able to identify them. These are
|
* A mapping for items and locations defining their names and ids for clients to be able to identify them. These are
|
||||||
`item_name_to_id` and `location_name_to_id`, respectively.
|
`item_name_to_id` and `location_name_to_id`, respectively.
|
||||||
* Create an item when `create_item` is called both by your code and externally
|
* An implementation of `create_item` that can create an item when called by either your code or by another process
|
||||||
* An `options_dataclass` defining the options players have available to them
|
within Archipelago
|
||||||
* A `Region` for your player with the name "Menu" to start from
|
* At least one `Region` for your player to start from (i.e. the Origin Region)
|
||||||
* Create a non-zero number of locations and add them to your regions
|
* The default name of this region is "Menu" but you may configure a different name with
|
||||||
* Create a non-zero number of items **equal** to the number of locations and add them to the multiworld itempool
|
[origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)
|
||||||
* All items submitted to the multiworld itempool must not be manually placed by the World. If you need to place specific
|
* A non-zero number of locations, added to your regions
|
||||||
items, there are multiple ways to do so, but they should not be added to the multiworld itempool.
|
* A non-zero number of items **equal** to the number of locations, added to the multiworld itempool
|
||||||
|
* In rare cases, there may be 0-location-0-item games, but this is extremely atypical.
|
||||||
|
* A set
|
||||||
|
[completion condition](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#L77) (aka "goal") for
|
||||||
|
the player.
|
||||||
|
* Use your player as the index (`multiworld.completion_condition[player]`) for your world's completion goal.
|
||||||
|
|
||||||
Notable caveats:
|
### Encouraged Features
|
||||||
* The "Menu" region will always be considered the "start" for the player
|
|
||||||
* The "Menu" region is *always* considered accessible; i.e. the player is expected to always be able to return to the
|
These are "nice to have" features for a world, but they are not strictly required. It is encouraged to add them
|
||||||
|
if possible.
|
||||||
|
|
||||||
|
* An implementation of
|
||||||
|
[get_filler_item_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L473)
|
||||||
|
* By default, this function chooses any item name from `item_name_to_id`, so you want to limit it to only the true
|
||||||
|
filler items.
|
||||||
|
* An `options_dataclass` defining the options players have available to them
|
||||||
|
* This should be accompanied by a type hint for `options` with the same class name
|
||||||
|
* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220)
|
||||||
|
* A list of [option groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L226)
|
||||||
|
for better organization on the webhost
|
||||||
|
* A dictionary of [options presets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L223)
|
||||||
|
for player convenience
|
||||||
|
* A dictionary of [item name groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L273)
|
||||||
|
for player convenience
|
||||||
|
* A dictionary of
|
||||||
|
[location name groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L276)
|
||||||
|
for player convenience
|
||||||
|
* Other games may also benefit from your name group dictionaries for hints, features, etc.
|
||||||
|
|
||||||
|
### Discouraged or Prohibited Behavior
|
||||||
|
|
||||||
|
These are behaviors or implementations that are known to cause various issues. Some of these points have notable
|
||||||
|
workarounds or preferred methods which should be used instead:
|
||||||
|
|
||||||
|
* All items submitted to the multiworld itempool must not be manually placed by the World.
|
||||||
|
* If you need to place specific items, there are multiple ways to do so, but they should not be added to the
|
||||||
|
multiworld itempool.
|
||||||
|
* It is not allowed to use `eval` for most reasons, chiefly due to security concerns.
|
||||||
|
* It is discouraged to use PyYAML (i.e. `yaml.load`) directly due to security concerns.
|
||||||
|
* When possible, use `Utils.parse_yaml` instead, as this defaults to the safe loader and the faster C parser.
|
||||||
|
* When submitting regions or items to the multiworld (`multiworld.regions` and `multiworld.itempool` respectively),
|
||||||
|
do **not** use `=` as this will overwrite all elements for all games in the seed.
|
||||||
|
* Instead, use `append`, `extend`, or `+=`.
|
||||||
|
|
||||||
|
### Notable Caveats
|
||||||
|
|
||||||
|
* The Origin Region will always be considered the "start" for the player
|
||||||
|
* The Origin Region is *always* considered accessible; i.e. the player is expected to always be able to return to the
|
||||||
start of the game from anywhere
|
start of the game from anywhere
|
||||||
* When submitting regions or items to the multiworld (multiworld.regions and multiworld.itempool respectively), use
|
|
||||||
`append`, `extend`, or `+=`. **Do not use `=`**
|
|
||||||
* Regions are simply containers for locations that share similar access rules. They do not have to map to
|
* Regions are simply containers for locations that share similar access rules. They do not have to map to
|
||||||
concrete, physical areas within your game and can be more abstract like tech trees or a questline.
|
concrete, physical areas within your game and can be more abstract like tech trees or a questline.
|
||||||
|
|
||||||
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call during
|
|
||||||
generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
|
|
||||||
regarding the API can be found in the [world api doc](/docs/world%20api.md).
|
|
||||||
Before publishing, make sure to also check out [world maintainer.md](/docs/world%20maintainer.md).
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ including [Contributing](contributing.md), [Adding Games](<adding games.md>), an
|
|||||||
|
|
||||||
### My game has a restrictive start that leads to fill errors
|
### My game has a restrictive start that leads to fill errors
|
||||||
|
|
||||||
Hint to the Generator that an item needs to be in sphere one with local_early_items. Here, `1` represents the number of "Sword" items to attempt to place in sphere one.
|
A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more
|
||||||
|
than one item to get a player to sphere 2.
|
||||||
|
|
||||||
|
One way to fix this is to hint to the Generator that an item needs to be in sphere one with local_early_items.
|
||||||
|
Here, `1` represents the number of "Sword" items the Generator will attempt to place in sphere one.
|
||||||
```py
|
```py
|
||||||
early_item_name = "Sword"
|
early_item_name = "Sword"
|
||||||
self.multiworld.local_early_items[self.player][early_item_name] = 1
|
self.multiworld.local_early_items[self.player][early_item_name] = 1
|
||||||
@@ -18,15 +22,19 @@ Some alternative ways to try to fix this problem are:
|
|||||||
* Add more locations to sphere one of your world, potentially only when there would be a restrictive start
|
* Add more locations to sphere one of your world, potentially only when there would be a restrictive start
|
||||||
* Pre-place items yourself, such as during `create_items`
|
* Pre-place items yourself, such as during `create_items`
|
||||||
* Put items into the player's starting inventory using `push_precollected`
|
* Put items into the player's starting inventory using `push_precollected`
|
||||||
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a restrictive start
|
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a
|
||||||
|
restrictive start
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### I have multiple settings that change the item/location pool counts and need to balance them out
|
### I have multiple options that change the item/location pool counts and need to make sure I am not submitting more/fewer items than locations
|
||||||
|
|
||||||
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be unbalanced. But in real, complex situations, that might be unfeasible.
|
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be
|
||||||
|
unbalanced. But in real, complex situations, that might be unfeasible.
|
||||||
|
|
||||||
If that's the case, you can create extra filler based on the difference between your unfilled locations and your itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations) to your list of items to submit
|
If that's the case, you can create extra filler based on the difference between your unfilled locations and your
|
||||||
|
itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations)
|
||||||
|
to your list of items to submit
|
||||||
|
|
||||||
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
|
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
|
||||||
```py
|
```py
|
||||||
@@ -39,7 +47,78 @@ for _ in range(total_locations - len(item_pool)):
|
|||||||
self.multiworld.itempool += item_pool
|
self.multiworld.itempool += item_pool
|
||||||
```
|
```
|
||||||
|
|
||||||
A faster alternative to the `for` loop would be to use a [list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
|
A faster alternative to the `for` loop would be to use a
|
||||||
|
[list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
|
||||||
```py
|
```py
|
||||||
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
|
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary?
|
||||||
|
|
||||||
|
The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and
|
||||||
|
**when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is
|
||||||
|
quite complicated.
|
||||||
|
|
||||||
|
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph.
|
||||||
|
It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to
|
||||||
|
the queue until there is nothing more to check.
|
||||||
|
|
||||||
|
For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region
|
||||||
|
access, then the following may happen:
|
||||||
|
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been
|
||||||
|
reached yet during the graph search.
|
||||||
|
2. Then, the region in its access_rule is determined to be reachable.
|
||||||
|
|
||||||
|
This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle.
|
||||||
|
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new
|
||||||
|
regions are reached.
|
||||||
|
|
||||||
|
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep
|
||||||
|
if a specific region is reached during it.
|
||||||
|
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness),
|
||||||
|
using them is significantly faster than just "rechecking each entrance until nothing new is found".
|
||||||
|
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they
|
||||||
|
call `region.can_reach` on their respective parent/source region.
|
||||||
|
|
||||||
|
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition,
|
||||||
|
and that some games have very complex access rules.
|
||||||
|
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682)
|
||||||
|
being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of
|
||||||
|
checking each entrance whenever a region has been reached, although this does come with a performance cost.
|
||||||
|
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should
|
||||||
|
be reasonable to know all entrance → region dependencies, making indirect conditions preferred because they are
|
||||||
|
much faster.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I uploaded the generated output of my world to the webhost and webhost is erroring on corrupted multidata
|
||||||
|
|
||||||
|
The error `Could not load multidata. File may be corrupted or incompatible.` occurs when uploading a locally generated
|
||||||
|
file where there is an issue with the multidata contained within it. It may come with a description like
|
||||||
|
`(No module named 'worlds.myworld')` or `(global 'worlds.myworld.names.ItemNames' is forbidden)`
|
||||||
|
|
||||||
|
Pickling is a way to compress python objects such that they can be decompressed and be used to rebuild the
|
||||||
|
python objects. This means that if one of your custom class instances ends up in the multidata, the server would not
|
||||||
|
be able to load that custom class to decompress the data, which can fail either because the custom class is unknown
|
||||||
|
(because it cannot load your world module) or the class it's attempting to import to decompress is deemed unsafe.
|
||||||
|
|
||||||
|
Common situations where this can happen include:
|
||||||
|
* Using Option instances directly in slot_data. Ex: using `options.option_name` instead of `options.option_name.value`.
|
||||||
|
Also, consider using the `options.as_dict("option_name", "option_two")` helper.
|
||||||
|
* Using enums as Location/Item names in the datapackage. When building out `location_name_to_id` and `item_name_to_id`,
|
||||||
|
make sure that you are not using your enum class for either the names or ids in these mappings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Some locations are technically possible to check with few or no items, but they'd be very tedious or frustrating. How do worlds deal with this?
|
||||||
|
|
||||||
|
Sometimes the game can be modded to skip these locations or make them less tedious. But when this issue is due to a fundamental aspect of the game, then the general answer is "soft logic" (and its subtypes like "combat logic", "money logic", etc.). For example: you can logically require that a player have several helpful items before fighting the final boss, even if a skilled player technically needs no items to beat it. Randomizer logic should describe what's *fun* rather than what's technically possible.
|
||||||
|
|
||||||
|
Concrete examples of soft logic include:
|
||||||
|
- Defeating a boss might logically require health upgrades, damage upgrades, certain weapons, etc. that aren't strictly necessary.
|
||||||
|
- Entering a high-level area might logically require access to enough other parts of the game that checking other locations should naturally get the player to the soft-required level.
|
||||||
|
- Buying expensive shop items might logically require access to a place where you can quickly farm money, or logically require access to enough parts of the game that checking other locations should naturally generate enough money without grinding.
|
||||||
|
|
||||||
|
Remember that all items referenced by logic (however hard or soft) must be `progression`. Since you typically don't want to turn a ton of `filler` items into `progression` just for this, it's common to e.g. write money logic using only the rare "$100" item, so the dozens of "$1" and "$10" items in your world can remain `filler`.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ game contributions:
|
|||||||
* **Do not introduce unit test failures/regressions.**
|
* **Do not introduce unit test failures/regressions.**
|
||||||
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
|
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
|
||||||
your changes. Currently, the oldest supported version
|
your changes. Currently, the oldest supported version
|
||||||
is [Python 3.8](https://www.python.org/downloads/release/python-380/).
|
is [Python 3.10](https://www.python.org/downloads/release/python-31015/).
|
||||||
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
|
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
|
||||||
pushing.
|
pushing.
|
||||||
You can turn them on here:
|
You can turn them on here:
|
||||||
|
|||||||
424
docs/entrance randomization.md
Normal file
424
docs/entrance randomization.md
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
# Entrance Randomization
|
||||||
|
|
||||||
|
This document discusses the API and underlying implementation of the generic entrance randomization algorithm
|
||||||
|
exposed in [entrance_rando.py](/entrance_rando.py). Throughout the doc, entrance randomization is frequently abbreviated
|
||||||
|
as "ER."
|
||||||
|
|
||||||
|
This doc assumes familiarity with Archipelago's graph logic model. If you don't have a solid understanding of how
|
||||||
|
regions work, you should start there.
|
||||||
|
|
||||||
|
## Entrance randomization concepts
|
||||||
|
|
||||||
|
### Terminology
|
||||||
|
|
||||||
|
Some important terminology to understand when reading this doc and working with ER is listed below.
|
||||||
|
|
||||||
|
* Entrance rando - sometimes called "room rando," "transition rando," "door rando," or similar,
|
||||||
|
this is a game mode in which the game map itself is randomized.
|
||||||
|
In Archipelago, these things are often represented as `Entrance`s in the region graph, so we call it Entrance rando.
|
||||||
|
* Entrances and exits - entrances are ways into your region, exits are ways out of the region. In code, they are both
|
||||||
|
represented as `Entrance` objects. In this doc, the terms "entrances" and "exits" will be used in this sense; the
|
||||||
|
`Entrance` class will always be referenced in a code block with an uppercase E.
|
||||||
|
* Dead end - a connected group of regions which can never help ER progress. This means that it:
|
||||||
|
* Is not in any indirect conditions/access rules.
|
||||||
|
* Has no plando'd or otherwise preplaced progression items, including events.
|
||||||
|
* Has no randomized exits.
|
||||||
|
* One way transition - a transition that, in the game, is not safe to reverse through (for example, in Hollow Knight,
|
||||||
|
some transitions are inaccessible backwards in vanilla and would put you out of bounds). One way transitions are
|
||||||
|
paired together during randomization to prevent such unsafe game states. Most transitions are not one way.
|
||||||
|
|
||||||
|
### Basic randomization strategy
|
||||||
|
|
||||||
|
The Generic ER algorithm works by using the logic structures you are already familiar with. To give a basic example,
|
||||||
|
let's assume a toy world is defined with the vanilla region graph modeled below. In this diagram, the smaller boxes
|
||||||
|
represent regions while the larger boxes represent scenes. Scenes are not an Archipelago concept, the grouping is
|
||||||
|
purely illustrative.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||||
|
graph LR
|
||||||
|
subgraph startingRoom [Starting Room]
|
||||||
|
S[Starting Room Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneB [Scene B]
|
||||||
|
BR1[Scene B Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneA [Scene A]
|
||||||
|
AL1[Scene A Lower Left Door] <--> AR1[Scene A Right Door]
|
||||||
|
AL2[Scene A Upper Left Door] <--> AR1
|
||||||
|
end
|
||||||
|
subgraph sceneC [Scene C]
|
||||||
|
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
|
||||||
|
CL1 <--> CR2[Scene C Lower Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneD [Scene D]
|
||||||
|
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
|
||||||
|
end
|
||||||
|
subgraph endingRoom [Ending Room]
|
||||||
|
EL1[Ending Room Upper Left Door] <--> Victory
|
||||||
|
EL2[Ending Room Lower Left Door] <--> Victory
|
||||||
|
end
|
||||||
|
Menu --> S
|
||||||
|
S <--> AL2
|
||||||
|
BR1 <--> AL1
|
||||||
|
AR1 <--> CL1
|
||||||
|
CR1 <--> DL1
|
||||||
|
DR1 <--> EL1
|
||||||
|
CR2 <--> EL2
|
||||||
|
|
||||||
|
classDef hidden display:none;
|
||||||
|
```
|
||||||
|
|
||||||
|
First, the world begins by splitting the `Entrance`s which should be randomized. This is essentially all that has to be
|
||||||
|
done on the world side; calling the `randomize_entrances` function will do the rest, using your region definitions and
|
||||||
|
logic to generate a valid world layout by connecting the partially connected edges you've defined. After you have done
|
||||||
|
that, your region graph might look something like the following diagram. Note how each randomizable entrance/exit pair
|
||||||
|
(represented as a bidirectional arrow) is disconnected on one end.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> It is required to use explicit indirect conditions when using Generic ER. Without this restriction,
|
||||||
|
> Generic ER would have no way to correctly determine that a region may be required in logic,
|
||||||
|
> leading to significantly higher failure rates due to mis-categorized regions.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||||
|
graph LR
|
||||||
|
subgraph startingRoom [Starting Room]
|
||||||
|
S[Starting Room Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneA [Scene A]
|
||||||
|
AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door]
|
||||||
|
AL2[Scene A Lower Left Door] <--> AR1
|
||||||
|
end
|
||||||
|
subgraph sceneB [Scene B]
|
||||||
|
BR1[Scene B Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneC [Scene C]
|
||||||
|
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
|
||||||
|
CL1 <--> CR2[Scene C Lower Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneD [Scene D]
|
||||||
|
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
|
||||||
|
end
|
||||||
|
subgraph endingRoom [Ending Room]
|
||||||
|
EL1[Ending Room Upper Left Door] <--> Victory
|
||||||
|
EL2[Ending Room Lower Left Door] <--> Victory
|
||||||
|
end
|
||||||
|
Menu --> S
|
||||||
|
S <--> T1:::hidden
|
||||||
|
T2:::hidden <--> AL1
|
||||||
|
T3:::hidden <--> AL2
|
||||||
|
AR1 <--> T5:::hidden
|
||||||
|
BR1 <--> T4:::hidden
|
||||||
|
T6:::hidden <--> CL1
|
||||||
|
CR1 <--> T7:::hidden
|
||||||
|
CR2 <--> T11:::hidden
|
||||||
|
T8:::hidden <--> DL1
|
||||||
|
DR1 <--> T9:::hidden
|
||||||
|
T10:::hidden <--> EL1
|
||||||
|
T12:::hidden <--> EL2
|
||||||
|
|
||||||
|
classDef hidden display:none;
|
||||||
|
```
|
||||||
|
|
||||||
|
From here, you can call the `randomize_entrances` function and Archipelago takes over. Starting from the Menu region,
|
||||||
|
the algorithm will sweep out to find eligible region exits to randomize. It will then select an eligible target entrance
|
||||||
|
and connect them, prioritizing giving access to unvisited regions first until all regions are placed. Once the exit has
|
||||||
|
been connected to the new region, placeholder entrances are deleted. This process is visualized in the diagram below
|
||||||
|
with the newly connected edge highlighted in red.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||||
|
graph LR
|
||||||
|
subgraph startingRoom [Starting Room]
|
||||||
|
S[Starting Room Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneA [Scene A]
|
||||||
|
AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door]
|
||||||
|
AL2[Scene A Lower Left Door] <--> AR1
|
||||||
|
end
|
||||||
|
subgraph sceneB [Scene B]
|
||||||
|
BR1[Scene B Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneC [Scene C]
|
||||||
|
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
|
||||||
|
CL1 <--> CR2[Scene C Lower Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneD [Scene D]
|
||||||
|
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
|
||||||
|
end
|
||||||
|
subgraph endingRoom [Ending Room]
|
||||||
|
EL1[Ending Room Upper Left Door] <--> Victory
|
||||||
|
EL2[Ending Room Lower Left Door] <--> Victory
|
||||||
|
end
|
||||||
|
Menu --> S
|
||||||
|
S <--> CL1
|
||||||
|
T2:::hidden <--> AL1
|
||||||
|
T3:::hidden <--> AL2
|
||||||
|
AR1 <--> T5:::hidden
|
||||||
|
BR1 <--> T4:::hidden
|
||||||
|
CR1 <--> T7:::hidden
|
||||||
|
CR2 <--> T11:::hidden
|
||||||
|
T8:::hidden <--> DL1
|
||||||
|
DR1 <--> T9:::hidden
|
||||||
|
T10:::hidden <--> EL1
|
||||||
|
T12:::hidden <--> EL2
|
||||||
|
|
||||||
|
classDef hidden display:none;
|
||||||
|
linkStyle 8 stroke:red,stroke-width:5px;
|
||||||
|
```
|
||||||
|
|
||||||
|
This process is then repeated until all disconnected `Entrance`s have been connected or deleted, eventually resulting
|
||||||
|
in a randomized region layout.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||||
|
graph LR
|
||||||
|
subgraph startingRoom [Starting Room]
|
||||||
|
S[Starting Room Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneA [Scene A]
|
||||||
|
AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door]
|
||||||
|
AL2[Scene A Lower Left Door] <--> AR1
|
||||||
|
end
|
||||||
|
subgraph sceneB [Scene B]
|
||||||
|
BR1[Scene B Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneC [Scene C]
|
||||||
|
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
|
||||||
|
CL1 <--> CR2[Scene C Lower Right Door]
|
||||||
|
end
|
||||||
|
subgraph sceneD [Scene D]
|
||||||
|
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
|
||||||
|
end
|
||||||
|
subgraph endingRoom [Ending Room]
|
||||||
|
EL1[Ending Room Upper Left Door] <--> Victory
|
||||||
|
EL2[Ending Room Lower Left Door] <--> Victory
|
||||||
|
end
|
||||||
|
Menu --> S
|
||||||
|
S <--> CL1
|
||||||
|
AR1 <--> DL1
|
||||||
|
BR1 <--> EL2
|
||||||
|
CR1 <--> EL1
|
||||||
|
CR2 <--> AL1
|
||||||
|
DR1 <--> AL2
|
||||||
|
|
||||||
|
classDef hidden display:none;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ER and minimal accessibility
|
||||||
|
|
||||||
|
In general, even on minimal accessibility, ER will prefer to provide access to as many regions as possible. This is for
|
||||||
|
2 reasons:
|
||||||
|
1. Generally, having items spread across the world is going to be a more fun/engaging experience for players than
|
||||||
|
severely restricting their map. Imagine an ER arrangement with just the start region, the goal region, and exactly
|
||||||
|
enough locations in between them to get the goal - this may be the intuitive behavior of minimal, or even the desired
|
||||||
|
behavior in some cases, but it is not a particularly interesting randomizer.
|
||||||
|
2. Giving access to more of the world will give item fill a higher chance to succeed.
|
||||||
|
|
||||||
|
However, ER will cull unreachable regions and exits if necessary to save the generation of a beaten minimal.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Defining entrances to be randomized
|
||||||
|
|
||||||
|
The first step to using generic ER is defining entrances to be randomized. In order to do this, you will need to
|
||||||
|
leave partially disconnected exits without a `target_region` and partially disconnected entrances without a
|
||||||
|
`parent_region`. You can do this either by hand using `region.create_exit` and `region.create_er_target`, or you can
|
||||||
|
create your vanilla region graph and then use `disconnect_entrance_for_randomization` to split the desired edges.
|
||||||
|
If you're not sure which to use, prefer the latter approach as it will automatically satisfy the requirements for
|
||||||
|
coupled randomization (discussed in more depth later).
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> It's recommended to give your `Entrance`s non-default names when creating them. The default naming scheme is
|
||||||
|
> `f"{parent_region} -> {target_region}"` which is generally not helpful in an entrance rando context - after all,
|
||||||
|
> the target region will not be the same as vanilla and regions are often not user-facing anyway. Instead consider names
|
||||||
|
> that describe the location of the exit, such as "Starting Room Right Door."
|
||||||
|
|
||||||
|
When creating your `Entrance`s you should also set the randomization type and group. One-way `Entrance`s represent
|
||||||
|
transitions which are impossible to traverse in reverse. All other transitions are two-ways. To ensure that all
|
||||||
|
transitions can be accessed in the game, one-ways are only randomized with other one-ways and two-ways are only
|
||||||
|
randomized with other two-ways. You can set whether an `Entrance` is one-way or two-way using the `randomization_type`
|
||||||
|
attribute.
|
||||||
|
|
||||||
|
`Entrance`s can also set the `randomization_group` attribute to allow for grouping during randomization. This can be
|
||||||
|
any integer you define and may be based on player options. Some possible use cases for grouping include:
|
||||||
|
* Directional matching - only match leftward-facing transitions to rightward-facing ones
|
||||||
|
* Terrain matching - only match water transitions to water transitions and land transitions to land transitions
|
||||||
|
* Dungeon shuffle - only shuffle entrances within a dungeon/area with each other
|
||||||
|
* Combinations of the above
|
||||||
|
|
||||||
|
By default, all `Entrance`s are placed in the group 0. An entrance can only be a member of one group, but a given group
|
||||||
|
may connect to many other groups.
|
||||||
|
|
||||||
|
### Calling generic ER
|
||||||
|
|
||||||
|
Once you have defined all your entrances and exits and connected the Menu region to your region graph, you can call
|
||||||
|
`randomize_entrances` to perform randomization.
|
||||||
|
|
||||||
|
#### Coupled and uncoupled modes
|
||||||
|
|
||||||
|
In coupled randomization, an entrance placed from A to B guarantees that the reverse placement B to A also exists
|
||||||
|
(assuming that A and B are both two-way doors). Uncoupled randomization does not make this guarantee.
|
||||||
|
|
||||||
|
When using coupled mode, there are some requirements for how placeholder ER targets for two-ways are named.
|
||||||
|
`disconnect_entrance_for_randomization` will handle this for you. However, if you opt to create your ER targets and
|
||||||
|
exits by hand, you will need to ensure that ER targets into a region are named the same as the exit they correspond to.
|
||||||
|
This allows the randomizer to find and connect the reverse pairing after the first pairing is completed. See the diagram
|
||||||
|
below for an example of incorrect and correct naming.
|
||||||
|
|
||||||
|
Incorrect target naming:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||||
|
graph LR
|
||||||
|
subgraph a [" "]
|
||||||
|
direction TB
|
||||||
|
target1
|
||||||
|
target2
|
||||||
|
end
|
||||||
|
subgraph b [" "]
|
||||||
|
direction TB
|
||||||
|
Region
|
||||||
|
end
|
||||||
|
Region["Room1"] -->|Room1 Right Door| target1:::hidden
|
||||||
|
Region --- target2:::hidden -->|Room2 Left Door| Region
|
||||||
|
|
||||||
|
linkStyle 1 stroke:none;
|
||||||
|
classDef hidden display:none;
|
||||||
|
style a display:none;
|
||||||
|
style b display:none;
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct target naming:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||||
|
graph LR
|
||||||
|
subgraph a [" "]
|
||||||
|
direction TB
|
||||||
|
target1
|
||||||
|
target2
|
||||||
|
end
|
||||||
|
subgraph b [" "]
|
||||||
|
direction TB
|
||||||
|
Region
|
||||||
|
end
|
||||||
|
Region["Room1"] -->|Room1 Right Door| target1:::hidden
|
||||||
|
Region --- target2:::hidden -->|Room1 Right Door| Region
|
||||||
|
|
||||||
|
linkStyle 1 stroke:none;
|
||||||
|
classDef hidden display:none;
|
||||||
|
style a display:none;
|
||||||
|
style b display:none;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Implementing grouping
|
||||||
|
|
||||||
|
When you created your entrances, you defined the group each entrance belongs to. Now you will have to define how groups
|
||||||
|
should connect with each other. This is done with the `target_group_lookup` and `preserve_group_order` parameters.
|
||||||
|
There is also a convenience function `bake_target_group_lookup` which can help to prepare group lookups when more
|
||||||
|
complex group mapping logic is needed. Some recipes for `target_group_lookup` are presented here.
|
||||||
|
|
||||||
|
For the recipes below, assume the following groups (if the syntax used here is unfamiliar to you, "bit masking" and
|
||||||
|
"bitwise operators" would be the terms to search for):
|
||||||
|
```python
|
||||||
|
class Groups(IntEnum):
|
||||||
|
# Directions
|
||||||
|
LEFT = 1
|
||||||
|
RIGHT = 2
|
||||||
|
TOP = 3
|
||||||
|
BOTTOM = 4
|
||||||
|
DOOR = 5
|
||||||
|
# Areas
|
||||||
|
FIELD = 1 << 3
|
||||||
|
CAVE = 2 << 3
|
||||||
|
MOUNTAIN = 3 << 3
|
||||||
|
# Bitmasks
|
||||||
|
DIRECTION_MASK = FIELD - 1
|
||||||
|
AREA_MASK = ~0 << 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Directional matching:
|
||||||
|
```python
|
||||||
|
direction_matching_group_lookup = {
|
||||||
|
# with preserve_group_order = False, pair a left transition to either a right transition or door randomly
|
||||||
|
# with preserve_group_order = True, pair a left transition to a right transition, or else a door if no
|
||||||
|
# viable right transitions remain
|
||||||
|
Groups.LEFT: [Groups.RIGHT, Groups.DOOR],
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Terrain matching or dungeon shuffle:
|
||||||
|
```python
|
||||||
|
def randomize_within_same_group(group: int) -> List[int]:
|
||||||
|
return [group]
|
||||||
|
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
|
||||||
|
```
|
||||||
|
|
||||||
|
Directional + area shuffle:
|
||||||
|
```python
|
||||||
|
def get_target_groups(group: int) -> List[int]:
|
||||||
|
# example group: LEFT | CAVE
|
||||||
|
# example result: [RIGHT | CAVE, DOOR | CAVE]
|
||||||
|
direction = group & Groups.DIRECTION_MASK
|
||||||
|
area = group & Groups.AREA_MASK
|
||||||
|
return [pair_direction | area for pair_direction in direction_matching_group_lookup[direction]]
|
||||||
|
target_group_lookup = bake_target_group_lookup(world, get_target_groups)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### When to call `randomize_entrances`
|
||||||
|
|
||||||
|
The correct step for this is `World.connect_entrances`.
|
||||||
|
|
||||||
|
Currently, you could theoretically do it as early as `World.create_regions` or as late as `pre_fill`.
|
||||||
|
However, there are upcoming changes to Item Plando and Generic Entrance Randomizer to make the two features work better
|
||||||
|
together.
|
||||||
|
These changes necessitate that entrance randomization is done exactly in `World.connect_entrances`.
|
||||||
|
It is fine for your Entrances to be connected differently or not at all before this step.
|
||||||
|
|
||||||
|
#### Informing your client about randomized entrances
|
||||||
|
|
||||||
|
`randomize_entrances` returns the completed `ERPlacementState`. The `pairings` attribute contains a list of the
|
||||||
|
created placements by name which can be used to populate slot data.
|
||||||
|
|
||||||
|
### Imposing custom constraints on randomization
|
||||||
|
|
||||||
|
Generic ER is, as the name implies, generic! That means that your world may have some use case which is not covered by
|
||||||
|
the ER implementation. To solve this, you can create a custom `Entrance` class which provides custom implementations
|
||||||
|
for `is_valid_source_transition` and `can_connect_to`. These allow arbitrary constraints to be implemented on
|
||||||
|
randomization, for instance helping to prevent restrictive sphere 1s or ensuring a maximum distance from a "hub" region.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> When implementing these functions, make sure to use `super().is_valid_source_transition` and `super().can_connect_to`
|
||||||
|
> as part of your implementation. Otherwise ER may behave unexpectedly.
|
||||||
|
|
||||||
|
## Implementation details
|
||||||
|
|
||||||
|
This section is a medium-level explainer of the implementation of ER for those who don't want to decipher the code.
|
||||||
|
However, a basic understanding of the mechanics of `fill_restrictive` will be helpful as many of the underlying
|
||||||
|
algorithms are shared
|
||||||
|
|
||||||
|
ER uses a forward fill approach to create the region layout. First, ER collects `all_state` and performs a region sweep
|
||||||
|
from Menu, similar to fill. ER then proceeds in stages to complete the randomization:
|
||||||
|
1. Attempt to connect all non-dead-end regions, prioritizing access to unseen regions so there will always be new exits
|
||||||
|
to pair off.
|
||||||
|
2. Attempt to connect all dead-end regions, so that all regions will be placed
|
||||||
|
3. Connect all remaining dangling edges now that all regions are placed.
|
||||||
|
1. Connect any other dead end entrances (e.g. second entrances to the same dead end regions).
|
||||||
|
2. Connect all remaining non-dead-ends amongst each other.
|
||||||
|
|
||||||
|
The process for each connection will do the following:
|
||||||
|
1. Select a randomizable exit of a reachable region which is a valid source transition.
|
||||||
|
2. Get its group and check `target_group_lookup` to determine which groups are valid targets.
|
||||||
|
3. Look up ER targets from those groups and find one which is valid according to `can_connect_to`
|
||||||
|
4. Connect the source exit to the target's target_region and delete the target.
|
||||||
|
* In stage 1, before placing the last valid source transition, an additional speculative sweep is performed to ensure
|
||||||
|
that there will be an available exit after the placement so randomization can continue.
|
||||||
|
5. If it's coupled mode, find the reverse exit and target by name and connect them as well.
|
||||||
|
6. Sweep to update reachable regions.
|
||||||
|
7. Call the `on_connect` callback.
|
||||||
|
|
||||||
|
This process repeats until the stage is complete, no valid source transition is found, or no valid target transition is
|
||||||
|
found for any source transition. Unlike fill, there is no attempt made to save a failed randomization.
|
||||||
@@ -117,8 +117,6 @@ flowchart LR
|
|||||||
%% Java Based Games
|
%% Java Based Games
|
||||||
subgraph Java
|
subgraph Java
|
||||||
JM[Mod with Archipelago.MultiClient.Java]
|
JM[Mod with Archipelago.MultiClient.Java]
|
||||||
STS[Slay the Spire]
|
|
||||||
JM <-- Mod the Spire --> STS
|
|
||||||
subgraph Minecraft
|
subgraph Minecraft
|
||||||
MCS[Minecraft Forge Server]
|
MCS[Minecraft Forge Server]
|
||||||
JMC[Any Java Minecraft Clients]
|
JMC[Any Java Minecraft Clients]
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ Packets are simple JSON lists in which any number of ordered network commands ca
|
|||||||
|
|
||||||
An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example.
|
An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example.
|
||||||
|
|
||||||
|
Websocket connections should support per-message compression. Uncompressed connections are deprecated and may stop
|
||||||
|
working in the future.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```javascript
|
```javascript
|
||||||
[{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }]
|
[{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }]
|
||||||
@@ -261,6 +264,7 @@ Sent to clients in response to a [Set](#Set) package if want_reply was set to tr
|
|||||||
| key | str | The key that was updated. |
|
| key | str | The key that was updated. |
|
||||||
| value | any | The new value for the key. |
|
| value | any | The new value for the key. |
|
||||||
| original_value | any | The value the key had before it was updated. Not present on "_read" prefixed special keys. |
|
| original_value | any | The value the key had before it was updated. Not present on "_read" prefixed special keys. |
|
||||||
|
| slot | int | The slot that originally sent the Set package causing this change. |
|
||||||
|
|
||||||
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
|
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
|
||||||
|
|
||||||
@@ -272,6 +276,7 @@ These packets are sent purely from client to server. They are not accepted by cl
|
|||||||
* [Sync](#Sync)
|
* [Sync](#Sync)
|
||||||
* [LocationChecks](#LocationChecks)
|
* [LocationChecks](#LocationChecks)
|
||||||
* [LocationScouts](#LocationScouts)
|
* [LocationScouts](#LocationScouts)
|
||||||
|
* [UpdateHint](#UpdateHint)
|
||||||
* [StatusUpdate](#StatusUpdate)
|
* [StatusUpdate](#StatusUpdate)
|
||||||
* [Say](#Say)
|
* [Say](#Say)
|
||||||
* [GetDataPackage](#GetDataPackage)
|
* [GetDataPackage](#GetDataPackage)
|
||||||
@@ -342,6 +347,33 @@ This is useful in cases where an item appears in the game world, such as 'ledge
|
|||||||
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
|
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
|
||||||
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
|
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
|
||||||
|
|
||||||
|
### UpdateHint
|
||||||
|
Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails.
|
||||||
|
|
||||||
|
### Arguments
|
||||||
|
| Name | Type | Notes |
|
||||||
|
| ---- | ---- | ----- |
|
||||||
|
| player | int | The ID of the player whose location is being hinted for. |
|
||||||
|
| location | int | The ID of the location to update the hint for. If no hint exists for this location, the packet is ignored. |
|
||||||
|
| status | [HintStatus](#HintStatus) | Optional. If included, sets the status of the hint to this status. Cannot set `HINT_FOUND`, or change the status from `HINT_FOUND`. |
|
||||||
|
|
||||||
|
#### HintStatus
|
||||||
|
An enumeration containing the possible hint states.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import enum
|
||||||
|
class HintStatus(enum.IntEnum):
|
||||||
|
HINT_UNSPECIFIED = 0 # The receiving player has not specified any status
|
||||||
|
HINT_NO_PRIORITY = 10 # The receiving player has specified that the item is unneeded
|
||||||
|
HINT_AVOID = 20 # The receiving player has specified that the item is detrimental
|
||||||
|
HINT_PRIORITY = 30 # The receiving player has specified that the item is needed
|
||||||
|
HINT_FOUND = 40 # The location has been collected. Status cannot be changed once found.
|
||||||
|
```
|
||||||
|
- Hints for items with `ItemClassification.trap` default to `HINT_AVOID`.
|
||||||
|
- Hints created with `LocationScouts`, `!hint_location`, or similar (hinting a location) default to `HINT_UNSPECIFIED`.
|
||||||
|
- Hints created with `!hint` or similar (hinting an item for yourself) default to `HINT_PRIORITY`.
|
||||||
|
- Once a hint is collected, its' status is updated to `HINT_FOUND` automatically, and can no longer be changed.
|
||||||
|
|
||||||
### StatusUpdate
|
### StatusUpdate
|
||||||
Sent to the server to update on the sender's status. Examples include readiness or goal completion. (Example: defeated Ganon in A Link to the Past)
|
Sent to the server to update on the sender's status. Examples include readiness or goal completion. (Example: defeated Ganon in A Link to the Past)
|
||||||
|
|
||||||
@@ -438,7 +470,7 @@ The following operations can be applied to a datastorage key
|
|||||||
| right_shift | Applies a bitwise right-shift to the current value of the key by `value`. |
|
| right_shift | Applies a bitwise right-shift to the current value of the key by `value`. |
|
||||||
| remove | List only: removes the first instance of `value` found in the list. |
|
| remove | List only: removes the first instance of `value` found in the list. |
|
||||||
| pop | List or Dict: for lists it will remove the index of the `value` given. for dicts it removes the element with the specified key of `value`. |
|
| pop | List or Dict: for lists it will remove the index of the `value` given. for dicts it removes the element with the specified key of `value`. |
|
||||||
| update | Dict only: Updates the dictionary with the specified elements given in `value` creating new keys, or updating old ones if they previously existed. |
|
| update | List or Dict: Adds the elements of `value` to the container if they weren't already present. In the case of a Dict, already present keys will have their corresponding values updated. |
|
||||||
|
|
||||||
### SetNotify
|
### SetNotify
|
||||||
Used to register your current session for receiving all [SetReply](#SetReply) packages of certain keys to allow your client to keep track of changes.
|
Used to register your current session for receiving all [SetReply](#SetReply) packages of certain keys to allow your client to keep track of changes.
|
||||||
@@ -501,9 +533,9 @@ In JSON this may look like:
|
|||||||
{"item": 3, "location": 3, "player": 3, "flags": 0}
|
{"item": 3, "location": 3, "player": 3, "flags": 0}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||||
|
|
||||||
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||||
|
|
||||||
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
|
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
|
||||||
|
|
||||||
@@ -512,7 +544,7 @@ In JSON this may look like:
|
|||||||
| ----- | ----- |
|
| ----- | ----- |
|
||||||
| 0 | Nothing special about this item |
|
| 0 | Nothing special about this item |
|
||||||
| 0b001 | If set, indicates the item can unlock logical advancement |
|
| 0b001 | If set, indicates the item can unlock logical advancement |
|
||||||
| 0b010 | If set, indicates the item is important but not in a way that unlocks advancement |
|
| 0b010 | If set, indicates the item is especially useful |
|
||||||
| 0b100 | If set, indicates the item is a trap |
|
| 0b100 | If set, indicates the item is a trap |
|
||||||
|
|
||||||
### JSONMessagePart
|
### JSONMessagePart
|
||||||
@@ -526,6 +558,7 @@ class JSONMessagePart(TypedDict):
|
|||||||
color: Optional[str] # only available if type is a color
|
color: Optional[str] # only available if type is a color
|
||||||
flags: Optional[int] # only available if type is an item_id or item_name
|
flags: Optional[int] # only available if type is an item_id or item_name
|
||||||
player: Optional[int] # only available if type is either item or location
|
player: Optional[int] # only available if type is either item or location
|
||||||
|
hint_status: Optional[HintStatus] # only available if type is hint_status
|
||||||
```
|
```
|
||||||
|
|
||||||
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.
|
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.
|
||||||
@@ -541,6 +574,7 @@ Possible values for `type` include:
|
|||||||
| location_id | Location ID, should be resolved to Location Name |
|
| location_id | Location ID, should be resolved to Location Name |
|
||||||
| location_name | Location Name, not currently used over network, but supported by reference Clients. |
|
| location_name | Location Name, not currently used over network, but supported by reference Clients. |
|
||||||
| entrance_name | Entrance Name. No ID mapping exists. |
|
| entrance_name | Entrance Name. No ID mapping exists. |
|
||||||
|
| hint_status | The [HintStatus](#HintStatus) of the hint. Both `text` and `hint_status` are given. |
|
||||||
| color | Regular text that should be colored. Only `type` that will contain `color` data. |
|
| color | Regular text that should be colored. Only `type` that will contain `color` data. |
|
||||||
|
|
||||||
|
|
||||||
@@ -644,6 +678,7 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Package Contents
|
### Data Package Contents
|
||||||
@@ -713,6 +748,7 @@ Tags are represented as a list of strings, the common client tags follow:
|
|||||||
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
||||||
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||||
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||||
|
| NoText | Indicates the client does not want to receive text messages, improving performance if not needed. |
|
||||||
|
|
||||||
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
||||||
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
||||||
@@ -720,8 +756,8 @@ Tags are represented as a list of strings, the common client tags follow:
|
|||||||
### DeathLink
|
### DeathLink
|
||||||
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
|
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
|
||||||
|
|
||||||
| Name | Type | Notes |
|
| Name | Type | Notes |
|
||||||
|--------|-------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|--------|-------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| time | float | Unix Time Stamp of time of death. |
|
| time | float | Unix Time Stamp of time of death. |
|
||||||
| cause | str | Optional. Text to explain the cause of death. When provided, or checked, this should contain the player name, ex. "Berserker was run over by a train." |
|
| cause | str | Optional. Text to explain the cause of death. When provided, or checked, if the string is non-empty, it should contain the player name, ex. "Berserker was run over by a train." |
|
||||||
| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. |
|
| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. |
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ user hovers over the yellow "(?)" icon, and included in the YAML templates gener
|
|||||||
The WebHost can display Option documentation either as plain text with all whitespace preserved (other than the base
|
The WebHost can display Option documentation either as plain text with all whitespace preserved (other than the base
|
||||||
indentation), or as HTML generated from the standard Python [reStructuredText] format. Although plain text is the
|
indentation), or as HTML generated from the standard Python [reStructuredText] format. Although plain text is the
|
||||||
default for backwards compatibility, world authors are encouraged to write their Option documentation as
|
default for backwards compatibility, world authors are encouraged to write their Option documentation as
|
||||||
reStructuredText and enable rich text rendering by setting `World.rich_text_options_doc = True`.
|
reStructuredText and enable rich text rendering by setting `WebWorld.rich_text_options_doc = True`.
|
||||||
|
|
||||||
[reStructuredText]: https://docutils.sourceforge.io/rst.html
|
[reStructuredText]: https://docutils.sourceforge.io/rst.html
|
||||||
|
|
||||||
@@ -352,8 +352,15 @@ template. If you set a [Schema](https://pypi.org/project/schema/) on the class w
|
|||||||
options system will automatically validate the user supplied data against the schema to ensure it's in the correct
|
options system will automatically validate the user supplied data against the schema to ensure it's in the correct
|
||||||
format.
|
format.
|
||||||
|
|
||||||
|
### OptionCounter
|
||||||
|
This is a special case of OptionDict where the dictionary values can only be integers.
|
||||||
|
It returns a [collections.Counter](https://docs.python.org/3/library/collections.html#collections.Counter).
|
||||||
|
This means that if you access a key that isn't present, its value will be 0.
|
||||||
|
The upside of using an OptionCounter (instead of an OptionDict with integer values) is that an OptionCounter can be
|
||||||
|
displayed on the Options page on WebHost.
|
||||||
|
|
||||||
### ItemDict
|
### ItemDict
|
||||||
Like OptionDict, except this will verify that every key in the dictionary is a valid name for an item for your world.
|
An OptionCounter that will verify that every key in the dictionary is a valid name for an item for your world.
|
||||||
|
|
||||||
### OptionList
|
### OptionList
|
||||||
This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You
|
This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use that version. These steps are for developers or platforms without compiled r
|
|||||||
## General
|
## General
|
||||||
|
|
||||||
What you'll need:
|
What you'll need:
|
||||||
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
|
* [Python 3.10.11 or newer](https://www.python.org/downloads/), not the Windows Store version
|
||||||
|
* On Windows, please consider only using the latest supported version in production environments since security
|
||||||
|
updates for older versions are not easily available.
|
||||||
* Python 3.12.x is currently the newest supported version
|
* Python 3.12.x is currently the newest supported version
|
||||||
* pip: included in downloads from python.org, separate in many Linux distributions
|
* pip: included in downloads from python.org, separate in many Linux distributions
|
||||||
* Matching C compiler
|
* Matching C compiler
|
||||||
@@ -41,9 +43,9 @@ Recommended steps
|
|||||||
[Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
[Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
||||||
|
|
||||||
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
|
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
|
||||||
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
|
* Run ModuleUpdate.py which will prompt installation of missing modules, press enter to confirm
|
||||||
* In PyCharm: right-click Generate.py and select `Run 'Generate'`
|
* In PyCharm: right-click ModuleUpdate.py and select `Run 'ModuleUpdate'`
|
||||||
* Without PyCharm: open a command prompt in the source folder and type `py Generate.py`
|
* Without PyCharm: open a command prompt in the source folder and type `py ModuleUpdate.py`
|
||||||
|
|
||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
|
|||||||
@@ -73,15 +73,47 @@ When tests are run, this class will create a multiworld with a single player hav
|
|||||||
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
|
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
|
||||||
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
|
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
|
||||||
overridden. For more information on what methods are available to your class, check the
|
overridden. For more information on what methods are available to your class, check the
|
||||||
[WorldTestBase definition](/test/bases.py#L104).
|
[WorldTestBase definition](/test/bases.py#L106).
|
||||||
|
|
||||||
#### Alternatives to WorldTestBase
|
#### Alternatives to WorldTestBase
|
||||||
|
|
||||||
Unit tests can also be created using [TestBase](/test/bases.py#L14) or
|
Unit tests can also be created using [TestBase](/test/bases.py#L16) or
|
||||||
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
|
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
|
||||||
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
|
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
|
||||||
testing portions of your code that can be tested without relying on a multiworld to be created first.
|
testing portions of your code that can be tested without relying on a multiworld to be created first.
|
||||||
|
|
||||||
|
#### Parametrization
|
||||||
|
|
||||||
|
When defining a test that needs to cover a range of inputs it is useful to parameterize (to run the same test
|
||||||
|
for multiple inputs) the base test. Some important things to consider when attempting to parametrize your test are:
|
||||||
|
|
||||||
|
* [Subtests](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests)
|
||||||
|
can be used to have parametrized assertions that show up similar to individual tests but without the overhead
|
||||||
|
of needing to instantiate multiple tests; however, subtests can not be multithreaded and do not have individual
|
||||||
|
timing data, so they are not suitable for slow tests.
|
||||||
|
|
||||||
|
* Archipelago's tests are test-runner-agnostic. That means tests are not allowed to use e.g. `@pytest.mark.parametrize`.
|
||||||
|
Instead, we define our own parametrization helpers in [test.param](/test/param.py).
|
||||||
|
|
||||||
|
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
|
||||||
|
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
|
||||||
|
extra CPU time. Consider using `TestBase` or `unittest.TestCase` directly
|
||||||
|
or setting `WorldTestBase.run_default_tests` to False.
|
||||||
|
|
||||||
|
#### Performance Considerations
|
||||||
|
|
||||||
|
Archipelago is big enough that the runtime of unittests can have an impact on productivity.
|
||||||
|
|
||||||
|
Individual tests should take less than a second, so they can be properly multithreaded.
|
||||||
|
|
||||||
|
Ideally, thorough tests are directed at actual code/functionality. Do not just create and/or fill a ton of individual
|
||||||
|
Multiworlds that spend most of the test time outside what you actually want to test.
|
||||||
|
|
||||||
|
Consider generating/validating "random" games as part of your APWorld release workflow rather than having that be part
|
||||||
|
of continuous integration, and add minimal reproducers to the "normal" tests for problems that were found.
|
||||||
|
You can use [@unittest.skipIf](https://docs.python.org/3/library/unittest.html#unittest.skipIf) with an environment
|
||||||
|
variable to keep all the benefits of the test framework while not running the marked tests by default.
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
#### Using Pycharm
|
#### Using Pycharm
|
||||||
@@ -100,3 +132,11 @@ next to the run and debug buttons.
|
|||||||
#### Running Tests without Pycharm
|
#### Running Tests without Pycharm
|
||||||
|
|
||||||
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
|
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
|
||||||
|
|
||||||
|
#### Running Tests Multithreaded
|
||||||
|
|
||||||
|
pytest can run multiple test runners in parallel with the pytest-xdist extension.
|
||||||
|
|
||||||
|
Install with `pip install pytest-xdist`.
|
||||||
|
|
||||||
|
Run with `pytest -n12` to spawn 12 process that each run 1/12th of the tests.
|
||||||
|
|||||||
@@ -27,8 +27,14 @@
|
|||||||
# If you wish to deploy, uncomment the following line and set it to something not easily guessable.
|
# If you wish to deploy, uncomment the following line and set it to something not easily guessable.
|
||||||
# SECRET_KEY: "Your secret key here"
|
# SECRET_KEY: "Your secret key here"
|
||||||
|
|
||||||
# TODO
|
# Slot limit to post a generation to Generator process pool instead of rolling directly in WebHost process
|
||||||
#JOB_THRESHOLD: 2
|
#JOB_THRESHOLD: 1
|
||||||
|
|
||||||
|
# After what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||||
|
#JOB_TIME: 600
|
||||||
|
|
||||||
|
# Memory limit for Generator processes in bytes, -1 for unlimited. Currently only works on Linux.
|
||||||
|
#GENERATOR_MEMORY_LIMIT: 4294967296
|
||||||
|
|
||||||
# waitress uses one thread for I/O, these are for processing of view that get sent
|
# waitress uses one thread for I/O, these are for processing of view that get sent
|
||||||
#WAITRESS_THREADS: 10
|
#WAITRESS_THREADS: 10
|
||||||
|
|||||||
@@ -222,8 +222,8 @@ could also be progress in a research tree, or even something more abstract like
|
|||||||
|
|
||||||
Each location has a `name` and an `address` (hereafter referred to as an `id`), is placed in a Region, has access rules,
|
Each location has a `name` and an `address` (hereafter referred to as an `id`), is placed in a Region, has access rules,
|
||||||
and has a classification. The name needs to be unique within each game and must not be numeric (must contain least 1
|
and has a classification. The name needs to be unique within each game and must not be numeric (must contain least 1
|
||||||
letter or symbol). The ID needs to be unique across all games, and is best kept in the same range as the item IDs.
|
letter or symbol). The ID needs to be unique across all locations within the game.
|
||||||
Locations and items can share IDs, so typically a game's locations and items start at the same ID.
|
Locations and items can share IDs, and locations can share IDs with other games' locations.
|
||||||
|
|
||||||
World-specific IDs must be in the range 1 to 2<sup>53</sup>-1; IDs ≤ 0 are global and reserved.
|
World-specific IDs must be in the range 1 to 2<sup>53</sup>-1; IDs ≤ 0 are global and reserved.
|
||||||
|
|
||||||
@@ -243,12 +243,15 @@ progression. Progression items will be assigned to locations with higher priorit
|
|||||||
and satisfy progression balancing.
|
and satisfy progression balancing.
|
||||||
|
|
||||||
The name needs to be unique within each game, meaning if you need to create multiple items with the same name, they
|
The name needs to be unique within each game, meaning if you need to create multiple items with the same name, they
|
||||||
will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol).
|
will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol).
|
||||||
|
The ID thus also needs to be unique across all items with different names within the game.
|
||||||
|
Items and locations can share IDs, and items can share IDs with other games' items.
|
||||||
|
|
||||||
Other classifications include:
|
Other classifications include:
|
||||||
|
|
||||||
* `filler`: a regular item or trash item
|
* `filler`: a regular item or trash item
|
||||||
* `useful`: generally quite useful, but not required for anything logical. Cannot be placed on excluded locations
|
* `useful`: item that is especially useful. Cannot be placed on excluded or unreachable locations. When combined with
|
||||||
|
another flag like "progression", it means "an especially useful progression item".
|
||||||
* `trap`: negative impact on the player
|
* `trap`: negative impact on the player
|
||||||
* `skip_balancing`: denotes that an item should not be moved to an earlier sphere for the purpose of balancing (to be
|
* `skip_balancing`: denotes that an item should not be moved to an earlier sphere for the purpose of balancing (to be
|
||||||
combined with `progression`; see below)
|
combined with `progression`; see below)
|
||||||
@@ -288,8 +291,8 @@ like entrance randomization in logic.
|
|||||||
|
|
||||||
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
|
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
|
||||||
|
|
||||||
There must be one special region, "Menu", from which the logic unfolds. AP assumes that a player will always be able to
|
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)),
|
||||||
return to the "Menu" region by resetting the game ("Save and quit").
|
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
|
||||||
|
|
||||||
### Entrances
|
### Entrances
|
||||||
|
|
||||||
@@ -328,6 +331,9 @@ Even doing `state.can_reach_location` or `state.can_reach_entrance` is problemat
|
|||||||
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
||||||
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
||||||
|
|
||||||
|
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L301-L304),
|
||||||
|
avoiding the need for indirect conditions at the expense of performance.
|
||||||
|
|
||||||
### Item Rules
|
### Item Rules
|
||||||
|
|
||||||
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
|
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
|
||||||
@@ -463,7 +469,7 @@ The world has to provide the following things for generation:
|
|||||||
|
|
||||||
* the properties mentioned above
|
* the properties mentioned above
|
||||||
* additions to the item pool
|
* additions to the item pool
|
||||||
* additions to the regions list: at least one called "Menu"
|
* additions to the regions list: at least one named after the world class's origin_region_name ("Menu" by default)
|
||||||
* locations placed inside those regions
|
* locations placed inside those regions
|
||||||
* a `def create_item(self, item: str) -> MyGameItem` to create any item on demand
|
* a `def create_item(self, item: str) -> MyGameItem` to create any item on demand
|
||||||
* applying `self.multiworld.push_precollected` for world-defined start inventory
|
* applying `self.multiworld.push_precollected` for world-defined start inventory
|
||||||
@@ -486,6 +492,9 @@ In addition, the following methods can be implemented and are called in this ord
|
|||||||
after this step. Locations cannot be moved to different regions after this step.
|
after this step. Locations cannot be moved to different regions after this step.
|
||||||
* `set_rules(self)`
|
* `set_rules(self)`
|
||||||
called to set access and item rules on locations and entrances.
|
called to set access and item rules on locations and entrances.
|
||||||
|
* `connect_entrances(self)`
|
||||||
|
by the end of this step, all entrances must exist and be connected to their source and target regions.
|
||||||
|
Entrance randomization should be done here.
|
||||||
* `generate_basic(self)`
|
* `generate_basic(self)`
|
||||||
player-specific randomization that does not affect logic can be done here.
|
player-specific randomization that does not affect logic can be done here.
|
||||||
* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)`
|
* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)`
|
||||||
@@ -516,7 +525,7 @@ def generate_early(self) -> None:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
def create_regions(self) -> None:
|
def create_regions(self) -> None:
|
||||||
# Add regions to the multiworld. "Menu" is the required starting point.
|
# Add regions to the multiworld. One of them must use the origin_region_name as its name ("Menu" by default).
|
||||||
# Arguments to Region() are name, player, multiworld, and optionally hint_text
|
# Arguments to Region() are name, player, multiworld, and optionally hint_text
|
||||||
menu_region = Region("Menu", self.player, self.multiworld)
|
menu_region = Region("Menu", self.player, self.multiworld)
|
||||||
self.multiworld.regions.append(menu_region) # or use += [menu_region...]
|
self.multiworld.regions.append(menu_region) # or use += [menu_region...]
|
||||||
@@ -552,18 +561,14 @@ from .items import is_progression # this is just a dummy
|
|||||||
|
|
||||||
|
|
||||||
def create_item(self, item: str) -> MyGameItem:
|
def create_item(self, item: str) -> MyGameItem:
|
||||||
# this is called when AP wants to create an item by name (for plando) or when you call it from your own code
|
# this is called when AP wants to create an item by name (for plando, start inventory, item links) or when you call it from your own code
|
||||||
classification = ItemClassification.progression if is_progression(item) else
|
classification = ItemClassification.progression if is_progression(item) else ItemClassification.filler
|
||||||
ItemClassification.filler
|
return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
|
||||||
|
|
||||||
|
|
||||||
return MyGameItem(item, classification, self.item_name_to_id[item],
|
|
||||||
self.player)
|
|
||||||
|
|
||||||
|
|
||||||
def create_event(self, event: str) -> MyGameItem:
|
def create_event(self, event: str) -> MyGameItem:
|
||||||
# while we are at it, we can also add a helper to create events
|
# while we are at it, we can also add a helper to create events
|
||||||
return MyGameItem(event, True, None, self.player)
|
return MyGameItem(event, ItemClassification.progression, None, self.player)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### create_items
|
#### create_items
|
||||||
@@ -601,8 +606,8 @@ from .items import get_item_type
|
|||||||
|
|
||||||
def set_rules(self) -> None:
|
def set_rules(self) -> None:
|
||||||
# For some worlds this step can be omitted if either a Logic mixin
|
# For some worlds this step can be omitted if either a Logic mixin
|
||||||
# (see below) is used, it's easier to apply the rules from data during
|
# (see below) is used or it's easier to apply the rules from data during
|
||||||
# location generation or everything is in generate_basic
|
# location generation
|
||||||
|
|
||||||
# set a simple rule for an region
|
# set a simple rule for an region
|
||||||
set_rule(self.multiworld.get_entrance("Boss Door", self.player),
|
set_rule(self.multiworld.get_entrance("Boss Door", self.player),
|
||||||
@@ -696,9 +701,92 @@ When importing a file that defines a class that inherits from `worlds.AutoWorld.
|
|||||||
is automatically extended by the mixin's members. These members should be prefixed with the name of the implementing
|
is automatically extended by the mixin's members. These members should be prefixed with the name of the implementing
|
||||||
world since the namespace is shared with all other logic mixins.
|
world since the namespace is shared with all other logic mixins.
|
||||||
|
|
||||||
Some uses could be to add additional variables to the state object, or to have a custom state machine that gets modified
|
LogicMixin is handy when your logic is more complex than one-to-one location-item relationships.
|
||||||
with the state.
|
A game in which "The red key opens the red door" can just express this relationship through a one-line access rule.
|
||||||
Please do this with caution and only when necessary.
|
But now, consider a game with a heavy focus on combat, where the main logical consideration is which enemies you can
|
||||||
|
defeat with your current items.
|
||||||
|
There could be dozens of weapons, armor pieces, or consumables that each improve your ability to defeat
|
||||||
|
specific enemies to varying degrees. It would be useful to be able to keep track of "defeatable enemies" as a state variable,
|
||||||
|
and have this variable be recalculated as necessary based on newly collected/removed items.
|
||||||
|
This is the capability of LogicMixin: Adding custom variables to state that get recalculated as necessary.
|
||||||
|
|
||||||
|
In general, a LogicMixin class should have at least one mutable variable that is tracking some custom state per player,
|
||||||
|
as well as `init_mixin` and `copy_mixin` functions so that this variable gets initialized and copied correctly when
|
||||||
|
`CollectionState()` and `CollectionState.copy()` are called respectively.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from BaseClasses import CollectionState, MultiWorld
|
||||||
|
from worlds.AutoWorld import LogicMixin
|
||||||
|
|
||||||
|
class MyGameState(LogicMixin):
|
||||||
|
mygame_defeatable_enemies: Dict[int, Set[str]] # per player
|
||||||
|
|
||||||
|
def init_mixin(self, multiworld: MultiWorld) -> None:
|
||||||
|
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
|
||||||
|
# You can also use something like Collections.defaultdict
|
||||||
|
self.mygame_defeatable_enemies = {
|
||||||
|
player: set() for player in multiworld.get_game_players("My Game")
|
||||||
|
}
|
||||||
|
|
||||||
|
def copy_mixin(self, new_state: CollectionState) -> CollectionState:
|
||||||
|
# Be careful to make a "deep enough" copy here!
|
||||||
|
new_state.mygame_defeatable_enemies = {
|
||||||
|
player: enemies.copy() for player, enemies in self.mygame_defeatable_enemies.items()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After doing this, you can now access `state.mygame_defeatable_enemies[player]` from your access rules.
|
||||||
|
|
||||||
|
Usually, doing this coincides with an override of `World.collect` and `World.remove`, where the custom state variable
|
||||||
|
gets recalculated when a relevant item is collected or removed.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# __init__.py
|
||||||
|
|
||||||
|
def collect(self, state: CollectionState, item: Item) -> bool:
|
||||||
|
change = super().collect(state, item)
|
||||||
|
if change and item in COMBAT_ITEMS:
|
||||||
|
state.mygame_defeatable_enemies[self.player] |= get_newly_unlocked_enemies(state)
|
||||||
|
return change
|
||||||
|
|
||||||
|
def remove(self, state: CollectionState, item: Item) -> bool:
|
||||||
|
change = super().remove(state, item)
|
||||||
|
if change and item in COMBAT_ITEMS:
|
||||||
|
state.mygame_defeatable_enemies[self.player] -= get_newly_locked_enemies(state)
|
||||||
|
return change
|
||||||
|
```
|
||||||
|
|
||||||
|
Using LogicMixin can greatly slow down your code if you don't use it intelligently. This is because `collect`
|
||||||
|
and `remove` are called very frequently during fill. If your `collect` & `remove` cause a heavy calculation
|
||||||
|
every time, your code might end up being *slower* than just doing calculations in your access rules.
|
||||||
|
|
||||||
|
One way to optimise recalculations is to make use of the fact that `collect` should only unlock things,
|
||||||
|
and `remove` should only lock things.
|
||||||
|
In our example, we have two different functions: `get_newly_unlocked_enemies` and `get_newly_locked_enemies`.
|
||||||
|
`get_newly_unlocked_enemies` should only consider enemies that are *not already in the set*
|
||||||
|
and check whether they were **unlocked**.
|
||||||
|
`get_newly_locked_enemies` should only consider enemies that are *already in the set*
|
||||||
|
and check whether they **became locked**.
|
||||||
|
|
||||||
|
Another impactful way to optimise LogicMixin is to use caching.
|
||||||
|
Your custom state variables don't actually need to be recalculated on every `collect` / `remove`, because there are
|
||||||
|
often multiple calls to `collect` / `remove` between access rule calls. Thus, it would be much more efficient to hold
|
||||||
|
off on recaculating until the an actual access rule call happens.
|
||||||
|
A common way to realize this is to define a `mygame_state_is_stale` variable that is set to True in `collect`, `remove`,
|
||||||
|
and `init_mixin`. The calls to the actual recalculating functions are then moved to the start of the relevant
|
||||||
|
access rules like this:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def can_defeat_enemy(state: CollectionState, player: int, enemy: str) -> bool:
|
||||||
|
if state.mygame_state_is_stale[player]:
|
||||||
|
state.mygame_defeatable_enemies[player] = recalculate_defeatable_enemies(state)
|
||||||
|
state.mygame_state_is_stale[player] = False
|
||||||
|
|
||||||
|
return enemy in state.mygame_defeatable_enemies[player]
|
||||||
|
```
|
||||||
|
|
||||||
|
Only use LogicMixin if necessary. There are often other ways to achieve what it does, like making clever use of
|
||||||
|
`state.prog_items`, using event items, pseudo-regions, etc.
|
||||||
|
|
||||||
#### pre_fill
|
#### pre_fill
|
||||||
|
|
||||||
@@ -748,14 +836,16 @@ def generate_output(self, output_directory: str) -> None:
|
|||||||
|
|
||||||
### Slot Data
|
### Slot Data
|
||||||
|
|
||||||
If the game client needs to know information about the generated seed, a preferred method of transferring the data
|
If a client or tracker needs to know information about the generated seed, a preferred method of transferring the data
|
||||||
is through the slot data. This is filled with the `fill_slot_data` method of your world by returning
|
is through the slot data. This is filled with the `fill_slot_data` method of your world by returning a `dict` with
|
||||||
a `dict` with `str` keys that can be serialized with json.
|
`str` keys that can be serialized with json. However, to not waste resources, it should be limited to data that is
|
||||||
But, to not waste resources, it should be limited to data that is absolutely necessary. Slot data is sent to your client
|
absolutely necessary. Slot data is sent to your client once it has successfully
|
||||||
once it has successfully [connected](network%20protocol.md#connected).
|
[connected](network%20protocol.md#connected).
|
||||||
|
|
||||||
If you need to know information about locations in your world, instead of propagating the slot data, it is preferable
|
If you need to know information about locations in your world, instead of propagating the slot data, it is preferable
|
||||||
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. The most
|
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. Adding
|
||||||
common usage of slot data is sending option results that the client needs to be aware of.
|
item/location pairs is unnecessary since the AP server already retains and freely gives that information to clients
|
||||||
|
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def fill_slot_data(self) -> Dict[str, Any]:
|
def fill_slot_data(self) -> Dict[str, Any]:
|
||||||
|
|||||||
491
entrance_rando.py
Normal file
491
entrance_rando.py
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
import itertools
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
|
||||||
|
from BaseClasses import CollectionState, Entrance, Region, EntranceType
|
||||||
|
from Options import Accessibility
|
||||||
|
from worlds.AutoWorld import World
|
||||||
|
|
||||||
|
|
||||||
|
class EntranceRandomizationError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EntranceLookup:
|
||||||
|
class GroupLookup:
|
||||||
|
_lookup: dict[int, list[Entrance]]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._lookup = {}
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return sum(map(len, self._lookup.values()))
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
return bool(self._lookup)
|
||||||
|
|
||||||
|
def __getitem__(self, item: int) -> list[Entrance]:
|
||||||
|
return self._lookup.get(item, [])
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return itertools.chain.from_iterable(self._lookup.values())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return str(self._lookup)
|
||||||
|
|
||||||
|
def add(self, entrance: Entrance) -> None:
|
||||||
|
self._lookup.setdefault(entrance.randomization_group, []).append(entrance)
|
||||||
|
|
||||||
|
def remove(self, entrance: Entrance) -> None:
|
||||||
|
group = self._lookup[entrance.randomization_group]
|
||||||
|
group.remove(entrance)
|
||||||
|
if not group:
|
||||||
|
del self._lookup[entrance.randomization_group]
|
||||||
|
|
||||||
|
dead_ends: GroupLookup
|
||||||
|
others: GroupLookup
|
||||||
|
_random: random.Random
|
||||||
|
_expands_graph_cache: dict[Entrance, bool]
|
||||||
|
_coupled: bool
|
||||||
|
_usable_exits: set[Entrance]
|
||||||
|
|
||||||
|
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]):
|
||||||
|
self.dead_ends = EntranceLookup.GroupLookup()
|
||||||
|
self.others = EntranceLookup.GroupLookup()
|
||||||
|
self._random = rng
|
||||||
|
self._expands_graph_cache = {}
|
||||||
|
self._coupled = coupled
|
||||||
|
self._usable_exits = usable_exits
|
||||||
|
|
||||||
|
def _can_expand_graph(self, entrance: Entrance) -> bool:
|
||||||
|
"""
|
||||||
|
Checks whether an entrance is able to expand the region graph, either by
|
||||||
|
providing access to randomizable exits or by granting access to items or
|
||||||
|
regions used in logic conditions.
|
||||||
|
|
||||||
|
:param entrance: A randomizable (no parent) region entrance
|
||||||
|
"""
|
||||||
|
# we've seen this, return cached result
|
||||||
|
if entrance in self._expands_graph_cache:
|
||||||
|
return self._expands_graph_cache[entrance]
|
||||||
|
|
||||||
|
visited = set()
|
||||||
|
q: deque[Region] = deque()
|
||||||
|
q.append(entrance.connected_region)
|
||||||
|
|
||||||
|
while q:
|
||||||
|
region = q.popleft()
|
||||||
|
visited.add(region)
|
||||||
|
|
||||||
|
# check if the region itself is progression
|
||||||
|
if region in region.multiworld.indirect_connections:
|
||||||
|
self._expands_graph_cache[entrance] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
# check if any placed locations are progression
|
||||||
|
for loc in region.locations:
|
||||||
|
if loc.advancement:
|
||||||
|
self._expands_graph_cache[entrance] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
# check if there is a randomized exit out (expands the graph directly) or else search any connected
|
||||||
|
# regions to see if they are/have progression
|
||||||
|
for exit_ in region.exits:
|
||||||
|
# randomizable exits which are not reverse of the incoming entrance.
|
||||||
|
# uncoupled mode is an exception because in this case going back in the door you just came in could
|
||||||
|
# actually lead somewhere new
|
||||||
|
if (not exit_.connected_region and (not self._coupled or exit_.name != entrance.name)
|
||||||
|
and exit_ in self._usable_exits):
|
||||||
|
self._expands_graph_cache[entrance] = True
|
||||||
|
return True
|
||||||
|
elif exit_.connected_region and exit_.connected_region not in visited:
|
||||||
|
q.append(exit_.connected_region)
|
||||||
|
|
||||||
|
self._expands_graph_cache[entrance] = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def add(self, entrance: Entrance) -> None:
|
||||||
|
lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends
|
||||||
|
lookup.add(entrance)
|
||||||
|
|
||||||
|
def remove(self, entrance: Entrance) -> None:
|
||||||
|
lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends
|
||||||
|
lookup.remove(entrance)
|
||||||
|
|
||||||
|
def get_targets(
|
||||||
|
self,
|
||||||
|
groups: Iterable[int],
|
||||||
|
dead_end: bool,
|
||||||
|
preserve_group_order: bool
|
||||||
|
) -> Iterable[Entrance]:
|
||||||
|
|
||||||
|
lookup = self.dead_ends if dead_end else self.others
|
||||||
|
if preserve_group_order:
|
||||||
|
for group in groups:
|
||||||
|
self._random.shuffle(lookup[group])
|
||||||
|
ret = [entrance for group in groups for entrance in lookup[group]]
|
||||||
|
else:
|
||||||
|
ret = [entrance for group in groups for entrance in lookup[group]]
|
||||||
|
self._random.shuffle(ret)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.dead_ends) + len(self.others)
|
||||||
|
|
||||||
|
|
||||||
|
class ERPlacementState:
|
||||||
|
"""The state of an ongoing or completed entrance randomization"""
|
||||||
|
placements: list[Entrance]
|
||||||
|
"""The list of randomized Entrance objects which have been connected successfully"""
|
||||||
|
pairings: list[tuple[str, str]]
|
||||||
|
"""A list of pairings of connected entrance names, of the form (source_exit, target_entrance)"""
|
||||||
|
world: World
|
||||||
|
"""The world which is having its entrances randomized"""
|
||||||
|
collection_state: CollectionState
|
||||||
|
"""The CollectionState backing the entrance randomization logic"""
|
||||||
|
coupled: bool
|
||||||
|
"""Whether entrance randomization is operating in coupled mode"""
|
||||||
|
|
||||||
|
def __init__(self, world: World, coupled: bool):
|
||||||
|
self.placements = []
|
||||||
|
self.pairings = []
|
||||||
|
self.world = world
|
||||||
|
self.coupled = coupled
|
||||||
|
self.collection_state = world.multiworld.get_all_state(False, True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def placed_regions(self) -> set[Region]:
|
||||||
|
return self.collection_state.reachable_regions[self.world.player]
|
||||||
|
|
||||||
|
def find_placeable_exits(self, check_validity: bool, usable_exits: list[Entrance]) -> list[Entrance]:
|
||||||
|
if check_validity:
|
||||||
|
blocked_connections = self.collection_state.blocked_connections[self.world.player]
|
||||||
|
placeable_randomized_exits = [ex for ex in usable_exits
|
||||||
|
if not ex.connected_region
|
||||||
|
and ex in blocked_connections
|
||||||
|
and ex.is_valid_source_transition(self)]
|
||||||
|
else:
|
||||||
|
# this is on a beaten minimal attempt, so any exit anywhere is fair game
|
||||||
|
placeable_randomized_exits = [ex for ex in usable_exits if not ex.connected_region]
|
||||||
|
self.world.random.shuffle(placeable_randomized_exits)
|
||||||
|
return placeable_randomized_exits
|
||||||
|
|
||||||
|
def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> None:
|
||||||
|
target_region = target_entrance.connected_region
|
||||||
|
|
||||||
|
target_region.entrances.remove(target_entrance)
|
||||||
|
source_exit.connect(target_region)
|
||||||
|
|
||||||
|
self.collection_state.stale[self.world.player] = True
|
||||||
|
self.placements.append(source_exit)
|
||||||
|
self.pairings.append((source_exit.name, target_entrance.name))
|
||||||
|
|
||||||
|
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
|
||||||
|
usable_exits: set[Entrance]) -> bool:
|
||||||
|
copied_state = self.collection_state.copy()
|
||||||
|
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
|
||||||
|
# propagate back to the real multiworld.
|
||||||
|
copied_state.reachable_regions[self.world.player].add(target_entrance.connected_region)
|
||||||
|
copied_state.blocked_connections[self.world.player].remove(source_exit)
|
||||||
|
copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits)
|
||||||
|
copied_state.update_reachable_regions(self.world.player)
|
||||||
|
copied_state.sweep_for_advancements()
|
||||||
|
# test that at there are newly reachable randomized exits that are ACTUALLY reachable
|
||||||
|
available_randomized_exits = copied_state.blocked_connections[self.world.player]
|
||||||
|
for _exit in available_randomized_exits:
|
||||||
|
if _exit.connected_region:
|
||||||
|
continue
|
||||||
|
# ignore the source exit, and, if coupled, the reverse exit. They're not actually new
|
||||||
|
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
|
||||||
|
continue
|
||||||
|
# make sure we are only paying attention to usable exits
|
||||||
|
if _exit not in usable_exits:
|
||||||
|
continue
|
||||||
|
# technically this should be is_valid_source_transition, but that may rely on side effects from
|
||||||
|
# on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would
|
||||||
|
# not want them to persist). can_reach is a close enough approximation most of the time.
|
||||||
|
if _exit.can_reach(copied_state):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def connect(
|
||||||
|
self,
|
||||||
|
source_exit: Entrance,
|
||||||
|
target_entrance: Entrance
|
||||||
|
) -> tuple[list[Entrance], list[Entrance]]:
|
||||||
|
"""
|
||||||
|
Connects a source exit to a target entrance in the graph, accounting for coupling
|
||||||
|
|
||||||
|
:returns: The newly placed exits and the dummy entrance(s) which were removed from the graph
|
||||||
|
"""
|
||||||
|
source_region = source_exit.parent_region
|
||||||
|
target_region = target_entrance.connected_region
|
||||||
|
|
||||||
|
self._connect_one_way(source_exit, target_entrance)
|
||||||
|
# if we're doing coupled randomization place the reverse transition as well.
|
||||||
|
if self.coupled and source_exit.randomization_type == EntranceType.TWO_WAY:
|
||||||
|
for reverse_entrance in source_region.entrances:
|
||||||
|
if reverse_entrance.name == source_exit.name:
|
||||||
|
if reverse_entrance.parent_region:
|
||||||
|
raise EntranceRandomizationError(
|
||||||
|
f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} "
|
||||||
|
f"because the reverse entrance is already parented to "
|
||||||
|
f"{reverse_entrance.parent_region.name}.")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise EntranceRandomizationError(f"Two way exit {source_exit.name} had no corresponding entrance in "
|
||||||
|
f"{source_exit.parent_region.name}")
|
||||||
|
for reverse_exit in target_region.exits:
|
||||||
|
if reverse_exit.name == target_entrance.name:
|
||||||
|
if reverse_exit.connected_region:
|
||||||
|
raise EntranceRandomizationError(
|
||||||
|
f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} "
|
||||||
|
f"because the reverse exit is already connected to "
|
||||||
|
f"{reverse_exit.connected_region.name}.")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise EntranceRandomizationError(f"Two way entrance {target_entrance.name} had no corresponding exit "
|
||||||
|
f"in {target_region.name}.")
|
||||||
|
self._connect_one_way(reverse_exit, reverse_entrance)
|
||||||
|
return [source_exit, reverse_exit], [target_entrance, reverse_entrance]
|
||||||
|
return [source_exit], [target_entrance]
|
||||||
|
|
||||||
|
|
||||||
|
def bake_target_group_lookup(world: World, get_target_groups: Callable[[int], list[int]]) \
|
||||||
|
-> dict[int, list[int]]:
|
||||||
|
"""
|
||||||
|
Applies a transformation to all known entrance groups on randomizable exists to build a group lookup table.
|
||||||
|
|
||||||
|
:param world: Your World instance
|
||||||
|
:param get_target_groups: Function to call that returns the groups that a specific group type is allowed to
|
||||||
|
connect to
|
||||||
|
"""
|
||||||
|
unique_groups = { entrance.randomization_group for entrance in world.multiworld.get_entrances(world.player)
|
||||||
|
if entrance.parent_region and not entrance.connected_region }
|
||||||
|
return { group: get_target_groups(group) for group in unique_groups }
|
||||||
|
|
||||||
|
|
||||||
|
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None,
|
||||||
|
one_way_target_name: str | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization
|
||||||
|
in randomize_entrances. This should be done after setting the type and group of the entrance. Because it attempts
|
||||||
|
to meet strict entrance naming requirements for coupled mode, this function may produce unintuitive results when
|
||||||
|
called only on a single entrance; it produces eventually-correct outputs only after calling it on all entrances.
|
||||||
|
|
||||||
|
:param entrance: The entrance which will be disconnected in preparation for randomization.
|
||||||
|
:param target_group: The group to assign to the created ER target. If not specified, the group from
|
||||||
|
the original entrance will be copied.
|
||||||
|
:param one_way_target_name: The name of the created ER target if `entrance` is one-way. This argument
|
||||||
|
is required for one-way entrances and is ignored otherwise.
|
||||||
|
"""
|
||||||
|
child_region = entrance.connected_region
|
||||||
|
parent_region = entrance.parent_region
|
||||||
|
|
||||||
|
# disconnect the edge
|
||||||
|
child_region.entrances.remove(entrance)
|
||||||
|
entrance.connected_region = None
|
||||||
|
|
||||||
|
# create the needed ER target
|
||||||
|
if entrance.randomization_type == EntranceType.TWO_WAY:
|
||||||
|
# for 2-ways, create a target in the parent region with a matching name to support coupling.
|
||||||
|
# targets in the child region will be created when the other direction edge is disconnected
|
||||||
|
target = parent_region.create_er_target(entrance.name)
|
||||||
|
else:
|
||||||
|
# for 1-ways, the child region needs a target. naming is not a concern for coupling so we
|
||||||
|
# allow it to be user provided (and require it, to prevent an unhelpful assumed name in pairings)
|
||||||
|
if not one_way_target_name:
|
||||||
|
raise ValueError("Cannot disconnect a one-way entrance without a target name specified")
|
||||||
|
target = child_region.create_er_target(one_way_target_name)
|
||||||
|
target.randomization_type = entrance.randomization_type
|
||||||
|
target.randomization_group = target_group or entrance.randomization_group
|
||||||
|
|
||||||
|
|
||||||
|
def randomize_entrances(
|
||||||
|
world: World,
|
||||||
|
coupled: bool,
|
||||||
|
target_group_lookup: dict[int, list[int]],
|
||||||
|
preserve_group_order: bool = False,
|
||||||
|
er_targets: list[Entrance] | None = None,
|
||||||
|
exits: list[Entrance] | None = None,
|
||||||
|
on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None
|
||||||
|
) -> ERPlacementState:
|
||||||
|
"""
|
||||||
|
Randomizes Entrances for a single world in the multiworld.
|
||||||
|
|
||||||
|
:param world: Your World instance
|
||||||
|
:param coupled: Whether connected entrances should be coupled to go in both directions
|
||||||
|
:param target_group_lookup: Map from each group to a list of the groups that it can be connect to. Every group
|
||||||
|
used on an exit must be provided and must map to at least one other group. The default
|
||||||
|
group is 0.
|
||||||
|
:param preserve_group_order: Whether the order of groupings should be preserved for the returned target_groups
|
||||||
|
:param er_targets: The list of ER targets (Entrance objects with no parent region) to use for randomization.
|
||||||
|
Remember to be deterministic! If not provided, automatically discovers all valid targets
|
||||||
|
in your world.
|
||||||
|
:param exits: The list of exits (Entrance objects with no target region) to use for randomization.
|
||||||
|
Remember to be deterministic! If not provided, automatically discovers all valid exits in your world.
|
||||||
|
:param on_connect: A callback function which allows specifying side effects after a placement is completed
|
||||||
|
successfully and the underlying collection state has been updated.
|
||||||
|
"""
|
||||||
|
if not world.explicit_indirect_conditions:
|
||||||
|
raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order "
|
||||||
|
+ "to correctly analyze whether dead end regions can be required in logic.")
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
er_state = ERPlacementState(world, coupled)
|
||||||
|
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
|
||||||
|
perform_validity_check = True
|
||||||
|
|
||||||
|
if not er_targets:
|
||||||
|
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
|
||||||
|
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
|
||||||
|
if not exits:
|
||||||
|
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
|
||||||
|
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
|
||||||
|
if len(er_targets) != len(exits):
|
||||||
|
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
|
||||||
|
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
|
||||||
|
|
||||||
|
# used when membership checks are needed on the exit list, e.g. speculative sweep
|
||||||
|
exits_set = set(exits)
|
||||||
|
entrance_lookup = EntranceLookup(world.random, coupled, exits_set)
|
||||||
|
for entrance in er_targets:
|
||||||
|
entrance_lookup.add(entrance)
|
||||||
|
|
||||||
|
# place the menu region and connected start region(s)
|
||||||
|
er_state.collection_state.update_reachable_regions(world.player)
|
||||||
|
|
||||||
|
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
|
||||||
|
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
|
||||||
|
# remove the placed targets from consideration
|
||||||
|
for entrance in removed_entrances:
|
||||||
|
entrance_lookup.remove(entrance)
|
||||||
|
# propagate new connections
|
||||||
|
er_state.collection_state.update_reachable_regions(world.player)
|
||||||
|
er_state.collection_state.sweep_for_advancements()
|
||||||
|
if on_connect:
|
||||||
|
on_connect(er_state, placed_exits)
|
||||||
|
|
||||||
|
def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool:
|
||||||
|
# speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph
|
||||||
|
# entirely
|
||||||
|
if len(placeable_exits) > 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# in certain stages of randomization we either expect or don't care if the search space shrinks.
|
||||||
|
# we should never speculative sweep here.
|
||||||
|
if dead_end or not require_new_exits or not perform_validity_check:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# edge case - if all dead ends have pre-placed progression or indirect connections, they are pulled forward
|
||||||
|
# into the non dead end stage. In this case, and only this case, it's possible that the last connection may
|
||||||
|
# actually be placeable in stage 1. We need to skip speculative sweep in this case because we expect the graph
|
||||||
|
# to get capped off.
|
||||||
|
|
||||||
|
# check to see if we are proposing the last placement
|
||||||
|
if not coupled:
|
||||||
|
# in uncoupled, this check is easy as there will only be one target.
|
||||||
|
is_last_placement = len(entrance_lookup) == 1
|
||||||
|
else:
|
||||||
|
# a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way.
|
||||||
|
# if it is two way, we can safely assume that one of the targets is the logical pair of the exit.
|
||||||
|
desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1
|
||||||
|
is_last_placement = len(entrance_lookup) == desired_target_count
|
||||||
|
# if it's not the last placement, we need a sweep
|
||||||
|
return not is_last_placement
|
||||||
|
|
||||||
|
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
|
||||||
|
nonlocal perform_validity_check
|
||||||
|
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
|
||||||
|
for source_exit in placeable_exits:
|
||||||
|
target_groups = target_group_lookup[source_exit.randomization_group]
|
||||||
|
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
|
||||||
|
# when requiring new exits, ideally we would like to make it so that every placement increases
|
||||||
|
# (or keeps the same number of) reachable exits. The goal is to continue to expand the search space
|
||||||
|
# so that we do not crash. In the interest of performance and bias reduction, generally, just checking
|
||||||
|
# that we are going to a new region is a good approximation. however, we should take extra care on the
|
||||||
|
# very last exit and check whatever exits we open up are functionally accessible.
|
||||||
|
# this requirement can be ignored on a beaten minimal, islands are no issue there.
|
||||||
|
exit_requirement_satisfied = (not perform_validity_check or not require_new_exits
|
||||||
|
or target_entrance.connected_region not in er_state.placed_regions)
|
||||||
|
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
|
||||||
|
if (needs_speculative_sweep(dead_end, require_new_exits, placeable_exits)
|
||||||
|
and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)):
|
||||||
|
continue
|
||||||
|
do_placement(source_exit, target_entrance)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# no source exits had any valid target so this stage is deadlocked. retries may be implemented if early
|
||||||
|
# deadlocking is a frequent issue.
|
||||||
|
lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others
|
||||||
|
|
||||||
|
# if we're in a stage where we're trying to get to new regions, we could also enter this
|
||||||
|
# branch in a success state (when all regions of the preferred type have been placed, but there are still
|
||||||
|
# additional unplaced entrances into those regions)
|
||||||
|
if require_new_exits:
|
||||||
|
if all(e.connected_region in er_state.placed_regions for e in lookup):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# if we're on minimal accessibility and can guarantee the game is beatable,
|
||||||
|
# we can prevent a failure by bypassing future validity checks. this check may be
|
||||||
|
# expensive; fortunately we only have to do it once
|
||||||
|
if perform_validity_check and world.options.accessibility == Accessibility.option_minimal \
|
||||||
|
and world.multiworld.has_beaten_game(er_state.collection_state, world.player):
|
||||||
|
# ensure that we have enough locations to place our progression
|
||||||
|
accessible_location_count = 0
|
||||||
|
prog_item_count = len([item for item in world.multiworld.itempool if item.advancement and item.player == world.player])
|
||||||
|
# short-circuit location checking in this case
|
||||||
|
if prog_item_count == 0:
|
||||||
|
return True
|
||||||
|
for region in er_state.placed_regions:
|
||||||
|
for loc in region.locations:
|
||||||
|
if not loc.item and loc.can_reach(er_state.collection_state):
|
||||||
|
# don't count locations with preplaced items
|
||||||
|
accessible_location_count += 1
|
||||||
|
if accessible_location_count >= prog_item_count:
|
||||||
|
perform_validity_check = False
|
||||||
|
# pretend that this was successful to retry the current stage
|
||||||
|
return True
|
||||||
|
|
||||||
|
unplaced_entrances = [entrance for region in world.multiworld.get_regions(world.player)
|
||||||
|
for entrance in region.entrances if not entrance.parent_region]
|
||||||
|
unplaced_exits = [exit_ for region in world.multiworld.get_regions(world.player)
|
||||||
|
for exit_ in region.exits if not exit_.connected_region]
|
||||||
|
entrance_kind = "dead ends" if dead_end else "non-dead ends"
|
||||||
|
region_access_requirement = "requires" if require_new_exits else "does not require"
|
||||||
|
raise EntranceRandomizationError(
|
||||||
|
f"None of the available entrances are valid targets for the available exits.\n"
|
||||||
|
f"Randomization stage is placing {entrance_kind} and {region_access_requirement} "
|
||||||
|
f"new region/exit access by default\n"
|
||||||
|
f"Placeable entrances: {lookup}\n"
|
||||||
|
f"Placeable exits: {placeable_exits}\n"
|
||||||
|
f"All unplaced entrances: {unplaced_entrances}\n"
|
||||||
|
f"All unplaced exits: {unplaced_exits}")
|
||||||
|
|
||||||
|
# stage 1 - try to place all the non-dead-end entrances
|
||||||
|
while entrance_lookup.others:
|
||||||
|
if not find_pairing(dead_end=False, require_new_exits=True):
|
||||||
|
break
|
||||||
|
# stage 2 - try to place all the dead-end entrances
|
||||||
|
while entrance_lookup.dead_ends:
|
||||||
|
if not find_pairing(dead_end=True, require_new_exits=True):
|
||||||
|
break
|
||||||
|
# stage 3 - all the regions should be placed at this point. We now need to connect dangling edges
|
||||||
|
# stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions)
|
||||||
|
# doing this before the non-dead-ends is important to ensure there are enough connections to
|
||||||
|
# go around
|
||||||
|
while entrance_lookup.dead_ends:
|
||||||
|
find_pairing(dead_end=True, require_new_exits=False)
|
||||||
|
# stage 3b - tie all the other loose ends connecting visited regions to each other
|
||||||
|
while entrance_lookup.others:
|
||||||
|
find_pairing(dead_end=False, require_new_exits=False)
|
||||||
|
|
||||||
|
running_time = time.perf_counter() - start_time
|
||||||
|
if running_time > 1.0:
|
||||||
|
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player},"
|
||||||
|
f"named {world.multiworld.player_name[world.player]}")
|
||||||
|
|
||||||
|
return er_state
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user