Compare commits
929 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db5b7e5db9 | ||
|
|
7c808bb03b | ||
|
|
530b6cc360 | ||
|
|
95012c004f | ||
|
|
59918b9dbc | ||
|
|
b47cca4515 | ||
|
|
5f27019855 | ||
|
|
0b228834c2 | ||
|
|
57979b9287 | ||
|
|
4b85000960 | ||
|
|
d1f34d088b | ||
|
|
3bc9392e5b | ||
|
|
75165803a0 | ||
|
|
afc9c772be | ||
|
|
07450bb83d | ||
|
|
2ff7e83ad9 | ||
|
|
d817fdcfdb | ||
|
|
f3d966897f | ||
|
|
9acaf1c279 | ||
|
|
fd6a0b547f | ||
|
|
c02f355479 | ||
|
|
7d9203ef84 | ||
|
|
e849e4792d | ||
|
|
4565b3af8d | ||
|
|
e5b868e0e9 | ||
|
|
489450d3fa | ||
|
|
73afab67c8 | ||
|
|
c61f77029b | ||
|
|
79702aba65 | ||
|
|
1e366ff66f | ||
|
|
a0482cf27e | ||
|
|
288a623ab6 | ||
|
|
3b2037a2d4 | ||
|
|
ce536fa3ac | ||
|
|
41883e44e7 | ||
|
|
c3ff201b90 | ||
|
|
e6635cdd77 | ||
|
|
cfc9d79c79 | ||
|
|
fe2c355739 | ||
|
|
04c3429839 | ||
|
|
cabbe0aaf6 | ||
|
|
a7787d87f9 | ||
|
|
79b851189f | ||
|
|
9e972eafb2 | ||
|
|
53a995372f | ||
|
|
17351021b3 | ||
|
|
8ff2c1b6f3 | ||
|
|
45aea2c8ff | ||
|
|
9f5e40283a | ||
|
|
025309ec64 | ||
|
|
bd4850b2b5 | ||
|
|
472e114fb9 | ||
|
|
828bcb1266 | ||
|
|
9897f4eb4b | ||
|
|
e1ef820184 | ||
|
|
b3ad766680 | ||
|
|
74b19dc1f5 | ||
|
|
449bc93307 | ||
|
|
622af17705 | ||
|
|
a42f7f99fe | ||
|
|
3c6bd555b4 | ||
|
|
a4211d5f11 | ||
|
|
090c5bcf00 | ||
|
|
82850d7f66 | ||
|
|
86112351a6 | ||
|
|
ce789d1e3e | ||
|
|
73fb1b8074 | ||
|
|
8e15fe51b6 | ||
|
|
aa954b776d | ||
|
|
76f6eb1434 | ||
|
|
e38308bac3 | ||
|
|
e804f592de | ||
|
|
6e0a0c5c4a | ||
|
|
122590fc68 | ||
|
|
c806366469 | ||
|
|
0d3bd6e2e8 | ||
|
|
beac0b1acd | ||
|
|
1cc9c7a469 | ||
|
|
17db0805a7 | ||
|
|
2f53972c85 | ||
|
|
9ac780102e | ||
|
|
60b80083e0 | ||
|
|
8597b04c41 | ||
|
|
6a60c46a99 | ||
|
|
5c2163a1a7 | ||
|
|
a49bcd618d | ||
|
|
d76b41afe7 | ||
|
|
ab2b635a77 | ||
|
|
7072c7bd45 | ||
|
|
530c5500c3 | ||
|
|
8870b577d0 | ||
|
|
7d85ab471a | ||
|
|
3205cbf932 | ||
|
|
b9fb4de878 | ||
|
|
bcd7096e1d | ||
|
|
b206f2846a | ||
|
|
8a8bc6aa34 | ||
|
|
bce7c258c3 | ||
|
|
cea7278faf | ||
|
|
d7a9b98ce8 | ||
|
|
7dcde12e2e | ||
|
|
ba2a5c4744 | ||
|
|
39ac3c38bf | ||
|
|
61f751a1db | ||
|
|
5f2193f2e4 | ||
|
|
98b714f84a | ||
|
|
2a0198b618 | ||
|
|
cd9f8f3119 | ||
|
|
37b569eca6 | ||
|
|
d317111d20 | ||
|
|
3f1d216d28 | ||
|
|
0ca3d73ae9 | ||
|
|
1972d531b9 | ||
|
|
5006c79a00 | ||
|
|
8788ee1aa7 | ||
|
|
17ba73b0b8 | ||
|
|
0407df83b7 | ||
|
|
f140aadafe | ||
|
|
b41c6185e4 | ||
|
|
aa3d7f5e21 | ||
|
|
efadf6fdf4 | ||
|
|
12863e9b04 | ||
|
|
1843618c99 | ||
|
|
4e5071fd68 | ||
|
|
6e918edce1 | ||
|
|
80ff5a18b1 | ||
|
|
d112cc585f | ||
|
|
3fec33f56c | ||
|
|
68674deb00 | ||
|
|
a9e530721d | ||
|
|
03e9034a98 | ||
|
|
6970c5ce97 | ||
|
|
10b3803a7f | ||
|
|
a7e8c82633 | ||
|
|
6d4c4295b3 | ||
|
|
47edc356ad | ||
|
|
b551e3a2ad | ||
|
|
a9c32bc2e2 | ||
|
|
60c7be87f8 | ||
|
|
2bac78b4a4 | ||
|
|
c4769eeebb | ||
|
|
51341f6255 | ||
|
|
c7a32dc91b | ||
|
|
3623678c93 | ||
|
|
a5d516e179 | ||
|
|
2045905c9b | ||
|
|
26c027a075 | ||
|
|
b86ee20f3f | ||
|
|
50c75e9684 | ||
|
|
d87c3d5323 | ||
|
|
247f674749 | ||
|
|
74fe03414c | ||
|
|
65d213c494 | ||
|
|
05a51346f9 | ||
|
|
6c525e1fe6 | ||
|
|
5be00e28dd | ||
|
|
d81dbbd951 | ||
|
|
83dee9d667 | ||
|
|
7d79cff66f | ||
|
|
0a63bd0fc6 | ||
|
|
55d8c8c928 | ||
|
|
681f7041dc | ||
|
|
d5f15e6408 | ||
|
|
70d510dff8 | ||
|
|
2a5c128267 | ||
|
|
e5a1052089 | ||
|
|
8c64f6221e | ||
|
|
0869a2acc3 | ||
|
|
e7ea827f02 | ||
|
|
84b6ece31d | ||
|
|
1bcc5b6582 | ||
|
|
c8c025ac34 | ||
|
|
d82d70ac97 | ||
|
|
3e86fd4e57 | ||
|
|
964eda13cc | ||
|
|
c16815b16d | ||
|
|
74ee8ec459 | ||
|
|
22ea72c1b2 | ||
|
|
613dc4184a | ||
|
|
9a471aff1b | ||
|
|
e69e42cabc | ||
|
|
1281426075 | ||
|
|
8b1baafddf | ||
|
|
ee65d7e5fa | ||
|
|
df0ae205cd | ||
|
|
1cbd384569 | ||
|
|
e47527087e | ||
|
|
517a2db9d8 | ||
|
|
fbf993566d | ||
|
|
25bea47872 | ||
|
|
78f22e895e | ||
|
|
fa3925cd74 | ||
|
|
d9418d5ce1 | ||
|
|
103f9e0b85 | ||
|
|
a2fc3d5b71 | ||
|
|
c66d64b9d8 | ||
|
|
0dd67f40ba | ||
|
|
f5dc39ddf0 | ||
|
|
6b47776b11 | ||
|
|
2b73c7f9e4 | ||
|
|
4558ac66fa | ||
|
|
d0a98949f5 | ||
|
|
e13e7f286c | ||
|
|
0045e3f9f7 | ||
|
|
ff608b72a2 | ||
|
|
19c3c8056b | ||
|
|
d31c24bbf7 | ||
|
|
768f9497fd | ||
|
|
20be691f36 | ||
|
|
3dd3f045e6 | ||
|
|
6d3538a35b | ||
|
|
1a0bfecb5f | ||
|
|
5d3b4c8efd | ||
|
|
8adc0dd7eb | ||
|
|
2cb71c5352 | ||
|
|
b6068f4519 | ||
|
|
21a6b0143d | ||
|
|
28949853f7 | ||
|
|
65c83393bb | ||
|
|
960988ddcd | ||
|
|
fb99dca83e | ||
|
|
e786243738 | ||
|
|
cec0e2cbfb | ||
|
|
dadd7d4693 | ||
|
|
dc558f906c | ||
|
|
8184e99409 | ||
|
|
ac87629550 | ||
|
|
1c231b703a | ||
|
|
a66b11e6ec | ||
|
|
4f24c4ea78 | ||
|
|
a800b148a2 | ||
|
|
1710e15e49 | ||
|
|
a332d4935d | ||
|
|
9b855c7de0 | ||
|
|
e8be80ccd7 | ||
|
|
c661da57d8 | ||
|
|
4165f58414 | ||
|
|
7126b7bca0 | ||
|
|
a7f647e3ca | ||
|
|
e901a87afd | ||
|
|
9eb237b3af | ||
|
|
909ea9dc99 | ||
|
|
86013328d6 | ||
|
|
0c80cd017f | ||
|
|
2b8a0f8cd8 | ||
|
|
e1926c973e | ||
|
|
f515f680a4 | ||
|
|
effba9fdec | ||
|
|
388f064307 | ||
|
|
bb15485965 | ||
|
|
cb9db5dff1 | ||
|
|
3b644a0af1 | ||
|
|
8ce2ecfaac | ||
|
|
bdd9ca76ee | ||
|
|
44ae50083d | ||
|
|
e5d999c755 | ||
|
|
4e90ebc7d9 | ||
|
|
dbf0458575 | ||
|
|
e6e44b8747 | ||
|
|
2b702528fd | ||
|
|
23144ff204 | ||
|
|
764b6c78c5 | ||
|
|
051e19e9c1 | ||
|
|
ad99850192 | ||
|
|
c93eeb3607 | ||
|
|
551cf8442f | ||
|
|
90d506ee7c | ||
|
|
45bca78e75 | ||
|
|
11faca1940 | ||
|
|
47b179dec4 | ||
|
|
05efbe0af8 | ||
|
|
48a7587c5a | ||
|
|
ff82145633 | ||
|
|
dcc703f454 | ||
|
|
07f66fb15a | ||
|
|
c0fb7d9f9a | ||
|
|
2b6fc6dd3a | ||
|
|
e147495fb9 | ||
|
|
b2e65a19a2 | ||
|
|
44638ccc1a | ||
|
|
5f4b2cfa52 | ||
|
|
0bc2301530 | ||
|
|
d1eda38745 | ||
|
|
dc10421531 | ||
|
|
00f5975a3c | ||
|
|
b41f444013 | ||
|
|
89b4060a06 | ||
|
|
98ca001da6 | ||
|
|
b0b41711d4 | ||
|
|
3f691d6977 | ||
|
|
977159e572 | ||
|
|
9e15e754c2 | ||
|
|
c085ee47ed | ||
|
|
a5ca118bbf | ||
|
|
521122fd4f | ||
|
|
86933d8150 | ||
|
|
976f34c19f | ||
|
|
a56340663c | ||
|
|
e3900e9f99 | ||
|
|
e8b1362172 | ||
|
|
f6d857b5b5 | ||
|
|
aa9f43dea1 | ||
|
|
513ab62ce7 | ||
|
|
a020dea277 | ||
|
|
19dd447dcb | ||
|
|
eb1abd9222 | ||
|
|
9ab7c8d9e5 | ||
|
|
1e592b4681 | ||
|
|
40a08d0d84 | ||
|
|
517e72f442 | ||
|
|
ea51df432d | ||
|
|
c27bfc515e | ||
|
|
7fad0b0f51 | ||
|
|
76663f819b | ||
|
|
666760f0cf | ||
|
|
2d73f2f46e | ||
|
|
c8e54bbcd0 | ||
|
|
76a4dce66a | ||
|
|
c102d602b3 | ||
|
|
e711490f6c | ||
|
|
c801cdbb3b | ||
|
|
9d638671bb | ||
|
|
4a703481ba | ||
|
|
897cbb9826 | ||
|
|
bb710cc360 | ||
|
|
5eab07d8d6 | ||
|
|
894a30b9bd | ||
|
|
e8579771a5 | ||
|
|
09670a4475 | ||
|
|
ff783cf9a5 | ||
|
|
46d31c3ee3 | ||
|
|
3e8c821c02 | ||
|
|
50eaf712a9 | ||
|
|
f476747ade | ||
|
|
d8d881085f | ||
|
|
fd6e1b3046 | ||
|
|
d6697924cb | ||
|
|
3001926ae4 | ||
|
|
578451fcfa | ||
|
|
d57bdf6dc3 | ||
|
|
0309fac592 | ||
|
|
9ecd320c8c | ||
|
|
c326566bd2 | ||
|
|
4f10dbb896 | ||
|
|
cb6d377796 | ||
|
|
b5f58b0a03 | ||
|
|
9ee5fae476 | ||
|
|
81feb2fd5e | ||
|
|
75a76fb184 | ||
|
|
21f1ccbfb4 | ||
|
|
0f5a7cda6c | ||
|
|
acd7bce903 | ||
|
|
1afacd28a1 | ||
|
|
6e171d19f0 | ||
|
|
66921499ad | ||
|
|
249972c7fd | ||
|
|
dae0e233b8 | ||
|
|
8bb566a250 | ||
|
|
6a25bbeef0 | ||
|
|
6286ac4a3b | ||
|
|
447f99ea15 | ||
|
|
587d4dc8b6 | ||
|
|
b5613ffcf5 | ||
|
|
1fe82b1312 | ||
|
|
a4daa78c0b | ||
|
|
618bdfc917 | ||
|
|
8e68aa0ccd | ||
|
|
df3757657e | ||
|
|
0eea1a1d89 | ||
|
|
15dcdca6fc | ||
|
|
7a6aef03e7 | ||
|
|
c61f3b9110 | ||
|
|
42fecc7491 | ||
|
|
0acca6dd64 | ||
|
|
ec00d1b710 | ||
|
|
f093e90c23 | ||
|
|
3d1f6d9b82 | ||
|
|
9bdcbb9008 | ||
|
|
491e6c8730 | ||
|
|
d32d268d97 | ||
|
|
30c447b9f3 | ||
|
|
2def8f35ad | ||
|
|
f2055daf1a | ||
|
|
944571ea89 | ||
|
|
f7c601b863 | ||
|
|
7315da2ccb | ||
|
|
2f7f6a0b58 | ||
|
|
3f43051c35 | ||
|
|
535c35310d | ||
|
|
8fbe6a4511 | ||
|
|
07ff0f1026 | ||
|
|
a080288e3e | ||
|
|
71bd87f293 | ||
|
|
574e2abba8 | ||
|
|
cffa772801 | ||
|
|
66bd793306 | ||
|
|
0eb37883ca | ||
|
|
356384ab05 | ||
|
|
8c2c6877b6 | ||
|
|
d1d40d8a60 | ||
|
|
b026a0a372 | ||
|
|
73bcd0058a | ||
|
|
0cf396e5d6 | ||
|
|
1bc09d4292 | ||
|
|
97d0c51db1 | ||
|
|
ed1c11267c | ||
|
|
a3e1ac896f | ||
|
|
37d9eb2752 | ||
|
|
05e267a0bd | ||
|
|
d1f0a29a02 | ||
|
|
fb2e780c56 | ||
|
|
ba3257f850 | ||
|
|
215d5e9adf | ||
|
|
5392b32d5c | ||
|
|
4dd0a75914 | ||
|
|
a2212002ae | ||
|
|
91ccee3513 | ||
|
|
2a593d5d0a | ||
|
|
a93b3d79aa | ||
|
|
938ab32cda | ||
|
|
6f5ab05345 | ||
|
|
95f8647f09 | ||
|
|
06c8caa3cc | ||
|
|
d206a562df | ||
|
|
a0a290e481 | ||
|
|
266ff0c520 | ||
|
|
931bf7da16 | ||
|
|
fe4a26d034 | ||
|
|
dca70a99ad | ||
|
|
1a24a73ccd | ||
|
|
ae163319e0 | ||
|
|
65864e273b | ||
|
|
199b778d2b | ||
|
|
70e3c47120 | ||
|
|
eddc5d6524 | ||
|
|
fae3068c25 | ||
|
|
b9014b2a60 | ||
|
|
6b07b6407c | ||
|
|
a10b987f1c | ||
|
|
1f16310797 | ||
|
|
0fd59063d9 | ||
|
|
aab477b874 | ||
|
|
098d939653 | ||
|
|
7d830362a7 | ||
|
|
0db1660369 | ||
|
|
c471a70b35 | ||
|
|
6aef6f2c11 | ||
|
|
000f0bf2f1 | ||
|
|
0f1c08b43a | ||
|
|
76ffb5cd53 | ||
|
|
23d245d43c | ||
|
|
aabc86fc01 | ||
|
|
cebd7fb545 | ||
|
|
8337689640 | ||
|
|
0263130126 | ||
|
|
c472d740ec | ||
|
|
0fd244eee0 | ||
|
|
7dcb6f66da | ||
|
|
14956d27bd | ||
|
|
420be2c44f | ||
|
|
3bb3a902b3 | ||
|
|
2b138ac940 | ||
|
|
b6eeef1db6 | ||
|
|
469dda7d85 | ||
|
|
3c2933d587 | ||
|
|
3b128c8512 | ||
|
|
fb1be7b003 | ||
|
|
e0aa52ed27 | ||
|
|
64ac619b46 | ||
|
|
902472be32 | ||
|
|
cb024b00d9 | ||
|
|
75de616465 | ||
|
|
c12d8e2f46 | ||
|
|
d8087660e6 | ||
|
|
87a8e6e20c | ||
|
|
b599a7607d | ||
|
|
a6b22d1f41 | ||
|
|
8e59761b03 | ||
|
|
8599506497 | ||
|
|
e4ab10fe92 | ||
|
|
171c297d1b | ||
|
|
5eccb0ed49 | ||
|
|
f326de2686 | ||
|
|
2ca6b7f929 | ||
|
|
79afae17e7 | ||
|
|
cb4d9dc365 | ||
|
|
4bf8b98681 | ||
|
|
7f1371ec00 | ||
|
|
cb3db8ae16 | ||
|
|
cf2e37f92d | ||
|
|
92319b0e31 | ||
|
|
d4ff653937 | ||
|
|
7df12930ef | ||
|
|
9ba70951d5 | ||
|
|
2d25369d06 | ||
|
|
affcaf1c02 | ||
|
|
7e314c0d7a | ||
|
|
1266ca314c | ||
|
|
7394598aff | ||
|
|
b02a710bc5 | ||
|
|
ce6966a823 | ||
|
|
689183edc0 | ||
|
|
43113c7844 | ||
|
|
fb8879a919 | ||
|
|
136b9f9138 | ||
|
|
eea326561e | ||
|
|
e3781c68be | ||
|
|
d2927dc68f | ||
|
|
ca95d47127 | ||
|
|
a5a0c94a2c | ||
|
|
cfa49ee757 | ||
|
|
8921baecd0 | ||
|
|
8b78477c69 | ||
|
|
14633724f2 | ||
|
|
8d3ea9c50f | ||
|
|
32a58b1adb | ||
|
|
f01a31ce56 | ||
|
|
3f69c3a2ab | ||
|
|
e0f3d6d0d7 | ||
|
|
a8f148acac | ||
|
|
0c57af40dc | ||
|
|
0714be6b73 | ||
|
|
b5ce6f0bb0 | ||
|
|
67d59067eb | ||
|
|
f1984a103d | ||
|
|
41fd7a8a56 | ||
|
|
14ac139d03 | ||
|
|
97b1ae5ee9 | ||
|
|
15e0763ed5 | ||
|
|
3ce5d14210 | ||
|
|
2c884e2ca5 | ||
|
|
c204fb9b14 | ||
|
|
69721d2d04 | ||
|
|
73b14d3826 | ||
|
|
7ca6f24e6c | ||
|
|
2c3e3f0d43 | ||
|
|
3b68c6902c | ||
|
|
c5926fcf2b | ||
|
|
e6546eea85 | ||
|
|
892357cc2c | ||
|
|
7c6fb26eb7 | ||
|
|
491530ad60 | ||
|
|
6667c1f03d | ||
|
|
e985fc41ce | ||
|
|
508eb04e94 | ||
|
|
68e9368bb3 | ||
|
|
db152e6790 | ||
|
|
6bf2f5611a | ||
|
|
11a13967d5 | ||
|
|
05fe423ef1 | ||
|
|
6e0165986f | ||
|
|
f167e11905 | ||
|
|
727cae902a | ||
|
|
f38f9a47da | ||
|
|
7708d3d157 | ||
|
|
4c64c5ad05 | ||
|
|
534ce179ec | ||
|
|
1b73bacde1 | ||
|
|
a13ad32ec5 | ||
|
|
13a6c86077 | ||
|
|
5fc1b760f4 | ||
|
|
a6d78d9af7 | ||
|
|
48669e96d1 | ||
|
|
071161176e | ||
|
|
f046d76c59 | ||
|
|
53ab224fba | ||
|
|
5faf1f27de | ||
|
|
f38b970ea2 | ||
|
|
5dbccfcbbd | ||
|
|
de5249f99e | ||
|
|
420320f896 | ||
|
|
06ac2d1805 | ||
|
|
cdc0b7a649 | ||
|
|
6c7be51221 | ||
|
|
1159137c0d | ||
|
|
a98cb040b7 | ||
|
|
170213e6d4 | ||
|
|
129c6d2d1e | ||
|
|
ad75ee8c50 | ||
|
|
e94b99da65 | ||
|
|
4f47709d32 | ||
|
|
71ea8d7148 | ||
|
|
919223cd2f | ||
|
|
fd8cace362 | ||
|
|
18d937d83e | ||
|
|
1d19868119 | ||
|
|
840e634161 | ||
|
|
731eef8c2f | ||
|
|
135ee018a9 | ||
|
|
7633392eea | ||
|
|
daea0f3e5e | ||
|
|
c525c80b49 | ||
|
|
311fb04647 | ||
|
|
219bd9c10e | ||
|
|
6d704eadd7 | ||
|
|
32da1993e1 | ||
|
|
d4cad980e5 | ||
|
|
53340ab22c | ||
|
|
2d3767a35c | ||
|
|
aaa9bc906e | ||
|
|
7503317d49 | ||
|
|
3fc93a33c8 | ||
|
|
d7d1d54a0b | ||
|
|
34b9344084 | ||
|
|
779f3a8a61 | ||
|
|
8c1690ef65 | ||
|
|
85f32d9a97 | ||
|
|
54c7ec5873 | ||
|
|
8d260708d3 | ||
|
|
f8009e4b84 | ||
|
|
a2260ee6b2 | ||
|
|
6193eafb7b | ||
|
|
a4eea3325f | ||
|
|
b93e61b758 | ||
|
|
14448ad97e | ||
|
|
3d17f0d588 | ||
|
|
ee5ea09cbc | ||
|
|
aac8ca97ed | ||
|
|
e4d6da47a4 | ||
|
|
9f7dbb394e | ||
|
|
f98063b97a | ||
|
|
ed607bdc37 | ||
|
|
a3c3e4cbd4 | ||
|
|
bffb8a034e | ||
|
|
8242d4fe92 | ||
|
|
279b682ac2 | ||
|
|
43ff476d98 | ||
|
|
28201a6c38 | ||
|
|
6923800081 | ||
|
|
700b83572e | ||
|
|
6e53cb2deb | ||
|
|
8e04182b3f | ||
|
|
9fd6d1b81f | ||
|
|
60379d9ae6 | ||
|
|
29ba1d4809 | ||
|
|
dc4b064c73 | ||
|
|
0f20888563 | ||
|
|
2361f8f9d3 | ||
|
|
feba54d5d2 | ||
|
|
3cecab25c7 | ||
|
|
814851ba60 | ||
|
|
6333cc3bea | ||
|
|
00bf9c569a | ||
|
|
6def1bce25 | ||
|
|
3ab5c90d7c | ||
|
|
0507d6923e | ||
|
|
e85baa8068 | ||
|
|
cbed5a0c14 | ||
|
|
e0628ec6c9 | ||
|
|
82637ff072 | ||
|
|
a95a18a8b5 | ||
|
|
d36637ed13 | ||
|
|
dd5e5dcda7 | ||
|
|
0ff7fe8479 | ||
|
|
8c638bcfd8 | ||
|
|
0bd252e7f5 | ||
|
|
ddd3073132 | ||
|
|
1788422abc | ||
|
|
6210630ce2 | ||
|
|
e5af7d11cc | ||
|
|
5777808aa9 | ||
|
|
a97e6833a3 | ||
|
|
79408ba0c4 | ||
|
|
25dd89ed17 | ||
|
|
dd61d0d395 | ||
|
|
65a92746d1 | ||
|
|
695e87689c | ||
|
|
8997e786da | ||
|
|
239f1afbbd | ||
|
|
df09b5baac | ||
|
|
de4aa78fd6 | ||
|
|
8175d4c31f | ||
|
|
2694bd37ea | ||
|
|
954d2e64ef | ||
|
|
c2be70b61d | ||
|
|
d701a7b04e | ||
|
|
2925aa6261 | ||
|
|
4ebd43104c | ||
|
|
2ebe8d0ed4 | ||
|
|
b26bce8fde | ||
|
|
dc31ee4f7e | ||
|
|
1b3b0f199d | ||
|
|
0800cfccb6 | ||
|
|
ea0ff6cbf7 | ||
|
|
341fefda01 | ||
|
|
8550c071a2 | ||
|
|
6b1c555d38 | ||
|
|
64ce90d5ca | ||
|
|
415526d23e | ||
|
|
b2ebb65c26 | ||
|
|
7a7e3544cf | ||
|
|
9fbc7470c1 | ||
|
|
056b38fd2a | ||
|
|
23211dd1ee | ||
|
|
b4ad0ebf52 | ||
|
|
0ee6dd3f77 | ||
|
|
70a422d354 | ||
|
|
9d7975ce33 | ||
|
|
9b5a1bedc0 | ||
|
|
1518168843 | ||
|
|
f0cfe30a36 | ||
|
|
219bcb3521 | ||
|
|
c7e87bc16a | ||
|
|
66c15c8639 | ||
|
|
00ccecac9c | ||
|
|
102c1fecb6 | ||
|
|
9d4d92167a | ||
|
|
e7fde3bacb | ||
|
|
c0fe9c179c | ||
|
|
929c684977 | ||
|
|
02e776bfe5 | ||
|
|
0c46cc6843 | ||
|
|
344f4afdbd | ||
|
|
4291912577 | ||
|
|
1e5c4c9b7c | ||
|
|
06ec72a064 | ||
|
|
31a823bc34 | ||
|
|
dc6f1c4dd2 | ||
|
|
fc8e3d1787 | ||
|
|
8a25471fbb | ||
|
|
ad06d9bb4a | ||
|
|
ec95ce8329 | ||
|
|
ab4fb6e69c | ||
|
|
238e2d0280 | ||
|
|
2c8a581923 | ||
|
|
e878d7d439 | ||
|
|
4f12660961 | ||
|
|
80a7e4175b | ||
|
|
b4f17e67d0 | ||
|
|
5df4d2f2fd | ||
|
|
ffc7715f1b | ||
|
|
a6cca3094d | ||
|
|
b82e0749b7 | ||
|
|
5c1d2b3393 | ||
|
|
4841926f83 | ||
|
|
eebf1a5126 | ||
|
|
028207022a | ||
|
|
c9fa49d40f | ||
|
|
5d356d509c | ||
|
|
22b361c281 | ||
|
|
38b98a97d1 | ||
|
|
9599f54b06 | ||
|
|
e74333cbd3 | ||
|
|
6a7e1d920a | ||
|
|
0dc714f947 | ||
|
|
62391d3074 | ||
|
|
c507efd920 | ||
|
|
6641d428a2 | ||
|
|
b8afc27e2f | ||
|
|
d577428ac8 | ||
|
|
fba8019f98 | ||
|
|
6f922ac3ac | ||
|
|
44cf8efc06 | ||
|
|
1990b893e5 | ||
|
|
684bb736bc | ||
|
|
01d6735803 | ||
|
|
4e674e0380 | ||
|
|
3acd966241 | ||
|
|
ee190601ee | ||
|
|
240d1423a3 | ||
|
|
e36f6d25b8 | ||
|
|
9339019308 | ||
|
|
9f5a2d1eb3 | ||
|
|
a0ade9ea31 | ||
|
|
71c2db0829 | ||
|
|
f33a15dc4e | ||
|
|
c330f4a35e | ||
|
|
fe25c9c483 | ||
|
|
f6fcff6a73 | ||
|
|
d1146b4fbc | ||
|
|
9be4a91028 | ||
|
|
6c3a4b8ffc | ||
|
|
faabcd8cb7 | ||
|
|
fc7319564e | ||
|
|
061de66397 | ||
|
|
55f21e077a | ||
|
|
821f98eb46 | ||
|
|
3ca8164326 | ||
|
|
88ce841bf6 | ||
|
|
b94d401d09 | ||
|
|
1c3b25d026 | ||
|
|
84ec3d5353 | ||
|
|
bde58fb677 | ||
|
|
651e22b14a | ||
|
|
111b7e204f | ||
|
|
9ff3791d9e | ||
|
|
7380df0256 | ||
|
|
7e32fa1311 | ||
|
|
0472147e9a | ||
|
|
68f282ee83 | ||
|
|
4909479c42 | ||
|
|
82e180cca8 | ||
|
|
aff9114c35 | ||
|
|
f656f08f9b | ||
|
|
967e3028fd | ||
|
|
428af55bd9 | ||
|
|
340725d395 | ||
|
|
f8030393c8 | ||
|
|
f6197d0a8d | ||
|
|
969ea5e6ee | ||
|
|
d4c6268a46 | ||
|
|
aeda76c058 | ||
|
|
9894d0672f | ||
|
|
1964547eb3 | ||
|
|
d2e884b1d9 | ||
|
|
80b3a5b1d4 | ||
|
|
a6a9989fcf | ||
|
|
bce63b0dab | ||
|
|
5ca0b6b18e | ||
|
|
19c0508b83 | ||
|
|
1891c95ae3 | ||
|
|
a722ec1c37 | ||
|
|
0c3b5439e9 | ||
|
|
963e9d4bb5 | ||
|
|
4dd7c63cab | ||
|
|
03a892aded | ||
|
|
b3c1c0bbe8 | ||
|
|
5a064b0979 | ||
|
|
f06e565441 | ||
|
|
41fdafa3fb | ||
|
|
27c528a6b3 | ||
|
|
9623c1fffd | ||
|
|
d4e0347d1d | ||
|
|
74bb057314 | ||
|
|
b2980178d1 | ||
|
|
08a0871168 | ||
|
|
51fa00399d | ||
|
|
7622f7f28f | ||
|
|
d98d693369 | ||
|
|
c7e8692964 | ||
|
|
0431c3fce0 | ||
|
|
411f0e40b6 | ||
|
|
a5d2046a87 | ||
|
|
f8893a7ed3 | ||
|
|
93ac018400 | ||
|
|
6b852d6e1a | ||
|
|
06dc76a78b | ||
|
|
1ff5908a4c | ||
|
|
e2f61636cc | ||
|
|
4db4b5305e | ||
|
|
c550fdaee8 | ||
|
|
d13b7988b7 | ||
|
|
d437f0105a | ||
|
|
b65618030f | ||
|
|
01a2376b74 | ||
|
|
d10ddb17b6 | ||
|
|
c42d489bf7 | ||
|
|
8fef6b8d8c | ||
|
|
35b1178c20 | ||
|
|
c0f95755ff | ||
|
|
b7676a3da2 | ||
|
|
3d65719170 | ||
|
|
18d262c1ae | ||
|
|
e5fedb90a6 | ||
|
|
dc82b384c5 | ||
|
|
2f56e40fb7 | ||
|
|
d719eb356f | ||
|
|
6a34fe5184 | ||
|
|
461961c3be | ||
|
|
39869bcdc5 | ||
|
|
a10d7ae5b9 | ||
|
|
4ed45248eb | ||
|
|
6e4b255be5 | ||
|
|
2e56c226db | ||
|
|
844ff402cd | ||
|
|
ec570be178 | ||
|
|
3508cf21c7 | ||
|
|
1f4ddc295a | ||
|
|
3e16593bb7 | ||
|
|
4ef0e054d6 | ||
|
|
61310c50d7 | ||
|
|
6eab838a70 | ||
|
|
52e01c0925 | ||
|
|
97d6e80556 | ||
|
|
d5abadc6d0 | ||
|
|
d08d716966 | ||
|
|
a864b893b8 | ||
|
|
9212505243 | ||
|
|
abbcb6dc72 | ||
|
|
3f49c169bb | ||
|
|
16c8256f0b | ||
|
|
75d94b04aa | ||
|
|
b9c2e7636c | ||
|
|
df29934968 | ||
|
|
3ee4be2e33 | ||
|
|
9172cc4925 | ||
|
|
7f03a86dee | ||
|
|
1603bab1da | ||
|
|
70aae514be | ||
|
|
5fa1185d6d | ||
|
|
3a2a584ad3 | ||
|
|
c42f53d64f | ||
|
|
450e0eacf4 | ||
|
|
aa40e811f1 | ||
|
|
af96f71190 | ||
|
|
9e4cb6ee33 | ||
|
|
5d0748983b | ||
|
|
c4981e4b91 | ||
|
|
3f36c436ad | ||
|
|
db456cbcf1 | ||
|
|
c0b8384319 | ||
|
|
13036539b7 | ||
|
|
5a2e477dba | ||
|
|
f003c7130f | ||
|
|
0558351a12 | ||
|
|
3f20bdaaa2 | ||
|
|
3bf367d630 | ||
|
|
706fc19ab4 | ||
|
|
4fe024041d | ||
|
|
7afbf8b45b | ||
|
|
e1fc44f4e0 | ||
|
|
21fbb545e8 | ||
|
|
6cd08ea8dc | ||
|
|
85efee1432 | ||
|
|
ba9974fe2a | ||
|
|
a8694cfb79 | ||
|
|
0968730382 | ||
|
|
71e5348cbb | ||
|
|
5b399bff89 | ||
|
|
b7ff5d9a57 | ||
|
|
bfa4d06ecf | ||
|
|
d45bbb89b9 | ||
|
|
40805ee870 | ||
|
|
385f41d461 | ||
|
|
52d8da16f6 | ||
|
|
fc210c2d18 | ||
|
|
56ef918a10 | ||
|
|
ac02019930 | ||
|
|
5121b0d09b |
98
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
# This workflow will build a release-like distribution when manually dispatched
|
||||
|
||||
name: Build
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-win-py38: # RCs will still be built and signed by hand
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-windows-amd64.zip -OutFile sni.zip
|
||||
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0.1/win-x64.zip -OutFile enemizer.zip
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes
|
||||
$NAME="$(ls build)".Split('.',2)[1]
|
||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||
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
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
|
||||
build-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.9'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
|
||||
tar xf sni-*.tar.xz
|
||||
rm sni-*.tar.xz
|
||||
mv sni-* SNI
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
# pygobject is an optional dependency for kivy that's not in requirements
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.APPIMAGE_NAME }}
|
||||
path: dist/${{ env.APPIMAGE_NAME }}
|
||||
retention-days: 7
|
||||
- name: Store .tar.gz
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.TAR_NAME }}
|
||||
path: dist/${{ env.TAR_NAME }}
|
||||
retention-days: 7
|
||||
84
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
# This workflow will create a release and store builds to it when an x.y.z tag is pushed
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
|
||||
with:
|
||||
draft: true # don't publish right away, especially since windows build is added by hand
|
||||
prerelease: false
|
||||
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# build-release-windows: # this is done by hand because of signing
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-release-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
# - code below copied from build.yml -
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.9'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
|
||||
tar xf sni-*.tar.xz
|
||||
rm sni-*.tar.xz
|
||||
mv sni-* SNI
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - code above copied from build.yml -
|
||||
- name: Add to Release
|
||||
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
|
||||
with:
|
||||
draft: true # see above
|
||||
prerelease: false
|
||||
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||
files: |
|
||||
dist/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
22
.github/workflows/unittests.yml
vendored
@@ -7,20 +7,34 @@ on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python:
|
||||
- {version: '3.8'}
|
||||
- {version: '3.9'}
|
||||
- {version: '3.10'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.10'} # current
|
||||
os: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python ${{ matrix.python.version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: ${{ matrix.python.version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 pytest
|
||||
python ModuleUpdate.py --yes --force
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest test
|
||||
|
||||
31
.gitignore
vendored
@@ -12,6 +12,7 @@
|
||||
*.sfc
|
||||
*.z64
|
||||
*.n64
|
||||
*.nes
|
||||
*.wixobj
|
||||
*.lck
|
||||
*.db3
|
||||
@@ -26,14 +27,9 @@ dist
|
||||
README.html
|
||||
.vs/
|
||||
EnemizerCLI/
|
||||
RaceRom.py
|
||||
weights/
|
||||
/MultiMystery/
|
||||
/Players/
|
||||
/QUsb2Snes/
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/uploads/
|
||||
/logs/
|
||||
_persistent_storage.yaml
|
||||
mystery_result_*.yaml
|
||||
@@ -44,6 +40,10 @@ Output Logs/
|
||||
/factorio/
|
||||
/Minecraft Forge Server/
|
||||
/WebHostLib/static/generated
|
||||
/freeze_requirements.txt
|
||||
/Archipelago.zip
|
||||
/setup.ini
|
||||
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -77,6 +77,7 @@ MANIFEST
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
installer.log
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
@@ -115,6 +116,9 @@ target/
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# vim editor
|
||||
*.swp
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
@@ -151,8 +155,17 @@ dmypy.json
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
Archipelago.zip
|
||||
|
||||
#minecraft server stuff
|
||||
# minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
minecraft*/
|
||||
minecraft_versions.json
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# OS General Files
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Thumbs.db
|
||||
[Dd]esktop.ini
|
||||
|
||||
453
BaseClasses.py
@@ -1,17 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from enum import Enum, unique
|
||||
from enum import unique, IntEnum, IntFlag
|
||||
import logging
|
||||
import json
|
||||
import functools
|
||||
from collections import OrderedDict, Counter, deque
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
|
||||
import typing # this can go away when Python 3.8 support is dropped
|
||||
import secrets
|
||||
import random
|
||||
|
||||
import Options
|
||||
import Utils
|
||||
import NetUtils
|
||||
|
||||
|
||||
class Group(TypedDict, total=False):
|
||||
name: str
|
||||
game: str
|
||||
world: auto_world
|
||||
players: Set[int]
|
||||
item_pool: Set[str]
|
||||
replacement_items: Dict[int, Optional[str]]
|
||||
local_items: Set[str]
|
||||
non_local_items: Set[str]
|
||||
|
||||
|
||||
class MultiWorld():
|
||||
@@ -23,11 +36,20 @@ class MultiWorld():
|
||||
dark_room_logic: Dict[int, str]
|
||||
restrict_dungeon_item_on_boss: Dict[int, bool]
|
||||
plando_texts: List[Dict[str, str]]
|
||||
plando_items: List
|
||||
plando_items: List[List[Dict[str, Any]]]
|
||||
plando_connections: List
|
||||
worlds: Dict[int, Any]
|
||||
worlds: Dict[int, auto_world]
|
||||
groups: Dict[int, Group]
|
||||
itempool: List[Item]
|
||||
is_race: bool = False
|
||||
precollected_items: Dict[int, List[Item]]
|
||||
state: CollectionState
|
||||
|
||||
accessibility: Dict[int, Options.Accessibility]
|
||||
local_items: Dict[int, Options.LocalItems]
|
||||
non_local_items: Dict[int, Options.NonLocalItems]
|
||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
||||
|
||||
class AttributeProxy():
|
||||
def __init__(self, rule):
|
||||
@@ -39,20 +61,21 @@ class MultiWorld():
|
||||
def __init__(self, players: int):
|
||||
self.random = random.Random() # world-local random state is saved for multiple generations running concurrently
|
||||
self.players = players
|
||||
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
|
||||
self.glitch_triforce = False
|
||||
self.algorithm = 'balanced'
|
||||
self.dungeons: Dict[Tuple[str, int], Dungeon] = {}
|
||||
self.groups = {}
|
||||
self.regions = []
|
||||
self.shops = []
|
||||
self.itempool = []
|
||||
self.seed = None
|
||||
self.seed_name: str = "Unavailable"
|
||||
self.precollected_items = {player: [] for player in self.player_ids}
|
||||
self.state = CollectionState(self)
|
||||
self._cached_entrances = None
|
||||
self._cached_locations = None
|
||||
self._entrance_cache = {}
|
||||
self._location_cache = {}
|
||||
self._location_cache: Dict[Tuple[str, int], Location] = {}
|
||||
self.required_locations = []
|
||||
self.light_world_light_cone = False
|
||||
self.dark_world_light_cone = False
|
||||
@@ -63,8 +86,6 @@ class MultiWorld():
|
||||
self.custom = False
|
||||
self.customitemarray = []
|
||||
self.shuffle_ganon = True
|
||||
self.dynamic_regions = []
|
||||
self.dynamic_locations = []
|
||||
self.spoiler = Spoiler(self)
|
||||
self.fix_trock_doors = self.AttributeProxy(
|
||||
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
|
||||
@@ -74,7 +95,6 @@ class MultiWorld():
|
||||
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
||||
self.fix_trock_exit = self.AttributeProxy(
|
||||
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
||||
self.NOTCURSED = self.AttributeProxy(lambda player: not self.CURSED[player])
|
||||
|
||||
for player in range(1, players + 1):
|
||||
def set_player_attr(attr, val):
|
||||
@@ -106,7 +126,6 @@ class MultiWorld():
|
||||
set_player_attr('beemizer_total_chance', 0)
|
||||
set_player_attr('beemizer_trap_chance', 0)
|
||||
set_player_attr('escape_assist', [])
|
||||
set_player_attr('open_pyramid', False)
|
||||
set_player_attr('treasure_hunt_icon', 'Triforce Piece')
|
||||
set_player_attr('treasure_hunt_count', 0)
|
||||
set_player_attr('clock_mode', False)
|
||||
@@ -130,6 +149,42 @@ class MultiWorld():
|
||||
self.worlds = {}
|
||||
self.slot_seeds = {}
|
||||
|
||||
def get_all_ids(self):
|
||||
return self.player_ids + tuple(self.groups)
|
||||
|
||||
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
||||
"""Create a group with name and return the assigned player ID and group.
|
||||
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
||||
for group_id, group in self.groups.items():
|
||||
if group["name"] == name:
|
||||
group["players"] |= players
|
||||
return group_id, group
|
||||
new_id: int = self.players + len(self.groups) + 1
|
||||
|
||||
self.game[new_id] = game
|
||||
self.custom_data[new_id] = {}
|
||||
self.player_types[new_id] = NetUtils.SlotType.group
|
||||
self._region_cache[new_id] = {}
|
||||
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
||||
for option_key, option in world_type.options.items():
|
||||
getattr(self, option_key)[new_id] = option(option.default)
|
||||
for option_key, option in Options.common_options.items():
|
||||
getattr(self, option_key)[new_id] = option(option.default)
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
getattr(self, option_key)[new_id] = option(option.default)
|
||||
|
||||
self.worlds[new_id] = world_type(self, new_id)
|
||||
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
|
||||
self.player_name[new_id] = name
|
||||
|
||||
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
|
||||
world=self.worlds[new_id])
|
||||
|
||||
return new_id, new_group
|
||||
|
||||
def get_player_groups(self, player) -> Set[int]:
|
||||
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
||||
|
||||
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
||||
self.seed = get_seed(seed)
|
||||
if secure:
|
||||
@@ -141,24 +196,78 @@ class MultiWorld():
|
||||
range(1, self.players + 1)}
|
||||
|
||||
def set_options(self, args):
|
||||
from worlds import AutoWorld
|
||||
for option_key in Options.common_options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
for option_key in Options.per_game_common_options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
|
||||
for player in self.player_ids:
|
||||
self.custom_data[player] = {}
|
||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||
for option_key in world_type.options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
for option_key in Options.common_options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
for option_key in Options.per_game_common_options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
|
||||
self.worlds[player] = world_type(self, player)
|
||||
|
||||
def set_item_links(self):
|
||||
item_links = {}
|
||||
|
||||
for player in self.player_ids:
|
||||
for item_link in self.item_links[player].value:
|
||||
if item_link["name"] in item_links:
|
||||
if item_links[item_link["name"]]["game"] != self.game[player]:
|
||||
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
|
||||
item_links[item_link["name"]]["players"][player] = item_link["replacement_item"]
|
||||
item_links[item_link["name"]]["item_pool"] &= set(item_link["item_pool"])
|
||||
item_links[item_link["name"]]["exclude"] |= set(item_link.get("exclude", []))
|
||||
item_links[item_link["name"]]["local_items"] &= set(item_link.get("local_items", []))
|
||||
item_links[item_link["name"]]["non_local_items"] &= set(item_link.get("non_local_items", []))
|
||||
else:
|
||||
if item_link["name"] in self.player_name.values():
|
||||
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) ({self.get_player_name(player)}).")
|
||||
item_links[item_link["name"]] = {
|
||||
"players": {player: item_link["replacement_item"]},
|
||||
"item_pool": set(item_link["item_pool"]),
|
||||
"exclude": set(item_link.get("exclude", [])),
|
||||
"game": self.game[player],
|
||||
"local_items": set(item_link.get("local_items", [])),
|
||||
"non_local_items": set(item_link.get("non_local_items", []))
|
||||
}
|
||||
|
||||
for name, item_link in item_links.items():
|
||||
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
||||
pool = set()
|
||||
local_items = set()
|
||||
non_local_items = set()
|
||||
for item in item_link["item_pool"]:
|
||||
pool |= current_item_name_groups.get(item, {item})
|
||||
for item in item_link["exclude"]:
|
||||
pool -= current_item_name_groups.get(item, {item})
|
||||
for item in item_link["local_items"]:
|
||||
local_items |= current_item_name_groups.get(item, {item})
|
||||
for item in item_link["non_local_items"]:
|
||||
non_local_items |= current_item_name_groups.get(item, {item})
|
||||
local_items &= pool
|
||||
non_local_items &= pool
|
||||
item_link["item_pool"] = pool
|
||||
item_link["local_items"] = local_items
|
||||
item_link["non_local_items"] = non_local_items
|
||||
|
||||
for group_name, item_link in item_links.items():
|
||||
game = item_link["game"]
|
||||
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
||||
group["item_pool"] = item_link["item_pool"]
|
||||
group["replacement_items"] = item_link["players"]
|
||||
group["local_items"] = item_link["local_items"]
|
||||
group["non_local_items"] = item_link["non_local_items"]
|
||||
|
||||
# intended for unittests
|
||||
def set_default_common_options(self):
|
||||
for option_key, option in Options.common_options.items():
|
||||
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
|
||||
self.state = CollectionState(self)
|
||||
|
||||
def secure(self):
|
||||
self.random = secrets.SystemRandom()
|
||||
@@ -174,7 +283,8 @@ class MultiWorld():
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_game_worlds(self, game_name: str):
|
||||
return tuple(world for player, world in self.worlds.items() if self.game[player] == game_name)
|
||||
return tuple(world for player, world in self.worlds.items() if
|
||||
player not in self.groups and self.game[player] == game_name)
|
||||
|
||||
def get_name_string_for_object(self, obj) -> str:
|
||||
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
|
||||
@@ -182,6 +292,9 @@ class MultiWorld():
|
||||
def get_player_name(self, player: int) -> str:
|
||||
return self.player_name[player]
|
||||
|
||||
def get_file_safe_player_name(self, player: int) -> str:
|
||||
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
|
||||
|
||||
def initialize_regions(self, regions=None):
|
||||
for region in regions if regions else self.regions:
|
||||
region.world = self
|
||||
@@ -193,6 +306,7 @@ class MultiWorld():
|
||||
|
||||
def _recache(self):
|
||||
"""Rebuild world cache"""
|
||||
self._cached_locations = None
|
||||
for region in self.regions:
|
||||
player = region.player
|
||||
self._region_cache[player][region.name] = region
|
||||
@@ -241,10 +355,9 @@ class MultiWorld():
|
||||
|
||||
for item in self.itempool:
|
||||
self.worlds[item.player].collect(ret, item)
|
||||
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
||||
for item in get_dungeon_item_pool(self):
|
||||
subworld = self.worlds[item.player]
|
||||
if item.name in subworld.dungeon_local_item_names:
|
||||
for player in self.player_ids:
|
||||
subworld = self.worlds[player]
|
||||
for item in subworld.get_pre_fill_items():
|
||||
subworld.collect(ret, item)
|
||||
ret.sweep_for_events()
|
||||
|
||||
@@ -252,7 +365,7 @@ class MultiWorld():
|
||||
self._all_state = ret
|
||||
return ret
|
||||
|
||||
def get_items(self) -> list:
|
||||
def get_items(self) -> List[Item]:
|
||||
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
||||
|
||||
def find_item_locations(self, item, player: int) -> List[Location]:
|
||||
@@ -276,20 +389,14 @@ class MultiWorld():
|
||||
self.state.collect(item, True)
|
||||
|
||||
def push_item(self, location: Location, item: Item, collect: bool = True):
|
||||
if not isinstance(location, Location):
|
||||
raise RuntimeError(
|
||||
'Cannot assign item %s to invalid location %s (player %d).' % (item, location, item.player))
|
||||
assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}."
|
||||
location.item = item
|
||||
item.location = location
|
||||
item.world = self # try to not have this here anymore and create it with item?
|
||||
if collect:
|
||||
self.state.collect(item, location.event, location)
|
||||
|
||||
if location.can_fill(self.state, item, False):
|
||||
location.item = item
|
||||
item.location = location
|
||||
item.world = self # try to not have this here anymore
|
||||
if collect:
|
||||
self.state.collect(item, location.event, location)
|
||||
|
||||
logging.debug('Placed %s at %s', item, location)
|
||||
else:
|
||||
raise RuntimeError('Cannot assign item %s to location %s.' % (item, location))
|
||||
logging.debug('Placed %s at %s', item, location)
|
||||
|
||||
def get_entrances(self) -> List[Entrance]:
|
||||
if self._cached_entrances is None:
|
||||
@@ -307,7 +414,7 @@ class MultiWorld():
|
||||
def clear_location_cache(self):
|
||||
self._cached_locations = None
|
||||
|
||||
def get_unfilled_locations(self, player=None) -> List[Location]:
|
||||
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||
if player is not None:
|
||||
return [location for location in self.get_locations() if
|
||||
location.player == player and not location.item]
|
||||
@@ -316,13 +423,13 @@ class MultiWorld():
|
||||
def get_unfilled_dungeon_locations(self):
|
||||
return [location for location in self.get_locations() if not location.item and location.parent_region.dungeon]
|
||||
|
||||
def get_filled_locations(self, player=None) -> List[Location]:
|
||||
def get_filled_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||
if player is not None:
|
||||
return [location for location in self.get_locations() if
|
||||
location.player == player and location.item is not None]
|
||||
return [location for location in self.get_locations() if location.item is not None]
|
||||
|
||||
def get_reachable_locations(self, state=None, player=None) -> List[Location]:
|
||||
def get_reachable_locations(self, state: Optional[CollectionState] = None, player: Optional[int] = None) -> List[Location]:
|
||||
if state is None:
|
||||
state = self.state
|
||||
return [location for location in self.get_locations() if
|
||||
@@ -334,13 +441,16 @@ class MultiWorld():
|
||||
return [location for location in self.get_locations() if
|
||||
(player is None or location.player == player) and location.item is None and location.can_reach(state)]
|
||||
|
||||
def get_unfilled_locations_for_players(self, location_name: str, players: Iterable[int]):
|
||||
def get_unfilled_locations_for_players(self, locations: List[str], players: Iterable[int]):
|
||||
for player in players:
|
||||
location = self.get_location(location_name, player)
|
||||
if location.item is None:
|
||||
yield location
|
||||
if len(locations) == 0:
|
||||
locations = [location.name for location in self.get_unfilled_locations(player)]
|
||||
for location_name in locations:
|
||||
location = self._location_cache.get((location_name, player), None)
|
||||
if location is not None and location.item is None:
|
||||
yield location
|
||||
|
||||
def unlocks_new_location(self, item) -> bool:
|
||||
def unlocks_new_location(self, item: Item) -> bool:
|
||||
temp_state = self.state.copy()
|
||||
temp_state.collect(item, True)
|
||||
|
||||
@@ -350,7 +460,7 @@ class MultiWorld():
|
||||
|
||||
return False
|
||||
|
||||
def has_beaten_game(self, state, player: Optional[int] = None):
|
||||
def has_beaten_game(self, state: CollectionState, player: Optional[int] = None) -> bool:
|
||||
if player:
|
||||
return self.completion_condition[player](state)
|
||||
else:
|
||||
@@ -429,8 +539,9 @@ class MultiWorld():
|
||||
|
||||
def location_relevant(location: Location):
|
||||
"""Determine if this location is relevant to sweep."""
|
||||
if location.player in players["locations"] or location.event or \
|
||||
(location.item and location.item.advancement):
|
||||
if location.progress_type != LocationProgressType.EXCLUDED \
|
||||
and (location.player in players["locations"] or location.event
|
||||
or (location.item and location.item.advancement)):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -468,17 +579,32 @@ class MultiWorld():
|
||||
return False
|
||||
|
||||
|
||||
class CollectionState(object):
|
||||
PathValue = Tuple[str, Optional["PathValue"]]
|
||||
|
||||
|
||||
class CollectionState():
|
||||
prog_items: typing.Counter[Tuple[str, int]]
|
||||
world: MultiWorld
|
||||
reachable_regions: Dict[int, Set[Region]]
|
||||
blocked_connections: Dict[int, Set[Entrance]]
|
||||
events: Set[Location]
|
||||
path: Dict[Union[Region, Entrance], PathValue]
|
||||
locations_checked: Set[Location]
|
||||
stale: Dict[int, bool]
|
||||
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
||||
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
||||
|
||||
def __init__(self, parent: MultiWorld):
|
||||
self.prog_items = Counter()
|
||||
self.world = parent
|
||||
self.reachable_regions = {player: set() for player in range(1, parent.players + 1)}
|
||||
self.blocked_connections = {player: set() for player in range(1, parent.players + 1)}
|
||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||
self.events = set()
|
||||
self.path = {}
|
||||
self.locations_checked = set()
|
||||
self.stale = {player: True for player in range(1, parent.players + 1)}
|
||||
self.stale = {player: True for player in parent.get_all_ids()}
|
||||
for function in self.additional_init_functions:
|
||||
function(self, parent)
|
||||
for items in parent.precollected_items.values():
|
||||
for item in items:
|
||||
self.collect(item, True)
|
||||
@@ -504,6 +630,7 @@ class CollectionState(object):
|
||||
if new_region in rrp:
|
||||
bc.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
assert new_region, "tried to search through an Entrance with no Region"
|
||||
rrp.add(new_region)
|
||||
bc.remove(connection)
|
||||
bc.update(new_region.exits)
|
||||
@@ -520,16 +647,22 @@ class CollectionState(object):
|
||||
ret = CollectionState(self.world)
|
||||
ret.prog_items = self.prog_items.copy()
|
||||
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
||||
range(1, self.world.players + 1)}
|
||||
self.reachable_regions}
|
||||
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
|
||||
range(1, self.world.players + 1)}
|
||||
self.blocked_connections}
|
||||
ret.events = copy.copy(self.events)
|
||||
ret.path = copy.copy(self.path)
|
||||
ret.locations_checked = copy.copy(self.locations_checked)
|
||||
for function in self.additional_copy_functions:
|
||||
ret = function(self, ret)
|
||||
return ret
|
||||
|
||||
def can_reach(self, spot, resolution_hint=None, player=None) -> bool:
|
||||
if not hasattr(spot, "spot_type"):
|
||||
def can_reach(self,
|
||||
spot: Union[Location, Entrance, Region, str],
|
||||
resolution_hint: Optional[str] = None,
|
||||
player: Optional[int] = None) -> bool:
|
||||
if isinstance(spot, str):
|
||||
assert isinstance(player, int), "can_reach: player is required if spot is str"
|
||||
# try to resolve a name
|
||||
if resolution_hint == 'Location':
|
||||
spot = self.world.get_location(spot, player)
|
||||
@@ -540,31 +673,34 @@ class CollectionState(object):
|
||||
spot = self.world.get_region(spot, player)
|
||||
return spot.can_reach(self)
|
||||
|
||||
def sweep_for_events(self, key_only: bool = False, locations=None):
|
||||
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
if locations is None:
|
||||
locations = self.world.get_filled_locations()
|
||||
new_locations = True
|
||||
# since the loop has a good chance to run more than once, only filter the events once
|
||||
locations = {location for location in locations if location.event}
|
||||
locations = {location for location in locations if location.event and
|
||||
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
||||
while new_locations:
|
||||
reachable_events = {location for location in locations if
|
||||
(not key_only or getattr(location.item, "locked_dungeon_item", False))
|
||||
and location.can_reach(self)}
|
||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||
new_locations = reachable_events - self.events
|
||||
for event in new_locations:
|
||||
self.events.add(event)
|
||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(event.item, True, event)
|
||||
|
||||
def has(self, item, player: int, count: int = 1):
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
return self.prog_items[item, player] >= count
|
||||
|
||||
def has_all(self, items: Set[str], player: int):
|
||||
def has_all(self, items: Set[str], player: int) -> bool:
|
||||
return all(self.prog_items[item, player] for item in items)
|
||||
|
||||
def has_any(self, items: Set[str], player: int):
|
||||
def has_any(self, items: Set[str], player: int) -> bool:
|
||||
return any(self.prog_items[item, player] for item in items)
|
||||
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1):
|
||||
def count(self, item: str, player: int) -> int:
|
||||
return self.prog_items[item, player]
|
||||
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||
found: int = 0
|
||||
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||
found += self.prog_items[item_name, player]
|
||||
@@ -572,7 +708,7 @@ class CollectionState(object):
|
||||
return True
|
||||
return False
|
||||
|
||||
def count_group(self, item_name_group: str, player: int):
|
||||
def count_group(self, item_name_group: str, player: int) -> int:
|
||||
found: int = 0
|
||||
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||
found += self.prog_items[item_name, player]
|
||||
@@ -638,7 +774,7 @@ class CollectionState(object):
|
||||
basemagic = basemagic + basemagic * self.bottle_count(player)
|
||||
return basemagic >= smallmagic
|
||||
|
||||
def can_kill_most_things(self, player: int, enemies=5) -> bool:
|
||||
def can_kill_most_things(self, player: int, enemies: int = 5) -> bool:
|
||||
return (self.has_melee_weapon(player)
|
||||
or self.has('Cane of Somaria', player)
|
||||
or (self.has('Cane of Byrna', player) and (enemies < 6 or self.can_extend_magic(player)))
|
||||
@@ -647,7 +783,7 @@ class CollectionState(object):
|
||||
or (self.has('Bombs (10)', player) and enemies < 6))
|
||||
|
||||
def can_shoot_arrows(self, player: int) -> bool:
|
||||
if self.world.retro[player]:
|
||||
if self.world.retro_bow[player]:
|
||||
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
|
||||
return self.has('Bow', player) or self.has('Silver Bow', player)
|
||||
|
||||
@@ -713,26 +849,26 @@ class CollectionState(object):
|
||||
def has_turtle_rock_medallion(self, player: int) -> bool:
|
||||
return self.has(self.world.required_medallions[player][1], player)
|
||||
|
||||
def can_boots_clip_lw(self, player: int):
|
||||
def can_boots_clip_lw(self, player: int) -> bool:
|
||||
if self.world.mode[player] == 'inverted':
|
||||
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
||||
return self.has('Pegasus Boots', player)
|
||||
|
||||
def can_boots_clip_dw(self, player: int):
|
||||
def can_boots_clip_dw(self, player: int) -> bool:
|
||||
if self.world.mode[player] != 'inverted':
|
||||
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
||||
return self.has('Pegasus Boots', player)
|
||||
|
||||
def can_get_glitched_speed_lw(self, player: int):
|
||||
def can_get_glitched_speed_lw(self, player: int) -> bool:
|
||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||
if self.world.mode[player] == 'inverted':
|
||||
rules.append(self.has('Moon Pearl', player))
|
||||
return all(rules)
|
||||
|
||||
def can_superbunny_mirror_with_sword(self, player: int):
|
||||
def can_superbunny_mirror_with_sword(self, player: int) -> bool:
|
||||
return self.has('Magic Mirror', player) and self.has_sword(player)
|
||||
|
||||
def can_get_glitched_speed_dw(self, player: int):
|
||||
def can_get_glitched_speed_dw(self, player: int) -> bool:
|
||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||
if self.world.mode[player] != 'inverted':
|
||||
rules.append(self.has('Moon Pearl', player))
|
||||
@@ -741,7 +877,7 @@ class CollectionState(object):
|
||||
def can_bomb_clip(self, region: Region, player: int) -> bool:
|
||||
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
|
||||
|
||||
def collect(self, item: Item, event: bool = False, location: Location = None) -> bool:
|
||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||
if location:
|
||||
self.locations_checked.add(location)
|
||||
|
||||
@@ -768,7 +904,7 @@ class CollectionState(object):
|
||||
|
||||
|
||||
@unique
|
||||
class RegionType(int, Enum):
|
||||
class RegionType(IntEnum):
|
||||
Generic = 0
|
||||
LightWorld = 1
|
||||
DarkWorld = 2
|
||||
@@ -781,20 +917,30 @@ class RegionType(int, Enum):
|
||||
return self in (RegionType.Cave, RegionType.Dungeon)
|
||||
|
||||
|
||||
class Region(object):
|
||||
class Region:
|
||||
name: str
|
||||
type: RegionType
|
||||
hint_text: str
|
||||
player: int
|
||||
world: Optional[MultiWorld]
|
||||
entrances: List[Entrance]
|
||||
exits: List[Entrance]
|
||||
locations: List[Location]
|
||||
dungeon: Optional[Dungeon] = None
|
||||
shop: Optional = None
|
||||
|
||||
def __init__(self, name: str, type: str, hint, player: int, world: Optional[MultiWorld] = None):
|
||||
# LttP specific. TODO: move to a LttPRegion
|
||||
# will be set after making connections.
|
||||
is_light_world: bool = False
|
||||
is_dark_world: bool = False
|
||||
|
||||
def __init__(self, name: str, type_: RegionType, hint: str, player: int, world: Optional[MultiWorld] = None):
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.type = type_
|
||||
self.entrances = []
|
||||
self.exits = []
|
||||
self.locations = []
|
||||
self.dungeon = None
|
||||
self.shop = None
|
||||
self.world = world
|
||||
self.is_light_world = False # will be set after making connections.
|
||||
self.is_dark_world = False
|
||||
self.spot_type = 'Region'
|
||||
self.hint_text = hint
|
||||
self.player = player
|
||||
|
||||
@@ -818,18 +964,21 @@ class Region(object):
|
||||
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
class Entrance(object):
|
||||
spot_type = 'Entrance'
|
||||
class Entrance:
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
hide_path: bool = False
|
||||
player: int
|
||||
name: str
|
||||
parent_region: Optional[Region]
|
||||
connected_region: Optional[Region] = None
|
||||
# LttP specific, TODO: should make a LttPEntrance
|
||||
addresses = None
|
||||
target = None
|
||||
|
||||
def __init__(self, player: int, name: str = '', parent=None):
|
||||
def __init__(self, player: int, name: str = '', parent: Region = None):
|
||||
self.name = name
|
||||
self.parent_region = parent
|
||||
self.connected_region = None
|
||||
self.target = None
|
||||
self.addresses = None
|
||||
self.access_rule = lambda state: True
|
||||
self.player = player
|
||||
self.hide_path = False
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
if self.parent_region.can_reach(state) and self.access_rule(state):
|
||||
@@ -839,7 +988,7 @@ class Entrance(object):
|
||||
|
||||
return False
|
||||
|
||||
def connect(self, region: Region, addresses=None, target = None):
|
||||
def connect(self, region: Region, addresses=None, target=None):
|
||||
self.connected_region = region
|
||||
self.target = target
|
||||
self.addresses = addresses
|
||||
@@ -854,7 +1003,8 @@ class Entrance(object):
|
||||
|
||||
|
||||
class Dungeon(object):
|
||||
def __init__(self, name: str, regions, big_key, small_keys, dungeon_items, player: int):
|
||||
def __init__(self, name: str, regions: List[Region], big_key: Item, small_keys: List[Item],
|
||||
dungeon_items: List[Item], player: int):
|
||||
self.name = name
|
||||
self.regions = regions
|
||||
self.big_key = big_key
|
||||
@@ -873,11 +1023,11 @@ class Dungeon(object):
|
||||
self.bosses[None] = value
|
||||
|
||||
@property
|
||||
def keys(self):
|
||||
def keys(self) -> List[Item]:
|
||||
return self.small_keys + ([self.big_key] if self.big_key else [])
|
||||
|
||||
@property
|
||||
def all_items(self):
|
||||
def all_items(self) -> List[Item]:
|
||||
return self.dungeon_items + self.keys
|
||||
|
||||
def is_dungeon_item(self, item: Item) -> bool:
|
||||
@@ -896,7 +1046,7 @@ class Dungeon(object):
|
||||
|
||||
|
||||
class Boss():
|
||||
def __init__(self, name: str, enemizer_name: str, defeat_rule, player: int):
|
||||
def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int):
|
||||
self.name = name
|
||||
self.enemizer_name = enemizer_name
|
||||
self.defeat_rule = defeat_rule
|
||||
@@ -909,27 +1059,33 @@ class Boss():
|
||||
return f"Boss({self.name})"
|
||||
|
||||
|
||||
class Location():
|
||||
class LocationProgressType(IntEnum):
|
||||
DEFAULT = 1
|
||||
PRIORITY = 2
|
||||
EXCLUDED = 3
|
||||
|
||||
|
||||
class Location:
|
||||
# If given as integer, then this is the shop's inventory index
|
||||
shop_slot: Optional[int] = None
|
||||
shop_slot_disabled: bool = False
|
||||
event: bool = False
|
||||
locked: bool = False
|
||||
spot_type = 'Location'
|
||||
game: str = "Generic"
|
||||
show_in_spoiler: bool = True
|
||||
excluded: bool = False
|
||||
crystal: bool = False
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow = staticmethod(lambda item, state: False)
|
||||
access_rule = staticmethod(lambda state: True)
|
||||
item_rule = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
parent_region: Optional[Region]
|
||||
|
||||
def __init__(self, player: int, name: str = '', address: int = None, parent=None):
|
||||
self.name: str = name
|
||||
self.address: Optional[int] = address
|
||||
self.parent_region: Region = parent
|
||||
self.parent_region = parent
|
||||
self.player: int = player
|
||||
self.item: Optional[Item] = None
|
||||
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
|
||||
@@ -944,6 +1100,7 @@ class Location():
|
||||
if self.item:
|
||||
raise Exception(f"Location {self} already filled.")
|
||||
self.item = item
|
||||
item.location = self
|
||||
self.event = item.advancement
|
||||
self.item.world = self.parent_region.world
|
||||
self.locked = True
|
||||
@@ -958,7 +1115,7 @@ class Location():
|
||||
def __hash__(self):
|
||||
return hash((self.name, self.player))
|
||||
|
||||
def __lt__(self, other):
|
||||
def __lt__(self, other: Location):
|
||||
return (self.player, self.name) < (other.player, other.name)
|
||||
|
||||
@property
|
||||
@@ -967,24 +1124,36 @@ class Location():
|
||||
return self.item and self.item.game == self.game
|
||||
|
||||
@property
|
||||
def hint_text(self):
|
||||
def hint_text(self) -> str:
|
||||
hint_text = getattr(self, "_hint_text", None)
|
||||
if hint_text:
|
||||
return hint_text
|
||||
return "at " + self.name.replace("_", " ").replace("-", " ")
|
||||
|
||||
|
||||
class Item():
|
||||
class ItemClassification(IntFlag):
|
||||
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
|
||||
progression = 0b0001 # Item that is logically relevant
|
||||
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
|
||||
trap = 0b0100 # detrimental or entirely useless (nothing) item
|
||||
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
|
||||
|
||||
def as_flag(self) -> int:
|
||||
"""As Network API flag int."""
|
||||
return int(self & 0b0111)
|
||||
|
||||
|
||||
class Item:
|
||||
location: Optional[Location] = None
|
||||
world: Optional[MultiWorld] = None
|
||||
code: Optional[int] = None # an item with ID None is called an Event, and does not get written to multidata
|
||||
name: str
|
||||
game: str = "Generic"
|
||||
type: str = None
|
||||
# indicates if this is a negative impact item. Causes these to be handled differently by various games.
|
||||
trap: bool = False
|
||||
# change manually to ensure that a specific non-progression item never goes on an excluded location
|
||||
never_exclude = False
|
||||
classification: ItemClassification
|
||||
|
||||
# need to find a decent place for these to live and to allow other games to register texts if they want.
|
||||
pedestal_credit_text: str = "and the Unknown Item"
|
||||
@@ -999,9 +1168,9 @@ class Item():
|
||||
map: bool = False
|
||||
compass: bool = False
|
||||
|
||||
def __init__(self, name: str, advancement: bool, code: Optional[int], player: int):
|
||||
def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int):
|
||||
self.name = name
|
||||
self.advancement = advancement
|
||||
self.classification = classification
|
||||
self.player = player
|
||||
self.code = code
|
||||
|
||||
@@ -1013,10 +1182,30 @@ class Item():
|
||||
def pedestal_hint_text(self):
|
||||
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
|
||||
|
||||
@property
|
||||
def advancement(self) -> bool:
|
||||
return ItemClassification.progression in self.classification
|
||||
|
||||
@property
|
||||
def skip_in_prog_balancing(self) -> bool:
|
||||
return ItemClassification.progression_skip_balancing in self.classification
|
||||
|
||||
@property
|
||||
def useful(self) -> bool:
|
||||
return ItemClassification.useful in self.classification
|
||||
|
||||
@property
|
||||
def trap(self) -> bool:
|
||||
return ItemClassification.trap in self.classification
|
||||
|
||||
@property
|
||||
def flags(self) -> int:
|
||||
return self.classification.as_flag()
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.name == other.name and self.player == other.player
|
||||
|
||||
def __lt__(self, other):
|
||||
def __lt__(self, other: Item):
|
||||
if other.player != self.player:
|
||||
return other.player < self.player
|
||||
return self.name < other.name
|
||||
@@ -1033,6 +1222,7 @@ class Item():
|
||||
|
||||
class Spoiler():
|
||||
world: MultiWorld
|
||||
unreachables: Set[Location]
|
||||
|
||||
def __init__(self, world):
|
||||
self.world = world
|
||||
@@ -1040,19 +1230,19 @@ class Spoiler():
|
||||
self.entrances = OrderedDict()
|
||||
self.medallions = {}
|
||||
self.playthrough = {}
|
||||
self.unreachables = []
|
||||
self.unreachables = set()
|
||||
self.locations = {}
|
||||
self.paths = {}
|
||||
self.shops = []
|
||||
self.bosses = OrderedDict()
|
||||
|
||||
def set_entrance(self, entrance, exit, direction, player):
|
||||
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
|
||||
if self.world.players == 1:
|
||||
self.entrances[(entrance, direction, player)] = OrderedDict(
|
||||
[('entrance', entrance), ('exit', exit), ('direction', direction)])
|
||||
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
||||
else:
|
||||
self.entrances[(entrance, direction, player)] = OrderedDict(
|
||||
[('player', player), ('entrance', entrance), ('exit', exit), ('direction', direction)])
|
||||
[('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
||||
|
||||
def parse_data(self):
|
||||
self.medallions = OrderedDict()
|
||||
@@ -1181,8 +1371,7 @@ class Spoiler():
|
||||
|
||||
return json.dumps(out)
|
||||
|
||||
def to_file(self, filename):
|
||||
from worlds.AutoWorld import call_all, call_single, call_stage
|
||||
def to_file(self, filename: str):
|
||||
self.parse_data()
|
||||
|
||||
def bool_to_text(variable: Union[bool, str]) -> str:
|
||||
@@ -1192,9 +1381,9 @@ class Spoiler():
|
||||
|
||||
def write_option(option_key: str, option_obj: type(Options.Option)):
|
||||
res = getattr(self.world, option_key)[player]
|
||||
displayname = getattr(option_obj, "displayname", option_key)
|
||||
display_name = getattr(option_obj, "display_name", option_key)
|
||||
try:
|
||||
outfile.write(f'{displayname + ":":33}{res.get_current_option_name()}\n')
|
||||
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
|
||||
except:
|
||||
raise Exception
|
||||
|
||||
@@ -1204,21 +1393,19 @@ class Spoiler():
|
||||
Utils.__version__, self.world.seed))
|
||||
outfile.write('Filling Algorithm: %s\n' % self.world.algorithm)
|
||||
outfile.write('Players: %d\n' % self.world.players)
|
||||
call_stage(self.world, "write_spoiler_header", outfile)
|
||||
AutoWorld.call_stage(self.world, "write_spoiler_header", outfile)
|
||||
|
||||
for player in range(1, self.world.players + 1):
|
||||
if self.world.players > 1:
|
||||
outfile.write('\nPlayer %d: %s\n' % (player, self.world.get_player_name(player)))
|
||||
outfile.write('Game: %s\n' % self.world.game[player])
|
||||
for f_option, option in Options.common_options.items():
|
||||
write_option(f_option, option)
|
||||
for f_option, option in Options.per_game_common_options.items():
|
||||
write_option(f_option, option)
|
||||
options = self.world.worlds[player].options
|
||||
if options:
|
||||
for f_option, option in options.items():
|
||||
write_option(f_option, option)
|
||||
call_single(self.world, "write_spoiler_header", player, outfile)
|
||||
AutoWorld.call_single(self.world, "write_spoiler_header", player, outfile)
|
||||
|
||||
if player in self.world.get_game_players("A Link to the Past"):
|
||||
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
|
||||
@@ -1237,8 +1424,6 @@ class Spoiler():
|
||||
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
|
||||
if self.world.shuffle[player] != "vanilla":
|
||||
outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
|
||||
outfile.write('Pyramid hole pre-opened: %s\n' % (
|
||||
'Yes' if self.world.open_pyramid[player] else 'No'))
|
||||
outfile.write('Shop inventory shuffle: %s\n' %
|
||||
bool_to_text("i" in self.world.shop_shuffle[player]))
|
||||
outfile.write('Shop price shuffle: %s\n' %
|
||||
@@ -1268,7 +1453,7 @@ class Spoiler():
|
||||
for dungeon, medallion in self.medallions.items():
|
||||
outfile.write(f'\n{dungeon}: {medallion}')
|
||||
|
||||
call_all(self.world, "write_spoiler", outfile)
|
||||
AutoWorld.call_all(self.world, "write_spoiler", outfile)
|
||||
|
||||
outfile.write('\n\nLocations:\n\n')
|
||||
outfile.write('\n'.join(
|
||||
@@ -1309,14 +1494,32 @@ class Spoiler():
|
||||
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
|
||||
|
||||
outfile.write('\n'.join(path_listings))
|
||||
call_all(self.world, "write_spoiler_end", outfile)
|
||||
AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
|
||||
|
||||
|
||||
class Tutorial(NamedTuple):
|
||||
"""Class to build website tutorial pages from a .md file in the world's /docs folder. Order is as follows.
|
||||
Name of the tutorial as it will appear on the site. Concise description covering what the guide will entail.
|
||||
Language the guide is written in. Name of the file ex 'setup_en.md'. Name of the link on the site; game name is
|
||||
filled automatically so 'setup/en' etc. Author or authors."""
|
||||
tutorial_name: str
|
||||
description: str
|
||||
language: str
|
||||
file_name: str
|
||||
link: str
|
||||
authors: List[str]
|
||||
|
||||
|
||||
seeddigits = 20
|
||||
|
||||
|
||||
def get_seed(seed=None):
|
||||
def get_seed(seed=None) -> int:
|
||||
if seed is None:
|
||||
random.seed(None)
|
||||
return random.randint(0, pow(10, seeddigits) - 1)
|
||||
return seed
|
||||
|
||||
|
||||
from worlds import AutoWorld
|
||||
|
||||
auto_world = AutoWorld.World
|
||||
|
||||
172
ChecksFinderClient.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("ChecksFinderClient", exception_logger="Client")
|
||||
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||
CommonContext, server_loop
|
||||
|
||||
|
||||
class ChecksFinderClientCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
|
||||
class ChecksFinderContext(CommonContext):
|
||||
command_processor: int = ChecksFinderClientCommandProcessor
|
||||
game = "ChecksFinder"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(ChecksFinderContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.syncing = False
|
||||
self.awaiting_bridge = False
|
||||
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
||||
if "localappdata" in os.environ:
|
||||
self.game_communication_path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
else:
|
||||
# not windows. game is an exe so let's see if wine might be around to run it
|
||||
if "WINEPREFIX" in os.environ:
|
||||
wineprefix = os.environ["WINEPREFIX"]
|
||||
elif shutil.which("wine") or shutil.which("wine-stable"):
|
||||
wineprefix = os.path.expanduser("~/.wine") # default root of wine system data, deep in which is app data
|
||||
else:
|
||||
msg = "ChecksFinderClient couldn't detect system type. Unable to infer required game_communication_path"
|
||||
logger.error("Error: " + msg)
|
||||
Utils.messagebox("Error", msg, error=True)
|
||||
sys.exit(1)
|
||||
self.game_communication_path = os.path.join(
|
||||
wineprefix,
|
||||
"drive_c",
|
||||
os.path.expandvars("users/$USER/Local Settings/Application Data/ChecksFinder"))
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(ChecksFinderContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
async def connection_closed(self):
|
||||
await super(ChecksFinderContext, self).connection_closed()
|
||||
for root, dirs, files in os.walk(self.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root + "/" + file)
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
|
||||
async def shutdown(self):
|
||||
await super(ChecksFinderContext, self).shutdown()
|
||||
for root, dirs, files in os.walk(self.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
if cmd in {"ReceivedItems"}:
|
||||
start_index = args["index"]
|
||||
if start_index != len(self.items_received):
|
||||
for item in args['items']:
|
||||
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.write(str(NetworkItem(*item).item))
|
||||
f.close()
|
||||
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if "checked_locations" in args:
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class ChecksFinderManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago ChecksFinder Client"
|
||||
|
||||
self.ui = ChecksFinderManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
async def game_watcher(ctx: ChecksFinderContext):
|
||||
from worlds.checksfinder.Locations import lookup_id_to_name
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.syncing == True:
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
ctx.syncing = False
|
||||
sending = []
|
||||
victory = False
|
||||
for root, dirs, files in os.walk(ctx.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("send") > -1:
|
||||
st = file.split("send", -1)[1]
|
||||
sending = sending+[(int(st))]
|
||||
if file.find("victory") > -1:
|
||||
victory = True
|
||||
ctx.locations_checked = sending
|
||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||
await ctx.send_msgs(message)
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
async def main(args):
|
||||
ctx = ChecksFinderContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="ChecksFinderProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="ChecksFinder Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
393
CommonClient.py
@@ -6,6 +6,9 @@ import sys
|
||||
import typing
|
||||
import time
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import websockets
|
||||
|
||||
import Utils
|
||||
@@ -14,13 +17,14 @@ if __name__ == "__main__":
|
||||
Utils.init_logging("TextClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
|
||||
from Utils import Version, stream_input
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
# without terminal we have to use gui mode
|
||||
# without terminal, we have to use gui mode
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
|
||||
|
||||
@@ -39,12 +43,14 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
def _cmd_connect(self, address: str = "") -> bool:
|
||||
"""Connect to a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
self.ctx.username = None
|
||||
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
||||
return True
|
||||
|
||||
def _cmd_disconnect(self) -> bool:
|
||||
"""Disconnect from a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
self.ctx.username = None
|
||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||
return True
|
||||
|
||||
@@ -52,7 +58,7 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
"""List all received items"""
|
||||
logger.info(f'{len(self.ctx.items_received)} received items:')
|
||||
for index, item in enumerate(self.ctx.items_received, 1):
|
||||
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
|
||||
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
|
||||
return True
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
@@ -110,24 +116,57 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||
|
||||
|
||||
class CommonContext():
|
||||
class CommonContext:
|
||||
# Should be adjusted as needed in subclasses
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
game: typing.Optional[str] = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
|
||||
# datapackage
|
||||
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||
|
||||
# defaults
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: int = ClientCommandProcessor
|
||||
game = None
|
||||
command_processor: type(CommandProcessor) = ClientCommandProcessor
|
||||
ui = None
|
||||
keep_alive_task = None
|
||||
ui_task: typing.Optional[asyncio.Task] = None
|
||||
input_task: typing.Optional[asyncio.Task] = None
|
||||
keep_alive_task: typing.Optional[asyncio.Task] = None
|
||||
server_task: typing.Optional[asyncio.Task] = None
|
||||
server: typing.Optional[Endpoint] = None
|
||||
server_version: Version = Version(0, 0, 0)
|
||||
current_energy_link_value: int = 0 # to display in UI, gets set by server
|
||||
|
||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# remaining type info
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
server_address: str
|
||||
password: typing.Optional[str]
|
||||
hint_cost: typing.Optional[int]
|
||||
player_names: typing.Dict[int, str]
|
||||
|
||||
# locations
|
||||
locations_checked: typing.Set[int] # local state
|
||||
locations_scouted: typing.Set[int]
|
||||
missing_locations: typing.Set[int]
|
||||
checked_locations: typing.Set[int] # server state
|
||||
locations_info: typing.Dict[int, NetworkItem]
|
||||
|
||||
# internals
|
||||
# current message box through kvui
|
||||
_messagebox = None
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
# server state
|
||||
self.server_address = server_address
|
||||
self.username = None
|
||||
self.password = password
|
||||
self.server_task = None
|
||||
self.server: typing.Optional[Endpoint] = None
|
||||
self.server_version = Version(0, 0, 0)
|
||||
self.hint_cost: typing.Optional[int] = None
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
self.hint_cost = None
|
||||
self.slot_info = {}
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"collect": "disabled",
|
||||
@@ -142,26 +181,23 @@ class CommonContext():
|
||||
self.auth = None
|
||||
self.seed_name = None
|
||||
|
||||
self.locations_checked: typing.Set[int] = set() # local state
|
||||
self.locations_scouted: typing.Set[int] = set()
|
||||
self.locations_checked = set() # local state
|
||||
self.locations_scouted = set()
|
||||
self.items_received = []
|
||||
self.missing_locations: typing.Set[int] = set()
|
||||
self.checked_locations: typing.Set[int] = set() # server state
|
||||
self.missing_locations = set()
|
||||
self.checked_locations = set() # server state
|
||||
self.locations_info = {}
|
||||
|
||||
self.input_queue = asyncio.Queue()
|
||||
self.input_requests = 0
|
||||
|
||||
self.last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# game state
|
||||
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
|
||||
self.player_names = {0: "Archipelago"}
|
||||
self.exit_event = asyncio.Event()
|
||||
self.watcher_event = asyncio.Event()
|
||||
|
||||
self.slow_mode = False
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.set_getters(network_data_package)
|
||||
self.update_datapackage(network_data_package)
|
||||
|
||||
# execution
|
||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||
@@ -173,50 +209,25 @@ class CommonContext():
|
||||
return len(self.checked_locations | self.missing_locations)
|
||||
|
||||
async def connection_closed(self):
|
||||
self.reset_server_state()
|
||||
if self.server and self.server.socket is not None:
|
||||
await self.server.socket.close()
|
||||
|
||||
def reset_server_state(self):
|
||||
self.auth = None
|
||||
self.slot = None
|
||||
self.team = None
|
||||
self.items_received = []
|
||||
self.locations_info = {}
|
||||
self.server_version = Version(0, 0, 0)
|
||||
if self.server and self.server.socket is not None:
|
||||
await self.server.socket.close()
|
||||
self.server = None
|
||||
self.server_task = None
|
||||
|
||||
# noinspection PyAttributeOutsideInit
|
||||
def set_getters(self, data_package: dict, network=False):
|
||||
if not network: # local data; check if newer data was already downloaded
|
||||
local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {})
|
||||
if local_package and local_package["version"] > network_data_package["version"]:
|
||||
data_package: dict = local_package
|
||||
elif network: # check if data from server is newer
|
||||
|
||||
if data_package["version"] > network_data_package["version"]:
|
||||
Utils.persistent_store("datapackage", "latest", network_data_package)
|
||||
|
||||
item_lookup: dict = {}
|
||||
locations_lookup: dict = {}
|
||||
for game, gamedata in data_package["games"].items():
|
||||
for item_name, item_id in gamedata["item_name_to_id"].items():
|
||||
item_lookup[item_id] = item_name
|
||||
for location_name, location_id in gamedata["location_name_to_id"].items():
|
||||
locations_lookup[location_id] = location_name
|
||||
|
||||
def get_item_name_from_id(code: int):
|
||||
return item_lookup.get(code, f'Unknown item (ID:{code})')
|
||||
|
||||
self.item_name_getter = get_item_name_from_id
|
||||
|
||||
def get_location_name_from_address(address: int):
|
||||
return locations_lookup.get(address, f'Unknown location (ID:{address})')
|
||||
|
||||
self.location_name_getter = get_location_name_from_address
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
self.hint_cost = None
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
|
||||
async def disconnect(self):
|
||||
if self.server and not self.server.socket.closed:
|
||||
@@ -245,11 +256,18 @@ class CommonContext():
|
||||
self.password = await self.console_input()
|
||||
return self.password
|
||||
|
||||
async def get_username(self):
|
||||
if not self.auth:
|
||||
self.auth = self.username
|
||||
if not self.auth:
|
||||
logger.info('Enter slot name:')
|
||||
self.auth = await self.console_input()
|
||||
|
||||
async def send_connect(self, **kwargs):
|
||||
payload = {
|
||||
"cmd": 'Connect',
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': self.tags,
|
||||
'tags': self.tags, 'items_handling': self.items_handling,
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game
|
||||
}
|
||||
if kwargs:
|
||||
@@ -264,6 +282,13 @@ class CommonContext():
|
||||
await self.disconnect()
|
||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||
|
||||
def slot_concerns_self(self, slot) -> bool:
|
||||
if slot == self.slot:
|
||||
return True
|
||||
if slot in self.slot_info:
|
||||
return self.slot in self.slot_info[slot].group_members
|
||||
return False
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
|
||||
@@ -293,7 +318,8 @@ class CommonContext():
|
||||
logger.exception(e)
|
||||
|
||||
async def shutdown(self):
|
||||
self.server_address = None
|
||||
self.server_address = ""
|
||||
self.username = None
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task:
|
||||
@@ -303,6 +329,54 @@ class CommonContext():
|
||||
self.input_queue.put_nowait(None)
|
||||
self.input_requests -= 1
|
||||
self.keep_alive_task.cancel()
|
||||
if self.ui_task:
|
||||
await self.ui_task
|
||||
if self.input_task:
|
||||
self.input_task.cancel()
|
||||
|
||||
# DataPackage
|
||||
async def prepare_datapackage(self, relevant_games: typing.Set[str],
|
||||
remote_datepackage_versions: typing.Dict[str, int]):
|
||||
"""Validate that all data is present for the current multiworld.
|
||||
Download, assimilate and cache missing data from the server."""
|
||||
# by documentation any game can use Archipelago locations/items -> always relevant
|
||||
relevant_games.add("Archipelago")
|
||||
|
||||
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||
needed_updates: typing.Set[str] = set()
|
||||
for game in relevant_games:
|
||||
remote_version: int = remote_datepackage_versions[game]
|
||||
|
||||
if remote_version == 0: # custom datapackage for this game
|
||||
needed_updates.add(game)
|
||||
continue
|
||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
||||
# no action required if local version is new enough
|
||||
if remote_version > local_version:
|
||||
cache_version: int = cache_package.get(game, {}).get("version", 0)
|
||||
# download remote version if cache is not new enough
|
||||
if remote_version > cache_version:
|
||||
needed_updates.add(game)
|
||||
else:
|
||||
self.update_game(cache_package[game])
|
||||
if needed_updates:
|
||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
|
||||
|
||||
def update_game(self, game_package: dict):
|
||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||
self.item_names[item_id] = item_name
|
||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||
self.location_names[location_id] = location_name
|
||||
|
||||
def update_datapackage(self, data_package: dict):
|
||||
for game, gamedata in data_package["games"].items():
|
||||
self.update_game(gamedata)
|
||||
|
||||
def consume_network_datapackage(self, data_package: dict):
|
||||
self.update_datapackage(data_package)
|
||||
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||
current_cache.update(data_package["games"])
|
||||
Utils.persistent_store("datapackage", "games", current_cache)
|
||||
|
||||
# DeathLink hooks
|
||||
|
||||
@@ -316,18 +390,19 @@ class CommonContext():
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
|
||||
async def send_death(self, death_text: str = ""):
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
await self.send_msgs([{
|
||||
"cmd": "Bounce", "tags": ["DeathLink"],
|
||||
"data": {
|
||||
"time": self.last_death_link,
|
||||
"source": self.player_names[self.slot],
|
||||
"cause": death_text
|
||||
}
|
||||
}])
|
||||
if self.server and self.server.socket:
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
await self.send_msgs([{
|
||||
"cmd": "Bounce", "tags": ["DeathLink"],
|
||||
"data": {
|
||||
"time": self.last_death_link,
|
||||
"source": self.player_names[self.slot],
|
||||
"cause": death_text
|
||||
}
|
||||
}])
|
||||
|
||||
async def update_death_link(self, death_link):
|
||||
async def update_death_link(self, death_link: bool):
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
self.tags.add("DeathLink")
|
||||
@@ -336,6 +411,48 @@ class CommonContext():
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
def gui_error(self, title: str, text: typing.Union[Exception, str]):
|
||||
"""Displays an error messagebox"""
|
||||
if not self.ui:
|
||||
return
|
||||
title = title or "Error"
|
||||
from kvui import MessageBox
|
||||
if self._messagebox:
|
||||
self._messagebox.dismiss()
|
||||
# make "Multiple exceptions" look nice
|
||||
text = str(text).replace('[Errno', '\n[Errno').strip()
|
||||
# split long messages into title and text
|
||||
parts = title.split('. ', 1)
|
||||
if len(parts) == 1:
|
||||
parts = title.split(', ', 1)
|
||||
if len(parts) > 1:
|
||||
text = parts[1] + '\n\n' + text
|
||||
title = parts[0]
|
||||
# display error
|
||||
self._messagebox = MessageBox(title, text, error=True)
|
||||
self._messagebox.open()
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class TextManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Text Client"
|
||||
|
||||
self.ui = TextManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def run_cli(self):
|
||||
if sys.stdin:
|
||||
# steam overlay breaks when starting console_loop
|
||||
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
|
||||
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
|
||||
else:
|
||||
self.input_task = asyncio.create_task(console_loop(self), name="Input")
|
||||
|
||||
|
||||
async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
||||
"""some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive)
|
||||
@@ -351,7 +468,6 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
||||
|
||||
|
||||
async def server_loop(ctx: CommonContext, address=None):
|
||||
cached_address = None
|
||||
if ctx.server and ctx.server.socket:
|
||||
logger.error('Already connected')
|
||||
return
|
||||
@@ -364,12 +480,20 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
logger.info('Please connect to an Archipelago server.')
|
||||
return
|
||||
|
||||
address = f"ws://{address}" if "://" not in address else address
|
||||
port = urllib.parse.urlparse(address).port or 38281
|
||||
address = f"ws://{address}" if "://" not in address \
|
||||
else address.replace("archipelago://", "ws://")
|
||||
|
||||
server_url = urllib.parse.urlparse(address)
|
||||
if server_url.username:
|
||||
ctx.username = server_url.username
|
||||
if server_url.password:
|
||||
ctx.password = server_url.password
|
||||
port = server_url.port or 38281
|
||||
|
||||
logger.info(f'Connecting to Archipelago server at {address}')
|
||||
try:
|
||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||
ctx.ui.update_address_bar(server_url.netloc)
|
||||
ctx.server = Endpoint(socket)
|
||||
logger.info('Connected')
|
||||
ctx.server_address = address
|
||||
@@ -378,18 +502,22 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
for msg in decode(data):
|
||||
await process_server_cmd(ctx, msg)
|
||||
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
|
||||
except ConnectionRefusedError:
|
||||
if cached_address:
|
||||
logger.error('Unable to connect to multiworld server at cached address. '
|
||||
'Please use the connect button above.')
|
||||
else:
|
||||
logger.exception('Connection refused by the multiworld server')
|
||||
except websockets.InvalidURI:
|
||||
logger.exception('Failed to connect to the multiworld server (invalid URI)')
|
||||
except (OSError, websockets.InvalidURI):
|
||||
logger.exception('Failed to connect to the multiworld server')
|
||||
except ConnectionRefusedError as e:
|
||||
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
except websockets.InvalidURI as e:
|
||||
msg = 'Failed to connect to the multiworld server (invalid URI)'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
except OSError as e:
|
||||
msg = 'Failed to connect to the multiworld server'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
except Exception as e:
|
||||
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
|
||||
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
finally:
|
||||
await ctx.connection_closed()
|
||||
if ctx.server_address:
|
||||
@@ -412,7 +540,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
raise
|
||||
if cmd == 'RoomInfo':
|
||||
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
|
||||
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
|
||||
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
else:
|
||||
logger.info('--------------------------------')
|
||||
logger.info('Room Information:')
|
||||
@@ -426,33 +556,32 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
if args['password']:
|
||||
logger.info('Password required')
|
||||
ctx.update_permissions(args.get("permissions", {}))
|
||||
if "games" in args:
|
||||
ctx.games = {x: game for x, game in enumerate(args["games"], start=1)}
|
||||
logger.info(
|
||||
f"A !hint costs {args['hint_cost']}% of your total location count as points"
|
||||
f" and you get {args['location_check_points']}"
|
||||
f" for each location checked. Use !hint for more information.")
|
||||
ctx.hint_cost = int(args['hint_cost'])
|
||||
ctx.check_points = int(args['location_check_points'])
|
||||
|
||||
if len(args['players']) < 1:
|
||||
players = args.get("players", [])
|
||||
if len(players) < 1:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
args['players'].sort()
|
||||
players.sort()
|
||||
current_team = -1
|
||||
logger.info('Players:')
|
||||
for network_player in args['players']:
|
||||
logger.info('Connected Players:')
|
||||
for network_player in players:
|
||||
if network_player.team != current_team:
|
||||
logger.info(f' Team #{network_player.team + 1}')
|
||||
current_team = network_player.team
|
||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
|
||||
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
|
||||
# update datapackage
|
||||
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
|
||||
|
||||
await ctx.server_auth(args['password'])
|
||||
|
||||
elif cmd == 'DataPackage':
|
||||
logger.info("Got new ID/Name Datapackage")
|
||||
ctx.set_getters(args['data'], network=True)
|
||||
logger.info("Got new ID/Name DataPackage")
|
||||
ctx.consume_network_datapackage(args['data'])
|
||||
|
||||
elif cmd == 'ConnectionRefused':
|
||||
errors = args["errors"]
|
||||
@@ -460,10 +589,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.event_invalid_slot()
|
||||
elif 'InvalidGame' in errors:
|
||||
ctx.event_invalid_game()
|
||||
elif 'SlotAlreadyTaken' in errors:
|
||||
raise Exception('Player slot already in use for that team')
|
||||
elif 'IncompatibleVersion' in errors:
|
||||
raise Exception('Server reported your client version as incompatible')
|
||||
elif 'InvalidItemsHandling' in errors:
|
||||
raise Exception('The item handling flags requested by the client are not supported')
|
||||
# last to check, recoverable problem
|
||||
elif 'InvalidPassword' in errors:
|
||||
logger.error('Invalid password')
|
||||
@@ -475,8 +604,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
raise Exception('Connection refused by the multiworld host, no reason provided')
|
||||
|
||||
elif cmd == 'Connected':
|
||||
ctx.username = ctx.auth
|
||||
ctx.team = args["team"]
|
||||
ctx.slot = args["slot"]
|
||||
# int keys get lost in JSON transfer
|
||||
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
||||
ctx.consume_players_package(args["players"])
|
||||
msgs = []
|
||||
if ctx.locations_checked:
|
||||
@@ -514,9 +646,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == 'LocationInfo':
|
||||
for item, location, player in args['locations']:
|
||||
if location not in ctx.locations_info:
|
||||
ctx.locations_info[location] = (item, player)
|
||||
for item in [NetworkItem(*item) for item in args['locations']]:
|
||||
ctx.locations_info[item.location] = item
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
@@ -545,7 +676,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
||||
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
||||
ctx.on_deathlink(args["data"])
|
||||
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == "EnergyLink":
|
||||
ctx.current_energy_link_value = args["value"]
|
||||
if ctx.ui:
|
||||
ctx.ui.set_new_energy_link_value()
|
||||
else:
|
||||
logger.debug(f"unknown command {cmd}")
|
||||
|
||||
@@ -553,7 +688,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
|
||||
|
||||
async def console_loop(ctx: CommonContext):
|
||||
import sys
|
||||
commandprocessor = ctx.command_processor(ctx)
|
||||
queue = asyncio.Queue()
|
||||
stream_input(sys.stdin, queue)
|
||||
@@ -587,52 +721,51 @@ if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
|
||||
class TextContext(CommonContext):
|
||||
tags = {"AP", "IgnoreGame"}
|
||||
game = "Archipelago"
|
||||
tags = {"AP", "IgnoreGame", "TextOnly"}
|
||||
game = "" # empty matches any game since 0.3.2
|
||||
items_handling = 0 # don't receive any NetworkItems
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(TextContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
logger.info('Enter slot name:')
|
||||
self.auth = await self.console_input()
|
||||
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.games.get(self.slot, None)
|
||||
self.game = self.slot_info[self.slot].game
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = TextContext(args.connect, args.password)
|
||||
ctx.auth = args.name
|
||||
ctx.server_address = args.connect
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
input_task = None
|
||||
|
||||
if gui_enabled:
|
||||
from kvui import TextManager
|
||||
ctx.ui = TextManager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
ui_task = None
|
||||
if sys.stdin:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
|
||||
await ctx.shutdown()
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.url:
|
||||
url = urllib.parse.urlparse(args.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)
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
78
FF1Client.py
@@ -18,6 +18,8 @@ CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
|
||||
class FF1CommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
@@ -28,8 +30,18 @@ class FF1CommandProcessor(ClientCommandProcessor):
|
||||
if isinstance(self.ctx, FF1Context):
|
||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||
|
||||
def _cmd_toggle_msgs(self):
|
||||
"""Toggle displaying messages in bizhawk"""
|
||||
global DISPLAY_MSGS
|
||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
||||
|
||||
|
||||
class FF1Context(CommonContext):
|
||||
command_processor = FF1CommandProcessor
|
||||
game = 'Final Fantasy'
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.nes_streams: (StreamReader, StreamWriter) = None
|
||||
@@ -37,10 +49,8 @@ class FF1Context(CommonContext):
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
||||
self.game = 'Final Fantasy'
|
||||
self.awaiting_rom = False
|
||||
|
||||
command_processor = FF1CommandProcessor
|
||||
self.display_msgs = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -53,18 +63,18 @@ class FF1Context(CommonContext):
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
if DISPLAY_MSGS:
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.game = self.games.get(self.slot, None)
|
||||
asyncio.create_task(parse_locations(self.locations_array, self, True))
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "ReceivedItems":
|
||||
msg = f"Received {', '.join([self.item_name_getter(item.item) for item in args['items']])}"
|
||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == 'PrintJSON':
|
||||
print_type = args['type']
|
||||
@@ -74,23 +84,35 @@ class FF1Context(CommonContext):
|
||||
sending_player_id = item.player
|
||||
sending_player_name = self.player_names[item.player]
|
||||
if print_type == 'Hint':
|
||||
msg = f"Hint: Your {self.item_name_getter(item.item)} is at" \
|
||||
f" {self.player_names[item.player]}'s {self.location_name_getter(item.location)}"
|
||||
msg = f"Hint: Your {self.item_names[item.item]} is at" \
|
||||
f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
|
||||
self._set_message(msg, item.item)
|
||||
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
|
||||
if sending_player_id == self.slot:
|
||||
if receiving_player_id == self.slot:
|
||||
msg = f"You found your own {self.item_name_getter(item.item)}"
|
||||
msg = f"You found your own {self.item_names[item.item]}"
|
||||
else:
|
||||
msg = f"You sent {self.item_name_getter(item.item)} to {receiving_player_name}"
|
||||
msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
|
||||
else:
|
||||
if receiving_player_id == sending_player_id:
|
||||
msg = f"{sending_player_name} found their {self.item_name_getter(item.item)}"
|
||||
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
|
||||
else:
|
||||
msg = f"{sending_player_name} sent {self.item_name_getter(item.item)} to " \
|
||||
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
|
||||
f"{receiving_player_name}"
|
||||
self._set_message(msg, item.item)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class FF1Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Final Fantasy 1 Client"
|
||||
|
||||
self.ui = FF1Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
def get_payload(ctx: FF1Context):
|
||||
current_time = time.time()
|
||||
@@ -128,13 +150,13 @@ async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bo
|
||||
index -= 0x200
|
||||
flag = 0x02
|
||||
|
||||
# print(f"Location: {ctx.location_name_getter(location)}")
|
||||
# print(f"Location: {ctx.location_names[location]}")
|
||||
# print(f"Index: {str(hex(index))}")
|
||||
# print(f"value: {locations_array[index] & flag != 0}")
|
||||
if locations_array[index] & flag != 0:
|
||||
locations_checked.append(location)
|
||||
if locations_checked:
|
||||
# print([ctx.location_name_getter(location) for location in locations_checked])
|
||||
# print([ctx.location_names[location] for location in locations_checked])
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "LocationChecks",
|
||||
"locations": locations_checked}
|
||||
@@ -164,6 +186,9 @@ async def nes_sync_task(ctx: FF1Context):
|
||||
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
||||
"the ROM using the same link but adding your slot name")
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
except asyncio.TimeoutError:
|
||||
@@ -214,18 +239,15 @@ if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
Utils.init_logging("FF1Client")
|
||||
|
||||
options = Utils.get_options()
|
||||
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
|
||||
|
||||
async def main(args):
|
||||
ctx = FF1Context(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
input_task = None
|
||||
from kvui import FF1Manager
|
||||
ctx.ui = FF1Manager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
ui_task = None
|
||||
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
@@ -236,20 +258,12 @@ if __name__ == '__main__':
|
||||
if ctx.nes_sync_task:
|
||||
await ctx.nes_sync_task
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser()
|
||||
args, rest = parser.parse_known_args()
|
||||
args = parser.parse_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
@@ -5,10 +5,12 @@ import json
|
||||
import string
|
||||
import copy
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import factorio_rcon
|
||||
import colorama
|
||||
import asyncio
|
||||
@@ -19,7 +21,7 @@ if __name__ == "__main__":
|
||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
|
||||
get_base_parser
|
||||
get_base_parser
|
||||
from MultiServer import mark_raw
|
||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
|
||||
@@ -49,6 +51,7 @@ class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
class FactorioContext(CommonContext):
|
||||
command_processor = FactorioCommandProcessor
|
||||
game = "Factorio"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
# updated by spinup server
|
||||
mod_version: Utils.Version = Utils.Version(0, 0, 0)
|
||||
@@ -61,6 +64,8 @@ class FactorioContext(CommonContext):
|
||||
self.write_data_path = None
|
||||
self.death_link_tick: int = 0 # last send death link on Factorio layer
|
||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||
self.energy_link_increment = 0
|
||||
self.last_deplete = 0
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -104,6 +109,34 @@ class FactorioContext(CommonContext):
|
||||
if "checked_locations" in args and args["checked_locations"]:
|
||||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||
item_name in args["checked_locations"]})
|
||||
if cmd == "Connected" and self.energy_link_increment:
|
||||
asyncio.create_task(self.send_msgs([{
|
||||
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
||||
}]))
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == "EnergyLink":
|
||||
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
|
||||
# it's our deplete request
|
||||
gained = int(args["original_value"] - args["value"])
|
||||
gained_text = Utils.format_SI_prefix(gained) + "J"
|
||||
if gained:
|
||||
logger.debug(f"EnergyLink: Received {gained_text}. "
|
||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class FactorioManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("FactorioServer", "Factorio Server Log"),
|
||||
("FactorioWatcher", "Bridge Data Log"),
|
||||
]
|
||||
base_title = "Archipelago Factorio Client"
|
||||
|
||||
self.ui = FactorioManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
async def game_watcher(ctx: FactorioContext):
|
||||
@@ -112,11 +145,14 @@ async def game_watcher(ctx: FactorioContext):
|
||||
next_bridge = time.perf_counter() + 1
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.awaiting_bridge and ctx.rcon_client and time.perf_counter() > next_bridge:
|
||||
# TODO: restore on-demand refresh
|
||||
if ctx.rcon_client and time.perf_counter() > next_bridge:
|
||||
next_bridge = time.perf_counter() + 1
|
||||
ctx.awaiting_bridge = False
|
||||
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
|
||||
if data["slot_name"] != ctx.auth:
|
||||
if not ctx.auth:
|
||||
pass # auth failed, wait for new attempt
|
||||
elif data["slot_name"] != ctx.auth:
|
||||
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
|
||||
elif data["seed_name"] != ctx.seed_name:
|
||||
bridge_logger.warning(
|
||||
@@ -126,8 +162,7 @@ async def game_watcher(ctx: FactorioContext):
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
if "death_link" in data: # TODO: Remove this if statement around version 0.2.4 or so
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
@@ -143,7 +178,31 @@ async def game_watcher(ctx: FactorioContext):
|
||||
if death_link_tick != ctx.death_link_tick:
|
||||
ctx.death_link_tick = death_link_tick
|
||||
if "DeathLink" in ctx.tags:
|
||||
await ctx.send_death()
|
||||
asyncio.create_task(ctx.send_death())
|
||||
if ctx.energy_link_increment:
|
||||
in_world_bridges = data["energy_bridges"]
|
||||
if in_world_bridges:
|
||||
in_world_energy = data["energy"]
|
||||
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
||||
# attempt to refill
|
||||
ctx.last_deplete = time.time()
|
||||
asyncio.create_task(ctx.send_msgs([{
|
||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
||||
{"operation": "max", "value": 0}],
|
||||
"last_deplete": ctx.last_deplete
|
||||
}]))
|
||||
# Above Capacity - (len(Bridges) * ENERGY_INCREMENT)
|
||||
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
||||
ctx.energy_link_increment*in_world_bridges:
|
||||
value = ctx.energy_link_increment * in_world_bridges
|
||||
asyncio.create_task(ctx.send_msgs([{
|
||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||
[{"operation": "add", "value": value}]
|
||||
}]))
|
||||
ctx.rcon_client.send_command(
|
||||
f"/ap-energylink -{value}")
|
||||
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@@ -232,12 +291,16 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
factorio_process.wait(5)
|
||||
|
||||
|
||||
async def get_info(ctx, rcon_client):
|
||||
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
|
||||
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
|
||||
ctx.auth = info["slot_name"]
|
||||
ctx.seed_name = info["seed_name"]
|
||||
# 0.2.0 addition, not present earlier
|
||||
death_link = bool(info.get("death_link", False))
|
||||
ctx.energy_link_increment = info.get("energy_link", 0)
|
||||
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
|
||||
if ctx.energy_link_increment and ctx.ui:
|
||||
ctx.ui.enable_energy_link()
|
||||
await ctx.update_death_link(death_link)
|
||||
|
||||
|
||||
@@ -281,8 +344,10 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
logger.error("Aborted Factorio Server Bridge")
|
||||
logger.exception(e, extra={"compact_gui": True})
|
||||
msg = "Aborted Factorio Server Bridge"
|
||||
logger.error(msg)
|
||||
ctx.gui_error(msg, e)
|
||||
ctx.exit_event.set()
|
||||
|
||||
else:
|
||||
@@ -298,18 +363,14 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
async def main(args):
|
||||
ctx = FactorioContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
input_task = None
|
||||
|
||||
if gui_enabled:
|
||||
from kvui import FactorioManager
|
||||
ctx.ui = FactorioManager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
ui_task = None
|
||||
if sys.stdin:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
|
||||
succesful_launch = await factorio_server_task
|
||||
if succesful_launch:
|
||||
successful_launch = await factorio_server_task
|
||||
if successful_launch:
|
||||
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="FactorioProgressionWatcher")
|
||||
@@ -322,23 +383,13 @@ async def main(args):
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
class FactorioJSONtoTextParser(JSONtoTextParser):
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
colors = node["color"].split(";")
|
||||
for color in colors:
|
||||
if color in {"red", "green", "blue", "orange", "yellow", "pink", "purple", "white", "black", "gray",
|
||||
"brown", "cyan", "acid"}:
|
||||
node["text"] = f"[color={color}]{node['text']}[/color]"
|
||||
return self._handle_text(node)
|
||||
elif color == "magenta":
|
||||
node["text"] = f"[color=pink]{node['text']}[/color]"
|
||||
if color in self.color_codes:
|
||||
node["text"] = f"[color=#{self.color_codes[color]}]{node['text']}[/color]"
|
||||
return self._handle_text(node)
|
||||
return self._handle_text(node)
|
||||
|
||||
@@ -349,6 +400,7 @@ if __name__ == '__main__':
|
||||
"Refer to Factorio --help for those.")
|
||||
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
||||
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
||||
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
@@ -359,6 +411,9 @@ if __name__ == '__main__':
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
options = Utils.get_options()
|
||||
executable = options["factorio_options"]["executable"]
|
||||
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
||||
if server_settings:
|
||||
server_settings = os.path.abspath(server_settings)
|
||||
|
||||
if not os.path.exists(os.path.dirname(executable)):
|
||||
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
||||
@@ -370,9 +425,10 @@ if __name__ == '__main__':
|
||||
else:
|
||||
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
||||
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
if server_settings and os.path.isfile(server_settings):
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
|
||||
else:
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
582
Fill.py
@@ -2,9 +2,10 @@ import logging
|
||||
import typing
|
||||
import collections
|
||||
import itertools
|
||||
from collections import Counter, deque
|
||||
|
||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
|
||||
|
||||
from BaseClasses import CollectionState, Location, MultiWorld
|
||||
from worlds.generic import PlandoItem
|
||||
from worlds.AutoWorld import call_all
|
||||
|
||||
|
||||
@@ -12,79 +13,146 @@ class FillError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations, itempool, single_player_placement=False,
|
||||
lock=False):
|
||||
def sweep_from_pool():
|
||||
new_state = base_state.copy()
|
||||
for item in itempool:
|
||||
new_state.collect(item, True)
|
||||
new_state.sweep_for_events()
|
||||
return new_state
|
||||
|
||||
unplaced_items = []
|
||||
placements = []
|
||||
|
||||
reachable_items = {}
|
||||
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
|
||||
new_state = base_state.copy()
|
||||
for item in itempool:
|
||||
reachable_items.setdefault(item.player, []).append(item)
|
||||
new_state.collect(item, True)
|
||||
new_state.sweep_for_events()
|
||||
return new_state
|
||||
|
||||
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
|
||||
for item in itempool:
|
||||
reachable_items.setdefault(item.player, deque()).append(item)
|
||||
|
||||
while any(reachable_items.values()) and locations:
|
||||
items_to_place = [items.pop() for items in reachable_items.values() if items] # grab one item per player
|
||||
# grab one item per player
|
||||
items_to_place = [items.pop()
|
||||
for items in reachable_items.values() if items]
|
||||
for item in items_to_place:
|
||||
itempool.remove(item)
|
||||
maximum_exploration_state = sweep_from_pool()
|
||||
maximum_exploration_state = sweep_from_pool(
|
||||
base_state, itempool + unplaced_items)
|
||||
|
||||
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
||||
|
||||
for item_to_place in items_to_place:
|
||||
while items_to_place:
|
||||
# if we have run out of locations to fill,break out of this loop
|
||||
if not locations:
|
||||
unplaced_items += items_to_place
|
||||
break
|
||||
item_to_place = items_to_place.pop(0)
|
||||
|
||||
spot_to_fill: typing.Optional[Location] = None
|
||||
|
||||
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||
if world.accessibility[item_to_place.player] == 'minimal':
|
||||
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
|
||||
item_to_place.player) if single_player_placement else not has_beaten_game
|
||||
item_to_place.player) \
|
||||
if single_player_placement else not has_beaten_game
|
||||
else:
|
||||
perform_access_check = True
|
||||
|
||||
for i, location in enumerate(locations):
|
||||
if (not single_player_placement or location.player == item_to_place.player) \
|
||||
and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check):
|
||||
spot_to_fill = locations.pop(i) # poping by index is faster than removing by content,
|
||||
# popping by index is faster than removing by content,
|
||||
spot_to_fill = locations.pop(i)
|
||||
# skipping a scan for the element
|
||||
break
|
||||
|
||||
else:
|
||||
# we filled all reachable spots. Maybe the game can be beaten anyway?
|
||||
unplaced_items.append(item_to_place)
|
||||
if world.accessibility[item_to_place.player] != 'minimal' and world.can_beat_game():
|
||||
logging.warning(
|
||||
f'Not all items placed. Game beatable anyway. (Could not place {item_to_place})')
|
||||
# we filled all reachable spots.
|
||||
# try swapping this item with previously placed items
|
||||
for (i, location) in enumerate(placements):
|
||||
placed_item = location.item
|
||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||
# number of times we will swap an individual item to prevent this
|
||||
swap_count = swapped_items[placed_item.player,
|
||||
placed_item.name]
|
||||
if swap_count > 1:
|
||||
continue
|
||||
|
||||
location.item = None
|
||||
placed_item.location = None
|
||||
swap_state = sweep_from_pool(base_state)
|
||||
if (not single_player_placement or location.player == item_to_place.player) \
|
||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||
|
||||
# Verify that placing this item won't reduce available locations
|
||||
prev_state = swap_state.copy()
|
||||
prev_state.collect(placed_item)
|
||||
prev_loc_count = len(
|
||||
world.get_reachable_locations(prev_state))
|
||||
|
||||
swap_state.collect(item_to_place, True)
|
||||
new_loc_count = len(
|
||||
world.get_reachable_locations(swap_state))
|
||||
|
||||
if new_loc_count >= prev_loc_count:
|
||||
# Add this item to the existing placement, and
|
||||
# add the old item to the back of the queue
|
||||
spot_to_fill = placements.pop(i)
|
||||
|
||||
swap_count += 1
|
||||
swapped_items[placed_item.player,
|
||||
placed_item.name] = swap_count
|
||||
|
||||
reachable_items[placed_item.player].appendleft(
|
||||
placed_item)
|
||||
itempool.append(placed_item)
|
||||
|
||||
break
|
||||
|
||||
# Item can't be placed here, restore original item
|
||||
location.item = placed_item
|
||||
placed_item.location = location
|
||||
|
||||
if spot_to_fill is None:
|
||||
# Can't place this item, move on to the next
|
||||
unplaced_items.append(item_to_place)
|
||||
continue
|
||||
raise FillError(f'No more spots to place {item_to_place}, locations {locations} are invalid. '
|
||||
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||
|
||||
world.push_item(spot_to_fill, item_to_place, False)
|
||||
spot_to_fill.locked = lock
|
||||
placements.append(spot_to_fill)
|
||||
spot_to_fill.event = True
|
||||
spot_to_fill.event = item_to_place.advancement
|
||||
|
||||
if len(unplaced_items) > 0 and len(locations) > 0:
|
||||
# There are leftover unplaceable items and locations that won't accept them
|
||||
if world.can_beat_game():
|
||||
logging.warning(
|
||||
f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
|
||||
else:
|
||||
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
||||
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
|
||||
def distribute_items_restrictive(world: MultiWorld, fill_locations=None):
|
||||
# If not passed in, then get a shuffled list of locations to fill in
|
||||
if not fill_locations:
|
||||
fill_locations = world.get_unfilled_locations()
|
||||
world.random.shuffle(fill_locations)
|
||||
def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||
fill_locations = sorted(world.get_unfilled_locations())
|
||||
world.random.shuffle(fill_locations)
|
||||
|
||||
# get items to distribute
|
||||
world.random.shuffle(world.itempool)
|
||||
progitempool = []
|
||||
nonexcludeditempool = []
|
||||
localrestitempool = {player: [] for player in range(1, world.players + 1)}
|
||||
nonlocalrestitempool = []
|
||||
restitempool = []
|
||||
itempool = sorted(world.itempool)
|
||||
world.random.shuffle(itempool)
|
||||
progitempool: typing.List[Item] = []
|
||||
nonexcludeditempool: typing.List[Item] = []
|
||||
localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)}
|
||||
nonlocalrestitempool: typing.List[Item] = []
|
||||
restitempool: typing.List[Item] = []
|
||||
|
||||
for item in world.itempool:
|
||||
for item in itempool:
|
||||
if item.advancement:
|
||||
progitempool.append(item)
|
||||
elif item.never_exclude: # this only gets nonprogression items which should not appear in excluded locations
|
||||
elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
|
||||
nonexcludeditempool.append(item)
|
||||
elif item.name in world.local_items[item.player].value:
|
||||
localrestitempool[item.player].append(item)
|
||||
@@ -93,21 +161,47 @@ def distribute_items_restrictive(world: MultiWorld, fill_locations=None):
|
||||
else:
|
||||
restitempool.append(item)
|
||||
|
||||
world.random.shuffle(fill_locations)
|
||||
call_all(world, "fill_hook", progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
|
||||
call_all(world, "fill_hook", progitempool, nonexcludeditempool,
|
||||
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
|
||||
|
||||
fill_restrictive(world, world.state, fill_locations, progitempool)
|
||||
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
||||
loc_type: [] for loc_type in LocationProgressType}
|
||||
|
||||
for loc in fill_locations:
|
||||
locations[loc.progress_type].append(loc)
|
||||
|
||||
prioritylocations = locations[LocationProgressType.PRIORITY]
|
||||
defaultlocations = locations[LocationProgressType.DEFAULT]
|
||||
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
||||
|
||||
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
|
||||
if prioritylocations:
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
if progitempool:
|
||||
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
||||
if progitempool:
|
||||
raise FillError(
|
||||
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
||||
|
||||
if nonexcludeditempool:
|
||||
world.random.shuffle(fill_locations)
|
||||
fill_restrictive(world, world.state, fill_locations, nonexcludeditempool) # needs logical fill to not conflict with local items
|
||||
world.random.shuffle(defaultlocations)
|
||||
# needs logical fill to not conflict with local items
|
||||
fill_restrictive(
|
||||
world, world.state, defaultlocations, nonexcludeditempool)
|
||||
if nonexcludeditempool:
|
||||
raise FillError(
|
||||
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
|
||||
|
||||
defaultlocations = defaultlocations + excludedlocations
|
||||
world.random.shuffle(defaultlocations)
|
||||
|
||||
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
|
||||
local_locations = {player: [] for player in world.player_ids}
|
||||
for location in fill_locations:
|
||||
local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
|
||||
for location in defaultlocations:
|
||||
local_locations[location.player].append(location)
|
||||
for locations in local_locations.values():
|
||||
world.random.shuffle(locations)
|
||||
for player_locations in local_locations.values():
|
||||
world.random.shuffle(player_locations)
|
||||
|
||||
for player, items in localrestitempool.items(): # items already shuffled
|
||||
player_local_locations = local_locations[player]
|
||||
@@ -118,34 +212,45 @@ def distribute_items_restrictive(world: MultiWorld, fill_locations=None):
|
||||
break
|
||||
spot_to_fill = player_local_locations.pop()
|
||||
world.push_item(spot_to_fill, item_to_place, False)
|
||||
fill_locations.remove(spot_to_fill)
|
||||
defaultlocations.remove(spot_to_fill)
|
||||
|
||||
for item_to_place in nonlocalrestitempool:
|
||||
for i, location in enumerate(fill_locations):
|
||||
for i, location in enumerate(defaultlocations):
|
||||
if location.player != item_to_place.player:
|
||||
world.push_item(fill_locations.pop(i), item_to_place, False)
|
||||
world.push_item(defaultlocations.pop(i), item_to_place, False)
|
||||
break
|
||||
else:
|
||||
logging.warning(f"Could not place non_local_item {item_to_place} among {fill_locations}, tossing.")
|
||||
logging.warning(
|
||||
f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing.")
|
||||
|
||||
world.random.shuffle(fill_locations)
|
||||
world.random.shuffle(defaultlocations)
|
||||
|
||||
restitempool, fill_locations = fast_fill(world, restitempool, fill_locations)
|
||||
restitempool, defaultlocations = fast_fill(
|
||||
world, restitempool, defaultlocations)
|
||||
unplaced = progitempool + restitempool
|
||||
unfilled = [location.name for location in fill_locations]
|
||||
unfilled = defaultlocations
|
||||
|
||||
if unplaced or unfilled:
|
||||
logging.warning(f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
|
||||
logging.warning(
|
||||
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
|
||||
items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
|
||||
locations_counter = Counter(location.player for location in world.get_locations())
|
||||
items_counter.update(item.player for item in unplaced)
|
||||
locations_counter.update(location.player for location in unfilled)
|
||||
print_data = {"items": items_counter, "locations": locations_counter}
|
||||
logging.info(f'Per-Player counts: {print_data})')
|
||||
|
||||
|
||||
def fast_fill(world: MultiWorld, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
|
||||
def fast_fill(world: MultiWorld,
|
||||
item_pool: typing.List[Item],
|
||||
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
||||
placing = min(len(item_pool), len(fill_locations))
|
||||
for item, location in zip(item_pool, fill_locations):
|
||||
world.push_item(location, item, False)
|
||||
return item_pool[placing:], fill_locations[placing:]
|
||||
|
||||
|
||||
def flood_items(world: MultiWorld):
|
||||
def flood_items(world: MultiWorld) -> None:
|
||||
# get items to distribute
|
||||
world.random.shuffle(world.itempool)
|
||||
itempool = world.itempool
|
||||
@@ -184,7 +289,8 @@ def flood_items(world: MultiWorld):
|
||||
item_to_place = item
|
||||
break
|
||||
|
||||
# we might be in a situation where all new locations require multiple items to reach. If that is the case, just place any advancement item we've found and continue trying
|
||||
# we might be in a situation where all new locations require multiple items to reach.
|
||||
# If that is the case, just place any advancement item we've found and continue trying
|
||||
if item_to_place is None:
|
||||
if candidate_item_to_place is not None:
|
||||
item_to_place = candidate_item_to_place
|
||||
@@ -205,68 +311,135 @@ def flood_items(world: MultiWorld):
|
||||
break
|
||||
|
||||
|
||||
def balance_multiworld_progression(world: MultiWorld):
|
||||
balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
|
||||
def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
|
||||
# Overall progression balancing algorithm:
|
||||
# Gather up all locations in a sphere.
|
||||
# Define a threshold value based on the player with the most available locations.
|
||||
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
|
||||
# which gives more locations available by this sphere.
|
||||
balanceable_players: typing.Dict[int, float] = {
|
||||
player: world.progression_balancing[player] / 100
|
||||
for player in world.player_ids
|
||||
if world.progression_balancing[player] > 0
|
||||
}
|
||||
if not balanceable_players:
|
||||
logging.info('Skipping multiworld progression balancing.')
|
||||
else:
|
||||
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
||||
state = CollectionState(world)
|
||||
checked_locations = set()
|
||||
unchecked_locations = set(world.get_locations())
|
||||
logging.debug(balanceable_players)
|
||||
state: CollectionState = CollectionState(world)
|
||||
checked_locations: typing.Set[Location] = set()
|
||||
unchecked_locations: typing.Set[Location] = set(world.get_locations())
|
||||
|
||||
reachable_locations_count = {player: 0 for player in world.player_ids}
|
||||
reachable_locations_count: typing.Dict[int, int] = {
|
||||
player: 0
|
||||
for player in world.player_ids
|
||||
if len(world.get_filled_locations(player)) != 0
|
||||
}
|
||||
total_locations_count: typing.Counter[int] = Counter(
|
||||
location.player
|
||||
for location in world.get_locations()
|
||||
if not location.locked
|
||||
)
|
||||
balanceable_players = {
|
||||
player: balanceable_players[player]
|
||||
for player in balanceable_players
|
||||
if total_locations_count[player]
|
||||
}
|
||||
sphere_num: int = 1
|
||||
moved_item_count: int = 0
|
||||
|
||||
def get_sphere_locations(sphere_state, locations):
|
||||
def get_sphere_locations(sphere_state: CollectionState,
|
||||
locations: typing.Set[Location]) -> typing.Set[Location]:
|
||||
sphere_state.sweep_for_events(key_only=True, locations=locations)
|
||||
return {loc for loc in locations if sphere_state.can_reach(loc)}
|
||||
|
||||
def item_percentage(player: int, num: int) -> float:
|
||||
return num / total_locations_count[player]
|
||||
|
||||
while True:
|
||||
# Gather non-locked locations.
|
||||
# This ensures that only shuffled locations get counted for progression balancing,
|
||||
# i.e. the items the players will be checking.
|
||||
sphere_locations = get_sphere_locations(state, unchecked_locations)
|
||||
for location in sphere_locations:
|
||||
unchecked_locations.remove(location)
|
||||
reachable_locations_count[location.player] += 1
|
||||
if not location.locked:
|
||||
reachable_locations_count[location.player] += 1
|
||||
|
||||
logging.debug(f"Sphere {sphere_num}")
|
||||
logging.debug(f"Reachable locations: {reachable_locations_count}")
|
||||
debug_percentages = {
|
||||
player: round(item_percentage(player, num), 2)
|
||||
for player, num in reachable_locations_count.items()
|
||||
}
|
||||
logging.debug(f"Reachable percentages: {debug_percentages}\n")
|
||||
sphere_num += 1
|
||||
|
||||
if checked_locations:
|
||||
threshold = max(reachable_locations_count.values()) - 20
|
||||
balancing_players = {player for player, reachables in reachable_locations_count.items() if
|
||||
reachables < threshold and player in balanceable_players}
|
||||
max_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
|
||||
reachable_locations_count))
|
||||
threshold_percentages = {
|
||||
player: max_percentage * balanceable_players[player]
|
||||
for player in balanceable_players
|
||||
}
|
||||
logging.debug(f"Thresholds: {threshold_percentages}")
|
||||
balancing_players = {
|
||||
player
|
||||
for player, reachables in reachable_locations_count.items()
|
||||
if (player in threshold_percentages
|
||||
and item_percentage(player, reachables) < threshold_percentages[player])
|
||||
}
|
||||
if balancing_players:
|
||||
balancing_state = state.copy()
|
||||
balancing_unchecked_locations = unchecked_locations.copy()
|
||||
balancing_reachables = reachable_locations_count.copy()
|
||||
balancing_sphere = sphere_locations.copy()
|
||||
candidate_items = collections.defaultdict(set)
|
||||
candidate_items: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||
while True:
|
||||
# Check locations in the current sphere and gather progression items to swap earlier
|
||||
for location in balancing_sphere:
|
||||
if location.event:
|
||||
balancing_state.collect(location.item, True, location)
|
||||
player = location.item.player
|
||||
# only replace items that end up in another player's world
|
||||
if not location.locked and player in balancing_players and location.player != player:
|
||||
if (not location.locked and not location.item.skip_in_prog_balancing and
|
||||
player in balancing_players and
|
||||
location.player != player and
|
||||
location.progress_type != LocationProgressType.PRIORITY):
|
||||
candidate_items[player].add(location)
|
||||
logging.debug(f"Candidate item: {location.name}, {location.item.name}")
|
||||
balancing_sphere = get_sphere_locations(balancing_state, balancing_unchecked_locations)
|
||||
for location in balancing_sphere:
|
||||
balancing_unchecked_locations.remove(location)
|
||||
balancing_reachables[location.player] += 1
|
||||
if not location.locked:
|
||||
balancing_reachables[location.player] += 1
|
||||
if world.has_beaten_game(balancing_state) or all(
|
||||
reachables >= threshold for reachables in balancing_reachables.values()):
|
||||
item_percentage(player, reachables) >= threshold_percentages[player]
|
||||
for player, reachables in balancing_reachables.items()
|
||||
if player in threshold_percentages):
|
||||
break
|
||||
elif not balancing_sphere:
|
||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
||||
unlocked_locations = collections.defaultdict(set)
|
||||
# Gather a set of locations which we can swap items into
|
||||
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||
for l in unchecked_locations:
|
||||
if l not in balancing_unchecked_locations:
|
||||
unlocked_locations[l.player].add(l)
|
||||
items_to_replace = []
|
||||
items_to_replace: typing.List[Location] = []
|
||||
for player in balancing_players:
|
||||
locations_to_test = unlocked_locations[player]
|
||||
items_to_test = candidate_items[player]
|
||||
items_to_test = list(candidate_items[player])
|
||||
items_to_test.sort()
|
||||
world.random.shuffle(items_to_test)
|
||||
while items_to_test:
|
||||
testing = items_to_test.pop()
|
||||
reducing_state = state.copy()
|
||||
for location in itertools.chain((l for l in items_to_replace if l.item.player == player),
|
||||
items_to_test):
|
||||
for location in itertools.chain((
|
||||
l for l in items_to_replace
|
||||
if l.item.player == player
|
||||
), items_to_test):
|
||||
reducing_state.collect(location.item, True, location)
|
||||
|
||||
reducing_state.sweep_for_events(locations=locations_to_test)
|
||||
@@ -276,7 +449,8 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
items_to_replace.append(testing)
|
||||
else:
|
||||
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
|
||||
if reachable_locations_count[player] + len(reduced_sphere) < threshold:
|
||||
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
|
||||
if p < threshold_percentages[player]:
|
||||
items_to_replace.append(testing)
|
||||
|
||||
replaced_items = False
|
||||
@@ -288,6 +462,7 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
items_to_replace.sort()
|
||||
world.random.shuffle(items_to_replace)
|
||||
|
||||
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
|
||||
while replacement_locations and items_to_replace:
|
||||
old_location = items_to_replace.pop()
|
||||
for new_location in replacement_locations:
|
||||
@@ -297,6 +472,7 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
swap_location_item(old_location, new_location)
|
||||
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
|
||||
f"displacing {old_location.item} into {old_location}")
|
||||
moved_item_count += 1
|
||||
state.collect(new_location.item, True, new_location)
|
||||
replaced_items = True
|
||||
break
|
||||
@@ -304,10 +480,12 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
logging.warning(f"Could not Progression Balance {old_location.item}")
|
||||
|
||||
if replaced_items:
|
||||
logging.debug(f"Moved {moved_item_count} items so far\n")
|
||||
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
|
||||
for location in get_sphere_locations(state, unlocked):
|
||||
unchecked_locations.remove(location)
|
||||
reachable_locations_count[location.player] += 1
|
||||
if not location.locked:
|
||||
reachable_locations_count[location.player] += 1
|
||||
sphere_locations.add(location)
|
||||
|
||||
for location in sphere_locations:
|
||||
@@ -322,7 +500,7 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
break
|
||||
|
||||
|
||||
def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
|
||||
def swap_location_item(location_1: Location, location_2: Location, check_locked: bool = True) -> None:
|
||||
"""Swaps Items of locations. Does NOT swap flags like shop_slot or locked, but does swap event"""
|
||||
if check_locked:
|
||||
if location_1.locked:
|
||||
@@ -335,78 +513,186 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked=
|
||||
location_1.event, location_2.event = location_2.event, location_1.event
|
||||
|
||||
|
||||
def distribute_planned(world: MultiWorld):
|
||||
def distribute_planned(world: MultiWorld) -> None:
|
||||
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
||||
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
||||
logging.warning(f'{warning}')
|
||||
else:
|
||||
logging.debug(f'{warning}')
|
||||
|
||||
def failed(warning: str, force: typing.Union[bool, str]) -> None:
|
||||
if force in [True, 'fail', 'failure']:
|
||||
raise Exception(warning)
|
||||
else:
|
||||
warn(warning, force)
|
||||
|
||||
# TODO: remove. Preferably by implementing key drop
|
||||
from worlds.alttp.Regions import key_drop_data
|
||||
world_name_lookup = world.world_name_lookup
|
||||
|
||||
for player in world.player_ids:
|
||||
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
||||
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
||||
player_ids = set(world.player_ids)
|
||||
for player in player_ids:
|
||||
for block in world.plando_items[player]:
|
||||
block['player'] = player
|
||||
if 'force' not in block:
|
||||
block['force'] = 'silent'
|
||||
if 'from_pool' not in block:
|
||||
block['from_pool'] = True
|
||||
if 'world' not in block:
|
||||
block['world'] = False
|
||||
items: block_value = []
|
||||
if "items" in block:
|
||||
items = block["items"]
|
||||
if 'count' not in block:
|
||||
block['count'] = False
|
||||
elif "item" in block:
|
||||
items = block["item"]
|
||||
if 'count' not in block:
|
||||
block['count'] = 1
|
||||
else:
|
||||
failed("You must specify at least one item to place items with plando.", block['force'])
|
||||
continue
|
||||
if isinstance(items, dict):
|
||||
item_list: typing.List[str] = []
|
||||
for key, value in items.items():
|
||||
if value is True:
|
||||
value = world.itempool.count(world.worlds[player].create_item(key))
|
||||
item_list += [key] * value
|
||||
items = item_list
|
||||
if isinstance(items, str):
|
||||
items = [items]
|
||||
block['items'] = items
|
||||
|
||||
locations: block_value = []
|
||||
if 'location' in block:
|
||||
locations = block['location'] # just allow 'location' to keep old yamls compatible
|
||||
elif 'locations' in block:
|
||||
locations = block['locations']
|
||||
if isinstance(locations, str):
|
||||
locations = [locations]
|
||||
|
||||
if isinstance(locations, dict):
|
||||
location_list = []
|
||||
for key, value in locations.items():
|
||||
location_list += [key] * value
|
||||
locations = location_list
|
||||
block['locations'] = locations
|
||||
|
||||
if not block['count']:
|
||||
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
||||
len(block['locations']) > 0 else len(block['items']))
|
||||
if isinstance(block['count'], int):
|
||||
block['count'] = {'min': block['count'], 'max': block['count']}
|
||||
if 'min' not in block['count']:
|
||||
block['count']['min'] = 0
|
||||
if 'max' not in block['count']:
|
||||
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
|
||||
len(block['locations']) > 0 else len(block['items']))
|
||||
if block['count']['max'] > len(block['items']):
|
||||
count = block['count']
|
||||
failed(f"Plando count {count} greater than items specified", block['force'])
|
||||
block['count'] = len(block['items'])
|
||||
if block['count']['max'] > len(block['locations']) > 0:
|
||||
count = block['count']
|
||||
failed(f"Plando count {count} greater than locations specified", block['force'])
|
||||
block['count'] = len(block['locations'])
|
||||
block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max'])
|
||||
|
||||
if block['count']['target'] > 0:
|
||||
plando_blocks.append(block)
|
||||
|
||||
# shuffle, but then sort blocks by number of locations minus number of items,
|
||||
# so less-flexible blocks get priority
|
||||
world.random.shuffle(plando_blocks)
|
||||
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
|
||||
if len(block['locations']) > 0
|
||||
else len(world.get_unfilled_locations(player)) - block['count']['target']))
|
||||
|
||||
for placement in plando_blocks:
|
||||
player = placement['player']
|
||||
try:
|
||||
placement: PlandoItem
|
||||
for placement in world.plando_items[player]:
|
||||
if placement.location in key_drop_data:
|
||||
placement.warn(
|
||||
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
|
||||
target_world = placement['world']
|
||||
locations = placement['locations']
|
||||
items = placement['items']
|
||||
maxcount = placement['count']['target']
|
||||
from_pool = placement['from_pool']
|
||||
if target_world is False or world.players == 1: # target own world
|
||||
worlds: typing.Set[int] = {player}
|
||||
elif target_world is True: # target any worlds besides own
|
||||
worlds = set(world.player_ids) - {player}
|
||||
elif target_world is None: # target all worlds
|
||||
worlds = set(world.player_ids)
|
||||
elif type(target_world) == list: # list of target worlds
|
||||
worlds = set()
|
||||
for listed_world in target_world:
|
||||
if listed_world not in world_name_lookup:
|
||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
placement['force'])
|
||||
continue
|
||||
worlds.add(world_name_lookup[listed_world])
|
||||
elif type(target_world) == int: # target world by slot number
|
||||
if target_world not in range(1, world.players + 1):
|
||||
failed(
|
||||
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
||||
placement['force'])
|
||||
continue
|
||||
item = world.worlds[player].create_item(placement.item)
|
||||
target_world: int = placement.world
|
||||
if target_world is False or world.players == 1:
|
||||
target_world = player # in own world
|
||||
elif target_world is True: # in any other world
|
||||
unfilled = list(location for location in world.get_unfilled_locations_for_players(
|
||||
placement.location,
|
||||
set(world.player_ids) - {player}) if location.item_rule(item)
|
||||
)
|
||||
if not unfilled:
|
||||
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
|
||||
FillError)
|
||||
continue
|
||||
|
||||
target_world = world.random.choice(unfilled).player
|
||||
|
||||
elif target_world is None: # any random world
|
||||
unfilled = list(location for location in world.get_unfilled_locations_for_players(
|
||||
placement.location,
|
||||
set(world.player_ids)) if location.item_rule(item)
|
||||
)
|
||||
if not unfilled:
|
||||
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
|
||||
FillError)
|
||||
continue
|
||||
|
||||
target_world = world.random.choice(unfilled).player
|
||||
|
||||
elif type(target_world) == int: # target world by player id
|
||||
if target_world not in range(1, world.players + 1):
|
||||
placement.failed(
|
||||
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
||||
ValueError)
|
||||
continue
|
||||
else: # find world by name
|
||||
if target_world not in world_name_lookup:
|
||||
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
ValueError)
|
||||
continue
|
||||
target_world = world_name_lookup[target_world]
|
||||
|
||||
location = world.get_location(placement.location, target_world)
|
||||
if location.item:
|
||||
placement.failed(f"Cannot place item into already filled location {location}.")
|
||||
worlds = {target_world}
|
||||
else: # target world by slot name
|
||||
if target_world not in world_name_lookup:
|
||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
placement['force'])
|
||||
continue
|
||||
worlds = {world_name_lookup[target_world]}
|
||||
|
||||
if location.can_fill(world.state, item, False):
|
||||
world.push_item(location, item, collect=False)
|
||||
location.event = True # flag location to be checked during fill
|
||||
location.locked = True
|
||||
logging.debug(f"Plando placed {item} at {location}")
|
||||
else:
|
||||
placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
|
||||
continue
|
||||
|
||||
if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
|
||||
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
|
||||
worlds))
|
||||
world.random.shuffle(candidates)
|
||||
world.random.shuffle(items)
|
||||
count = 0
|
||||
err: typing.List[str] = []
|
||||
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
|
||||
for item_name in items:
|
||||
item = world.worlds[player].create_item(item_name)
|
||||
for location in reversed(candidates):
|
||||
if location in key_drop_data:
|
||||
warn(
|
||||
f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
|
||||
continue
|
||||
if not location.item:
|
||||
if location.item_rule(item):
|
||||
if location.can_fill(world.state, item, False):
|
||||
successful_pairs.append((item, location))
|
||||
candidates.remove(location)
|
||||
count = count + 1
|
||||
break
|
||||
else:
|
||||
err.append(f"Can't place item at {location} due to fill condition not met.")
|
||||
else:
|
||||
err.append(f"{item_name} not allowed at {location}.")
|
||||
else:
|
||||
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
||||
if count == maxcount:
|
||||
break
|
||||
if count < placement['count']['min']:
|
||||
m = placement['count']['min']
|
||||
failed(
|
||||
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
|
||||
placement['force'])
|
||||
for (item, location) in successful_pairs:
|
||||
world.push_item(location, item, collect=False)
|
||||
location.event = True # flag location to be checked during fill
|
||||
location.locked = True
|
||||
logging.debug(f"Plando placed {item} at {location}")
|
||||
if from_pool:
|
||||
try:
|
||||
world.itempool.remove(item)
|
||||
except ValueError:
|
||||
placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
|
||||
warn(
|
||||
f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.",
|
||||
placement['force'])
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error running plando for player {player} ({world.player_name[player]})") from e
|
||||
raise Exception(
|
||||
f"Error running plando for player {player} ({world.player_name[player]})") from e
|
||||
|
||||
285
Generate.py
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import typing
|
||||
from typing import Set, Dict, Tuple, Callable, Any, Union
|
||||
import os
|
||||
from collections import Counter
|
||||
import string
|
||||
import enum
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -14,8 +17,8 @@ ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoItem, PlandoConnection
|
||||
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options
|
||||
from worlds.generic import PlandoConnection
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
@@ -23,27 +26,71 @@ import Options
|
||||
from worlds.alttp import Bosses
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import copy
|
||||
|
||||
|
||||
class PlandoSettings(enum.IntFlag):
|
||||
items = 0b0001
|
||||
connections = 0b0010
|
||||
texts = 0b0100
|
||||
bosses = 0b1000
|
||||
|
||||
@classmethod
|
||||
def from_option_string(cls, option_string: str) -> PlandoSettings:
|
||||
result = cls(0)
|
||||
for part in option_string.split(","):
|
||||
part = part.strip().lower()
|
||||
if part:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
|
||||
result = cls(0)
|
||||
for part in option_set:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
|
||||
try:
|
||||
part = cls[part]
|
||||
except Exception as e:
|
||||
raise KeyError(f"{part} is not a recognized name for a plando module. "
|
||||
f"Known options: {', '.join(flag.name for flag in cls)}") from e
|
||||
else:
|
||||
return base | part
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.value:
|
||||
return ", ".join((flag.name for flag in PlandoSettings if self.value & flag.value))
|
||||
return "Off"
|
||||
|
||||
categories = set(AutoWorldRegister.world_types)
|
||||
|
||||
def mystery_argparse():
|
||||
options = get_options()
|
||||
defaults = options["generator"]
|
||||
|
||||
def resolve_path(path: str, resolver: Callable[[str], str]) -> str:
|
||||
return path if os.path.isabs(path) else resolver(path)
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default = defaults["weights_file_path"],
|
||||
parser.add_argument('--weights_file_path', default=defaults["weights_file_path"],
|
||||
help='Path to the weights file to use for rolling game settings, urls are also valid')
|
||||
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
|
||||
action='store_true')
|
||||
parser.add_argument('--player_files_path', default=defaults["player_files_path"],
|
||||
parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path),
|
||||
help="Input directory for player files.")
|
||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
||||
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
|
||||
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"], help="Path to the 1.0 JP SM Baserom.")
|
||||
parser.add_argument('--enemizercli', default=defaults["enemizer_path"])
|
||||
parser.add_argument('--outputpath', default=options["general_options"]["output_path"])
|
||||
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"],
|
||||
help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path
|
||||
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"],
|
||||
help="Path to the 1.0 JP SM Baserom.")
|
||||
parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path))
|
||||
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
|
||||
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('--meta_file_path', default=defaults["meta_file_path"])
|
||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||
@@ -56,12 +103,12 @@ def mystery_argparse():
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_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.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
||||
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
|
||||
return args, options
|
||||
|
||||
|
||||
def get_seed_name(random):
|
||||
return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||
def get_seed_name(random_source) -> str:
|
||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||
|
||||
|
||||
def main(args=None, callback=ERmain):
|
||||
@@ -75,21 +122,21 @@ def main(args=None, callback=ERmain):
|
||||
if args.race:
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache = {}
|
||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||
try:
|
||||
weights_cache[args.weights_file_path] = read_weights_yaml(args.weights_file_path)
|
||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
||||
print(f"Weights: {args.weights_file_path} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path], 'No description specified')}")
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
||||
|
||||
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||
try:
|
||||
weights_cache[args.meta_file_path] = read_weights_yaml(args.meta_file_path)
|
||||
weights_cache[args.meta_file_path] = read_weights_yamls(args.meta_file_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||
meta_weights = weights_cache[args.meta_file_path]
|
||||
meta_weights = weights_cache[args.meta_file_path][-1]
|
||||
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||
del(meta_weights["meta_description"])
|
||||
if args.samesettings:
|
||||
@@ -100,21 +147,26 @@ def main(args=None, callback=ERmain):
|
||||
player_files = {}
|
||||
for file in os.scandir(args.player_files_path):
|
||||
fname = file.name
|
||||
if file.is_file() and os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||
if file.is_file() and not file.name.startswith(".") and \
|
||||
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)
|
||||
try:
|
||||
weights_cache[fname] = read_weights_yaml(path)
|
||||
weights_cache[fname] = read_weights_yamls(path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
print(f"P{player_id} Weights: {fname} >> "
|
||||
f"{get_choice('description', weights_cache[fname], 'No description specified')}")
|
||||
player_files[player_id] = fname
|
||||
player_id += 1
|
||||
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||
for filename, yaml_data in weights_cache.items():
|
||||
for yaml in yaml_data:
|
||||
print(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
player_id += 1
|
||||
|
||||
args.multi = max(player_id-1, args.multi)
|
||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
||||
f"{', '.join(args.plando)}")
|
||||
f"{args.plando}")
|
||||
|
||||
if not weights_cache:
|
||||
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
@@ -133,8 +185,9 @@ def main(args=None, callback=ERmain):
|
||||
erargs.sm_rom = args.sm_rom
|
||||
erargs.enemizercli = args.enemizercli
|
||||
|
||||
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
|
||||
for k, v in weights_cache.items()}
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: ( tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||
for fname, yamls in weights_cache.items()}
|
||||
player_path_cache = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
@@ -145,41 +198,48 @@ def main(args=None, callback=ERmain):
|
||||
option = get_choice(key, category_dict)
|
||||
if option is not None:
|
||||
for player, path in player_path_cache.items():
|
||||
if category_name is None:
|
||||
weights_cache[path][key] = option
|
||||
elif category_name not in weights_cache[path]:
|
||||
raise Exception(f"Meta: Category {category_name} is not present in {path}.")
|
||||
else:
|
||||
weights_cache[path][category_name][key] = option
|
||||
for yaml in weights_cache[path]:
|
||||
if category_name is None:
|
||||
yaml[key] = option
|
||||
elif category_name not in yaml:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
else:
|
||||
yaml[category_name][key] = option
|
||||
|
||||
name_counter = Counter()
|
||||
erargs.player_settings = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
|
||||
player = 1
|
||||
while player <= args.multi:
|
||||
path = player_path_cache[player]
|
||||
if path:
|
||||
try:
|
||||
settings = settings_cache[path] if settings_cache[path] else \
|
||||
roll_settings(weights_cache[path], args.plando)
|
||||
for k, v in vars(settings).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||
for settingsObject in settings:
|
||||
for k, v in vars(settingsObject).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
player += 1
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {erargs.name}")
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
@@ -190,8 +250,6 @@ def main(args=None, callback=ERmain):
|
||||
if len(player_settings.values()) > 1:
|
||||
important[option] = {player: value for player, value in player_settings.items() if
|
||||
player <= args.yaml_output}
|
||||
elif len(player_settings.values()) > 0:
|
||||
important[option] = player_settings[1]
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
|
||||
@@ -208,28 +266,28 @@ def main(args=None, callback=ERmain):
|
||||
callback(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yaml(path):
|
||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||
try:
|
||||
if urllib.parse.urlparse(path).scheme:
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
|
||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||
else:
|
||||
with open(path, 'rb') as f:
|
||||
yaml = str(f.read(), "utf-8")
|
||||
yaml = str(f.read(), "utf-8-sig")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to read weights ({path})") from e
|
||||
|
||||
return parse_yaml(yaml)
|
||||
return tuple(parse_yamls(yaml))
|
||||
|
||||
|
||||
def interpret_on_off(value):
|
||||
def interpret_on_off(value) -> bool:
|
||||
return {"on": True, "off": False}.get(value, value)
|
||||
|
||||
|
||||
def convert_to_on_off(value):
|
||||
def convert_to_on_off(value) -> str:
|
||||
return {True: "on", False: "off"}.get(value, value)
|
||||
|
||||
|
||||
def get_choice_legacy(option, root, value=None) -> typing.Any:
|
||||
def get_choice_legacy(option, root, value=None) -> Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
@@ -244,7 +302,7 @@ def get_choice_legacy(option, root, value=None) -> typing.Any:
|
||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||
|
||||
|
||||
def get_choice(option, root, value=None) -> typing.Any:
|
||||
def get_choice(option, root, value=None) -> Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
@@ -277,16 +335,16 @@ def handle_name(name: str, player: int, name_counter: Counter):
|
||||
return new_name
|
||||
|
||||
|
||||
def prefer_int(input_data: str) -> typing.Union[str, int]:
|
||||
def prefer_int(input_data: str) -> Union[str, int]:
|
||||
try:
|
||||
return int(input_data)
|
||||
except:
|
||||
return input_data
|
||||
|
||||
|
||||
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
||||
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
||||
available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||
Bosses.boss_location_table}
|
||||
|
||||
boss_shuffle_options = {None: 'none',
|
||||
@@ -311,7 +369,7 @@ goals = {
|
||||
}
|
||||
|
||||
|
||||
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
|
||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
@@ -330,7 +388,7 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di
|
||||
|
||||
|
||||
def roll_linked_options(weights: dict) -> dict:
|
||||
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
|
||||
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
|
||||
for option_set in weights["linked_options"]:
|
||||
if "name" not in option_set:
|
||||
raise ValueError("One of your linked options does not have a name.")
|
||||
@@ -352,7 +410,7 @@ def roll_linked_options(weights: dict) -> dict:
|
||||
|
||||
|
||||
def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
|
||||
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
|
||||
weights["_Generator_Version"] = Utils.__version__
|
||||
for i, option_set in enumerate(triggers):
|
||||
try:
|
||||
@@ -381,10 +439,10 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
return weights
|
||||
|
||||
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
|
||||
if boss_shuffle in boss_shuffle_options:
|
||||
return boss_shuffle_options[boss_shuffle]
|
||||
elif "bosses" in plando_options:
|
||||
elif PlandoSettings.bosses in plando_options:
|
||||
options = boss_shuffle.lower().split(";")
|
||||
remainder_shuffle = "none" # vanilla
|
||||
bosses = []
|
||||
@@ -427,22 +485,13 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||
else:
|
||||
# verify item names existing
|
||||
if getattr(player_option, "verify_item_name", False):
|
||||
for item_name in player_option.value:
|
||||
if item_name not in AutoWorldRegister.world_types[ret.game].item_names:
|
||||
raise Exception(f"Item {item_name} from option {player_option} "
|
||||
f"is not a valid item name from {ret.game}")
|
||||
elif getattr(player_option, "verify_location_name", False):
|
||||
for location_name in player_option.value:
|
||||
if location_name not in AutoWorldRegister.world_types[ret.game].location_names:
|
||||
raise Exception(f"Location {location_name} from option {player_option} "
|
||||
f"is not a valid location name from {ret.game}")
|
||||
if hasattr(player_option, "verify"):
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game])
|
||||
else:
|
||||
setattr(ret, option_key, option(option.default))
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
|
||||
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
@@ -455,21 +504,15 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
||||
if tuplize_version(version) > version_tuple:
|
||||
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
||||
f"however generator is of version {__version__}")
|
||||
required_plando_options = requirements.get("plando", "")
|
||||
if required_plando_options:
|
||||
required_plando_options = set(option.strip() for option in required_plando_options.split(","))
|
||||
required_plando_options -= plando_options
|
||||
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
|
||||
if required_plando_options not in plando_options:
|
||||
if required_plando_options:
|
||||
if len(required_plando_options) == 1:
|
||||
raise Exception(f"Settings reports required plando module {', '.join(required_plando_options)}, "
|
||||
f"which is not enabled.")
|
||||
else:
|
||||
raise Exception(f"Settings reports required plando modules {', '.join(required_plando_options)}, "
|
||||
f"which are not enabled.")
|
||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||
f"which is not enabled.")
|
||||
|
||||
ret = argparse.Namespace()
|
||||
for option_key in Options.per_game_common_options:
|
||||
if option_key in weights:
|
||||
if option_key in weights and option_key not in Options.common_options:
|
||||
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
|
||||
|
||||
ret.game = get_choice("game", weights)
|
||||
@@ -491,13 +534,15 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
||||
for option_key, option in world_type.options.items():
|
||||
handle_option(ret, game_weights, option_key, option)
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
handle_option(ret, game_weights, option_key, option)
|
||||
if "items" in plando_options:
|
||||
ret.plando_items = roll_item_plando(world_type, game_weights)
|
||||
# skip setting this option if already set from common_options, defaulting to root option
|
||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||
handle_option(ret, game_weights, option_key, option)
|
||||
if PlandoSettings.items in plando_options:
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
if PlandoSettings.connections in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
@@ -510,47 +555,10 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
else:
|
||||
raise Exception(f"Unsupported game {ret.game}")
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def roll_item_plando(world_type, weights):
|
||||
plando_items = []
|
||||
|
||||
def add_plando_item(item: str, location: str):
|
||||
if item not in world_type.item_name_to_id:
|
||||
raise Exception(f"Could not plando item {item} as the item was not recognized")
|
||||
if location not in world_type.location_name_to_id:
|
||||
raise Exception(
|
||||
f"Could not plando item {item} at location {location} as the location was not recognized")
|
||||
plando_items.append(PlandoItem(item, location, location_world, from_pool, force))
|
||||
|
||||
options = weights.get("plando_items", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
from_pool = get_choice_legacy("from_pool", placement, PlandoItem._field_defaults["from_pool"])
|
||||
location_world = get_choice_legacy("world", placement, PlandoItem._field_defaults["world"])
|
||||
force = str(get_choice_legacy("force", placement, PlandoItem._field_defaults["force"])).lower()
|
||||
if "items" in placement and "locations" in placement:
|
||||
items = placement["items"]
|
||||
locations = placement["locations"]
|
||||
if isinstance(items, dict):
|
||||
item_list = []
|
||||
for key, value in items.items():
|
||||
item_list += [key] * value
|
||||
items = item_list
|
||||
if not items or not locations:
|
||||
raise Exception("You must specify at least one item and one location to place items.")
|
||||
random.shuffle(items)
|
||||
random.shuffle(locations)
|
||||
for item, location in zip(items, locations):
|
||||
add_plando_item(item, location)
|
||||
else:
|
||||
item = get_choice_legacy("item", placement, get_choice_legacy("items", placement))
|
||||
location = get_choice_legacy("location", placement)
|
||||
add_plando_item(item, location)
|
||||
return plando_items
|
||||
|
||||
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
|
||||
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
|
||||
@@ -580,9 +588,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
|
||||
ret.goal = goals[goal]
|
||||
|
||||
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
|
||||
# fast ganon + ganon at hole
|
||||
ret.open_pyramid = get_choice_legacy('open_pyramid', weights, 'goal')
|
||||
|
||||
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
|
||||
|
||||
@@ -654,7 +659,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||
|
||||
ret.plando_texts = {}
|
||||
if "texts" in plando_options:
|
||||
if PlandoSettings.texts in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
@@ -666,7 +671,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
if PlandoSettings.connections in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
|
||||
4
LICENSE
@@ -1,8 +1,8 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 LLCoolDave
|
||||
Copyright (c) 2021 Berserker66
|
||||
Copyright (c) 2021 CaitSith2
|
||||
Copyright (c) 2022 Berserker66
|
||||
Copyright (c) 2022 CaitSith2
|
||||
Copyright (c) 2021 LegendaryLinux
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
|
||||
291
Launcher.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Archipelago launcher for bundled app.
|
||||
|
||||
* if run with APBP as argument, launch corresponding client.
|
||||
* if run with executable as argument, run it passing argv[2:] as arguments
|
||||
* if run without arguments, open launcher GUI
|
||||
|
||||
Scroll down to components= to add components to the launcher as well as setup.py
|
||||
"""
|
||||
|
||||
|
||||
import argparse
|
||||
from os.path import isfile
|
||||
import sys
|
||||
from typing import Iterable, Sequence, Callable, Union, Optional
|
||||
import subprocess
|
||||
import itertools
|
||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
|
||||
is_windows, is_macos, is_linux
|
||||
from shutil import which
|
||||
import shlex
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
def open_host_yaml():
|
||||
file = user_path('host.yaml')
|
||||
if is_linux:
|
||||
exe = which('sensible-editor') or which('gedit') or \
|
||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, file])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
import webbrowser
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
def open_patch():
|
||||
suffixes = []
|
||||
for c in components:
|
||||
if isfile(get_exe(c)[-1]):
|
||||
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
|
||||
isinstance(c.file_identifier, SuffixIdentifier) else []
|
||||
try:
|
||||
filename = open_filename('Select patch', (('Patches', suffixes),))
|
||||
except Exception as e:
|
||||
messagebox('Error', str(e), error=True)
|
||||
else:
|
||||
file, _, component = identify(filename)
|
||||
if file and component:
|
||||
launch([*get_exe(component), file], component.cli)
|
||||
|
||||
|
||||
def browse_files():
|
||||
file = user_path()
|
||||
if is_linux:
|
||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, file])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
import webbrowser
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
class Type(Enum):
|
||||
TOOL = auto()
|
||||
FUNC = auto() # not a real component
|
||||
CLIENT = auto()
|
||||
ADJUSTER = auto()
|
||||
|
||||
|
||||
class SuffixIdentifier:
|
||||
suffixes: Iterable[str]
|
||||
|
||||
def __init__(self, *args: str):
|
||||
self.suffixes = args
|
||||
|
||||
def __call__(self, path: str):
|
||||
if isinstance(path, str):
|
||||
for suffix in self.suffixes:
|
||||
if path.endswith(suffix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Component:
|
||||
display_name: str
|
||||
type: Optional[Type]
|
||||
script_name: Optional[str]
|
||||
frozen_name: Optional[str]
|
||||
icon: str # just the name, no suffix
|
||||
cli: bool
|
||||
func: Optional[Callable]
|
||||
file_identifier: Optional[Callable[[str], bool]]
|
||||
|
||||
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
||||
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
|
||||
file_identifier: Optional[Callable[[str], bool]] = None):
|
||||
self.display_name = display_name
|
||||
self.script_name = script_name
|
||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||
self.icon = icon
|
||||
self.cli = cli
|
||||
self.type = component_type or \
|
||||
None if not display_name else \
|
||||
Type.FUNC if func else \
|
||||
Type.CLIENT if 'Client' in display_name else \
|
||||
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
|
||||
self.func = func
|
||||
self.file_identifier = file_identifier
|
||||
|
||||
def handles_file(self, path: str):
|
||||
return self.file_identifier(path) if self.file_identifier else False
|
||||
|
||||
|
||||
components: Iterable[Component] = (
|
||||
# Launcher
|
||||
Component('', 'Launcher'),
|
||||
# Core
|
||||
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
||||
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
||||
Component('Generate', 'Generate', cli=True),
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||
# SNI
|
||||
Component('SNI Client', 'SNIClient',
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
|
||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||
# Factorio
|
||||
Component('Factorio Client', 'FactorioClient'),
|
||||
# Minecraft
|
||||
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
|
||||
file_identifier=SuffixIdentifier('.apmc')),
|
||||
# Ocarina of Time
|
||||
Component('OoT Client', 'OoTClient',
|
||||
file_identifier=SuffixIdentifier('.apz5')),
|
||||
Component('OoT Adjuster', 'OoTAdjuster'),
|
||||
# FF1
|
||||
Component('FF1 Client', 'FF1Client'),
|
||||
# ChecksFinder
|
||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||
# Starcraft 2
|
||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||
# Functions
|
||||
Component('Open host.yaml', func=open_host_yaml),
|
||||
Component('Open Patch', func=open_patch),
|
||||
Component('Browse Files', func=browse_files),
|
||||
)
|
||||
icon_paths = {
|
||||
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
|
||||
'mcicon': local_path('data', 'mcicon.ico')
|
||||
}
|
||||
|
||||
|
||||
def identify(path: Union[None, str]):
|
||||
if path is None:
|
||||
return None, None, None
|
||||
for component in components:
|
||||
if component.handles_file(path):
|
||||
return path, component.script_name, component
|
||||
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
|
||||
|
||||
|
||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||
if isinstance(component, str):
|
||||
name = component
|
||||
component = None
|
||||
if name.startswith('Archipelago'):
|
||||
name = name[11:]
|
||||
if name.endswith('.exe'):
|
||||
name = name[:-4]
|
||||
if name.endswith('.py'):
|
||||
name = name[:-3]
|
||||
if not name:
|
||||
return None
|
||||
for c in components:
|
||||
if c.script_name == name or c.frozen_name == f'Archipelago{name}':
|
||||
component = c
|
||||
break
|
||||
if not component:
|
||||
return None
|
||||
if is_frozen():
|
||||
suffix = '.exe' if is_windows else ''
|
||||
return [local_path(f'{component.frozen_name}{suffix}')]
|
||||
else:
|
||||
return [sys.executable, local_path(f'{component.script_name}.py')]
|
||||
|
||||
|
||||
def launch(exe, in_terminal=False):
|
||||
if in_terminal:
|
||||
if is_windows:
|
||||
subprocess.Popen(['start', *exe], shell=True)
|
||||
return
|
||||
elif is_linux:
|
||||
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
||||
if terminal:
|
||||
subprocess.Popen([terminal, '-e', shlex.join(exe)])
|
||||
return
|
||||
elif is_macos:
|
||||
terminal = [which('open'), '-W', '-a', 'Terminal.app']
|
||||
subprocess.Popen([*terminal, *exe])
|
||||
return
|
||||
subprocess.Popen(exe)
|
||||
|
||||
|
||||
def run_gui():
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label
|
||||
|
||||
class Launcher(App):
|
||||
base_title: str = "Archipelago Launcher"
|
||||
container: ContainerLayout
|
||||
grid: GridLayout
|
||||
|
||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])}
|
||||
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
self.title = self.base_title
|
||||
self.ctx = ctx
|
||||
self.icon = r"data/icon.png"
|
||||
super().__init__()
|
||||
|
||||
def build(self):
|
||||
self.container = ContainerLayout()
|
||||
self.grid = GridLayout(cols=2)
|
||||
self.container.add_widget(self.grid)
|
||||
|
||||
button_layout = self.grid # make buttons fill the window
|
||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
button = Button(text=tool[0])
|
||||
button.component = tool[1]
|
||||
button.bind(on_release=self.component_action)
|
||||
button_layout.add_widget(button)
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
# column 2
|
||||
if client:
|
||||
button = Button(text=client[0])
|
||||
button.component = client[1]
|
||||
button.bind(on_press=self.component_action)
|
||||
button_layout.add_widget(button)
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
|
||||
return self.container
|
||||
|
||||
@staticmethod
|
||||
def component_action(button):
|
||||
if button.component.type == Type.FUNC:
|
||||
button.component.func()
|
||||
else:
|
||||
launch(get_exe(button.component), button.component.cli)
|
||||
|
||||
Launcher().run()
|
||||
|
||||
|
||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
if isinstance(args, argparse.Namespace):
|
||||
args = {k: v for k, v in args._get_kwargs()}
|
||||
elif not args:
|
||||
args = {}
|
||||
|
||||
if "Patch|Game|Component" in args:
|
||||
file, component, _ = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
args['component'] = component
|
||||
|
||||
if 'file' in args:
|
||||
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
|
||||
elif 'component' in args:
|
||||
subprocess.run([*get_exe(args['component']), *args['args']])
|
||||
else:
|
||||
run_gui()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
|
||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
|
||||
main(parser.parse_args())
|
||||
270
LttPAdjuster.py
@@ -14,13 +14,19 @@ import tkinter as tk
|
||||
from argparse import Namespace
|
||||
from concurrent.futures import as_completed, ThreadPoolExecutor
|
||||
from glob import glob
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, LEFT, X, TOP, LabelFrame, \
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
|
||||
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
|
||||
from tkinter.constants import DISABLED, NORMAL
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||
from Utils import output_path, local_path, open_file
|
||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
||||
get_adjuster_settings, tkinter_center_window, init_logging
|
||||
from Patch import GAME_ALTTP
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
@@ -39,9 +45,9 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
|
||||
|
||||
parser.add_argument('--rom', default='ER_base.sfc', help='Path to an ALttP rom to adjust.')
|
||||
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
||||
parser.add_argument('--baserom', default='Zelda no Densetsu - Kamigami no Triforce (Japan).sfc',
|
||||
help='Path to an ALttP JAP(1.0) rom to use as a base.')
|
||||
help='Path to an ALttP Japan(1.0) rom to use as a base.')
|
||||
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
|
||||
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
|
||||
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
|
||||
@@ -52,6 +58,7 @@ def main():
|
||||
''')
|
||||
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
|
||||
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
|
||||
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
|
||||
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
|
||||
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
|
||||
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
|
||||
@@ -102,14 +109,15 @@ def main():
|
||||
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
|
||||
args = parser.parse_args()
|
||||
args.music = not args.disablemusic
|
||||
if args.update_sprites:
|
||||
run_sprite_update()
|
||||
sys.exit()
|
||||
# set up logger
|
||||
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
||||
args.loglevel]
|
||||
logging.basicConfig(format='%(message)s', level=loglevel)
|
||||
|
||||
if args.update_sprites:
|
||||
run_sprite_update()
|
||||
sys.exit()
|
||||
|
||||
if not os.path.isfile(args.rom):
|
||||
adjustGUI()
|
||||
else:
|
||||
@@ -118,18 +126,20 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
args, path = adjust(args=args)
|
||||
from Utils import persistent_store
|
||||
if isinstance(args.sprite, Sprite):
|
||||
args.sprite = args.sprite.name
|
||||
persistent_store("adjuster", "last_settings_3", args)
|
||||
persistent_store("adjuster", GAME_ALTTP, args)
|
||||
|
||||
|
||||
def adjust(args):
|
||||
start = time.perf_counter()
|
||||
init_logging("LttP Adjuster")
|
||||
logger = logging.getLogger('Adjuster')
|
||||
logger.info('Patching ROM.')
|
||||
vanillaRom = args.baserom
|
||||
if os.path.splitext(args.rom)[-1].lower() == '.apbp':
|
||||
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
||||
vanillaRom = local_path(vanillaRom)
|
||||
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
|
||||
import Patch
|
||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||
|
||||
@@ -154,7 +164,7 @@ def adjust(args):
|
||||
|
||||
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
|
||||
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
|
||||
deathlink=args.deathlink)
|
||||
deathlink=args.deathlink, allowcollect=args.allowcollect)
|
||||
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
||||
rom.write_to_file(path)
|
||||
|
||||
@@ -196,6 +206,7 @@ def adjustGUI():
|
||||
|
||||
def adjustRom():
|
||||
guiargs = Namespace()
|
||||
guiargs.auto_apply = rom_vars.auto_apply.get()
|
||||
guiargs.heartbeep = rom_vars.heartbeepVar.get()
|
||||
guiargs.heartcolor = rom_vars.heartcolorVar.get()
|
||||
guiargs.menuspeed = rom_vars.menuspeedVar.get()
|
||||
@@ -208,6 +219,7 @@ def adjustGUI():
|
||||
guiargs.music = bool(rom_vars.MusicVar.get())
|
||||
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
|
||||
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
|
||||
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
|
||||
guiargs.rom = romVar2.get()
|
||||
guiargs.baserom = romVar.get()
|
||||
guiargs.sprite = rom_vars.sprite
|
||||
@@ -224,36 +236,70 @@ def adjustGUI():
|
||||
messagebox.showerror(title="Error while adjusting Rom", message=str(e))
|
||||
else:
|
||||
messagebox.showinfo(title="Success", message=f"Rom patched successfully to {path}")
|
||||
from Utils import persistent_store
|
||||
if isinstance(guiargs.sprite, Sprite):
|
||||
guiargs.sprite = guiargs.sprite.name
|
||||
persistent_store("adjuster", "last_settings_3", guiargs)
|
||||
delattr(guiargs, "rom")
|
||||
persistent_store("adjuster", GAME_ALTTP, guiargs)
|
||||
|
||||
def saveGUISettings():
|
||||
guiargs = Namespace()
|
||||
guiargs.auto_apply = rom_vars.auto_apply.get()
|
||||
guiargs.heartbeep = rom_vars.heartbeepVar.get()
|
||||
guiargs.heartcolor = rom_vars.heartcolorVar.get()
|
||||
guiargs.menuspeed = rom_vars.menuspeedVar.get()
|
||||
guiargs.ow_palettes = rom_vars.owPalettesVar.get()
|
||||
guiargs.uw_palettes = rom_vars.uwPalettesVar.get()
|
||||
guiargs.hud_palettes = rom_vars.hudPalettesVar.get()
|
||||
guiargs.sword_palettes = rom_vars.swordPalettesVar.get()
|
||||
guiargs.shield_palettes = rom_vars.shieldPalettesVar.get()
|
||||
guiargs.quickswap = bool(rom_vars.quickSwapVar.get())
|
||||
guiargs.music = bool(rom_vars.MusicVar.get())
|
||||
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
|
||||
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
|
||||
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
|
||||
guiargs.baserom = romVar.get()
|
||||
if isinstance(rom_vars.sprite, Sprite):
|
||||
guiargs.sprite = rom_vars.sprite.name
|
||||
else:
|
||||
guiargs.sprite = rom_vars.sprite
|
||||
guiargs.sprite_pool = rom_vars.sprite_pool
|
||||
persistent_store("adjuster", GAME_ALTTP, guiargs)
|
||||
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
||||
|
||||
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
|
||||
rom_options_frame.pack(side=TOP)
|
||||
adjustButton.pack(side=BOTTOM, padx=(5, 5))
|
||||
adjustButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
bottomFrame2.pack(side=BOTTOM, pady=(5, 5))
|
||||
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
|
||||
saveButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
bottomFrame2.pack(side=TOP, pady=(5,5))
|
||||
|
||||
tkinter_center_window(adjustWindow)
|
||||
adjustWindow.mainloop()
|
||||
|
||||
|
||||
def run_sprite_update():
|
||||
import threading
|
||||
done = threading.Event()
|
||||
top = Tk()
|
||||
top.withdraw()
|
||||
BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||
while not done.isSet():
|
||||
top.update()
|
||||
print("Done updating sprites")
|
||||
try:
|
||||
top = Tk()
|
||||
except:
|
||||
task = BackgroundTaskProgressNullWindow(update_sprites, lambda successful, resultmessage: done.set())
|
||||
else:
|
||||
top.withdraw()
|
||||
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||
while not done.is_set():
|
||||
task.do_events()
|
||||
logging.info("Done updating sprites")
|
||||
|
||||
|
||||
def update_sprites(task, on_finish=None):
|
||||
resultmessage = ""
|
||||
successful = True
|
||||
sprite_dir = local_path("data", "sprites", "alttpr")
|
||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
||||
os.makedirs(sprite_dir, exist_ok=True)
|
||||
ctx = get_cert_none_ssl_context()
|
||||
|
||||
def finished():
|
||||
task.close_window()
|
||||
@@ -262,7 +308,7 @@ def update_sprites(task, on_finish=None):
|
||||
|
||||
try:
|
||||
task.update_status("Downloading alttpr sprites list")
|
||||
with urlopen('https://alttpr.com/sprites') as response:
|
||||
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
|
||||
sprites_arr = json.loads(response.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||
@@ -289,7 +335,7 @@ def update_sprites(task, on_finish=None):
|
||||
|
||||
def dl(sprite_url, filename):
|
||||
target = os.path.join(sprite_dir, filename)
|
||||
with urlopen(sprite_url) as response, open(target, 'wb') as out:
|
||||
with urlopen(sprite_url, context=ctx) as response, open(target, 'wb') as out:
|
||||
shutil.copyfileobj(response, out)
|
||||
|
||||
def rem(sprite):
|
||||
@@ -400,16 +446,48 @@ class BackgroundTaskProgress(BackgroundTask):
|
||||
def update_status(self, text):
|
||||
self.queue_event(lambda: self.label_var.set(text))
|
||||
|
||||
def do_events(self):
|
||||
self.parent.update()
|
||||
|
||||
# only call this in an event callback
|
||||
def close_window(self):
|
||||
self.stop()
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
class BackgroundTaskProgressNullWindow(BackgroundTask):
|
||||
def __init__(self, code_to_run, *args):
|
||||
super().__init__(None, code_to_run, *args)
|
||||
|
||||
def process_queue(self):
|
||||
try:
|
||||
while True:
|
||||
if not self.running:
|
||||
return
|
||||
event = self.queue.get_nowait()
|
||||
event()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def do_events(self):
|
||||
self.process_queue()
|
||||
|
||||
def update_status(self, text):
|
||||
self.queue_event(lambda: logging.info(text))
|
||||
|
||||
def close_window(self):
|
||||
self.stop()
|
||||
|
||||
|
||||
def get_rom_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
if not adjuster_settings:
|
||||
adjuster_settings = Namespace()
|
||||
adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||
|
||||
romFrame = Frame(parent)
|
||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||
romVar = StringVar(value="Zelda no Densetsu - Kamigami no Triforce (Japan).sfc")
|
||||
romVar = StringVar(value=adjuster_settings.baserom)
|
||||
romEntry = Entry(romFrame, textvariable=romVar)
|
||||
|
||||
def RomSelect():
|
||||
@@ -435,6 +513,31 @@ def get_rom_frame(parent=None):
|
||||
|
||||
|
||||
def get_rom_options_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
defaults = {
|
||||
"auto_apply": 'ask',
|
||||
"music": True,
|
||||
"reduceflashing": True,
|
||||
"deathlink": False,
|
||||
"sprite": None,
|
||||
"quickswap": True,
|
||||
"menuspeed": 'normal',
|
||||
"heartcolor": 'red',
|
||||
"heartbeep": 'normal',
|
||||
"ow_palettes": 'default',
|
||||
"uw_palettes": 'default',
|
||||
"hud_palettes": 'default',
|
||||
"sword_palettes": 'default',
|
||||
"shield_palettes": 'default',
|
||||
"sprite_pool": [],
|
||||
"allowcollect": False,
|
||||
}
|
||||
if not adjuster_settings:
|
||||
adjuster_settings = Namespace()
|
||||
for key, defaultvalue in defaults.items():
|
||||
if not hasattr(adjuster_settings, key):
|
||||
setattr(adjuster_settings, key, defaultvalue)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||
romOptionsFrame.columnconfigure(0, weight=1)
|
||||
romOptionsFrame.columnconfigure(1, weight=1)
|
||||
@@ -443,25 +546,29 @@ def get_rom_options_frame(parent=None):
|
||||
vars = Namespace()
|
||||
|
||||
vars.MusicVar = IntVar()
|
||||
vars.MusicVar.set(1)
|
||||
vars.MusicVar.set(adjuster_settings.music)
|
||||
MusicCheckbutton = Checkbutton(romOptionsFrame, text="Music", variable=vars.MusicVar)
|
||||
MusicCheckbutton.grid(row=0, column=0, sticky=E)
|
||||
|
||||
vars.disableFlashingVar = IntVar(value=1)
|
||||
vars.disableFlashingVar = IntVar(value=adjuster_settings.reduceflashing)
|
||||
disableFlashingCheckbutton = Checkbutton(romOptionsFrame, text="Disable flashing (anti-epilepsy)",
|
||||
variable=vars.disableFlashingVar)
|
||||
disableFlashingCheckbutton.grid(row=6, column=0, sticky=W)
|
||||
|
||||
vars.DeathLinkVar = IntVar(value=0)
|
||||
vars.DeathLinkVar = IntVar(value=adjuster_settings.deathlink)
|
||||
DeathLinkCheckbutton = Checkbutton(romOptionsFrame, text="DeathLink (Team Deaths)", variable=vars.DeathLinkVar)
|
||||
DeathLinkCheckbutton.grid(row=7, column=0, sticky=W)
|
||||
|
||||
vars.AllowCollectVar = IntVar(value=adjuster_settings.allowcollect)
|
||||
AllowCollectCheckbutton = Checkbutton(romOptionsFrame, text="Allow Collect", variable=vars.AllowCollectVar)
|
||||
AllowCollectCheckbutton.grid(row=8, column=0, sticky=W)
|
||||
|
||||
spriteDialogFrame = Frame(romOptionsFrame)
|
||||
spriteDialogFrame.grid(row=0, column=1)
|
||||
baseSpriteLabel = Label(spriteDialogFrame, text='Sprite:')
|
||||
|
||||
vars.spriteNameVar = StringVar()
|
||||
vars.sprite = None
|
||||
vars.sprite = adjuster_settings.sprite
|
||||
|
||||
def set_sprite(sprite_param):
|
||||
nonlocal vars
|
||||
@@ -475,8 +582,8 @@ def get_rom_options_frame(parent=None):
|
||||
vars.sprite = sprite_param
|
||||
vars.spriteNameVar.set(vars.sprite.name)
|
||||
|
||||
set_sprite(None)
|
||||
vars.spriteNameVar.set('(unchanged)')
|
||||
set_sprite(adjuster_settings.sprite)
|
||||
#vars.spriteNameVar.set(adjuster_settings.sprite)
|
||||
spriteEntry = Label(spriteDialogFrame, textvariable=vars.spriteNameVar)
|
||||
|
||||
def SpriteSelect():
|
||||
@@ -489,7 +596,7 @@ def get_rom_options_frame(parent=None):
|
||||
spriteEntry.pack(side=LEFT)
|
||||
spriteSelectButton.pack(side=LEFT)
|
||||
|
||||
vars.quickSwapVar = IntVar(value=1)
|
||||
vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap)
|
||||
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
|
||||
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
|
||||
|
||||
@@ -498,7 +605,7 @@ def get_rom_options_frame(parent=None):
|
||||
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
|
||||
menuspeedLabel.pack(side=LEFT)
|
||||
vars.menuspeedVar = StringVar()
|
||||
vars.menuspeedVar.set('normal')
|
||||
vars.menuspeedVar.set(adjuster_settings.menuspeed)
|
||||
menuspeedOptionMenu = OptionMenu(menuspeedFrame, vars.menuspeedVar, 'normal', 'instant', 'double', 'triple',
|
||||
'quadruple', 'half')
|
||||
menuspeedOptionMenu.pack(side=LEFT)
|
||||
@@ -508,7 +615,7 @@ def get_rom_options_frame(parent=None):
|
||||
heartcolorLabel = Label(heartcolorFrame, text='Heart color')
|
||||
heartcolorLabel.pack(side=LEFT)
|
||||
vars.heartcolorVar = StringVar()
|
||||
vars.heartcolorVar.set('red')
|
||||
vars.heartcolorVar.set(adjuster_settings.heartcolor)
|
||||
heartcolorOptionMenu = OptionMenu(heartcolorFrame, vars.heartcolorVar, 'red', 'blue', 'green', 'yellow', 'random')
|
||||
heartcolorOptionMenu.pack(side=LEFT)
|
||||
|
||||
@@ -517,7 +624,7 @@ def get_rom_options_frame(parent=None):
|
||||
heartbeepLabel = Label(heartbeepFrame, text='Heartbeep')
|
||||
heartbeepLabel.pack(side=LEFT)
|
||||
vars.heartbeepVar = StringVar()
|
||||
vars.heartbeepVar.set('normal')
|
||||
vars.heartbeepVar.set(adjuster_settings.heartbeep)
|
||||
heartbeepOptionMenu = OptionMenu(heartbeepFrame, vars.heartbeepVar, 'double', 'normal', 'half', 'quarter', 'off')
|
||||
heartbeepOptionMenu.pack(side=LEFT)
|
||||
|
||||
@@ -526,7 +633,7 @@ def get_rom_options_frame(parent=None):
|
||||
owPalettesLabel = Label(owPalettesFrame, text='Overworld palettes')
|
||||
owPalettesLabel.pack(side=LEFT)
|
||||
vars.owPalettesVar = StringVar()
|
||||
vars.owPalettesVar.set('default')
|
||||
vars.owPalettesVar.set(adjuster_settings.ow_palettes)
|
||||
owPalettesOptionMenu = OptionMenu(owPalettesFrame, vars.owPalettesVar, 'default', 'good', 'blackout', 'grayscale',
|
||||
'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
owPalettesOptionMenu.pack(side=LEFT)
|
||||
@@ -536,7 +643,7 @@ def get_rom_options_frame(parent=None):
|
||||
uwPalettesLabel = Label(uwPalettesFrame, text='Dungeon palettes')
|
||||
uwPalettesLabel.pack(side=LEFT)
|
||||
vars.uwPalettesVar = StringVar()
|
||||
vars.uwPalettesVar.set('default')
|
||||
vars.uwPalettesVar.set(adjuster_settings.uw_palettes)
|
||||
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, vars.uwPalettesVar, 'default', 'good', 'blackout', 'grayscale',
|
||||
'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
uwPalettesOptionMenu.pack(side=LEFT)
|
||||
@@ -546,7 +653,7 @@ def get_rom_options_frame(parent=None):
|
||||
hudPalettesLabel = Label(hudPalettesFrame, text='HUD palettes')
|
||||
hudPalettesLabel.pack(side=LEFT)
|
||||
vars.hudPalettesVar = StringVar()
|
||||
vars.hudPalettesVar.set('default')
|
||||
vars.hudPalettesVar.set(adjuster_settings.hud_palettes)
|
||||
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, vars.hudPalettesVar, 'default', 'good', 'blackout',
|
||||
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
hudPalettesOptionMenu.pack(side=LEFT)
|
||||
@@ -556,7 +663,7 @@ def get_rom_options_frame(parent=None):
|
||||
swordPalettesLabel = Label(swordPalettesFrame, text='Sword palettes')
|
||||
swordPalettesLabel.pack(side=LEFT)
|
||||
vars.swordPalettesVar = StringVar()
|
||||
vars.swordPalettesVar.set('default')
|
||||
vars.swordPalettesVar.set(adjuster_settings.sword_palettes)
|
||||
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, vars.swordPalettesVar, 'default', 'good', 'blackout',
|
||||
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
swordPalettesOptionMenu.pack(side=LEFT)
|
||||
@@ -566,7 +673,7 @@ def get_rom_options_frame(parent=None):
|
||||
shieldPalettesLabel = Label(shieldPalettesFrame, text='Shield palettes')
|
||||
shieldPalettesLabel.pack(side=LEFT)
|
||||
vars.shieldPalettesVar = StringVar()
|
||||
vars.shieldPalettesVar.set('default')
|
||||
vars.shieldPalettesVar.set(adjuster_settings.shield_palettes)
|
||||
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, vars.shieldPalettesVar, 'default', 'good', 'blackout',
|
||||
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
shieldPalettesOptionMenu.pack(side=LEFT)
|
||||
@@ -576,7 +683,7 @@ def get_rom_options_frame(parent=None):
|
||||
baseSpritePoolLabel = Label(spritePoolFrame, text='Sprite Pool:')
|
||||
|
||||
vars.spritePoolCountVar = StringVar()
|
||||
vars.sprite_pool = []
|
||||
vars.sprite_pool = adjuster_settings.sprite_pool
|
||||
|
||||
def set_sprite_pool(sprite_param):
|
||||
nonlocal vars
|
||||
@@ -595,7 +702,7 @@ def get_rom_options_frame(parent=None):
|
||||
vars.spritePoolCountVar.set(str(len(vars.sprite_pool)))
|
||||
|
||||
set_sprite_pool(None)
|
||||
vars.spritePoolCountVar.set('0')
|
||||
vars.spritePoolCountVar.set(len(adjuster_settings.sprite_pool))
|
||||
spritePoolEntry = Label(spritePoolFrame, textvariable=vars.spritePoolCountVar)
|
||||
|
||||
def SpritePoolSelect():
|
||||
@@ -615,6 +722,18 @@ def get_rom_options_frame(parent=None):
|
||||
spritePoolSelectButton.pack(side=LEFT)
|
||||
spritePoolClearButton.pack(side=LEFT)
|
||||
|
||||
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
||||
autoApplyFrame = Frame(romOptionsFrame)
|
||||
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
|
||||
filler.pack(side=TOP, expand=True, fill=X)
|
||||
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
||||
askRadio.pack(side=LEFT, padx=5, pady=5)
|
||||
alwaysRadio = Radiobutton(autoApplyFrame, text='Always', variable=vars.auto_apply, value='always')
|
||||
alwaysRadio.pack(side=LEFT, padx=5, pady=5)
|
||||
neverRadio = Radiobutton(autoApplyFrame, text='Never', variable=vars.auto_apply, value='never')
|
||||
neverRadio.pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
return romOptionsFrame, vars, set_sprite
|
||||
|
||||
|
||||
@@ -663,6 +782,9 @@ class SpriteSelector():
|
||||
|
||||
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
|
||||
button.pack(side=RIGHT, padx=(5, 0))
|
||||
|
||||
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
||||
button.pack(side=LEFT,padx=(0,5))
|
||||
|
||||
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
@@ -680,36 +802,36 @@ class SpriteSelector():
|
||||
self.randomOnItemVar = IntVar()
|
||||
self.randomOnBonkVar = IntVar()
|
||||
self.randomOnRandomVar = IntVar()
|
||||
self.randomOnAllVar = IntVar()
|
||||
|
||||
if self.randomOnEvent:
|
||||
button = Checkbutton(frame, text="Hit", command=self.update_random_button, variable=self.randomOnHitVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
self.buttonHit = Checkbutton(frame, text="Hit", command=self.update_random_button, variable=self.randomOnHitVar)
|
||||
self.buttonHit.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Enter", command=self.update_random_button, variable=self.randomOnEnterVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
self.buttonEnter = Checkbutton(frame, text="Enter", command=self.update_random_button, variable=self.randomOnEnterVar)
|
||||
self.buttonEnter.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Exit", command=self.update_random_button, variable=self.randomOnExitVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
self.buttonExit = Checkbutton(frame, text="Exit", command=self.update_random_button, variable=self.randomOnExitVar)
|
||||
self.buttonExit.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Slash", command=self.update_random_button, variable=self.randomOnSlashVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
self.buttonSlash = Checkbutton(frame, text="Slash", command=self.update_random_button, variable=self.randomOnSlashVar)
|
||||
self.buttonSlash.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Item", command=self.update_random_button, variable=self.randomOnItemVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
self.buttonItem = Checkbutton(frame, text="Item", command=self.update_random_button, variable=self.randomOnItemVar)
|
||||
self.buttonItem.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Bonk", command=self.update_random_button, variable=self.randomOnBonkVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
self.buttonBonk = Checkbutton(frame, text="Bonk", command=self.update_random_button, variable=self.randomOnBonkVar)
|
||||
self.buttonBonk.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Random", command=self.update_random_button,
|
||||
variable=self.randomOnRandomVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
self.buttonRandom = Checkbutton(frame, text="Random", command=self.update_random_button, variable=self.randomOnRandomVar)
|
||||
self.buttonRandom.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
if adjuster:
|
||||
button = Button(frame, text="Current sprite from rom", command=self.use_default_sprite)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
self.buttonAll = Checkbutton(frame, text="All", command=self.update_random_button, variable=self.randomOnAllVar)
|
||||
self.buttonAll.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
set_icon(self.window)
|
||||
self.window.focus()
|
||||
tkinter_center_window(self.window)
|
||||
|
||||
def remove_from_sprite_pool(self, button, spritename):
|
||||
self.callback(("remove", spritename))
|
||||
@@ -849,9 +971,31 @@ class SpriteSelector():
|
||||
self.add_to_sprite_pool("link")
|
||||
|
||||
def update_random_button(self):
|
||||
if self.randomOnRandomVar.get():
|
||||
if self.randomOnAllVar.get():
|
||||
randomon = "all"
|
||||
self.buttonHit.config(state=DISABLED)
|
||||
self.buttonEnter.config(state=DISABLED)
|
||||
self.buttonExit.config(state=DISABLED)
|
||||
self.buttonSlash.config(state=DISABLED)
|
||||
self.buttonItem.config(state=DISABLED)
|
||||
self.buttonBonk.config(state=DISABLED)
|
||||
self.buttonRandom.config(state=DISABLED)
|
||||
elif self.randomOnRandomVar.get():
|
||||
randomon = "random"
|
||||
self.buttonHit.config(state=DISABLED)
|
||||
self.buttonEnter.config(state=DISABLED)
|
||||
self.buttonExit.config(state=DISABLED)
|
||||
self.buttonSlash.config(state=DISABLED)
|
||||
self.buttonItem.config(state=DISABLED)
|
||||
self.buttonBonk.config(state=DISABLED)
|
||||
else:
|
||||
self.buttonHit.config(state=NORMAL)
|
||||
self.buttonEnter.config(state=NORMAL)
|
||||
self.buttonExit.config(state=NORMAL)
|
||||
self.buttonSlash.config(state=NORMAL)
|
||||
self.buttonItem.config(state=NORMAL)
|
||||
self.buttonBonk.config(state=NORMAL)
|
||||
self.buttonRandom.config(state=NORMAL)
|
||||
randomon = "-hit" if self.randomOnHitVar.get() else ""
|
||||
randomon += "-enter" if self.randomOnEnterVar.get() else ""
|
||||
randomon += "-exit" if self.randomOnExitVar.get() else ""
|
||||
@@ -890,11 +1034,11 @@ class SpriteSelector():
|
||||
|
||||
@property
|
||||
def alttpr_sprite_dir(self):
|
||||
return local_path("data", "sprites", "alttpr")
|
||||
return user_path("data", "sprites", "alttpr")
|
||||
|
||||
@property
|
||||
def custom_sprite_dir(self):
|
||||
return local_path("data", "sprites", "custom")
|
||||
return user_path("data", "sprites", "custom")
|
||||
|
||||
|
||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||
|
||||
175
Main.py
@@ -1,3 +1,4 @@
|
||||
import collections
|
||||
from itertools import zip_longest, chain
|
||||
import logging
|
||||
import os
|
||||
@@ -7,18 +8,17 @@ import concurrent.futures
|
||||
import pickle
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Dict, Tuple, Optional
|
||||
from typing import Dict, Tuple, Optional, Set
|
||||
|
||||
from BaseClasses import MultiWorld, CollectionState, Region, RegionType
|
||||
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||
from worlds.alttp.Items import item_name_groups
|
||||
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
from Utils import output_path, get_options, __version__, version_tuple
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
ordered_areas = (
|
||||
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
||||
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
|
||||
@@ -47,14 +47,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world.item_functionality = args.item_functionality.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.goal = args.goal.copy()
|
||||
|
||||
if hasattr(args, "algorithm"): # current GUI options
|
||||
world.algorithm = args.algorithm
|
||||
world.shuffleganon = args.shuffleganon
|
||||
world.custom = args.custom
|
||||
world.customitemarray = args.customitemarray
|
||||
|
||||
world.open_pyramid = args.open_pyramid.copy()
|
||||
world.boss_shuffle = args.shufflebosses.copy()
|
||||
world.enemy_health = args.enemy_health.copy()
|
||||
world.enemy_damage = args.enemy_damage.copy()
|
||||
@@ -77,12 +69,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world.plando_connections = args.plando_connections.copy()
|
||||
world.required_medallions = args.required_medallions.copy()
|
||||
world.game = args.game.copy()
|
||||
world.set_options(args)
|
||||
world.player_name = args.name.copy()
|
||||
world.enemizer = args.enemizercli
|
||||
world.sprite = args.sprite.copy()
|
||||
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||
|
||||
world.set_options(args)
|
||||
world.set_item_links()
|
||||
world.state = CollectionState(world)
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||
|
||||
logger.info("Found World Types:")
|
||||
@@ -90,12 +84,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
numlength = 8
|
||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||
if not cls.hidden:
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | "
|
||||
f"{len(cls.location_names):3} Locations")
|
||||
logger.info(f" Item IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||
f"{max(cls.item_id_to_name):{numlength}} | "
|
||||
f"Location IDs: {min(cls.location_id_to_name):{numlength}} - "
|
||||
f"{max(cls.location_id_to_name):{numlength}}")
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
|
||||
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||
f"{max(cls.item_id_to_name):{numlength}}) | "
|
||||
f"{len(cls.location_names):3} "
|
||||
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
|
||||
f"{max(cls.location_id_to_name):{numlength}})")
|
||||
|
||||
AutoWorld.call_stage(world, "assert_generate")
|
||||
|
||||
AutoWorld.call_all(world, "generate_early")
|
||||
|
||||
@@ -129,6 +125,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
if world.players > 1:
|
||||
for player in world.player_ids:
|
||||
locality_rules(world, player)
|
||||
group_locality_rules(world)
|
||||
else:
|
||||
world.non_local_items[1].value = set()
|
||||
world.local_items[1].value = set()
|
||||
@@ -137,9 +134,87 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
for player in world.player_ids:
|
||||
exclusion_rules(world, player, world.exclude_locations[player].value)
|
||||
world.priority_locations[player].value -= world.exclude_locations[player].value
|
||||
for location_name in world.priority_locations[player].value:
|
||||
world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
|
||||
|
||||
AutoWorld.call_all(world, "generate_basic")
|
||||
|
||||
# temporary home for item links, should be moved out of Main
|
||||
for group_id, group in world.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]):
|
||||
classifications = collections.defaultdict(int)
|
||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||
for item in world.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
counters[item.player][item.name] += 1
|
||||
classifications[item.name] |= item.classification
|
||||
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
players.remove(player)
|
||||
del(counters[player])
|
||||
|
||||
if not players:
|
||||
return None, None
|
||||
|
||||
for item in shared_pool:
|
||||
count = min(counters[player][item] for player in players)
|
||||
if count:
|
||||
for player in players:
|
||||
counters[player][item] = count
|
||||
else:
|
||||
for player in players:
|
||||
del(counters[player][item])
|
||||
return counters, classifications
|
||||
|
||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool = []
|
||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
# mangle together all original classification bits
|
||||
new_item.classification |= classifications[item_name]
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
|
||||
world.regions.append(region)
|
||||
locations = region.locations = []
|
||||
for item in world.itempool:
|
||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||
if count:
|
||||
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
|
||||
None, region)
|
||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||
state.has(item_name, group_id_, count_)
|
||||
|
||||
locations.append(loc)
|
||||
loc.place_locked_item(item)
|
||||
common_item_count[item.player][item.name] -= 1
|
||||
else:
|
||||
new_itempool.append(item)
|
||||
|
||||
itemcount = len(world.itempool)
|
||||
world.itempool = new_itempool
|
||||
|
||||
while itemcount > len(world.itempool):
|
||||
items_to_add = []
|
||||
for player in group["players"]:
|
||||
if group["replacement_items"][player]:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_item", player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_filler", player))
|
||||
world.random.shuffle(items_to_add)
|
||||
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
|
||||
|
||||
if any(world.item_links.values()):
|
||||
world._recache()
|
||||
world._all_state = None
|
||||
|
||||
logger.info("Running Item Plando")
|
||||
|
||||
for item in world.itempool:
|
||||
@@ -187,7 +262,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
# collect ER hint info
|
||||
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
||||
world.shuffle[player] != "vanilla" or world.retro[player]}
|
||||
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
|
||||
|
||||
for region in world.regions:
|
||||
if region.player in er_hint_data and region.locations:
|
||||
@@ -227,7 +302,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
||||
for index, take_any in enumerate(takeanyregions):
|
||||
for region in [world.get_region(take_any, player) for player in
|
||||
world.get_game_players("A Link to the Past") if world.retro[player]]:
|
||||
world.get_game_players("A Link to the Past") if world.retro_caves[player]]:
|
||||
item = world.create_item(
|
||||
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||
region.player)
|
||||
@@ -250,45 +325,61 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
import NetUtils
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
minimum_versions = {"server": (0, 1, 8), "clients": client_versions}
|
||||
games = {}
|
||||
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
||||
slot_info = {}
|
||||
names = [[name for player, name in sorted(world.player_name.items())]]
|
||||
for slot in world.player_ids:
|
||||
client_versions[slot] = world.worlds[slot].get_required_client_version()
|
||||
player_world: AutoWorld.World = world.worlds[slot]
|
||||
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
|
||||
client_versions[slot] = player_world.required_client_version
|
||||
games[slot] = world.game[slot]
|
||||
precollected_items = {player: [item.code for item in world_precollected]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
|
||||
world.player_types[slot])
|
||||
for slot, group in world.groups.items():
|
||||
games[slot] = world.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
|
||||
group_members=sorted(group["players"]))
|
||||
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
||||
for player, world_precollected in world.precollected_items.items()}
|
||||
precollected_hints = {player: set() for player in range(1, world.players + 1)}
|
||||
# for now special case Factorio tech_tree_information
|
||||
sending_visible_players = set()
|
||||
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
||||
|
||||
|
||||
for slot in world.player_ids:
|
||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||
if world.worlds[slot].sending_visible:
|
||||
sending_visible_players.add(slot)
|
||||
|
||||
def precollect_hint(location):
|
||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False)
|
||||
location.item.code, False, entrance, location.item.flags)
|
||||
precollected_hints[location.player].add(hint)
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
if location.item.player not in world.groups:
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
else:
|
||||
for player in world.groups[location.item.player]["players"]:
|
||||
precollected_hints[player].add(hint)
|
||||
|
||||
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
|
||||
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) == int:
|
||||
# item code None should be event, location.address should then also be None
|
||||
assert location.item.code is not None
|
||||
locations_data[location.player][location.address] = location.item.code, location.item.player
|
||||
if location.player in sending_visible_players:
|
||||
precollect_hint(location)
|
||||
elif location.name in world.start_location_hints[location.player]:
|
||||
assert location.item.code is not None, "item code None should be event, " \
|
||||
"location.address should then also be None. Location: " \
|
||||
f" {location}"
|
||||
locations_data[location.player][location.address] = \
|
||||
location.item.code, location.item.player, location.item.flags
|
||||
if location.name in world.start_location_hints[location.player]:
|
||||
precollect_hint(location)
|
||||
elif location.item.name in world.start_hints[location.item.player]:
|
||||
precollect_hint(location)
|
||||
elif any([location.item.name in world.start_hints[player]
|
||||
for player in world.groups.get(location.item.player, {}).get("players", [])]):
|
||||
precollect_hint(location)
|
||||
|
||||
multidata = {
|
||||
"slot_data": slot_data,
|
||||
"games": games,
|
||||
"names": [[name for player, name in sorted(world.player_name.items())]],
|
||||
"slot_info": slot_info,
|
||||
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||
"remote_items": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_items},
|
||||
@@ -310,7 +401,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
||||
|
||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||
f.write(bytes([1])) # version of format
|
||||
f.write(bytes([3])) # version of format
|
||||
f.write(multidata)
|
||||
|
||||
multidata_task = pool.submit(write_multidata)
|
||||
@@ -320,7 +411,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
|
||||
# retrieve exceptions via .result() if they occured.
|
||||
# retrieve exceptions via .result() if they occurred.
|
||||
multidata_task.result()
|
||||
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
|
||||
if i % 10 == 0 or i == len(output_file_futures):
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import argparse
|
||||
import os, sys
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import atexit
|
||||
import shutil
|
||||
from subprocess import Popen
|
||||
from shutil import copyfile
|
||||
from time import strftime
|
||||
@@ -10,12 +13,12 @@ import logging
|
||||
import requests
|
||||
|
||||
import Utils
|
||||
from Utils import is_windows
|
||||
|
||||
atexit.register(input, "Press enter to exit.")
|
||||
|
||||
# 1 or more digits followed by m or g, then optional b
|
||||
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
|
||||
forge_version = "1.17.1-37.0.109"
|
||||
|
||||
|
||||
def prompt_yes_no(prompt):
|
||||
@@ -31,16 +34,14 @@ def prompt_yes_no(prompt):
|
||||
print('Please respond with "y" or "n".')
|
||||
|
||||
|
||||
# Create mods folder if needed; find AP randomizer jar; return None if not found.
|
||||
def find_ap_randomizer_jar(forge_dir):
|
||||
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
|
||||
mods_dir = os.path.join(forge_dir, 'mods')
|
||||
if os.path.isdir(mods_dir):
|
||||
ap_mod_re = re.compile(r"^aprandomizer-[\d\.]+\.jar$")
|
||||
for entry in os.scandir(mods_dir):
|
||||
match = ap_mod_re.match(entry.name)
|
||||
if match:
|
||||
logging.info(f"Found AP randomizer mod: {match.group()}")
|
||||
return match.group()
|
||||
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
|
||||
logging.info(f"Found AP randomizer mod: {entry.name}")
|
||||
return entry.name
|
||||
return None
|
||||
else:
|
||||
os.mkdir(mods_dir)
|
||||
@@ -48,8 +49,8 @@ def find_ap_randomizer_jar(forge_dir):
|
||||
return None
|
||||
|
||||
|
||||
# Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory.
|
||||
def replace_apmc_files(forge_dir, apmc_file):
|
||||
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
|
||||
if apmc_file is None:
|
||||
return
|
||||
apdata_dir = os.path.join(forge_dir, 'APData')
|
||||
@@ -71,27 +72,21 @@ def replace_apmc_files(forge_dir, apmc_file):
|
||||
|
||||
def read_apmc_file(apmc_file):
|
||||
from base64 import b64decode
|
||||
import json
|
||||
|
||||
with open(apmc_file, 'r') as f:
|
||||
data = json.loads(b64decode(f.read()))
|
||||
return data
|
||||
return json.loads(b64decode(f.read()))
|
||||
|
||||
|
||||
# Check mod version, download new mod from GitHub releases page if needed.
|
||||
def update_mod(forge_dir, apmc_file, get_prereleases=False):
|
||||
def update_mod(forge_dir, minecraft_version: str, get_prereleases=False):
|
||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
||||
|
||||
if apmc_file is not None:
|
||||
data = read_apmc_file(apmc_file)
|
||||
minecraft_version = data.get('minecraft_version', '')
|
||||
|
||||
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
|
||||
resp = requests.get(client_releases_endpoint)
|
||||
if resp.status_code == 200: # OK
|
||||
try:
|
||||
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
|
||||
(apmc_file is None or minecraft_version in release['assets'][0]['name']),
|
||||
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
|
||||
(minecraft_version in release['assets'][0]['name']),
|
||||
resp.json()))
|
||||
if ap_randomizer != latest_release['assets'][0]['name']:
|
||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
||||
@@ -127,8 +122,8 @@ def update_mod(forge_dir, apmc_file, get_prereleases=False):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# Check if the EULA is agreed to, and prompt the user to read and agree if necessary.
|
||||
def check_eula(forge_dir):
|
||||
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
|
||||
eula_path = os.path.join(forge_dir, "eula.txt")
|
||||
if not os.path.isfile(eula_path):
|
||||
# Create eula.txt
|
||||
@@ -151,31 +146,39 @@ def check_eula(forge_dir):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# get the current JDK16
|
||||
def find_jdk_dir() -> str:
|
||||
def find_jdk_dir(version: str) -> str:
|
||||
"""get the specified versions jdk directory"""
|
||||
for entry in os.listdir():
|
||||
if os.path.isdir(entry) and entry.startswith("jdk16"):
|
||||
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
|
||||
return os.path.abspath(entry)
|
||||
|
||||
|
||||
# get the java exe location
|
||||
def find_jdk() -> str:
|
||||
jdk = find_jdk_dir()
|
||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
||||
if os.path.isfile(jdk_exe):
|
||||
def find_jdk(version: str) -> str:
|
||||
"""get the java exe location"""
|
||||
|
||||
if is_windows:
|
||||
jdk = find_jdk_dir(version)
|
||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
||||
if os.path.isfile(jdk_exe):
|
||||
return jdk_exe
|
||||
else:
|
||||
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
|
||||
if not jdk_exe:
|
||||
raise Exception("Could not find Java. Is Java installed on the system?")
|
||||
return jdk_exe
|
||||
|
||||
|
||||
# Download Corretto 16 (Amazon JDK)
|
||||
def download_java():
|
||||
jdk = find_jdk_dir()
|
||||
def download_java(java: str):
|
||||
"""Download Corretto (Amazon JDK)"""
|
||||
|
||||
jdk = find_jdk_dir(java)
|
||||
if jdk is not None:
|
||||
print(f"Removing old JDK...")
|
||||
from shutil import rmtree
|
||||
rmtree(jdk)
|
||||
|
||||
print(f"Downloading Java...")
|
||||
jdk_url = "https://corretto.aws/downloads/latest/amazon-corretto-16-x64-windows-jdk.zip"
|
||||
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
|
||||
resp = requests.get(jdk_url)
|
||||
if resp.status_code == 200: # OK
|
||||
print(f"Extracting...")
|
||||
@@ -190,10 +193,11 @@ def download_java():
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# download and install forge
|
||||
def install_forge(directory: str):
|
||||
jdk = find_jdk()
|
||||
if jdk is not None:
|
||||
def install_forge(directory: str, forge_version: str, java_version: str):
|
||||
"""download and install forge"""
|
||||
|
||||
java_exe = find_jdk(java_version)
|
||||
if java_exe is not None:
|
||||
print(f"Downloading Forge {forge_version}...")
|
||||
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
|
||||
resp = requests.get(forge_url)
|
||||
@@ -204,70 +208,144 @@ def install_forge(directory: str):
|
||||
with open(forge_install_jar, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
print(f"Installing Forge...")
|
||||
argstring = ' '.join([jdk, "-jar", "\"" + forge_install_jar+ "\"", "--installServer", "\"" + directory + "\""])
|
||||
install_process = Popen(argstring)
|
||||
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
|
||||
install_process.wait()
|
||||
os.remove(forge_install_jar)
|
||||
|
||||
|
||||
# Run the Forge server. Return process object
|
||||
def run_forge_server(forge_dir: str, heap_arg):
|
||||
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
|
||||
"""Run the Forge server."""
|
||||
|
||||
java_exe = find_jdk()
|
||||
java_exe = find_jdk(java_version)
|
||||
if not os.path.isfile(java_exe):
|
||||
java_exe = "java" # try to fall back on java in the PATH
|
||||
|
||||
heap_arg = max_heap_re.match(max_heap).group()
|
||||
heap_arg = max_heap_re.match(heap_arg).group()
|
||||
if heap_arg[-1] in ['b', 'B']:
|
||||
heap_arg = heap_arg[:-1]
|
||||
heap_arg = "-Xmx" + heap_arg
|
||||
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, "win_args.txt")
|
||||
win_args = []
|
||||
os_args = "win_args.txt" if is_windows else "unix_args.txt"
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
|
||||
forge_args = []
|
||||
with open(args_file) as argfile:
|
||||
for line in argfile:
|
||||
win_args.append(line.strip())
|
||||
forge_args.extend(line.strip().split(" "))
|
||||
|
||||
argstring = ' '.join([java_exe, heap_arg] + win_args + ["-nogui"])
|
||||
logging.info(f"Running Forge server: {argstring}")
|
||||
args = [java_exe, heap_arg, *forge_args, "-nogui"]
|
||||
logging.info(f"Running Forge server: {args}")
|
||||
os.chdir(forge_dir)
|
||||
return Popen(argstring)
|
||||
return Popen(args)
|
||||
|
||||
|
||||
def get_minecraft_versions(version, release_channel="release"):
|
||||
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
|
||||
resp = requests.get(version_file_endpoint)
|
||||
local = False
|
||||
if resp.status_code == 200: # OK
|
||||
try:
|
||||
data = resp.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
||||
local = True
|
||||
else:
|
||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
||||
local = True
|
||||
|
||||
if local:
|
||||
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
|
||||
json.dump(data, f)
|
||||
|
||||
try:
|
||||
if version:
|
||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
||||
else:
|
||||
return resp.json()[release_channel][0]
|
||||
except StopIteration:
|
||||
logging.error(f"No compatible mod version found for client version {version}.")
|
||||
|
||||
|
||||
def is_correct_forge(forge_dir) -> bool:
|
||||
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
Utils.init_logging("MinecraftClient")
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
|
||||
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
|
||||
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
|
||||
parser.add_argument('--prerelease', default=False, action='store_true',
|
||||
help="Auto-update prerelease versions.")
|
||||
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
|
||||
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
|
||||
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
|
||||
help="Specify release channel to use.")
|
||||
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
|
||||
help="specify java version.")
|
||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
||||
help="specify forge version. (Minecraft Version-Forge Version)")
|
||||
|
||||
args = parser.parse_args()
|
||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
||||
|
||||
# Change to executable's working directory
|
||||
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
|
||||
|
||||
|
||||
options = Utils.get_options()
|
||||
forge_dir = options["minecraft_options"]["forge_directory"]
|
||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
||||
apmc_data = None
|
||||
data_version = None
|
||||
|
||||
if apmc_file is None and not args.install:
|
||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
||||
|
||||
if apmc_file is not None:
|
||||
apmc_data = read_apmc_file(apmc_file)
|
||||
data_version = apmc_data.get('client_version', '')
|
||||
|
||||
versions = get_minecraft_versions(data_version, channel)
|
||||
|
||||
forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"])
|
||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||
forge_version = args.forge or versions["forge"]
|
||||
java_version = args.java or versions["java"]
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
|
||||
if args.install:
|
||||
print("Installing Java and Minecraft Forge")
|
||||
download_java()
|
||||
install_forge(forge_dir)
|
||||
if is_windows:
|
||||
print("Installing Java")
|
||||
download_java(java_version)
|
||||
if not is_correct_forge(forge_dir):
|
||||
print("Installing Minecraft Forge")
|
||||
install_forge(forge_dir, forge_version, java_version)
|
||||
else:
|
||||
print("Correct Forge version already found, skipping install.")
|
||||
sys.exit(0)
|
||||
|
||||
if apmc_file is not None and not os.path.isfile(apmc_file):
|
||||
raise FileNotFoundError(f"Path {apmc_file} does not exist or could not be accessed.")
|
||||
if not os.path.isdir(forge_dir):
|
||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
||||
if apmc_data is None:
|
||||
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
|
||||
|
||||
if is_windows:
|
||||
if java_dir is None or not os.path.isdir(java_dir):
|
||||
if prompt_yes_no("Did not find java directory. Download and install java now?"):
|
||||
download_java(java_version)
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
if java_dir is None or not os.path.isdir(java_dir):
|
||||
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
|
||||
|
||||
if not is_correct_forge(forge_dir):
|
||||
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
|
||||
install_forge(forge_dir, forge_version, java_version)
|
||||
if not os.path.isdir(forge_dir):
|
||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
||||
|
||||
if not max_heap_re.match(max_heap):
|
||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
||||
|
||||
update_mod(forge_dir, apmc_file, args.prerelease)
|
||||
update_mod(forge_dir, f"MC{forge_version.split('-')[0]}", channel != "release")
|
||||
replace_apmc_files(forge_dir, apmc_file)
|
||||
check_eula(forge_dir)
|
||||
server_process = run_forge_server(forge_dir, max_heap)
|
||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
||||
server_process.wait()
|
||||
|
||||
@@ -3,7 +3,8 @@ import sys
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
|
||||
requirements_files = {'requirements.txt'}
|
||||
local_dir = os.path.dirname(__file__)
|
||||
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
||||
|
||||
if sys.version_info < (3, 8, 6):
|
||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||
@@ -11,7 +12,7 @@ if sys.version_info < (3, 8, 6):
|
||||
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir("worlds"):
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
@@ -23,7 +24,7 @@ def update_command():
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
|
||||
|
||||
|
||||
def update(yes = False, force = False):
|
||||
def update(yes=False, force=False):
|
||||
global update_ran
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
@@ -38,9 +39,8 @@ def update(yes = False, force = False):
|
||||
for line in requirementsfile:
|
||||
if line.startswith('https://'):
|
||||
# extract name and version from url
|
||||
url = line.split(';')[0]
|
||||
wheel = line.split('/')[-1]
|
||||
name, version, _ = wheel.split('-',2)
|
||||
name, version, _ = wheel.split('-', 2)
|
||||
line = f'{name}=={version}'
|
||||
requirements = pkg_resources.parse_requirements(line)
|
||||
for requirement in requirements:
|
||||
@@ -58,9 +58,13 @@ def update(yes = False, force = False):
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Install archipelago requirements')
|
||||
parser.add_argument('-y', '--yes', dest='yes', action='store_true', help='answer "yes" to all questions')
|
||||
parser.add_argument('-f', '--force', dest='force', action='store_true', help='force update')
|
||||
parser.add_argument('-a', '--append', nargs="*", dest='additional_requirements',
|
||||
help='List paths to additional requirement files.')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.additional_requirements:
|
||||
requirements_files.update(args.additional_requirements)
|
||||
update(args.yes, args.force)
|
||||
|
||||
631
MultiServer.py
83
NetUtils.py
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import typing
|
||||
import enum
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
@@ -17,6 +16,8 @@ class JSONMessagePart(typing.TypedDict, total=False):
|
||||
color: str
|
||||
# owning player for location/item
|
||||
player: int
|
||||
# if type == item indicates item flags
|
||||
flags: int
|
||||
|
||||
|
||||
class ClientStatus(enum.IntEnum):
|
||||
@@ -27,7 +28,18 @@ class ClientStatus(enum.IntEnum):
|
||||
CLIENT_GOAL = 30
|
||||
|
||||
|
||||
class Permission(enum.IntEnum):
|
||||
class SlotType(enum.IntFlag):
|
||||
spectator = 0b00
|
||||
player = 0b01
|
||||
group = 0b10
|
||||
|
||||
@property
|
||||
def always_goal(self) -> bool:
|
||||
"""Mark this slot has having reached its goal instantly."""
|
||||
return self.value != 0b01
|
||||
|
||||
|
||||
class Permission(enum.IntFlag):
|
||||
disabled = 0b000 # 0, completely disables access
|
||||
enabled = 0b001 # 1, allows manual use
|
||||
goal = 0b010 # 2, allows manual use after goal completion
|
||||
@@ -47,16 +59,26 @@ class Permission(enum.IntEnum):
|
||||
|
||||
|
||||
class NetworkPlayer(typing.NamedTuple):
|
||||
"""Represents a particular player on a particular team."""
|
||||
team: int
|
||||
slot: int
|
||||
alias: str
|
||||
name: str
|
||||
|
||||
|
||||
class NetworkSlot(typing.NamedTuple):
|
||||
"""Represents a particular slot across teams."""
|
||||
name: str
|
||||
game: str
|
||||
type: SlotType
|
||||
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
|
||||
|
||||
|
||||
class NetworkItem(typing.NamedTuple):
|
||||
item: int
|
||||
location: int
|
||||
player: int
|
||||
flags: int = 0
|
||||
|
||||
|
||||
def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
@@ -74,6 +96,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
_encode = JSONEncoder(
|
||||
ensure_ascii=False,
|
||||
check_circular=False,
|
||||
separators=(',', ':'),
|
||||
).encode
|
||||
|
||||
|
||||
@@ -86,9 +109,11 @@ def get_any_version(data: dict) -> Version:
|
||||
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
||||
|
||||
|
||||
whitelist = {"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
}
|
||||
whitelist = {
|
||||
"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
"NetworkSlot": NetworkSlot
|
||||
}
|
||||
|
||||
custom_hooks = {
|
||||
"Version": get_any_version
|
||||
@@ -119,9 +144,6 @@ class Endpoint:
|
||||
def __init__(self, socket):
|
||||
self.socket = socket
|
||||
|
||||
async def disconnect(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class HandlerMeta(type):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
@@ -163,6 +185,21 @@ class JSONTypes(str, enum.Enum):
|
||||
|
||||
|
||||
class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
color_codes = {
|
||||
# not exact color names, close enough but decent looking
|
||||
"black": "000000",
|
||||
"red": "EE0000",
|
||||
"green": "00FF7F",
|
||||
"yellow": "FAFAD2",
|
||||
"blue": "6495ED",
|
||||
"magenta": "EE00EE",
|
||||
"cyan": "00EEEE",
|
||||
"slateblue": "6D8BE8",
|
||||
"plum": "AF99EF",
|
||||
"salmon": "FA8072",
|
||||
"white": "FFFFFF"
|
||||
}
|
||||
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
|
||||
@@ -176,7 +213,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
codes = node["color"].split(";")
|
||||
buffer = "".join(color_code(code) for code in codes)
|
||||
buffer = "".join(color_code(code) for code in codes if code in color_codes)
|
||||
return buffer + self._handle_text(node) + color_code("reset")
|
||||
|
||||
def _handle_text(self, node: JSONMessagePart):
|
||||
@@ -194,12 +231,22 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_item_name(self, node: JSONMessagePart):
|
||||
node["color"] = 'cyan'
|
||||
flags = node.get("flags", 0)
|
||||
if flags == 0:
|
||||
node["color"] = 'cyan'
|
||||
elif flags & 0b001: # advancement
|
||||
node["color"] = 'plum'
|
||||
elif flags & 0b010: # useful
|
||||
node["color"] = 'slateblue'
|
||||
elif flags & 0b100: # trap
|
||||
node["color"] = 'salmon'
|
||||
else:
|
||||
node["color"] = 'cyan'
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_item_id(self, node: JSONMessagePart):
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.item_name_getter(item_id)
|
||||
node["text"] = self.ctx.item_names[item_id]
|
||||
return self._handle_item_name(node)
|
||||
|
||||
def _handle_location_name(self, node: JSONMessagePart):
|
||||
@@ -208,7 +255,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
|
||||
def _handle_location_id(self, node: JSONMessagePart):
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.location_name_getter(item_id)
|
||||
node["text"] = self.ctx.location_names[item_id]
|
||||
return self._handle_location_name(node)
|
||||
|
||||
def _handle_entrance_name(self, node: JSONMessagePart):
|
||||
@@ -238,8 +285,8 @@ def add_json_text(parts: list, text: typing.Any, **kwargs) -> None:
|
||||
parts.append({"text": str(text), **kwargs})
|
||||
|
||||
|
||||
def add_json_item(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
|
||||
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.item_id, **kwargs})
|
||||
def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int = 0, **kwargs) -> None:
|
||||
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
|
||||
|
||||
|
||||
def add_json_location(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
|
||||
@@ -253,13 +300,15 @@ class Hint(typing.NamedTuple):
|
||||
item: int
|
||||
found: bool
|
||||
entrance: str = ""
|
||||
item_flags: int = 0
|
||||
|
||||
def re_check(self, ctx, team) -> Hint:
|
||||
if self.found:
|
||||
return self
|
||||
found = self.location in ctx.location_checks[team, self.finding_player]
|
||||
if found:
|
||||
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance)
|
||||
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
|
||||
self.item_flags)
|
||||
return self
|
||||
|
||||
def __hash__(self):
|
||||
@@ -270,7 +319,7 @@ class Hint(typing.NamedTuple):
|
||||
add_json_text(parts, "[Hint]: ")
|
||||
add_json_text(parts, self.receiving_player, type="player_id")
|
||||
add_json_text(parts, "'s ")
|
||||
add_json_item(parts, self.item, self.receiving_player)
|
||||
add_json_item(parts, self.item, self.receiving_player, self.item_flags)
|
||||
add_json_text(parts, " is at ")
|
||||
add_json_location(parts, self.location, self.finding_player)
|
||||
add_json_text(parts, " in ")
|
||||
@@ -288,7 +337,7 @@ class Hint(typing.NamedTuple):
|
||||
|
||||
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
||||
"receiving": self.receiving_player,
|
||||
"item": NetworkItem(self.item, self.location, self.finding_player),
|
||||
"item": NetworkItem(self.item, self.location, self.finding_player, self.item_flags),
|
||||
"found": self.found}
|
||||
|
||||
@property
|
||||
|
||||
@@ -12,6 +12,7 @@ from worlds.oot.Cosmetics import patch_cosmetics
|
||||
from worlds.oot.Options import cosmetic_options, sfx_options
|
||||
from worlds.oot.Rom import Rom, compress_rom_file
|
||||
from worlds.oot.N64Patch import apply_patch_file
|
||||
from worlds.oot.Utils import data_path
|
||||
from Utils import local_path
|
||||
|
||||
logger = logging.getLogger('OoTAdjuster')
|
||||
@@ -86,7 +87,7 @@ def adjustGUI():
|
||||
option = sfx_options[option_name]
|
||||
optionFrame = Frame(romSettingsFrame)
|
||||
optionFrame.grid(row=row, column=column, sticky=E)
|
||||
optionLabel = Label(optionFrame, text=option.displayname)
|
||||
optionLabel = Label(optionFrame, text=option.display_name)
|
||||
optionLabel.pack(side=LEFT)
|
||||
setattr(opts, option_name, StringVar())
|
||||
getattr(opts, option_name).set(option.name_lookup[option.default])
|
||||
@@ -142,7 +143,7 @@ def adjustGUI():
|
||||
option = cosmetic_options['sword_trail_duration']
|
||||
optionFrame = Frame(romSettingsFrame)
|
||||
optionFrame.grid(row=8, column=2, sticky=E)
|
||||
optionLabel = Label(optionFrame, text=option.displayname)
|
||||
optionLabel = Label(optionFrame, text=option.display_name)
|
||||
optionLabel.pack(side=LEFT)
|
||||
setattr(opts, 'sword_trail_duration', StringVar())
|
||||
getattr(opts, 'sword_trail_duration').set(option.default)
|
||||
@@ -211,9 +212,11 @@ def adjust(args):
|
||||
ootworld.logic_rules = 'glitched' if args.is_glitched else 'glitchless'
|
||||
ootworld.death_link = args.deathlink
|
||||
|
||||
delete_zootdec = False
|
||||
if os.path.splitext(args.rom)[-1] in ['.z64', '.n64']:
|
||||
# Load up the ROM
|
||||
rom = Rom(file=args.rom, force_use=True)
|
||||
delete_zootdec = True
|
||||
elif os.path.splitext(args.rom)[-1] == '.apz5':
|
||||
# Load vanilla ROM
|
||||
rom = Rom(file=args.vanilla_rom, force_use=True)
|
||||
@@ -222,15 +225,21 @@ def adjust(args):
|
||||
else:
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
|
||||
# Call patch_cosmetics
|
||||
patch_cosmetics(ootworld, rom)
|
||||
rom.write_byte(rom.sym('DEATH_LINK'), args.deathlink)
|
||||
# Output new file
|
||||
path_pieces = os.path.splitext(args.rom)
|
||||
decomp_path = path_pieces[0] + '-adjusted-decomp.n64'
|
||||
comp_path = path_pieces[0] + '-adjusted.n64'
|
||||
rom.write_to_file(decomp_path)
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
os.remove(decomp_path)
|
||||
try:
|
||||
patch_cosmetics(ootworld, rom)
|
||||
rom.write_byte(rom.sym('DEATH_LINK'), args.deathlink)
|
||||
# Output new file
|
||||
path_pieces = os.path.splitext(args.rom)
|
||||
decomp_path = path_pieces[0] + '-adjusted-decomp.n64'
|
||||
comp_path = path_pieces[0] + '-adjusted.n64'
|
||||
rom.write_to_file(decomp_path)
|
||||
os.chdir(data_path("Compress"))
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
os.remove(decomp_path)
|
||||
finally:
|
||||
if delete_zootdec:
|
||||
os.chdir(os.path.split(__file__)[0])
|
||||
os.remove("ZOOTDEC.z64")
|
||||
return comp_path
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
307
OoTClient.py
Normal file
@@ -0,0 +1,307 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
import Utils
|
||||
from worlds import network_data_package
|
||||
from worlds.oot.Rom import Rom, compress_rom_file
|
||||
from worlds.oot.N64Patch import apply_patch_file
|
||||
from worlds.oot.Utils import data_path
|
||||
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
"""
|
||||
Payload: lua -> client
|
||||
{
|
||||
playerName: string,
|
||||
locations: dict,
|
||||
deathlinkActive: bool,
|
||||
isDead: bool,
|
||||
gameComplete: bool
|
||||
}
|
||||
|
||||
Payload: client -> lua
|
||||
{
|
||||
items: list,
|
||||
playerNames: list,
|
||||
triggerDeath: bool
|
||||
}
|
||||
|
||||
Deathlink logic:
|
||||
"Dead" is true <-> Link is at 0 hp.
|
||||
|
||||
deathlink_pending: we need to kill the player
|
||||
deathlink_sent_this_death: we interacted with the multiworld on this death, waiting to reset with living link
|
||||
|
||||
"""
|
||||
|
||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||
|
||||
script_version: int = 2
|
||||
|
||||
def get_item_value(ap_id):
|
||||
return ap_id - 66000
|
||||
|
||||
|
||||
class OoTCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_n64(self):
|
||||
"""Check N64 Connection State"""
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
logger.info(f"N64 Status: {self.ctx.n64_status}")
|
||||
|
||||
def _cmd_deathlink(self):
|
||||
"""Toggle deathlink from client. Overrides default setting."""
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
self.ctx.deathlink_client_override = True
|
||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||
asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||
|
||||
|
||||
class OoTContext(CommonContext):
|
||||
command_processor = OoTCommandProcessor
|
||||
items_handling = 0b001 # full local
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.game = 'Ocarina of Time'
|
||||
self.n64_streams: (StreamReader, StreamWriter) = None
|
||||
self.n64_sync_task = None
|
||||
self.n64_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.location_table = {}
|
||||
self.deathlink_enabled = False
|
||||
self.deathlink_pending = False
|
||||
self.deathlink_sent_this_death = False
|
||||
self.deathlink_client_override = False
|
||||
self.version_warning = False
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(OoTContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to Bizhawk to get player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
self.deathlink_pending = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class OoTManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Ocarina of Time Client"
|
||||
|
||||
self.ui = OoTManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
def get_payload(ctx: OoTContext):
|
||||
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
||||
trigger_death = True
|
||||
ctx.deathlink_sent_this_death = True
|
||||
else:
|
||||
trigger_death = False
|
||||
|
||||
return json.dumps({
|
||||
"items": [get_item_value(item.item) for item in ctx.items_received],
|
||||
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
|
||||
"triggerDeath": trigger_death
|
||||
})
|
||||
|
||||
|
||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
|
||||
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
||||
await ctx.update_death_link(True)
|
||||
ctx.deathlink_enabled = True
|
||||
|
||||
# Game completion handling
|
||||
if payload['gameComplete'] and not ctx.finished_game:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "StatusUpdate",
|
||||
"status": 30
|
||||
}])
|
||||
ctx.finished_game = True
|
||||
|
||||
# Locations handling
|
||||
if ctx.location_table != payload['locations']:
|
||||
ctx.location_table = payload['locations']
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
|
||||
}])
|
||||
|
||||
# Deathlink handling
|
||||
if ctx.deathlink_enabled:
|
||||
if payload['isDead']: # link is dead
|
||||
ctx.deathlink_pending = False
|
||||
if not ctx.deathlink_sent_this_death:
|
||||
ctx.deathlink_sent_this_death = True
|
||||
await ctx.send_death()
|
||||
else: # link is alive
|
||||
ctx.deathlink_sent_this_death = False
|
||||
|
||||
|
||||
async def n64_sync_task(ctx: OoTContext):
|
||||
logger.info("Starting n64 connector. Use /n64 for status information.")
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.n64_streams:
|
||||
(reader, writer) = ctx.n64_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to six fields:
|
||||
# 1. str: player name (always)
|
||||
# 2. int: script version (always)
|
||||
# 3. bool: deathlink active (always)
|
||||
# 4. dict[str, bool]: checked locations
|
||||
# 5. bool: whether Link is currently at 0 HP
|
||||
# 6. bool: whether the game currently registers as complete
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
reported_version = data_decoded.get('scriptVersion', 0)
|
||||
if reported_version >= script_version:
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = data_decoded['playerName']
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
else:
|
||||
if not ctx.version_warning:
|
||||
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}. "
|
||||
"Please update to the latest version. "
|
||||
"Your connection to the Archipelago server will not be accepted.")
|
||||
ctx.version_warning = True
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
if ctx.n64_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to N64")
|
||||
ctx.n64_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.n64_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.n64_status = error_status
|
||||
logger.info("Lost connection to N64 and attempting to reconnect. Use /n64 for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to N64")
|
||||
ctx.n64_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28921), timeout=10)
|
||||
ctx.n64_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.n64_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def patch_and_run_game(apz5_file):
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
decomp_path = base_name + '-decomp.z64'
|
||||
comp_path = base_name + '.z64'
|
||||
# Load vanilla ROM, patch file, compress ROM
|
||||
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
|
||||
apply_patch_file(rom, apz5_file)
|
||||
rom.write_to_file(decomp_path)
|
||||
os.chdir(data_path("Compress"))
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
os.remove(decomp_path)
|
||||
asyncio.create_task(run_game(comp_path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Utils.init_logging("OoTClient")
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('apz5_file', default="", type=str, nargs="?",
|
||||
help='Path to an APZ5 file')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.apz5_file:
|
||||
logger.info("APZ5 file supplied, beginning patching process...")
|
||||
asyncio.create_task(patch_and_run_game(args.apz5_file))
|
||||
|
||||
ctx = OoTContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
ctx.n64_sync_task = asyncio.create_task(n64_sync_task(ctx), name="N64 Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.n64_sync_task:
|
||||
await ctx.n64_sync_task
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
553
Options.py
@@ -1,9 +1,15 @@
|
||||
from __future__ import annotations
|
||||
import abc
|
||||
import math
|
||||
import numbers
|
||||
import typing
|
||||
import random
|
||||
|
||||
from schema import Schema, And, Or, Optional
|
||||
from Utils import get_fuzzy_results
|
||||
|
||||
class AssembleOptions(type):
|
||||
|
||||
class AssembleOptions(abc.ABCMeta):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
options = attrs["options"] = {}
|
||||
name_lookup = attrs["name_lookup"] = {}
|
||||
@@ -14,44 +20,71 @@ class AssembleOptions(type):
|
||||
name_lookup.update(base.name_lookup)
|
||||
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("option_")}
|
||||
if "random" in new_options:
|
||||
raise Exception("Choice option 'random' cannot be manually assigned.")
|
||||
|
||||
assert "random" not in new_options, "Choice option 'random' cannot be manually assigned."
|
||||
assert len(new_options) == len(set(new_options.values())), "same ID cannot be used twice. Try alias?"
|
||||
|
||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||
options.update(new_options)
|
||||
|
||||
# apply aliases, without name_lookup
|
||||
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("alias_")})
|
||||
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("alias_")}
|
||||
|
||||
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
||||
|
||||
options.update(aliases)
|
||||
|
||||
# auto-validate schema on __init__
|
||||
if "schema" in attrs.keys():
|
||||
def validate_decorator(func):
|
||||
def validate(self, *args, **kwargs):
|
||||
func(self, *args, **kwargs)
|
||||
|
||||
if "__init__" in attrs:
|
||||
def validate_decorator(func):
|
||||
def validate(self, *args, **kwargs):
|
||||
ret = func(self, *args, **kwargs)
|
||||
self.value = self.schema.validate(self.value)
|
||||
return ret
|
||||
|
||||
return validate
|
||||
|
||||
attrs["__init__"] = validate_decorator(attrs["__init__"])
|
||||
else:
|
||||
# construct an __init__ that calls parent __init__
|
||||
|
||||
cls = super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
def meta__init__(self, *args, **kwargs):
|
||||
super(cls, self).__init__(*args, **kwargs)
|
||||
self.value = self.schema.validate(self.value)
|
||||
|
||||
return validate
|
||||
cls.__init__ = meta__init__
|
||||
return cls
|
||||
|
||||
attrs["__init__"] = validate_decorator(attrs["__init__"])
|
||||
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class Option(metaclass=AssembleOptions):
|
||||
value: int
|
||||
name_lookup: typing.Dict[int, str]
|
||||
T = typing.TypeVar('T')
|
||||
|
||||
|
||||
class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
value: T
|
||||
default = 0
|
||||
|
||||
# convert option_name_long into Name Long as displayname, otherwise name_long is the result.
|
||||
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
|
||||
# Handled in get_option_name()
|
||||
autodisplayname = False
|
||||
auto_display_name = False
|
||||
|
||||
# can be weighted between selections
|
||||
supports_weighting = True
|
||||
|
||||
# filled by AssembleOptions:
|
||||
name_lookup: typing.Dict[int, str]
|
||||
options: typing.Dict[str, int]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.get_current_option_name()})"
|
||||
|
||||
def __hash__(self):
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.value)
|
||||
|
||||
@property
|
||||
@@ -63,35 +96,199 @@ class Option(metaclass=AssembleOptions):
|
||||
return self.get_option_name(self.value)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: typing.Any) -> str:
|
||||
if cls.autodisplayname:
|
||||
def get_option_name(cls, value: T) -> str:
|
||||
if cls.auto_display_name:
|
||||
return cls.name_lookup[value].replace("_", " ").title()
|
||||
else:
|
||||
return cls.name_lookup[value]
|
||||
|
||||
def __int__(self) -> int:
|
||||
def __int__(self) -> T:
|
||||
return self.value
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.value)
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
def from_any(cls, data: typing.Any) -> Option[T]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Toggle(Option):
|
||||
class NumericOption(Option[int], numbers.Integral):
|
||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||
# `int` is not a `numbers.Integral` according to the official typestubs
|
||||
# (even though isinstance(5, numbers.Integral) == True)
|
||||
# https://github.com/python/typing/issues/272
|
||||
# https://github.com/python/mypy/issues/3186
|
||||
# https://github.com/microsoft/pyright/issues/1575
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value == other.value
|
||||
else:
|
||||
return typing.cast(bool, self.value == other)
|
||||
|
||||
def __lt__(self, other: typing.Union[int, NumericOption]) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value < other.value
|
||||
else:
|
||||
return self.value < other
|
||||
|
||||
def __le__(self, other: typing.Union[int, NumericOption]) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value <= other.value
|
||||
else:
|
||||
return self.value <= other
|
||||
|
||||
def __gt__(self, other: typing.Union[int, NumericOption]) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value > other.value
|
||||
else:
|
||||
return self.value > other
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.value)
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
def __mul__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value * other.value
|
||||
else:
|
||||
return self.value * other
|
||||
|
||||
def __rmul__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return other.value * self.value
|
||||
else:
|
||||
return other * self.value
|
||||
|
||||
def __sub__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value - other.value
|
||||
else:
|
||||
return self.value - other
|
||||
|
||||
def __rsub__(self, left: typing.Any) -> typing.Any:
|
||||
if isinstance(left, NumericOption):
|
||||
return left.value - self.value
|
||||
else:
|
||||
return left - self.value
|
||||
|
||||
def __add__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value + other.value
|
||||
else:
|
||||
return self.value + other
|
||||
|
||||
def __radd__(self, left: typing.Any) -> typing.Any:
|
||||
if isinstance(left, NumericOption):
|
||||
return left.value + self.value
|
||||
else:
|
||||
return left + self.value
|
||||
|
||||
def __truediv__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value / other.value
|
||||
else:
|
||||
return self.value / other
|
||||
|
||||
def __rtruediv__(self, left: typing.Any) -> typing.Any:
|
||||
if isinstance(left, NumericOption):
|
||||
return left.value / self.value
|
||||
else:
|
||||
return left / self.value
|
||||
|
||||
def __abs__(self) -> typing.Any:
|
||||
return abs(self.value)
|
||||
|
||||
def __and__(self, other: typing.Any) -> int:
|
||||
return self.value & int(other)
|
||||
|
||||
def __ceil__(self) -> int:
|
||||
return math.ceil(self.value)
|
||||
|
||||
def __floor__(self) -> int:
|
||||
return math.floor(self.value)
|
||||
|
||||
def __floordiv__(self, other: typing.Any) -> int:
|
||||
return self.value // int(other)
|
||||
|
||||
def __invert__(self) -> int:
|
||||
return ~(self.value)
|
||||
|
||||
def __lshift__(self, other: typing.Any) -> int:
|
||||
return self.value << int(other)
|
||||
|
||||
def __mod__(self, other: typing.Any) -> int:
|
||||
return self.value % int(other)
|
||||
|
||||
def __neg__(self) -> int:
|
||||
return -(self.value)
|
||||
|
||||
def __or__(self, other: typing.Any) -> int:
|
||||
return self.value | int(other)
|
||||
|
||||
def __pos__(self) -> int:
|
||||
return +(self.value)
|
||||
|
||||
def __pow__(self, exponent: numbers.Complex, modulus: typing.Optional[numbers.Integral] = None) -> int:
|
||||
if not (modulus is None):
|
||||
assert isinstance(exponent, numbers.Integral)
|
||||
return pow(self.value, exponent, modulus) # type: ignore
|
||||
return self.value ** exponent # type: ignore
|
||||
|
||||
def __rand__(self, other: typing.Any) -> int:
|
||||
return int(other) & self.value
|
||||
|
||||
def __rfloordiv__(self, other: typing.Any) -> int:
|
||||
return int(other) // self.value
|
||||
|
||||
def __rlshift__(self, other: typing.Any) -> int:
|
||||
return int(other) << self.value
|
||||
|
||||
def __rmod__(self, other: typing.Any) -> int:
|
||||
return int(other) % self.value
|
||||
|
||||
def __ror__(self, other: typing.Any) -> int:
|
||||
return int(other) | self.value
|
||||
|
||||
def __round__(self, ndigits: typing.Optional[int] = None) -> int:
|
||||
return round(self.value, ndigits)
|
||||
|
||||
def __rpow__(self, base: typing.Any) -> typing.Any:
|
||||
return base ** self.value
|
||||
|
||||
def __rrshift__(self, other: typing.Any) -> int:
|
||||
return int(other) >> self.value
|
||||
|
||||
def __rshift__(self, other: typing.Any) -> int:
|
||||
return self.value >> int(other)
|
||||
|
||||
def __rxor__(self, other: typing.Any) -> int:
|
||||
return int(other) ^ self.value
|
||||
|
||||
def __trunc__(self) -> int:
|
||||
return math.trunc(self.value)
|
||||
|
||||
def __xor__(self, other: typing.Any) -> int:
|
||||
return self.value ^ int(other)
|
||||
|
||||
|
||||
class Toggle(NumericOption):
|
||||
option_false = 0
|
||||
option_true = 1
|
||||
default = 0
|
||||
|
||||
def __init__(self, value: int):
|
||||
assert value == 0 or value == 1
|
||||
assert value == 0 or value == 1, "value of Toggle can only be 0 or 1"
|
||||
self.value = value
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> Toggle:
|
||||
if text.lower() in {"off", "0", "false", "none", "null", "no"}:
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
elif text.lower() in {"off", "0", "false", "none", "null", "no"}:
|
||||
return cls(0)
|
||||
else:
|
||||
return cls(1)
|
||||
@@ -103,35 +300,19 @@ class Toggle(Option):
|
||||
else:
|
||||
return cls(data)
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, Toggle):
|
||||
return self.value == other.value
|
||||
else:
|
||||
return self.value == other
|
||||
|
||||
def __gt__(self, other):
|
||||
if isinstance(other, Toggle):
|
||||
return self.value > other.value
|
||||
else:
|
||||
return self.value > other
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.value)
|
||||
|
||||
def __int__(self):
|
||||
return int(self.value)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
return ["No", "Yes"][int(value)]
|
||||
|
||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||
|
||||
|
||||
class DefaultOnToggle(Toggle):
|
||||
default = 1
|
||||
|
||||
|
||||
class Choice(Option):
|
||||
autodisplayname = True
|
||||
class Choice(NumericOption):
|
||||
auto_display_name = True
|
||||
|
||||
def __init__(self, value: int):
|
||||
self.value: int = value
|
||||
@@ -141,8 +322,8 @@ class Choice(Option):
|
||||
text = text.lower()
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
for optionname, value in cls.options.items():
|
||||
if optionname == text:
|
||||
for option_name, value in cls.options.items():
|
||||
if option_name == text:
|
||||
return cls(value)
|
||||
raise KeyError(
|
||||
f'Could not find option "{text}" for "{cls.__name__}", '
|
||||
@@ -158,10 +339,10 @@ class Choice(Option):
|
||||
if isinstance(other, self.__class__):
|
||||
return other.value == self.value
|
||||
elif isinstance(other, str):
|
||||
assert other in self.options
|
||||
assert other in self.options, f"compared against a str that could never be equal. {self} == {other}"
|
||||
return other == self.current_key
|
||||
elif isinstance(other, int):
|
||||
assert other in self.name_lookup
|
||||
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
|
||||
return other == self.value
|
||||
elif isinstance(other, bool):
|
||||
return other == bool(self.value)
|
||||
@@ -172,10 +353,10 @@ class Choice(Option):
|
||||
if isinstance(other, self.__class__):
|
||||
return other.value != self.value
|
||||
elif isinstance(other, str):
|
||||
assert other in self.options
|
||||
assert other in self.options, f"compared against a str that could never be equal. {self} != {other}"
|
||||
return other != self.current_key
|
||||
elif isinstance(other, int):
|
||||
assert other in self.name_lookup
|
||||
assert other in self.name_lookup, f"compared against am int that could never be equal. {self} != {other}"
|
||||
return other != self.value
|
||||
elif isinstance(other, bool):
|
||||
return other != bool(self.value)
|
||||
@@ -184,8 +365,10 @@ class Choice(Option):
|
||||
else:
|
||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||
|
||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||
|
||||
class Range(Option, int):
|
||||
|
||||
class Range(NumericOption):
|
||||
range_start = 0
|
||||
range_end = 1
|
||||
|
||||
@@ -200,50 +383,155 @@ class Range(Option, int):
|
||||
def from_text(cls, text: str) -> Range:
|
||||
text = text.lower()
|
||||
if text.startswith("random"):
|
||||
if text == "random-low":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_start), 0)))
|
||||
elif text == "random-high":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_end), 0)))
|
||||
elif text == "random-middle":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end), 0)))
|
||||
else:
|
||||
return cls(random.randint(cls.range_start, cls.range_end))
|
||||
return cls.weighted_range(text)
|
||||
elif text == "default" and hasattr(cls, "default"):
|
||||
return cls(cls.default)
|
||||
elif text == "high":
|
||||
return cls(cls.range_end)
|
||||
elif text == "low":
|
||||
return cls(cls.range_start)
|
||||
elif cls.range_start == 0 \
|
||||
and hasattr(cls, "default") \
|
||||
and cls.default != 0 \
|
||||
and text in ("true", "false"):
|
||||
# these are the conditions where "true" and "false" make sense
|
||||
if text == "true":
|
||||
return cls(cls.default)
|
||||
else: # "false"
|
||||
return cls(0)
|
||||
return cls(int(text))
|
||||
|
||||
@classmethod
|
||||
def weighted_range(cls, text) -> Range:
|
||||
if text == "random-low":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
|
||||
elif text == "random-high":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
|
||||
elif text == "random-middle":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||
elif text.startswith("random-range-"):
|
||||
return cls.custom_range(text)
|
||||
elif text == "random":
|
||||
return cls(random.randint(cls.range_start, cls.range_end))
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||
f"Acceptable values are: random, random-high, random-middle, random-low, "
|
||||
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
|
||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
|
||||
@classmethod
|
||||
def custom_range(cls, text) -> Range:
|
||||
textsplit = text.split("-")
|
||||
try:
|
||||
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
||||
random_range.sort()
|
||||
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
|
||||
raise Exception(
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
if text.startswith("random-range-low"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
|
||||
elif text.startswith("random-range-middle"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||
elif text.startswith("random-range-high"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
|
||||
else:
|
||||
return cls(random.randint(random_range[0], random_range[1]))
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any) -> Range:
|
||||
if type(data) == int:
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
@classmethod
|
||||
def get_option_name(cls, value: int) -> str:
|
||||
return str(value)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
|
||||
return int(round(random.triangular(lower, end, tri), 0))
|
||||
|
||||
class OptionNameSet(Option):
|
||||
default = frozenset()
|
||||
|
||||
def __init__(self, value: typing.Set[str]):
|
||||
self.value: typing.Set[str] = value
|
||||
class SpecialRange(Range):
|
||||
special_range_cutoff = 0
|
||||
special_range_names: typing.Dict[str, int] = {}
|
||||
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> OptionNameSet:
|
||||
return cls({option.strip() for option in text.split(",")})
|
||||
def from_text(cls, text: str) -> Range:
|
||||
text = text.lower()
|
||||
if text in cls.special_range_names:
|
||||
return cls(cls.special_range_names[text])
|
||||
return super().from_text(text)
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any) -> OptionNameSet:
|
||||
if type(data) == set:
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
def weighted_range(cls, text) -> Range:
|
||||
if text == "random-low":
|
||||
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
|
||||
elif text == "random-high":
|
||||
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
|
||||
elif text == "random-middle":
|
||||
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
|
||||
elif text.startswith("random-range-"):
|
||||
return cls.custom_range(text)
|
||||
elif text == "random":
|
||||
return cls(random.randint(cls.special_range_cutoff, cls.range_end))
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||
f"Acceptable values are: random, random-high, random-middle, random-low, "
|
||||
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
|
||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
|
||||
|
||||
class OptionDict(Option):
|
||||
class VerifyKeys:
|
||||
valid_keys = frozenset()
|
||||
valid_keys_casefold: bool = False
|
||||
convert_name_groups: bool = False
|
||||
verify_item_name: bool = False
|
||||
verify_location_name: bool = False
|
||||
value: typing.Any
|
||||
|
||||
@classmethod
|
||||
def verify_keys(cls, data):
|
||||
if cls.valid_keys:
|
||||
data = set(data)
|
||||
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
||||
extra = dataset - cls.valid_keys
|
||||
if extra:
|
||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||
f"Allowed keys: {cls.valid_keys}.")
|
||||
|
||||
def verify(self, world):
|
||||
if self.convert_name_groups and self.verify_item_name:
|
||||
new_value = type(self.value)() # empty container of whatever value is
|
||||
for item_name in self.value:
|
||||
new_value |= world.item_name_groups.get(item_name, {item_name})
|
||||
self.value = new_value
|
||||
if self.verify_item_name:
|
||||
for item_name in self.value:
|
||||
if item_name not in world.item_names:
|
||||
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
||||
raise Exception(f"Item {item_name} from option {self} "
|
||||
f"is not a valid item name from {world.game}. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||
elif self.verify_location_name:
|
||||
for location_name in self.value:
|
||||
if location_name not in world.location_names:
|
||||
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
|
||||
raise Exception(f"Location {location_name} from option {self} "
|
||||
f"is not a valid location name from {world.game}. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||
|
||||
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||
default = {}
|
||||
supports_weighting = False
|
||||
value: typing.Dict[str, typing.Any]
|
||||
|
||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||
self.value = value
|
||||
@@ -251,6 +539,7 @@ class OptionDict(Option):
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||
if type(data) == dict:
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
else:
|
||||
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
||||
@@ -263,7 +552,6 @@ class OptionDict(Option):
|
||||
|
||||
|
||||
class ItemDict(OptionDict):
|
||||
# implemented by Generate
|
||||
verify_item_name = True
|
||||
|
||||
def __init__(self, value: typing.Dict[str, int]):
|
||||
@@ -272,10 +560,9 @@ class ItemDict(OptionDict):
|
||||
super(ItemDict, self).__init__(value)
|
||||
|
||||
|
||||
class OptionList(Option):
|
||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
default = []
|
||||
supports_weighting = False
|
||||
value: list
|
||||
|
||||
def __init__(self, value: typing.List[typing.Any]):
|
||||
self.value = value or []
|
||||
@@ -288,6 +575,7 @@ class OptionList(Option):
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
if type(data) == list:
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
@@ -298,10 +586,9 @@ class OptionList(Option):
|
||||
return item in self.value
|
||||
|
||||
|
||||
class OptionSet(Option):
|
||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
default = frozenset()
|
||||
supports_weighting = False
|
||||
value: set
|
||||
|
||||
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
|
||||
self.value = set(value)
|
||||
@@ -314,13 +601,15 @@ class OptionSet(Option):
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
if type(data) == list:
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
elif type(data) == set:
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
return ", ".join(value)
|
||||
return ", ".join(sorted(value))
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.value
|
||||
@@ -334,7 +623,7 @@ class Accessibility(Choice):
|
||||
Locations: ensure everything can be reached and acquired.
|
||||
Items: ensure all logically relevant items can be acquired.
|
||||
Minimal: ensure what is needed to reach your goal can be acquired."""
|
||||
|
||||
display_name = "Accessibility"
|
||||
option_locations = 0
|
||||
option_items = 1
|
||||
option_minimal = 2
|
||||
@@ -342,8 +631,18 @@ class Accessibility(Choice):
|
||||
default = 1
|
||||
|
||||
|
||||
class ProgressionBalancing(DefaultOnToggle):
|
||||
"""A system that moves progression earlier, to try and prevent the player from getting stuck and bored early."""
|
||||
class ProgressionBalancing(SpecialRange):
|
||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||
default = 50
|
||||
range_start = 0
|
||||
range_end = 99
|
||||
display_name = "Progression Balancing"
|
||||
special_range_names = {
|
||||
"disabled": 0,
|
||||
"normal": 50,
|
||||
"extreme": 99,
|
||||
}
|
||||
|
||||
|
||||
common_options = {
|
||||
@@ -353,54 +652,124 @@ common_options = {
|
||||
|
||||
|
||||
class ItemSet(OptionSet):
|
||||
# implemented by Generate
|
||||
verify_item_name = True
|
||||
convert_name_groups = True
|
||||
|
||||
|
||||
class LocalItems(ItemSet):
|
||||
"""Forces these items to be in their native world."""
|
||||
displayname = "Local Items"
|
||||
display_name = "Local Items"
|
||||
|
||||
|
||||
class NonLocalItems(ItemSet):
|
||||
"""Forces these items to be outside their native world."""
|
||||
displayname = "Not Local Items"
|
||||
display_name = "Not Local Items"
|
||||
|
||||
|
||||
class StartInventory(ItemDict):
|
||||
"""Start with these items."""
|
||||
verify_item_name = True
|
||||
displayname = "Start Inventory"
|
||||
display_name = "Start Inventory"
|
||||
|
||||
|
||||
class StartHints(ItemSet):
|
||||
"""Start with these item's locations prefilled into the !hint command."""
|
||||
displayname = "Start Hints"
|
||||
display_name = "Start Hints"
|
||||
|
||||
|
||||
class StartLocationHints(OptionSet):
|
||||
"""Start with these locations and their item prefilled into the !hint command"""
|
||||
displayname = "Start Location Hints"
|
||||
display_name = "Start Location Hints"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class ExcludeLocations(OptionSet):
|
||||
"""Prevent these locations from having an important item"""
|
||||
displayname = "Excluded Locations"
|
||||
display_name = "Excluded Locations"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class PriorityLocations(OptionSet):
|
||||
"""Prevent these locations from having an unimportant item"""
|
||||
display_name = "Priority Locations"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||
displayname = "Death Link"
|
||||
display_name = "Death Link"
|
||||
|
||||
|
||||
class ItemLinks(OptionList):
|
||||
"""Share part of your item pool with other players."""
|
||||
default = []
|
||||
schema = Schema([
|
||||
{
|
||||
"name": And(str, len),
|
||||
"item_pool": [And(str, len)],
|
||||
Optional("exclude"): [And(str, len)],
|
||||
"replacement_item": Or(And(str, len), None),
|
||||
Optional("local_items"): [And(str, len)],
|
||||
Optional("non_local_items"): [And(str, len)]
|
||||
}
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
|
||||
pool = set()
|
||||
for item_name in items:
|
||||
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
|
||||
picks = get_fuzzy_results(item_name, world.item_names, 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 ""
|
||||
|
||||
raise Exception(f"Item {item_name} from item link {item_link} "
|
||||
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}")
|
||||
if allow_item_groups:
|
||||
pool |= world.item_name_groups.get(item_name, {item_name})
|
||||
else:
|
||||
pool |= {item_name}
|
||||
return pool
|
||||
|
||||
def verify(self, world):
|
||||
super(ItemLinks, self).verify(world)
|
||||
existing_links = set()
|
||||
for link in self.value:
|
||||
if link["name"] in existing_links:
|
||||
raise Exception(f"You cannot have more than one link named {link['name']}.")
|
||||
existing_links.add(link["name"])
|
||||
|
||||
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
||||
local_items = set()
|
||||
non_local_items = set()
|
||||
|
||||
if "exclude" in link:
|
||||
pool -= self.verify_items(link["exclude"], link["name"], "exclude", world)
|
||||
if link["replacement_item"]:
|
||||
self.verify_items([link["replacement_item"]], link["name"], "replacement_item", world, False)
|
||||
if "local_items" in link:
|
||||
local_items = self.verify_items(link["local_items"], link["name"], "local_items", world)
|
||||
local_items &= pool
|
||||
if "non_local_items" in link:
|
||||
non_local_items = self.verify_items(link["non_local_items"], link["name"], "non_local_items", world)
|
||||
non_local_items &= pool
|
||||
|
||||
intersection = local_items.intersection(non_local_items)
|
||||
if intersection:
|
||||
raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
|
||||
|
||||
|
||||
per_game_common_options = {
|
||||
**common_options, # can be overwritten per-game
|
||||
"local_items": LocalItems,
|
||||
"non_local_items": NonLocalItems,
|
||||
"start_inventory": StartInventory,
|
||||
"start_hints": StartHints,
|
||||
"start_location_hints": StartLocationHints,
|
||||
"exclude_locations": ExcludeLocations
|
||||
"exclude_locations": ExcludeLocations,
|
||||
"priority_locations": PriorityLocations,
|
||||
"item_links": ItemLinks
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -410,8 +779,8 @@ if __name__ == "__main__":
|
||||
|
||||
map_shuffle = Toggle
|
||||
compass_shuffle = Toggle
|
||||
keyshuffle = Toggle
|
||||
bigkey_shuffle = Toggle
|
||||
key_shuffle = Toggle
|
||||
big_key_shuffle = Toggle
|
||||
hints = Toggle
|
||||
test = argparse.Namespace()
|
||||
test.logic = Logic.from_text("no_logic")
|
||||
|
||||
297
Patch.py
@@ -1,5 +1,7 @@
|
||||
# TODO: convert this into a system like AutoWorld
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import json
|
||||
import bsdiff4
|
||||
import yaml
|
||||
import os
|
||||
@@ -8,31 +10,187 @@ import threading
|
||||
import concurrent.futures
|
||||
import zipfile
|
||||
import sys
|
||||
from typing import Tuple, Optional
|
||||
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
current_patch_version = 3
|
||||
current_patch_version = 4
|
||||
|
||||
|
||||
class AutoPatchRegister(type):
|
||||
patch_types: Dict[str, APDeltaPatch] = {}
|
||||
file_endings: Dict[str, APDeltaPatch] = {}
|
||||
|
||||
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
|
||||
# construct class
|
||||
new_class = super().__new__(cls, name, bases, dct)
|
||||
if "game" in dct:
|
||||
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
||||
if not dct["patch_file_ending"]:
|
||||
raise Exception(f"Need an expected file ending for {name}")
|
||||
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
||||
return new_class
|
||||
|
||||
@staticmethod
|
||||
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
|
||||
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
||||
if file.endswith(file_ending):
|
||||
return handler
|
||||
|
||||
|
||||
class APContainer:
|
||||
"""A zipfile containing at least archipelago.json"""
|
||||
version: int = current_patch_version
|
||||
compression_level: int = 9
|
||||
compression_method: int = zipfile.ZIP_DEFLATED
|
||||
game: Optional[str] = None
|
||||
|
||||
# instance attributes:
|
||||
path: Optional[str]
|
||||
player: Optional[int]
|
||||
player_name: str
|
||||
server: str
|
||||
|
||||
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
|
||||
player_name: str = "", server: str = ""):
|
||||
self.path = path
|
||||
self.player = player
|
||||
self.player_name = player_name
|
||||
self.server = server
|
||||
|
||||
def write(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||
if not self.path and not file:
|
||||
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
|
||||
as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.write_contents(zf)
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
manifest = self.get_manifest()
|
||||
try:
|
||||
manifest = json.dumps(manifest)
|
||||
except Exception as e:
|
||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||
else:
|
||||
opened_zipfile.writestr("archipelago.json", manifest)
|
||||
|
||||
def read(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
||||
if not self.path and not file:
|
||||
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(file if file else self.path, "r") as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.read_contents(zf)
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
if manifest["compatible_version"] > self.version:
|
||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||
f"for this handler (version: {self.version})")
|
||||
self.player = manifest["player"]
|
||||
self.server = manifest["server"]
|
||||
self.player_name = manifest["player_name"]
|
||||
|
||||
def get_manifest(self) -> dict:
|
||||
return {
|
||||
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
"player": self.player,
|
||||
"player_name": self.player_name,
|
||||
"game": self.game,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"compatible_version": 4,
|
||||
"version": current_patch_version,
|
||||
}
|
||||
|
||||
|
||||
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
||||
"""An APContainer that additionally has delta.bsdiff4
|
||||
containing a delta patch to get the desired file, often a rom."""
|
||||
|
||||
hash = Optional[str] # base checksum of source file
|
||||
patch_file_ending: str = ""
|
||||
delta: Optional[bytes] = None
|
||||
result_file_ending: str = ".sfc"
|
||||
source_data: bytes
|
||||
|
||||
def __init__(self, *args, patched_path: str = "", **kwargs):
|
||||
self.patched_path = patched_path
|
||||
super(APDeltaPatch, self).__init__(*args, **kwargs)
|
||||
|
||||
def get_manifest(self) -> dict:
|
||||
manifest = super(APDeltaPatch, self).get_manifest()
|
||||
manifest["base_checksum"] = self.hash
|
||||
manifest["result_file_ending"] = self.result_file_ending
|
||||
return manifest
|
||||
|
||||
@classmethod
|
||||
def get_source_data(cls) -> bytes:
|
||||
"""Get Base data"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def get_source_data_with_cache(cls) -> bytes:
|
||||
if not hasattr(cls, "source_data"):
|
||||
cls.source_data = cls.get_source_data()
|
||||
return cls.source_data
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
super(APDeltaPatch, self).write_contents(opened_zipfile)
|
||||
# write Delta
|
||||
opened_zipfile.writestr("delta.bsdiff4",
|
||||
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
|
||||
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
super(APDeltaPatch, self).read_contents(opened_zipfile)
|
||||
self.delta = opened_zipfile.read("delta.bsdiff4")
|
||||
|
||||
def patch(self, target: str):
|
||||
"""Base + Delta -> Patched"""
|
||||
if not self.delta:
|
||||
self.read()
|
||||
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
|
||||
with open(target, "wb") as f:
|
||||
f.write(result)
|
||||
|
||||
|
||||
# legacy patch handling follows:
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
GAME_SM = "Super Metroid"
|
||||
GAME_SOE = "Secret of Evermore"
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore"}
|
||||
GAME_SMZ3 = "SMZ3"
|
||||
GAME_DKC3 = "Donkey Kong Country 3"
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
|
||||
|
||||
preferred_endings = {
|
||||
GAME_ALTTP: "apbp",
|
||||
GAME_SM: "apm3",
|
||||
GAME_SOE: "apsoe"
|
||||
GAME_SOE: "apsoe",
|
||||
GAME_SMZ3: "apsmz",
|
||||
GAME_DKC3: "apdkc3"
|
||||
}
|
||||
|
||||
|
||||
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||
if game == GAME_ALTTP:
|
||||
from worlds.alttp.Rom import JAP10HASH as HASH
|
||||
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import JAP10HASH as HASH
|
||||
from worlds.sm.Rom import SMJUHASH as HASH
|
||||
elif game == GAME_SOE:
|
||||
from worlds.soe.Patch import USHASH as HASH
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
|
||||
from worlds.sm.Rom import SMJUHASH as SMHASH
|
||||
HASH = ALTTPHASH + SMHASH
|
||||
elif game == GAME_DKC3:
|
||||
from worlds.dkc3.Rom import USHASH as HASH
|
||||
else:
|
||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||
|
||||
@@ -62,7 +220,10 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
|
||||
meta,
|
||||
game)
|
||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
||||
".apbp" if game == GAME_ALTTP else ".apm3")
|
||||
".apbp" if game == GAME_ALTTP
|
||||
else ".apsmz" if game == GAME_SMZ3
|
||||
else ".apdkc3" if game == GAME_DKC3
|
||||
else ".apm3")
|
||||
write_lzma(bytes, target)
|
||||
return target
|
||||
|
||||
@@ -87,18 +248,31 @@ def get_base_rom_data(game: str):
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import get_base_rom_bytes
|
||||
elif game == GAME_SOE:
|
||||
file_name = Utils.get_options()["soe_options"]["rom_file"]
|
||||
get_base_rom_bytes = lambda: bytes(read_rom(open(file_name, "rb")))
|
||||
from worlds.soe.Patch import get_base_rom_path
|
||||
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.smz3.Rom import get_base_rom_bytes
|
||||
elif game == GAME_DKC3:
|
||||
from worlds.dkc3.Rom import get_base_rom_bytes
|
||||
else:
|
||||
raise RuntimeError("Selected game for base rom not found.")
|
||||
return get_base_rom_bytes()
|
||||
|
||||
|
||||
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||
data, target, patched_data = create_rom_bytes(patch_file)
|
||||
with open(target, "wb") as f:
|
||||
f.write(patched_data)
|
||||
return data, target
|
||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||
if auto_handler:
|
||||
handler: APDeltaPatch = auto_handler(patch_file)
|
||||
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
|
||||
handler.patch(target)
|
||||
return {"server": handler.server,
|
||||
"player": handler.player,
|
||||
"player_name": handler.player_name}, target
|
||||
else:
|
||||
data, target, patched_data = create_rom_bytes(patch_file)
|
||||
with open(target, "wb") as f:
|
||||
f.write(patched_data)
|
||||
return data, target
|
||||
|
||||
|
||||
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
|
||||
@@ -146,10 +320,63 @@ if __name__ == "__main__":
|
||||
elif rom.endswith(".apbp"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
romfile, adjusted = Utils.get_adjuster_settings(target)
|
||||
#romfile, adjusted = Utils.get_adjuster_settings(target)
|
||||
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
|
||||
adjusted = False
|
||||
if adjuster_settings:
|
||||
import pprint
|
||||
from worlds.alttp.Rom import get_base_rom_path
|
||||
adjuster_settings.rom = target
|
||||
adjuster_settings.baserom = get_base_rom_path()
|
||||
adjuster_settings.world = None
|
||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
||||
"reduceflashing", "deathlink"}
|
||||
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
sprite_pool = {}
|
||||
for sprite in getattr(adjuster_settings, "sprite_pool"):
|
||||
if sprite in sprite_pool:
|
||||
sprite_pool[sprite] += 1
|
||||
else:
|
||||
sprite_pool[sprite] = 1
|
||||
if sprite_pool:
|
||||
printed_options["sprite_pool"] = sprite_pool
|
||||
|
||||
adjust_wanted = str('no')
|
||||
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
|
||||
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
||||
f"{pprint.pformat(printed_options)}\n"
|
||||
f"Enter yes, no, always or never: ")
|
||||
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
|
||||
adjust_wanted = 'no'
|
||||
elif adjuster_settings.auto_apply == 'always':
|
||||
adjust_wanted = 'yes'
|
||||
|
||||
if adjust_wanted and "never" in adjust_wanted:
|
||||
adjuster_settings.auto_apply = 'never'
|
||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||
|
||||
elif adjust_wanted and "always" in adjust_wanted:
|
||||
adjuster_settings.auto_apply = 'always'
|
||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||
|
||||
if adjust_wanted and adjust_wanted.startswith("y"):
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
from LttPAdjuster import AdjusterWorld
|
||||
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
|
||||
|
||||
adjusted = True
|
||||
import LttPAdjuster
|
||||
_, romfile = LttPAdjuster.adjust(adjuster_settings)
|
||||
|
||||
if hasattr(adjuster_settings, "world"):
|
||||
delattr(adjuster_settings, "world")
|
||||
else:
|
||||
adjusted = False
|
||||
if adjusted:
|
||||
try:
|
||||
os.replace(romfile, target)
|
||||
shutil.move(romfile, target)
|
||||
romfile = target
|
||||
except Exception as e:
|
||||
print(e)
|
||||
@@ -164,24 +391,20 @@ if __name__ == "__main__":
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".archipelago"):
|
||||
import json
|
||||
import zlib
|
||||
|
||||
with open(rom, 'rb') as fr:
|
||||
|
||||
multidata = zlib.decompress(fr.read()).decode("utf-8")
|
||||
with open(rom + '.txt', 'w') as fw:
|
||||
fw.write(multidata)
|
||||
multidata = json.loads(multidata)
|
||||
for romname in multidata['roms']:
|
||||
Utils.persistent_store("servers", "".join(chr(byte) for byte in romname[2]), address)
|
||||
from Utils import get_options
|
||||
|
||||
multidata["server_options"] = get_options()["server_options"]
|
||||
multidata = zlib.compress(json.dumps(multidata).encode("utf-8"), 9)
|
||||
with open(rom + "_updated.archipelago", 'wb') as f:
|
||||
f.write(multidata)
|
||||
elif rom.endswith(".apsmz"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apdkc3"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
|
||||
elif rom.endswith(".zip"):
|
||||
print(f"Updating host in patch files contained in {rom}")
|
||||
@@ -189,7 +412,9 @@ if __name__ == "__main__":
|
||||
|
||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||
data = zfr.read(zfinfo)
|
||||
if zfinfo.filename.endswith(".apbp") or zfinfo.filename.endswith(".apm3"):
|
||||
if zfinfo.filename.endswith(".apbp") or \
|
||||
zfinfo.filename.endswith(".apm3") or \
|
||||
zfinfo.filename.endswith(".apdkc3"):
|
||||
data = update_patch_data(data, server)
|
||||
with ziplock:
|
||||
zfw.writestr(zfinfo, data)
|
||||
@@ -210,4 +435,4 @@ if __name__ == "__main__":
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close.")
|
||||
input("Press enter to close.")
|
||||
|
||||
22
README.md
@@ -13,6 +13,21 @@ Currently, the following games are supported:
|
||||
* Timespinner
|
||||
* Super Metroid
|
||||
* Secret of Evermore
|
||||
* Final Fantasy
|
||||
* Rogue Legacy
|
||||
* VVVVVV
|
||||
* Raft
|
||||
* Super Mario 64
|
||||
* Meritous
|
||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||
* ChecksFinder
|
||||
* ArchipIDLE
|
||||
* Hollow Knight
|
||||
* The Witness
|
||||
* Sonic Adventure 2: Battle
|
||||
* Starcraft 2: Wings of Liberty
|
||||
* Donkey Kong Country 3
|
||||
* Dark Souls 3
|
||||
|
||||
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
|
||||
@@ -36,7 +51,7 @@ Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEnt
|
||||
## Running Archipelago
|
||||
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
|
||||
|
||||
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our wiki page on [running Archipelago from source](https://github.com/ArchipelagoMW/Archipelago/wiki/Running-from-source).
|
||||
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
|
||||
|
||||
## Related Repositories
|
||||
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
|
||||
@@ -53,6 +68,11 @@ Contributions are welcome. We have a few asks of any new contributors.
|
||||
|
||||
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
|
||||
|
||||
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see [the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
|
||||
|
||||
## FAQ
|
||||
For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/)
|
||||
|
||||
## Code of Conduct
|
||||
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
|
||||
|
||||
|
||||
611
SNIClient.py
@@ -10,24 +10,30 @@ import base64
|
||||
import shutil
|
||||
import logging
|
||||
import asyncio
|
||||
import enum
|
||||
import typing
|
||||
|
||||
from json import loads, dumps
|
||||
|
||||
from Utils import get_item_name_from_id, init_logging
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import init_logging, messagebox
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging("SNIClient", exception_logger="Client")
|
||||
|
||||
import colorama
|
||||
import websockets
|
||||
|
||||
from NetUtils import *
|
||||
from NetUtils import ClientStatus, color
|
||||
from worlds.alttp import Regions, Shops
|
||||
from worlds.alttp import Items
|
||||
from worlds.alttp.Rom import ROM_PLAYER_LIMIT
|
||||
from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
|
||||
from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT
|
||||
import Utils
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
from Patch import GAME_ALTTP, GAME_SM
|
||||
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3, GAME_DKC3
|
||||
|
||||
snes_logger = logging.getLogger("SNES")
|
||||
|
||||
@@ -40,7 +46,7 @@ class DeathState(enum.IntEnum):
|
||||
dead = 3
|
||||
|
||||
|
||||
class LttPCommandProcessor(ClientCommandProcessor):
|
||||
class SNIClientCommandProcessor(ClientCommandProcessor):
|
||||
ctx: Context
|
||||
|
||||
def _cmd_slow_mode(self, toggle: str = ""):
|
||||
@@ -55,7 +61,8 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
||||
@mark_raw
|
||||
def _cmd_snes(self, snes_options: str = "") -> bool:
|
||||
"""Connect to a snes. Optionally include network address of a snes to connect to,
|
||||
otherwise show available devices; and a SNES device number if more than one SNES is detected"""
|
||||
otherwise show available devices; and a SNES device number if more than one SNES is detected.
|
||||
Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """
|
||||
|
||||
snes_address = self.ctx.snes_address
|
||||
snes_device_number = -1
|
||||
@@ -64,16 +71,17 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
||||
num_options = len(options)
|
||||
|
||||
if num_options > 0:
|
||||
snes_address = options[0]
|
||||
snes_device_number = int(options[0])
|
||||
|
||||
if num_options > 1:
|
||||
try:
|
||||
snes_device_number = int(options[1])
|
||||
except:
|
||||
pass
|
||||
snes_address = options[0]
|
||||
snes_device_number = int(options[1])
|
||||
|
||||
self.ctx.snes_reconnect_address = None
|
||||
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), name="SNES Connect")
|
||||
if self.ctx.snes_connect_task:
|
||||
self.ctx.snes_connect_task.cancel()
|
||||
self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number),
|
||||
name="SNES Connect")
|
||||
return True
|
||||
|
||||
def _cmd_snes_close(self) -> bool:
|
||||
@@ -91,16 +99,26 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
||||
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
|
||||
# self.output("No attached SNES Device.")
|
||||
# return False
|
||||
#
|
||||
# snes_buffered_write(self.ctx, int(address, 16), bytes([int(data)]))
|
||||
# asyncio.create_task(snes_flush_writes(self.ctx))
|
||||
# self.output("Data Sent")
|
||||
# return True
|
||||
|
||||
# def _cmd_snes_read(self, address, size=1):
|
||||
# """Read the SNES' memory address (base16)."""
|
||||
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
|
||||
# self.output("No attached SNES Device.")
|
||||
# return False
|
||||
# data = await snes_read(self.ctx, int(address, 16), size)
|
||||
# self.output(f"Data Read: {data}")
|
||||
# return True
|
||||
|
||||
|
||||
class Context(CommonContext):
|
||||
command_processor = LttPCommandProcessor
|
||||
command_processor = SNIClientCommandProcessor
|
||||
game = "A Link to the Past"
|
||||
items_handling = None # set in game_watcher
|
||||
snes_connect_task: typing.Optional[asyncio.Task] = None
|
||||
|
||||
def __init__(self, snes_address, server_address, password):
|
||||
super(Context, self).__init__(server_address, password)
|
||||
@@ -117,6 +135,8 @@ class Context(CommonContext):
|
||||
self.snes_connector_lock = threading.Lock()
|
||||
self.death_state = DeathState.alive # for death link flop behaviour
|
||||
self.killing_player_task = None
|
||||
self.allow_collect = False
|
||||
self.slow_mode = False
|
||||
|
||||
self.awaiting_rom = False
|
||||
self.rom = None
|
||||
@@ -165,21 +185,63 @@ class Context(CommonContext):
|
||||
if not currently_dead:
|
||||
self.death_state = DeathState.alive
|
||||
|
||||
async def shutdown(self):
|
||||
await super(Context, self).shutdown()
|
||||
if self.snes_connect_task:
|
||||
try:
|
||||
await asyncio.wait_for(self.snes_connect_task, 1)
|
||||
except asyncio.TimeoutError:
|
||||
self.snes_connect_task.cancel()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected", "RoomUpdate"}:
|
||||
if "checked_locations" in args and args["checked_locations"]:
|
||||
new_locations = set(args["checked_locations"])
|
||||
self.checked_locations |= new_locations
|
||||
self.locations_scouted |= new_locations
|
||||
# Items belonging to the player should not be marked as checked in game, since the player will likely need that item.
|
||||
# Once the games handled by SNIClient gets made to be remote items, this will no longer be needed.
|
||||
asyncio.create_task(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class SNIManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("SNES", "SNES"),
|
||||
]
|
||||
base_title = "Archipelago SNI Client"
|
||||
|
||||
self.ui = SNIManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
async def deathlink_kill_player(ctx: Context):
|
||||
ctx.death_state = DeathState.killing_player
|
||||
while ctx.death_state == DeathState.killing_player and \
|
||||
ctx.snes_state == SNESState.SNES_ATTACHED:
|
||||
if ctx.game == GAME_ALTTP:
|
||||
snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0
|
||||
snes_buffered_write(ctx, WRAM_START + 0x0373, bytes([8])) # deal 1 full heart of damage at next opportunity
|
||||
invincible = await snes_read(ctx, WRAM_START + 0x037B, 1)
|
||||
last_health = await snes_read(ctx, WRAM_START + 0xF36D, 1)
|
||||
await asyncio.sleep(0.25)
|
||||
health = await snes_read(ctx, WRAM_START + 0xF36D, 1)
|
||||
if not invincible or not last_health or not health:
|
||||
ctx.death_state = DeathState.dead
|
||||
ctx.last_death_link = time.time()
|
||||
continue
|
||||
if not invincible[0] and last_health[0] == health[0]:
|
||||
snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0
|
||||
snes_buffered_write(ctx, WRAM_START + 0x0373,
|
||||
bytes([8])) # deal 1 full heart of damage at next opportunity
|
||||
elif ctx.game == GAME_SM:
|
||||
snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([0, 0])) # set current health to 0
|
||||
snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([1, 0])) # set current health to 1 (to prevent saving with 0 energy)
|
||||
snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity
|
||||
if not ctx.death_link_allow_survive:
|
||||
snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0
|
||||
await snes_flush_writes(ctx)
|
||||
await asyncio.sleep(1)
|
||||
gamemode = None
|
||||
|
||||
if ctx.game == GAME_ALTTP:
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
|
||||
if not gamemode or gamemode[0] in DEATH_MODES:
|
||||
@@ -189,19 +251,15 @@ async def deathlink_kill_player(ctx: Context):
|
||||
health = await snes_read(ctx, WRAM_START + 0x09C2, 2)
|
||||
if health is not None:
|
||||
health = health[0] | (health[1] << 8)
|
||||
if not gamemode or gamemode[0] in SM_DEATH_MODES or (ctx.death_link_allow_survive and health is not None and health > 0):
|
||||
if not gamemode or gamemode[0] in SM_DEATH_MODES or (
|
||||
ctx.death_link_allow_survive and health is not None and health > 0):
|
||||
ctx.death_state = DeathState.dead
|
||||
elif ctx.game == GAME_DKC3:
|
||||
from worlds.dkc3.Client import deathlink_kill_player as dkc3_deathlink_kill_player
|
||||
await dkc3_deathlink_kill_player(ctx)
|
||||
ctx.last_death_link = time.time()
|
||||
|
||||
|
||||
def color_item(item_id: int, green: bool = False) -> str:
|
||||
item_name = get_item_name_from_id(item_id)
|
||||
item_colors = ['green' if green else 'cyan']
|
||||
if item_name in Items.progression_items:
|
||||
item_colors.append("white_bg")
|
||||
return color(item_name, *item_colors)
|
||||
|
||||
|
||||
SNES_RECONNECT_DELAY = 5
|
||||
|
||||
# LttP
|
||||
@@ -230,11 +288,12 @@ SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte
|
||||
SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
|
||||
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
|
||||
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
|
||||
SHOP_LEN = (len(Shops.shop_table) * 3) + 5
|
||||
|
||||
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
|
||||
|
||||
# SM
|
||||
SM_ROMNAME_START = 0x1C4F00
|
||||
SM_ROMNAME_START = 0x007FC0
|
||||
|
||||
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||
SM_ENDGAME_MODES = {0x26, 0x27}
|
||||
@@ -245,6 +304,19 @@ SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
|
||||
SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
|
||||
|
||||
SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte
|
||||
SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte
|
||||
|
||||
# SMZ3
|
||||
SMZ3_ROMNAME_START = 0x00FFC0
|
||||
|
||||
SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||
SMZ3_ENDGAME_MODES = {0x26, 0x27}
|
||||
SMZ3_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
|
||||
|
||||
SMZ3_RECV_PROGRESS_ADDR = SRAM_START + 0x4000 # 2 bytes
|
||||
SMZ3_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
|
||||
SMZ3_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
|
||||
|
||||
|
||||
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
|
||||
|
||||
@@ -469,6 +541,18 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
|
||||
'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40),
|
||||
'Ganons Tower - Validation Chest': (0x4d, 0x10)}
|
||||
|
||||
boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss',
|
||||
'Desert Palace - Boss',
|
||||
'Tower of Hera - Boss',
|
||||
'Palace of Darkness - Boss',
|
||||
'Swamp Palace - Boss',
|
||||
'Skull Woods - Boss',
|
||||
"Thieves' Town - Boss",
|
||||
'Ice Palace - Boss',
|
||||
'Misery Mire - Boss',
|
||||
'Turtle Rock - Boss',
|
||||
'Sahasrahla'}}
|
||||
|
||||
location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()}
|
||||
|
||||
location_table_npc = {'Mushroom': 0x1000,
|
||||
@@ -517,25 +601,34 @@ class SNESState(enum.IntEnum):
|
||||
SNES_ATTACHED = 3
|
||||
|
||||
|
||||
def launch_sni(ctx: Context):
|
||||
def launch_sni():
|
||||
sni_path = Utils.get_options()["lttp_options"]["sni"]
|
||||
|
||||
if not os.path.isdir(sni_path):
|
||||
sni_path = Utils.local_path(sni_path)
|
||||
if os.path.isdir(sni_path):
|
||||
for file in os.listdir(sni_path):
|
||||
lower_file = file.lower()
|
||||
if (lower_file.startswith("sni.") and not lower_file.endswith(".proto")) or lower_file == "sni":
|
||||
sni_path = os.path.join(sni_path, file)
|
||||
dir_entry: os.DirEntry
|
||||
for dir_entry in os.scandir(sni_path):
|
||||
if dir_entry.is_file():
|
||||
lower_file = dir_entry.name.lower()
|
||||
if (lower_file.startswith("sni.") and not lower_file.endswith(".proto")) or (lower_file == "sni"):
|
||||
sni_path = dir_entry.path
|
||||
break
|
||||
|
||||
if os.path.isfile(sni_path):
|
||||
snes_logger.info(f"Attempting to start {sni_path}")
|
||||
import sys
|
||||
if not sys.stdout: # if it spawns a visible console, may as well populate it
|
||||
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path))
|
||||
subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path))
|
||||
else:
|
||||
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
proc = subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
proc.wait(.1) # wait a bit to see if startup fails (missing dependencies)
|
||||
snes_logger.info('Failed to start SNI. Try running it externally for error output.')
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # seems to be running
|
||||
|
||||
else:
|
||||
snes_logger.info(
|
||||
f"Attempt to start SNI was aborted as path {sni_path} was not found, "
|
||||
@@ -546,11 +639,9 @@ async def _snes_connect(ctx: Context, address: str):
|
||||
address = f"ws://{address}" if "://" not in address else address
|
||||
snes_logger.info("Connecting to SNI at %s ..." % address)
|
||||
seen_problems = set()
|
||||
succesful = False
|
||||
while not succesful:
|
||||
while 1:
|
||||
try:
|
||||
snes_socket = await websockets.connect(address, ping_timeout=None, ping_interval=None)
|
||||
succesful = True
|
||||
except Exception as e:
|
||||
problem = "%s" % e
|
||||
# only tell the user about new problems, otherwise silently lay in wait for a working connection
|
||||
@@ -560,14 +651,14 @@ async def _snes_connect(ctx: Context, address: str):
|
||||
|
||||
if len(seen_problems) == 1:
|
||||
# this is the first problem. Let's try launching SNI if it isn't already running
|
||||
launch_sni(ctx)
|
||||
launch_sni()
|
||||
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
return snes_socket
|
||||
|
||||
|
||||
async def get_snes_devices(ctx: Context):
|
||||
async def get_snes_devices(ctx: Context) -> typing.List[str]:
|
||||
socket = await _snes_connect(ctx, ctx.snes_address) # establish new connection to poll
|
||||
DeviceList_Request = {
|
||||
"Opcode": "DeviceList",
|
||||
@@ -575,19 +666,20 @@ async def get_snes_devices(ctx: Context):
|
||||
}
|
||||
await socket.send(dumps(DeviceList_Request))
|
||||
|
||||
reply = loads(await socket.recv())
|
||||
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
|
||||
reply: dict = loads(await socket.recv())
|
||||
devices: typing.List[str] = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else []
|
||||
|
||||
if not devices:
|
||||
snes_logger.info('No SNES device found. Please connect a SNES device to SNI.')
|
||||
while not devices:
|
||||
await asyncio.sleep(1)
|
||||
while not devices and not ctx.exit_event.is_set():
|
||||
await asyncio.sleep(0.1)
|
||||
await socket.send(dumps(DeviceList_Request))
|
||||
reply = loads(await socket.recv())
|
||||
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
|
||||
await verify_snes_app(socket)
|
||||
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else []
|
||||
if devices:
|
||||
await verify_snes_app(socket)
|
||||
await socket.close()
|
||||
return devices
|
||||
return sorted(devices)
|
||||
|
||||
|
||||
async def verify_snes_app(socket):
|
||||
@@ -619,24 +711,24 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
|
||||
|
||||
try:
|
||||
devices = await get_snes_devices(ctx)
|
||||
numDevices = len(devices)
|
||||
device_count = len(devices)
|
||||
|
||||
if numDevices == 1:
|
||||
if device_count == 1:
|
||||
device = devices[0]
|
||||
elif ctx.snes_reconnect_address:
|
||||
if ctx.snes_attached_device[1] in devices:
|
||||
device = ctx.snes_attached_device[1]
|
||||
else:
|
||||
device = devices[ctx.snes_attached_device[0]]
|
||||
elif numDevices > 1:
|
||||
elif device_count > 1:
|
||||
if deviceIndex == -1:
|
||||
snes_logger.info(
|
||||
"Found " + str(numDevices) + " SNES devices; connect to one with /snes <address> <device number>:")
|
||||
snes_logger.info(f"Found {device_count} SNES devices. "
|
||||
f"Connect to one with /snes <address> <device number>. For example /snes {address} 1")
|
||||
|
||||
for idx, availableDevice in enumerate(devices):
|
||||
snes_logger.info(str(idx + 1) + ": " + availableDevice)
|
||||
|
||||
elif (deviceIndex < 0) or (deviceIndex - 1) > numDevices:
|
||||
elif (deviceIndex < 0) or (deviceIndex - 1) > device_count:
|
||||
snes_logger.warning("SNES device number out of range")
|
||||
|
||||
else:
|
||||
@@ -658,8 +750,6 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
|
||||
ctx.snes_attached_device = (devices.index(device), device)
|
||||
ctx.snes_reconnect_address = address
|
||||
recv_task = asyncio.create_task(snes_recv_loop(ctx))
|
||||
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
|
||||
snes_logger.info(f"Attached to {device}")
|
||||
|
||||
except Exception as e:
|
||||
if recv_task is not None:
|
||||
@@ -678,6 +768,10 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
|
||||
asyncio.create_task(snes_autoreconnect(ctx))
|
||||
SNES_RECONNECT_DELAY *= 2
|
||||
|
||||
else:
|
||||
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
|
||||
snes_logger.info(f"Attached to {device}")
|
||||
|
||||
|
||||
async def snes_disconnect(ctx: Context):
|
||||
if ctx.snes_socket:
|
||||
@@ -803,24 +897,32 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
def new_check(location_id):
|
||||
new_locations.append(location_id)
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
location = ctx.location_names[location_id]
|
||||
snes_logger.info(
|
||||
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
|
||||
try:
|
||||
if roomid in location_shop_ids:
|
||||
misc_data = await snes_read(ctx, SHOP_ADDR, (len(Shops.shop_table) * 3) + 5)
|
||||
for cnt, b in enumerate(misc_data):
|
||||
if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked:
|
||||
new_check(Shops.SHOP_ID_START + cnt)
|
||||
shop_data = await snes_read(ctx, SHOP_ADDR, SHOP_LEN)
|
||||
shop_data_changed = False
|
||||
shop_data = list(shop_data)
|
||||
for cnt, b in enumerate(shop_data):
|
||||
location = Shops.SHOP_ID_START + cnt
|
||||
if int(b) and location not in ctx.locations_checked:
|
||||
new_check(location)
|
||||
if ctx.allow_collect and location in ctx.checked_locations and location not in ctx.locations_checked \
|
||||
and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot:
|
||||
if not int(b):
|
||||
shop_data[cnt] += 1
|
||||
shop_data_changed = True
|
||||
if shop_data_changed:
|
||||
snes_buffered_write(ctx, SHOP_ADDR, bytes(shop_data))
|
||||
except Exception as e:
|
||||
snes_logger.info(f"Exception: {e}")
|
||||
|
||||
for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items():
|
||||
try:
|
||||
|
||||
if location_id not in ctx.locations_checked and loc_roomid == roomid and (
|
||||
roomdata << 4) & loc_mask != 0:
|
||||
if location_id not in ctx.locations_checked and loc_roomid == roomid and \
|
||||
(roomdata << 4) & loc_mask != 0:
|
||||
new_check(location_id)
|
||||
except Exception as e:
|
||||
snes_logger.exception(f"Exception: {e}")
|
||||
@@ -828,12 +930,19 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
uw_begin = 0x129
|
||||
ow_end = uw_end = 0
|
||||
uw_unchecked = {}
|
||||
uw_checked = {}
|
||||
for location, (roomid, mask) in location_table_uw.items():
|
||||
location_id = Regions.lookup_name_to_id[location]
|
||||
if location_id not in ctx.locations_checked:
|
||||
uw_unchecked[location_id] = (roomid, mask)
|
||||
uw_begin = min(uw_begin, roomid)
|
||||
uw_end = max(uw_end, roomid + 1)
|
||||
if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \
|
||||
and location_id not in ctx.locations_checked and location_id in ctx.locations_info \
|
||||
and ctx.locations_info[location_id].player != ctx.slot:
|
||||
uw_begin = min(uw_begin, roomid)
|
||||
uw_end = max(uw_end, roomid + 1)
|
||||
uw_checked[location_id] = (roomid, mask)
|
||||
|
||||
if uw_begin < uw_end:
|
||||
uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2)
|
||||
@@ -843,14 +952,27 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
roomdata = uw_data[offset] | (uw_data[offset + 1] << 8)
|
||||
if roomdata & mask != 0:
|
||||
new_check(location_id)
|
||||
if uw_checked:
|
||||
uw_data = list(uw_data)
|
||||
for location_id, (roomid, mask) in uw_checked.items():
|
||||
offset = (roomid - uw_begin) * 2
|
||||
roomdata = uw_data[offset] | (uw_data[offset + 1] << 8)
|
||||
roomdata |= mask
|
||||
uw_data[offset] = roomdata & 0xFF
|
||||
uw_data[offset + 1] = roomdata >> 8
|
||||
snes_buffered_write(ctx, SAVEDATA_START + (uw_begin * 2), bytes(uw_data))
|
||||
|
||||
ow_begin = 0x82
|
||||
ow_unchecked = {}
|
||||
ow_checked = {}
|
||||
for location_id, screenid in location_table_ow_id.items():
|
||||
if location_id not in ctx.locations_checked:
|
||||
ow_unchecked[location_id] = screenid
|
||||
ow_begin = min(ow_begin, screenid)
|
||||
ow_end = max(ow_end, screenid + 1)
|
||||
if ctx.allow_collect and location_id in ctx.checked_locations and location_id in ctx.locations_info \
|
||||
and ctx.locations_info[location_id].player != ctx.slot:
|
||||
ow_checked[location_id] = screenid
|
||||
|
||||
if ow_begin < ow_end:
|
||||
ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin)
|
||||
@@ -858,25 +980,49 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
for location_id, screenid in ow_unchecked.items():
|
||||
if ow_data[screenid - ow_begin] & 0x40 != 0:
|
||||
new_check(location_id)
|
||||
if ow_checked:
|
||||
ow_data = list(ow_data)
|
||||
for location_id, screenid in ow_checked.items():
|
||||
ow_data[screenid - ow_begin] |= 0x40
|
||||
snes_buffered_write(ctx, SAVEDATA_START + 0x280 + ow_begin, bytes(ow_data))
|
||||
|
||||
if not ctx.locations_checked.issuperset(location_table_npc_id):
|
||||
npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2)
|
||||
if npc_data is not None:
|
||||
npc_value_changed = False
|
||||
npc_value = npc_data[0] | (npc_data[1] << 8)
|
||||
for location_id, mask in location_table_npc_id.items():
|
||||
if npc_value & mask != 0 and location_id not in ctx.locations_checked:
|
||||
new_check(location_id)
|
||||
if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \
|
||||
and location_id not in ctx.locations_checked and location_id in ctx.locations_info \
|
||||
and ctx.locations_info[location_id].player != ctx.slot:
|
||||
npc_value |= mask
|
||||
npc_value_changed = True
|
||||
if npc_value_changed:
|
||||
npc_data = bytes([npc_value & 0xFF, npc_value >> 8])
|
||||
snes_buffered_write(ctx, SAVEDATA_START + 0x410, npc_data)
|
||||
|
||||
if not ctx.locations_checked.issuperset(location_table_misc_id):
|
||||
misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4)
|
||||
if misc_data is not None:
|
||||
misc_data = list(misc_data)
|
||||
misc_data_changed = False
|
||||
for location_id, (offset, mask) in location_table_misc_id.items():
|
||||
assert (0x3c6 <= offset <= 0x3c9)
|
||||
if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
|
||||
new_check(location_id)
|
||||
if ctx.allow_collect and location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
|
||||
and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
|
||||
misc_data_changed = True
|
||||
misc_data[offset - 0x3c6] |= mask
|
||||
if misc_data_changed:
|
||||
snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data))
|
||||
|
||||
|
||||
if new_locations:
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
||||
await snes_flush_writes(ctx)
|
||||
|
||||
|
||||
async def game_watcher(ctx: Context):
|
||||
@@ -892,28 +1038,48 @@ async def game_watcher(ctx: Context):
|
||||
if not ctx.rom:
|
||||
ctx.finished_game = False
|
||||
ctx.death_link_allow_survive = False
|
||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 2)
|
||||
if game_name is None:
|
||||
continue
|
||||
elif game_name == b"SM":
|
||||
ctx.game = GAME_SM
|
||||
else:
|
||||
ctx.game = GAME_ALTTP
|
||||
|
||||
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else ROMNAME_START, ROMNAME_SIZE)
|
||||
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
|
||||
continue
|
||||
from worlds.dkc3.Client import dkc3_rom_init
|
||||
init_handled = await dkc3_rom_init(ctx)
|
||||
if not init_handled:
|
||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
|
||||
if game_name is None:
|
||||
continue
|
||||
elif game_name[:2] == b"SM":
|
||||
ctx.game = GAME_SM
|
||||
# versions lower than 0.3.0 dont have item handling flag nor remote item support
|
||||
romVersion = int(game_name[2:5].decode('UTF-8'))
|
||||
if romVersion < 30:
|
||||
ctx.items_handling = 0b001 # full local
|
||||
else:
|
||||
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
|
||||
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
|
||||
else:
|
||||
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
|
||||
if game_name == b"ZSM":
|
||||
ctx.game = GAME_SMZ3
|
||||
ctx.items_handling = 0b101 # local items and remote start inventory
|
||||
else:
|
||||
ctx.game = GAME_ALTTP
|
||||
ctx.items_handling = 0b001 # full local
|
||||
|
||||
ctx.rom = rom
|
||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
|
||||
SM_DEATH_LINK_ACTIVE_ADDR, 1)
|
||||
if death_link:
|
||||
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
||||
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
||||
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
||||
ctx.locations_checked = set()
|
||||
ctx.locations_scouted = set()
|
||||
ctx.prev_rom = ctx.rom
|
||||
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE)
|
||||
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
|
||||
continue
|
||||
|
||||
ctx.rom = rom
|
||||
if ctx.game != GAME_SMZ3:
|
||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
|
||||
SM_DEATH_LINK_ACTIVE_ADDR, 1)
|
||||
if death_link:
|
||||
ctx.allow_collect = bool(death_link[0] & 0b100)
|
||||
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
||||
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
||||
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
||||
ctx.locations_checked = set()
|
||||
ctx.locations_scouted = set()
|
||||
ctx.locations_info = {}
|
||||
ctx.prev_rom = ctx.rom
|
||||
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
@@ -968,8 +1134,9 @@ async def game_watcher(ctx: Context):
|
||||
item = ctx.items_received[recv_index]
|
||||
recv_index += 1
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
|
||||
color(ctx.item_names[item.item], 'red', 'bold'),
|
||||
color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_names[item.location], recv_index, len(ctx.items_received)))
|
||||
|
||||
snes_buffered_write(ctx, RECV_PROGRESS_ADDR,
|
||||
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
@@ -981,16 +1148,16 @@ async def game_watcher(ctx: Context):
|
||||
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR,
|
||||
bytes([scout_location]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR,
|
||||
bytes([ctx.locations_info[scout_location][0]]))
|
||||
bytes([ctx.locations_info[scout_location].item]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR,
|
||||
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location][1])]))
|
||||
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)]))
|
||||
|
||||
await snes_flush_writes(ctx)
|
||||
|
||||
if scout_location > 0 and scout_location not in ctx.locations_scouted:
|
||||
ctx.locations_scouted.add(scout_location)
|
||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
|
||||
await track_locations(ctx, roomid, roomdata)
|
||||
await track_locations(ctx, roomid, roomdata)
|
||||
elif ctx.game == GAME_SM:
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
|
||||
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
|
||||
@@ -1017,14 +1184,16 @@ async def game_watcher(ctx: Context):
|
||||
itemIndex = (message[4] | (message[5] << 8)) >> 3
|
||||
|
||||
recv_index += 1
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680,
|
||||
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
|
||||
from worlds.sm.Locations import locations_start_id
|
||||
location_id = locations_start_id + itemIndex
|
||||
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
location = ctx.location_names[location_id]
|
||||
snes_logger.info(
|
||||
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
|
||||
|
||||
data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x600, 4)
|
||||
@@ -1035,18 +1204,93 @@ async def game_watcher(ctx: Context):
|
||||
itemOutPtr = data[2] | (data[3] << 8)
|
||||
|
||||
from worlds.sm.Items import items_start_id
|
||||
from worlds.sm.Locations import locations_start_id
|
||||
if itemOutPtr < len(ctx.items_received):
|
||||
item = ctx.items_received[itemOutPtr]
|
||||
itemId = item.item - items_start_id
|
||||
if bool(ctx.items_handling & 0b010):
|
||||
locationId = (item.location - locations_start_id) if (item.location >= 0 and item.player == ctx.slot) else 0xFF
|
||||
else:
|
||||
locationId = 0x00 #backward compat
|
||||
|
||||
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes(
|
||||
[playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF]))
|
||||
itemOutPtr += 1
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602,
|
||||
bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
color(ctx.item_names[item.item], 'red', 'bold'),
|
||||
color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
|
||||
await snes_flush_writes(ctx)
|
||||
elif ctx.game == GAME_SMZ3:
|
||||
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
|
||||
if (currentGame is not None):
|
||||
if (currentGame[0] != 0):
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
|
||||
endGameModes = SM_ENDGAME_MODES
|
||||
else:
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
|
||||
endGameModes = ENDGAME_MODES
|
||||
|
||||
if gamemode is not None and (gamemode[0] in endGameModes):
|
||||
if not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
continue
|
||||
|
||||
data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, 4)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
recv_index = data[0] | (data[1] << 8)
|
||||
recv_item = data[2] | (data[3] << 8)
|
||||
|
||||
while (recv_index < recv_item):
|
||||
itemAdress = recv_index * 8
|
||||
message = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8)
|
||||
# worldId = message[0] | (message[1] << 8) # unused
|
||||
# itemId = message[2] | (message[3] << 8) # unused
|
||||
isZ3Item = ((message[5] & 0x80) != 0)
|
||||
maskedPart = (message[5] & 0x7F) if isZ3Item else message[5]
|
||||
itemIndex = ((message[4] | (maskedPart << 8)) >> 3) + (256 if isZ3Item else 0)
|
||||
|
||||
recv_index += 1
|
||||
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
|
||||
from worlds.smz3.TotalSMZ3.Location import locations_start_id
|
||||
location_id = locations_start_id + itemIndex
|
||||
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_names[location_id]
|
||||
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
|
||||
|
||||
data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x600, 4)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
# recv_itemOutPtr = data[0] | (data[1] << 8) # unused
|
||||
itemOutPtr = data[2] | (data[3] << 8)
|
||||
|
||||
from worlds.smz3.TotalSMZ3.Item import items_start_id
|
||||
if itemOutPtr < len(ctx.items_received):
|
||||
item = ctx.items_received[itemOutPtr]
|
||||
itemId = item.item - items_start_id
|
||||
|
||||
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF]))
|
||||
playerID = item.player if item.player <= SMZ3_ROM_PLAYER_LIMIT else 0
|
||||
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF]))
|
||||
itemOutPtr += 1
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
|
||||
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_name_getter(item.location), itemOutPtr, len(ctx.items_received)))
|
||||
color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
|
||||
await snes_flush_writes(ctx)
|
||||
elif ctx.game == GAME_DKC3:
|
||||
from worlds.dkc3.Client import dkc3_game_watcher
|
||||
await dkc3_game_watcher(ctx)
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_options()["lttp_options"].get("rom_start", True)
|
||||
@@ -1063,14 +1307,18 @@ async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||
help='Path to a Archipelago Binary Patch file')
|
||||
parser.add_argument('--snes', default='localhost:8080', help='Address of the SNI server.')
|
||||
parser.add_argument('--snes', default='localhost:23074', help='Address of the SNI server.')
|
||||
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logging.info("Patch file was supplied. Creating sfc rom..")
|
||||
meta, romfile = Patch.create_rom_file(args.diff_file)
|
||||
try:
|
||||
meta, romfile = Patch.create_rom_file(args.diff_file)
|
||||
except Exception as e:
|
||||
messagebox('Error', str(e), True)
|
||||
raise
|
||||
if "server" in meta:
|
||||
args.connect = meta["server"]
|
||||
logging.info(f"Wrote rom file to {romfile}")
|
||||
@@ -1082,14 +1330,8 @@ async def main():
|
||||
import time
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
elif args.diff_file.endswith((".apbp", "apz3")):
|
||||
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled)
|
||||
if adjusted:
|
||||
try:
|
||||
shutil.move(adjustedromfile, romfile)
|
||||
adjustedromfile = romfile
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
elif args.diff_file.endswith((".apbp", ".apz3", ".aplttp")):
|
||||
adjustedromfile, adjusted = get_alttp_settings(romfile)
|
||||
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
|
||||
else:
|
||||
asyncio.create_task(run_game(romfile))
|
||||
@@ -1097,17 +1339,12 @@ async def main():
|
||||
ctx = Context(args.snes, args.connect, args.password)
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
input_task = None
|
||||
if gui_enabled:
|
||||
from kvui import SNIManager
|
||||
ctx.ui = SNIManager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
ui_task = None
|
||||
if sys.stdin:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
|
||||
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
|
||||
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
@@ -1116,21 +1353,137 @@ async def main():
|
||||
ctx.snes_reconnect_address = None
|
||||
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
if snes_connect_task:
|
||||
snes_connect_task.cancel()
|
||||
await watcher_task
|
||||
await ctx.shutdown()
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
def get_alttp_settings(romfile: str):
|
||||
lastSettings = Utils.get_adjuster_settings(GAME_ALTTP)
|
||||
adjustedromfile = ''
|
||||
if lastSettings:
|
||||
choice = 'no'
|
||||
if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply:
|
||||
|
||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
||||
"reduceflashing", "deathlink", "allowcollect"}
|
||||
printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist}
|
||||
if hasattr(lastSettings, "sprite_pool"):
|
||||
sprite_pool = {}
|
||||
for sprite in lastSettings.sprite_pool:
|
||||
if sprite in sprite_pool:
|
||||
sprite_pool[sprite] += 1
|
||||
else:
|
||||
sprite_pool[sprite] = 1
|
||||
if sprite_pool:
|
||||
printed_options["sprite_pool"] = sprite_pool
|
||||
import pprint
|
||||
|
||||
if gui_enabled:
|
||||
|
||||
try:
|
||||
from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button
|
||||
applyPromptWindow = Tk()
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed.')
|
||||
return '', False
|
||||
|
||||
applyPromptWindow.resizable(False, False)
|
||||
applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick())
|
||||
logo = PhotoImage(file=Utils.local_path('data', 'icon.png'))
|
||||
applyPromptWindow.tk.call('wm', 'iconphoto', applyPromptWindow._w, logo)
|
||||
applyPromptWindow.wm_title("Last adjuster settings LttP")
|
||||
|
||||
label = LabelFrame(applyPromptWindow,
|
||||
text='Last used adjuster settings were found. Would you like to apply these?')
|
||||
label.grid(column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5)
|
||||
label.grid_columnconfigure(0, weight=1)
|
||||
label.grid_columnconfigure(1, weight=1)
|
||||
label.grid_columnconfigure(2, weight=1)
|
||||
label.grid_columnconfigure(3, weight=1)
|
||||
|
||||
def onButtonClick(answer: str = 'no'):
|
||||
setattr(onButtonClick, 'choice', answer)
|
||||
applyPromptWindow.destroy()
|
||||
|
||||
framedOptions = Frame(label)
|
||||
framedOptions.grid(column=0, columnspan=4, row=0)
|
||||
framedOptions.grid_columnconfigure(0, weight=1)
|
||||
framedOptions.grid_columnconfigure(1, weight=1)
|
||||
framedOptions.grid_columnconfigure(2, weight=1)
|
||||
curRow = 0
|
||||
curCol = 0
|
||||
for name, value in printed_options.items():
|
||||
Label(framedOptions, text=name + ": " + str(value)).grid(column=curCol, row=curRow, padx=5)
|
||||
if (curCol == 2):
|
||||
curRow += 1
|
||||
curCol = 0
|
||||
else:
|
||||
curCol += 1
|
||||
|
||||
yesButton = Button(label, text='Yes', command=lambda: onButtonClick('yes'), width=10)
|
||||
yesButton.grid(column=0, row=1)
|
||||
noButton = Button(label, text='No', command=lambda: onButtonClick('no'), width=10)
|
||||
noButton.grid(column=1, row=1)
|
||||
alwaysButton = Button(label, text='Always', command=lambda: onButtonClick('always'), width=10)
|
||||
alwaysButton.grid(column=2, row=1)
|
||||
neverButton = Button(label, text='Never', command=lambda: onButtonClick('never'), width=10)
|
||||
neverButton.grid(column=3, row=1)
|
||||
|
||||
Utils.tkinter_center_window(applyPromptWindow)
|
||||
applyPromptWindow.mainloop()
|
||||
choice = getattr(onButtonClick, 'choice')
|
||||
else:
|
||||
choice = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
||||
f"{pprint.pformat(printed_options)}\n"
|
||||
f"Enter yes, no, always or never: ")
|
||||
if choice and choice.startswith("y"):
|
||||
choice = 'yes'
|
||||
elif choice and "never" in choice:
|
||||
choice = 'no'
|
||||
lastSettings.auto_apply = 'never'
|
||||
Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings)
|
||||
elif choice and "always" in choice:
|
||||
choice = 'yes'
|
||||
lastSettings.auto_apply = 'always'
|
||||
Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings)
|
||||
else:
|
||||
choice = 'no'
|
||||
elif 'never' in lastSettings.auto_apply:
|
||||
choice = 'no'
|
||||
elif 'always' in lastSettings.auto_apply:
|
||||
choice = 'yes'
|
||||
|
||||
if 'yes' in choice:
|
||||
from worlds.alttp.Rom import get_base_rom_path
|
||||
lastSettings.rom = romfile
|
||||
lastSettings.baserom = get_base_rom_path()
|
||||
lastSettings.world = None
|
||||
|
||||
if hasattr(lastSettings, "sprite_pool"):
|
||||
from LttPAdjuster import AdjusterWorld
|
||||
lastSettings.world = AdjusterWorld(getattr(lastSettings, "sprite_pool"))
|
||||
|
||||
adjusted = True
|
||||
import LttPAdjuster
|
||||
_, adjustedromfile = LttPAdjuster.adjust(lastSettings)
|
||||
|
||||
if hasattr(lastSettings, "world"):
|
||||
delattr(lastSettings, "world")
|
||||
else:
|
||||
adjusted = False
|
||||
if adjusted:
|
||||
try:
|
||||
shutil.move(adjustedromfile, romfile)
|
||||
adjustedromfile = romfile
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
else:
|
||||
adjusted = False
|
||||
return adjustedromfile, adjusted
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
loop.close()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
|
||||
920
Starcraft2Client.py
Normal file
@@ -0,0 +1,920 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
import logging
|
||||
import asyncio
|
||||
import os.path
|
||||
|
||||
import nest_asyncio
|
||||
import sc2
|
||||
|
||||
from sc2.main import run_game
|
||||
from sc2.data import Race
|
||||
from sc2.bot_ai import BotAI
|
||||
from sc2.player import Bot
|
||||
|
||||
from worlds.sc2wol.Regions import MissionInfo
|
||||
from worlds.sc2wol.MissionTables import lookup_id_to_mission
|
||||
from worlds.sc2wol.Items import lookup_id_to_name, item_table
|
||||
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
|
||||
from worlds.sc2wol import SC2WoLWorld
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
from MultiServer import mark_raw
|
||||
import ctypes
|
||||
import sys
|
||||
|
||||
from Utils import init_logging, is_windows
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging("SC2Client", exception_logger="Client")
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
sc2_logger = logging.getLogger("Starcraft2")
|
||||
|
||||
import colorama
|
||||
|
||||
from NetUtils import *
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
|
||||
nest_asyncio.apply()
|
||||
|
||||
|
||||
class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
ctx: SC2Context
|
||||
|
||||
def _cmd_disable_mission_check(self) -> bool:
|
||||
"""Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
|
||||
the next mission in a chain the other player is doing."""
|
||||
self.ctx.missions_unlocked = True
|
||||
sc2_logger.info("Mission check has been disabled")
|
||||
|
||||
def _cmd_play(self, mission_id: str = "") -> bool:
|
||||
"""Start a Starcraft 2 mission"""
|
||||
|
||||
options = mission_id.split()
|
||||
num_options = len(options)
|
||||
|
||||
if num_options > 0:
|
||||
mission_number = int(options[0])
|
||||
|
||||
self.ctx.play_mission(mission_number)
|
||||
|
||||
else:
|
||||
sc2_logger.info(
|
||||
"Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
|
||||
|
||||
return True
|
||||
|
||||
def _cmd_available(self) -> bool:
|
||||
"""Get what missions are currently available to play"""
|
||||
|
||||
request_available_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui)
|
||||
return True
|
||||
|
||||
def _cmd_unfinished(self) -> bool:
|
||||
"""Get what missions are currently available to play and have not had all locations checked"""
|
||||
|
||||
request_unfinished_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui, self.ctx)
|
||||
return True
|
||||
|
||||
@mark_raw
|
||||
def _cmd_set_path(self, path: str = '') -> bool:
|
||||
"""Manually set the SC2 install directory (if the automatic detection fails)."""
|
||||
if path:
|
||||
os.environ["SC2PATH"] = path
|
||||
check_mod_install()
|
||||
return True
|
||||
else:
|
||||
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
||||
return False
|
||||
|
||||
|
||||
class SC2Context(CommonContext):
|
||||
command_processor = StarcraftClientProcessor
|
||||
game = "Starcraft 2 Wings of Liberty"
|
||||
items_handling = 0b111
|
||||
difficulty = -1
|
||||
all_in_choice = 0
|
||||
mission_req_table = None
|
||||
items_rec_to_announce = []
|
||||
rec_announce_pos = 0
|
||||
items_sent_to_announce = []
|
||||
sent_announce_pos = 0
|
||||
announcements = []
|
||||
announcement_pos = 0
|
||||
sc2_run_task: typing.Optional[asyncio.Task] = None
|
||||
missions_unlocked = False
|
||||
current_tooltip = None
|
||||
last_loc_list = None
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(SC2Context, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
self.difficulty = args["slot_data"]["game_difficulty"]
|
||||
self.all_in_choice = args["slot_data"]["all_in_map"]
|
||||
slot_req_table = args["slot_data"]["mission_req"]
|
||||
self.mission_req_table = {}
|
||||
# Compatibility for 0.3.2 server data.
|
||||
if "category" not in next(iter(slot_req_table)):
|
||||
for i, mission_data in enumerate(slot_req_table.values()):
|
||||
mission_data["category"] = wol_default_categories[i]
|
||||
for mission in slot_req_table:
|
||||
self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission])
|
||||
|
||||
# Look for and set SC2PATH.
|
||||
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
|
||||
if "SC2PATH" not in os.environ and check_game_install_path():
|
||||
check_mod_install()
|
||||
|
||||
if cmd in {"PrintJSON"}:
|
||||
if "receiving" in args:
|
||||
if self.slot_concerns_self(args["receiving"]):
|
||||
self.announcements.append(args["data"])
|
||||
return
|
||||
if "item" in args:
|
||||
if self.slot_concerns_self(args["item"].player):
|
||||
self.announcements.append(args["data"])
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager, HoverBehavior, ServerToolTip, fade_in_animation
|
||||
from kivy.app import App
|
||||
from kivy.clock import Clock
|
||||
from kivy.uix.tabbedpanel import TabbedPanelItem
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.lang import Builder
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.floatlayout import FloatLayout
|
||||
from kivy.properties import StringProperty
|
||||
|
||||
import Utils
|
||||
|
||||
class HoverableButton(HoverBehavior, Button):
|
||||
pass
|
||||
|
||||
class MissionButton(HoverableButton):
|
||||
tooltip_text = StringProperty("Test")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(HoverableButton, self).__init__(*args, **kwargs)
|
||||
self.layout = FloatLayout()
|
||||
self.popuplabel = ServerToolTip(text=self.text)
|
||||
self.layout.add_widget(self.popuplabel)
|
||||
|
||||
def on_enter(self):
|
||||
self.popuplabel.text = self.tooltip_text
|
||||
|
||||
if self.ctx.current_tooltip:
|
||||
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
|
||||
|
||||
if self.tooltip_text == "":
|
||||
self.ctx.current_tooltip = None
|
||||
else:
|
||||
App.get_running_app().root.add_widget(self.layout)
|
||||
self.ctx.current_tooltip = self.layout
|
||||
|
||||
def on_leave(self):
|
||||
if self.ctx.current_tooltip:
|
||||
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
|
||||
|
||||
self.ctx.current_tooltip = None
|
||||
|
||||
@property
|
||||
def ctx(self) -> CommonContext:
|
||||
return App.get_running_app().ctx
|
||||
|
||||
class MissionLayout(GridLayout):
|
||||
pass
|
||||
|
||||
class MissionCategory(GridLayout):
|
||||
pass
|
||||
|
||||
class SC2Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("Starcraft2", "Starcraft2"),
|
||||
]
|
||||
base_title = "Archipelago Starcraft 2 Client"
|
||||
|
||||
mission_panel = None
|
||||
last_checked_locations = {}
|
||||
mission_id_to_button = {}
|
||||
launching = False
|
||||
refresh_from_launching = True
|
||||
first_check = True
|
||||
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def build(self):
|
||||
container = super().build()
|
||||
|
||||
panel = TabbedPanelItem(text="Starcraft 2 Launcher")
|
||||
self.mission_panel = panel.content = MissionLayout()
|
||||
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
Clock.schedule_interval(self.build_mission_table, 0.5)
|
||||
|
||||
return container
|
||||
|
||||
def build_mission_table(self, dt):
|
||||
if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
|
||||
not self.refresh_from_launching)) or self.first_check:
|
||||
self.refresh_from_launching = True
|
||||
|
||||
self.mission_panel.clear_widgets()
|
||||
|
||||
if self.ctx.mission_req_table:
|
||||
self.last_checked_locations = self.ctx.checked_locations.copy()
|
||||
self.first_check = False
|
||||
|
||||
self.mission_id_to_button = {}
|
||||
categories = {}
|
||||
available_missions = []
|
||||
unfinished_locations = initialize_blank_mission_dict(self.ctx.mission_req_table)
|
||||
unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations,
|
||||
self.ctx.mission_req_table,
|
||||
self.ctx, available_missions=available_missions,
|
||||
unfinished_locations=unfinished_locations)
|
||||
|
||||
# separate missions into categories
|
||||
for mission in self.ctx.mission_req_table:
|
||||
if not self.ctx.mission_req_table[mission].category in categories:
|
||||
categories[self.ctx.mission_req_table[mission].category] = []
|
||||
|
||||
categories[self.ctx.mission_req_table[mission].category].append(mission)
|
||||
|
||||
for category in categories:
|
||||
category_panel = MissionCategory()
|
||||
category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1))
|
||||
|
||||
# Map is completed
|
||||
for mission in categories[category]:
|
||||
text = mission
|
||||
tooltip = ""
|
||||
|
||||
# Map has uncollected locations
|
||||
if mission in unfinished_missions:
|
||||
text = f"[color=6495ED]{text}[/color]"
|
||||
|
||||
tooltip = f"Uncollected locations:\n"
|
||||
tooltip += "\n".join(location for location in unfinished_locations[mission])
|
||||
elif mission in available_missions:
|
||||
text = f"[color=FFFFFF]{text}[/color]"
|
||||
# Map requirements not met
|
||||
else:
|
||||
text = f"[color=a9a9a9]{text}[/color]"
|
||||
tooltip = f"Requires: "
|
||||
if len(self.ctx.mission_req_table[mission].required_world) > 0:
|
||||
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission-1] for
|
||||
req_mission in
|
||||
self.ctx.mission_req_table[mission].required_world)
|
||||
|
||||
if self.ctx.mission_req_table[mission].number > 0:
|
||||
tooltip += " and "
|
||||
if self.ctx.mission_req_table[mission].number > 0:
|
||||
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
|
||||
|
||||
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
|
||||
mission_button.tooltip_text = tooltip
|
||||
mission_button.bind(on_press=self.mission_callback)
|
||||
self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
|
||||
category_panel.add_widget(mission_button)
|
||||
|
||||
category_panel.add_widget(Label(text=""))
|
||||
self.mission_panel.add_widget(category_panel)
|
||||
|
||||
elif self.launching:
|
||||
self.refresh_from_launching = False
|
||||
|
||||
self.mission_panel.clear_widgets()
|
||||
self.mission_panel.add_widget(Label(text="Launching Mission"))
|
||||
|
||||
def mission_callback(self, button):
|
||||
if not self.launching:
|
||||
self.ctx.play_mission(list(self.mission_id_to_button.keys())
|
||||
[list(self.mission_id_to_button.values()).index(button)])
|
||||
self.launching = True
|
||||
Clock.schedule_once(self.finish_launching, 10)
|
||||
|
||||
def finish_launching(self, dt):
|
||||
self.launching = False
|
||||
|
||||
self.ui = SC2Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
|
||||
|
||||
async def shutdown(self):
|
||||
await super(SC2Context, self).shutdown()
|
||||
if self.sc2_run_task:
|
||||
self.sc2_run_task.cancel()
|
||||
|
||||
def play_mission(self, mission_id):
|
||||
if self.missions_unlocked or \
|
||||
is_mission_available(mission_id, self.checked_locations, self.mission_req_table):
|
||||
if self.sc2_run_task:
|
||||
if not self.sc2_run_task.done():
|
||||
sc2_logger.warning("Starcraft 2 Client is still running!")
|
||||
self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
|
||||
if self.slot is None:
|
||||
sc2_logger.warning("Launching Mission without Archipelago authentication, "
|
||||
"checks will not be registered to server.")
|
||||
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
|
||||
name="Starcraft 2 Launch")
|
||||
else:
|
||||
sc2_logger.info(
|
||||
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
|
||||
f"Use /unfinished or /available to see what is available.")
|
||||
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||
args = parser.parse_args()
|
||||
|
||||
ctx = SC2Context(args.connect, args.password)
|
||||
ctx.auth = args.name
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
maps_table = [
|
||||
"ap_traynor01", "ap_traynor02", "ap_traynor03",
|
||||
"ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b",
|
||||
"ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05",
|
||||
"ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b",
|
||||
"ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s",
|
||||
"ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04",
|
||||
"ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03"
|
||||
]
|
||||
|
||||
wol_default_categories = [
|
||||
"Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist",
|
||||
"Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert",
|
||||
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
|
||||
"Char", "Char", "Char", "Char"
|
||||
]
|
||||
|
||||
|
||||
def calculate_items(items):
|
||||
unit_unlocks = 0
|
||||
armory1_unlocks = 0
|
||||
armory2_unlocks = 0
|
||||
upgrade_unlocks = 0
|
||||
building_unlocks = 0
|
||||
merc_unlocks = 0
|
||||
lab_unlocks = 0
|
||||
protoss_unlock = 0
|
||||
minerals = 0
|
||||
vespene = 0
|
||||
supply = 0
|
||||
|
||||
for item in items:
|
||||
data = lookup_id_to_name[item.item]
|
||||
|
||||
if item_table[data].type == "Unit":
|
||||
unit_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Upgrade":
|
||||
upgrade_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Armory 1":
|
||||
armory1_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Armory 2":
|
||||
armory2_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Building":
|
||||
building_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Mercenary":
|
||||
merc_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Laboratory":
|
||||
lab_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Protoss":
|
||||
protoss_unlock += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Minerals":
|
||||
minerals += item_table[data].number
|
||||
elif item_table[data].type == "Vespene":
|
||||
vespene += item_table[data].number
|
||||
elif item_table[data].type == "Supply":
|
||||
supply += item_table[data].number
|
||||
|
||||
return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks,
|
||||
lab_unlocks, protoss_unlock, minerals, vespene, supply]
|
||||
|
||||
|
||||
def calc_difficulty(difficulty):
|
||||
if difficulty == 0:
|
||||
return 'C'
|
||||
elif difficulty == 1:
|
||||
return 'N'
|
||||
elif difficulty == 2:
|
||||
return 'H'
|
||||
elif difficulty == 3:
|
||||
return 'B'
|
||||
|
||||
return 'X'
|
||||
|
||||
|
||||
async def starcraft_launch(ctx: SC2Context, mission_id):
|
||||
ctx.rec_announce_pos = len(ctx.items_rec_to_announce)
|
||||
ctx.sent_announce_pos = len(ctx.items_sent_to_announce)
|
||||
ctx.announcements_pos = len(ctx.announcements)
|
||||
|
||||
sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
|
||||
|
||||
with DllDirectory(None):
|
||||
run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
|
||||
name="Archipelago", fullscreen=True)], realtime=True)
|
||||
|
||||
|
||||
class ArchipelagoBot(sc2.bot_ai.BotAI):
|
||||
game_running = False
|
||||
mission_completed = False
|
||||
first_bonus = False
|
||||
second_bonus = False
|
||||
third_bonus = False
|
||||
fourth_bonus = False
|
||||
fifth_bonus = False
|
||||
sixth_bonus = False
|
||||
seventh_bonus = False
|
||||
eight_bonus = False
|
||||
ctx: SC2Context = None
|
||||
mission_id = 0
|
||||
|
||||
can_read_game = False
|
||||
|
||||
last_received_update = 0
|
||||
|
||||
def __init__(self, ctx: SC2Context, mission_id):
|
||||
self.ctx = ctx
|
||||
self.mission_id = mission_id
|
||||
|
||||
super(ArchipelagoBot, self).__init__()
|
||||
|
||||
async def on_step(self, iteration: int):
|
||||
game_state = 0
|
||||
if iteration == 0:
|
||||
start_items = calculate_items(self.ctx.items_received)
|
||||
difficulty = calc_difficulty(self.ctx.difficulty)
|
||||
await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
|
||||
difficulty,
|
||||
start_items[0], start_items[1], start_items[2], start_items[3], start_items[4],
|
||||
start_items[5], start_items[6], start_items[7], start_items[8], start_items[9],
|
||||
self.ctx.all_in_choice, start_items[10]))
|
||||
self.last_received_update = len(self.ctx.items_received)
|
||||
|
||||
else:
|
||||
if self.ctx.announcement_pos < len(self.ctx.announcements):
|
||||
index = 0
|
||||
message = ""
|
||||
while index < len(self.ctx.announcements[self.ctx.announcement_pos]):
|
||||
message += self.ctx.announcements[self.ctx.announcement_pos][index]["text"]
|
||||
index += 1
|
||||
|
||||
index = 0
|
||||
start_rem_pos = -1
|
||||
# Remove unneeded [Color] tags
|
||||
while index < len(message):
|
||||
if message[index] == '[':
|
||||
start_rem_pos = index
|
||||
index += 1
|
||||
elif message[index] == ']' and start_rem_pos > -1:
|
||||
temp_msg = ""
|
||||
|
||||
if start_rem_pos > 0:
|
||||
temp_msg = message[:start_rem_pos]
|
||||
if index < len(message) - 1:
|
||||
temp_msg += message[index + 1:]
|
||||
|
||||
message = temp_msg
|
||||
index += start_rem_pos - index
|
||||
start_rem_pos = -1
|
||||
else:
|
||||
index += 1
|
||||
|
||||
await self.chat_send("SendMessage " + message)
|
||||
self.ctx.announcement_pos += 1
|
||||
|
||||
# Archipelago reads the health
|
||||
for unit in self.all_own_units():
|
||||
if unit.health_max == 38281:
|
||||
game_state = int(38281 - unit.health)
|
||||
self.can_read_game = True
|
||||
|
||||
if iteration == 160 and not game_state & 1:
|
||||
await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " +
|
||||
"Starcraft 2 (This is likely a map issue)")
|
||||
|
||||
if self.last_received_update < len(self.ctx.items_received):
|
||||
current_items = calculate_items(self.ctx.items_received)
|
||||
await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format(
|
||||
current_items[0], current_items[1], current_items[2], current_items[3], current_items[4],
|
||||
current_items[5], current_items[6], current_items[7]))
|
||||
self.last_received_update = len(self.ctx.items_received)
|
||||
|
||||
if game_state & 1:
|
||||
if not self.game_running:
|
||||
print("Archipelago Connected")
|
||||
self.game_running = True
|
||||
|
||||
if self.can_read_game:
|
||||
if game_state & (1 << 1) and not self.mission_completed:
|
||||
if self.mission_id != 29:
|
||||
print("Mission Completed")
|
||||
await self.ctx.send_msgs([
|
||||
{"cmd": 'LocationChecks', "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id]}])
|
||||
self.mission_completed = True
|
||||
else:
|
||||
print("Game Complete")
|
||||
await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
|
||||
self.mission_completed = True
|
||||
|
||||
if game_state & (1 << 2) and not self.first_bonus:
|
||||
print("1st Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 1]}])
|
||||
self.first_bonus = True
|
||||
|
||||
if not self.second_bonus and game_state & (1 << 3):
|
||||
print("2nd Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 2]}])
|
||||
self.second_bonus = True
|
||||
|
||||
if not self.third_bonus and game_state & (1 << 4):
|
||||
print("3rd Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 3]}])
|
||||
self.third_bonus = True
|
||||
|
||||
if not self.fourth_bonus and game_state & (1 << 5):
|
||||
print("4th Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 4]}])
|
||||
self.fourth_bonus = True
|
||||
|
||||
if not self.fifth_bonus and game_state & (1 << 6):
|
||||
print("5th Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 5]}])
|
||||
self.fifth_bonus = True
|
||||
|
||||
if not self.sixth_bonus and game_state & (1 << 7):
|
||||
print("6th Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 6]}])
|
||||
self.sixth_bonus = True
|
||||
|
||||
if not self.seventh_bonus and game_state & (1 << 8):
|
||||
print("6th Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 7]}])
|
||||
self.seventh_bonus = True
|
||||
|
||||
if not self.eight_bonus and game_state & (1 << 9):
|
||||
print("6th Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 8]}])
|
||||
self.eight_bonus = True
|
||||
|
||||
else:
|
||||
await self.chat_send("LostConnection - Lost connection to game.")
|
||||
|
||||
|
||||
def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
|
||||
objectives_complete = 0
|
||||
|
||||
if missions_info[mission].extra_locations > 0:
|
||||
for i in range(missions_info[mission].extra_locations):
|
||||
if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done:
|
||||
objectives_complete += 1
|
||||
else:
|
||||
unfinished_locations[mission].append(ctx.location_names[
|
||||
missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i])
|
||||
|
||||
return objectives_complete
|
||||
|
||||
else:
|
||||
return -1
|
||||
|
||||
|
||||
def request_unfinished_missions(locations_done, location_table, ui, ctx):
|
||||
if location_table:
|
||||
message = "Unfinished Missions: "
|
||||
unlocks = initialize_blank_mission_dict(location_table)
|
||||
unfinished_locations = initialize_blank_mission_dict(location_table)
|
||||
|
||||
unfinished_missions = calc_unfinished_missions(locations_done, location_table, ctx, unlocks=unlocks,
|
||||
unfinished_locations=unfinished_locations)
|
||||
|
||||
message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " +
|
||||
mark_up_objectives(
|
||||
f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]",
|
||||
ctx, unfinished_locations, mission)
|
||||
for mission in unfinished_missions)
|
||||
|
||||
if ui:
|
||||
ui.log_panels['All'].on_message_markup(message)
|
||||
ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||
else:
|
||||
sc2_logger.info(message)
|
||||
else:
|
||||
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
|
||||
|
||||
|
||||
def calc_unfinished_missions(locations_done, locations, ctx, unlocks=None, unfinished_locations=None,
|
||||
available_missions=[]):
|
||||
unfinished_missions = []
|
||||
locations_completed = []
|
||||
|
||||
if not unlocks:
|
||||
unlocks = initialize_blank_mission_dict(locations)
|
||||
|
||||
if not unfinished_locations:
|
||||
unfinished_locations = initialize_blank_mission_dict(locations)
|
||||
|
||||
if len(available_missions) > 0:
|
||||
available_missions = []
|
||||
|
||||
available_missions.extend(calc_available_missions(locations_done, locations, unlocks))
|
||||
|
||||
for name in available_missions:
|
||||
if not locations[name].extra_locations == -1:
|
||||
objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx)
|
||||
|
||||
if objectives_completed < locations[name].extra_locations:
|
||||
unfinished_missions.append(name)
|
||||
locations_completed.append(objectives_completed)
|
||||
|
||||
else:
|
||||
unfinished_missions.append(name)
|
||||
locations_completed.append(-1)
|
||||
|
||||
return {unfinished_missions[i]: locations_completed[i] for i in range(len(unfinished_missions))}
|
||||
|
||||
|
||||
def is_mission_available(mission_id_to_check, locations_done, locations):
|
||||
unfinished_missions = calc_available_missions(locations_done, locations)
|
||||
|
||||
return any(mission_id_to_check == locations[mission].id for mission in unfinished_missions)
|
||||
|
||||
|
||||
def mark_up_mission_name(mission, location_table, ui, unlock_table):
|
||||
"""Checks if the mission is required for game completion and adds '*' to the name to mark that."""
|
||||
|
||||
if location_table[mission].completion_critical:
|
||||
if ui:
|
||||
message = "[color=AF99EF]" + mission + "[/color]"
|
||||
else:
|
||||
message = "*" + mission + "*"
|
||||
else:
|
||||
message = mission
|
||||
|
||||
if ui:
|
||||
unlocks = unlock_table[mission]
|
||||
|
||||
if len(unlocks) > 0:
|
||||
pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: "
|
||||
pre_message += ", ".join(f"{unlock}({location_table[unlock].id})" for unlock in unlocks)
|
||||
pre_message += f"]"
|
||||
message = pre_message + message + "[/ref]"
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def mark_up_objectives(message, ctx, unfinished_locations, mission):
|
||||
formatted_message = message
|
||||
|
||||
if ctx.ui:
|
||||
locations = unfinished_locations[mission]
|
||||
|
||||
pre_message = f"[ref={list(ctx.mission_req_table).index(mission)+30}|"
|
||||
pre_message += "<br>".join(location for location in locations)
|
||||
pre_message += f"]"
|
||||
formatted_message = pre_message + message + "[/ref]"
|
||||
|
||||
return formatted_message
|
||||
|
||||
|
||||
def request_available_missions(locations_done, location_table, ui):
|
||||
if location_table:
|
||||
message = "Available Missions: "
|
||||
|
||||
# Initialize mission unlock table
|
||||
unlocks = initialize_blank_mission_dict(location_table)
|
||||
|
||||
missions = calc_available_missions(locations_done, location_table, unlocks)
|
||||
message += \
|
||||
", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]"
|
||||
for mission in missions)
|
||||
|
||||
if ui:
|
||||
ui.log_panels['All'].on_message_markup(message)
|
||||
ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||
else:
|
||||
sc2_logger.info(message)
|
||||
else:
|
||||
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
|
||||
|
||||
|
||||
def calc_available_missions(locations_done, locations, unlocks=None):
|
||||
available_missions = []
|
||||
missions_complete = 0
|
||||
|
||||
# Get number of missions completed
|
||||
for loc in locations_done:
|
||||
if loc % 100 == 0:
|
||||
missions_complete += 1
|
||||
|
||||
for name in locations:
|
||||
# Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
|
||||
if unlocks:
|
||||
for unlock in locations[name].required_world:
|
||||
unlocks[list(locations)[unlock-1]].append(name)
|
||||
|
||||
if mission_reqs_completed(name, missions_complete, locations_done, locations):
|
||||
available_missions.append(name)
|
||||
|
||||
return available_missions
|
||||
|
||||
|
||||
def mission_reqs_completed(location_to_check, missions_complete, locations_done, locations):
|
||||
"""Returns a bool signifying if the mission has all requirements complete and can be done
|
||||
|
||||
Keyword arguments:
|
||||
locations_to_check -- the mission string name to check
|
||||
missions_complete -- an int of how many missions have been completed
|
||||
locations_done -- a list of the location ids that have been complete
|
||||
locations -- a dict of MissionInfo for mission requirements for this world"""
|
||||
if len(locations[location_to_check].required_world) >= 1:
|
||||
# A check for when the requirements are being or'd
|
||||
or_success = False
|
||||
|
||||
# Loop through required missions
|
||||
for req_mission in locations[location_to_check].required_world:
|
||||
req_success = True
|
||||
|
||||
# Check if required mission has been completed
|
||||
if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done:
|
||||
if not locations[location_to_check].or_requirements:
|
||||
return False
|
||||
else:
|
||||
req_success = False
|
||||
|
||||
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
|
||||
if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done,
|
||||
locations):
|
||||
if not locations[location_to_check].or_requirements:
|
||||
return False
|
||||
else:
|
||||
req_success = False
|
||||
|
||||
# If requirement check succeeded mark or as satisfied
|
||||
if locations[location_to_check].or_requirements and req_success:
|
||||
or_success = True
|
||||
|
||||
if locations[location_to_check].or_requirements:
|
||||
# Return false if or requirements not met
|
||||
if not or_success:
|
||||
return False
|
||||
|
||||
# Check number of missions
|
||||
if missions_complete >= locations[location_to_check].number:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def initialize_blank_mission_dict(location_table):
|
||||
unlocks = {}
|
||||
|
||||
for mission in list(location_table):
|
||||
unlocks[mission] = []
|
||||
|
||||
return unlocks
|
||||
|
||||
|
||||
def check_game_install_path() -> bool:
|
||||
# First thing: go to the default location for ExecuteInfo.
|
||||
# An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it.
|
||||
if is_windows:
|
||||
# The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow.
|
||||
# https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555#
|
||||
import ctypes.wintypes
|
||||
CSIDL_PERSONAL = 5 # My Documents
|
||||
SHGFP_TYPE_CURRENT = 0 # Get current, not default value
|
||||
|
||||
buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
|
||||
ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
|
||||
documentspath = buf.value
|
||||
einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt"))
|
||||
else:
|
||||
einfo = str(sc2.paths.get_home() / Path(sc2.paths.USERPATH[sc2.paths.PF]))
|
||||
|
||||
# Check if the file exists.
|
||||
if os.path.isfile(einfo):
|
||||
|
||||
# Open the file and read it, picking out the latest executable's path.
|
||||
with open(einfo) as f:
|
||||
content = f.read()
|
||||
if content:
|
||||
base = re.search(r" = (.*)Versions", content).group(1)
|
||||
if os.path.exists(base):
|
||||
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
|
||||
|
||||
# Finally, check the path for an actual executable.
|
||||
# If we find one, great. Set up the SC2PATH.
|
||||
if os.path.isfile(executable):
|
||||
sc2_logger.info(f"Found an SC2 install at {base}!")
|
||||
sc2_logger.debug(f"Latest executable at {executable}.")
|
||||
os.environ["SC2PATH"] = base
|
||||
sc2_logger.debug(f"SC2PATH set to {base}.")
|
||||
return True
|
||||
else:
|
||||
sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.")
|
||||
else:
|
||||
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
|
||||
else:
|
||||
sc2_logger.warning(f"Couldn't find {einfo}. Please run /set_path with your SC2 install directory.")
|
||||
return False
|
||||
|
||||
|
||||
def check_mod_install() -> bool:
|
||||
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
|
||||
try:
|
||||
# Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
|
||||
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
|
||||
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
||||
return True
|
||||
else:
|
||||
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
|
||||
except KeyError:
|
||||
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
|
||||
return False
|
||||
|
||||
|
||||
class DllDirectory:
|
||||
# Credit to Black Sliver for this code.
|
||||
# More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw
|
||||
_old: typing.Optional[str] = None
|
||||
_new: typing.Optional[str] = None
|
||||
|
||||
def __init__(self, new: typing.Optional[str]):
|
||||
self._new = new
|
||||
|
||||
def __enter__(self):
|
||||
old = self.get()
|
||||
if self.set(self._new):
|
||||
self._old = old
|
||||
|
||||
def __exit__(self, *args):
|
||||
if self._old is not None:
|
||||
self.set(self._old)
|
||||
|
||||
@staticmethod
|
||||
def get() -> str:
|
||||
if sys.platform == "win32":
|
||||
n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
|
||||
buf = ctypes.create_unicode_buffer(n)
|
||||
ctypes.windll.kernel32.GetDllDirectoryW(n, buf)
|
||||
return buf.value
|
||||
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def set(s: typing.Optional[str]) -> bool:
|
||||
if sys.platform == "win32":
|
||||
return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0
|
||||
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
333
Utils.py
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import typing
|
||||
import builtins
|
||||
import os
|
||||
@@ -11,6 +12,12 @@ import io
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
import decimal
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tkinter import Tk
|
||||
else:
|
||||
Tk = typing.Any
|
||||
|
||||
|
||||
def tuplize_version(version: str) -> Version:
|
||||
@@ -23,10 +30,15 @@ class Version(typing.NamedTuple):
|
||||
build: int
|
||||
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.3.4"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
from yaml import load, dump, safe_load
|
||||
is_linux = sys.platform.startswith('linux')
|
||||
is_macos = sys.platform == 'darwin'
|
||||
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
||||
|
||||
import jellyfish
|
||||
from yaml import load, load_all, dump, SafeLoader
|
||||
|
||||
try:
|
||||
from yaml import CLoader as Loader
|
||||
@@ -34,47 +46,50 @@ except ImportError:
|
||||
from yaml import Loader
|
||||
|
||||
|
||||
def int16_as_bytes(value):
|
||||
def int16_as_bytes(value: int) -> typing.List[int]:
|
||||
value = value & 0xFFFF
|
||||
return [value & 0xFF, (value >> 8) & 0xFF]
|
||||
|
||||
|
||||
def int32_as_bytes(value):
|
||||
def int32_as_bytes(value: int) -> typing.List[int]:
|
||||
value = value & 0xFFFFFFFF
|
||||
return [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF]
|
||||
|
||||
|
||||
def pc_to_snes(value):
|
||||
def pc_to_snes(value: int) -> int:
|
||||
return ((value << 1) & 0x7F0000) | (value & 0x7FFF) | 0x8000
|
||||
|
||||
|
||||
def snes_to_pc(value):
|
||||
def snes_to_pc(value: int) -> int:
|
||||
return ((value & 0x7F0000) >> 1) | (value & 0x7FFF)
|
||||
|
||||
|
||||
def cache_argsless(function):
|
||||
if function.__code__.co_argcount:
|
||||
raise Exception("Can only cache 0 argument functions with this cache.")
|
||||
RetType = typing.TypeVar("RetType")
|
||||
|
||||
result = sentinel = object()
|
||||
|
||||
def _wrap():
|
||||
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
|
||||
assert not function.__code__.co_argcount, "Can only cache 0 argument functions with this cache."
|
||||
|
||||
sentinel = object()
|
||||
result: typing.Union[object, RetType] = sentinel
|
||||
|
||||
def _wrap() -> RetType:
|
||||
nonlocal result
|
||||
if result is sentinel:
|
||||
result = function()
|
||||
return result
|
||||
return typing.cast(RetType, result)
|
||||
|
||||
return _wrap
|
||||
|
||||
|
||||
def is_frozen() -> bool:
|
||||
return getattr(sys, 'frozen', False)
|
||||
return typing.cast(bool, getattr(sys, 'frozen', False))
|
||||
|
||||
|
||||
def local_path(*path):
|
||||
if local_path.cached_path:
|
||||
return os.path.join(local_path.cached_path, *path)
|
||||
|
||||
def local_path(*path: str) -> str:
|
||||
"""Returns path to a file in the local Archipelago installation or source."""
|
||||
if hasattr(local_path, 'cached_path'):
|
||||
pass
|
||||
elif is_frozen():
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
# we are running in a PyInstaller bundle
|
||||
@@ -94,21 +109,47 @@ def local_path(*path):
|
||||
return os.path.join(local_path.cached_path, *path)
|
||||
|
||||
|
||||
local_path.cached_path = None
|
||||
def home_path(*path: str) -> str:
|
||||
"""Returns path to a file in the user home's Archipelago directory."""
|
||||
if hasattr(home_path, 'cached_path'):
|
||||
pass
|
||||
elif sys.platform.startswith('linux'):
|
||||
home_path.cached_path = os.path.expanduser('~/Archipelago')
|
||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||
else:
|
||||
# not implemented
|
||||
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
||||
|
||||
return os.path.join(home_path.cached_path, *path)
|
||||
|
||||
|
||||
def output_path(*path):
|
||||
if output_path.cached_path:
|
||||
def user_path(*path: str) -> str:
|
||||
"""Returns either local_path or home_path based on write permissions."""
|
||||
if hasattr(user_path, 'cached_path'):
|
||||
pass
|
||||
elif os.access(local_path(), os.W_OK):
|
||||
user_path.cached_path = local_path()
|
||||
else:
|
||||
user_path.cached_path = home_path()
|
||||
# populate home from local - TODO: upgrade feature
|
||||
if user_path.cached_path != local_path() and not os.path.exists(user_path('host.yaml')):
|
||||
for dn in ('Players', 'data/sprites'):
|
||||
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
||||
for fn in ('manifest.json', 'host.yaml'):
|
||||
shutil.copy2(local_path(fn), user_path(fn))
|
||||
|
||||
return os.path.join(user_path.cached_path, *path)
|
||||
|
||||
|
||||
def output_path(*path: str):
|
||||
if hasattr(output_path, 'cached_path'):
|
||||
return os.path.join(output_path.cached_path, *path)
|
||||
output_path.cached_path = local_path(get_options()["general_options"]["output_path"])
|
||||
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
||||
path = os.path.join(output_path.cached_path, *path)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
output_path.cached_path = None
|
||||
|
||||
|
||||
def open_file(filename):
|
||||
if sys.platform == 'win32':
|
||||
os.startfile(filename)
|
||||
@@ -117,7 +158,21 @@ def open_file(filename):
|
||||
subprocess.call([open_command, filename])
|
||||
|
||||
|
||||
parse_yaml = safe_load
|
||||
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
|
||||
class UniqueKeyLoader(SafeLoader):
|
||||
def construct_mapping(self, node, deep=False):
|
||||
mapping = set()
|
||||
for key_node, value_node in node.value:
|
||||
key = self.construct_object(key_node, deep=deep)
|
||||
if key in mapping:
|
||||
logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}")
|
||||
raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.")
|
||||
mapping.add(key)
|
||||
return super().construct_mapping(node, deep)
|
||||
|
||||
|
||||
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
|
||||
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
|
||||
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
|
||||
|
||||
|
||||
@@ -168,7 +223,7 @@ def get_default_options() -> dict:
|
||||
"output_path": "output",
|
||||
},
|
||||
"factorio_options": {
|
||||
"executable": "factorio\\bin\\x64\\factorio",
|
||||
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
||||
},
|
||||
"sm_options": {
|
||||
"rom_file": "Super Metroid (JU).sfc",
|
||||
@@ -205,7 +260,7 @@ def get_default_options() -> dict:
|
||||
},
|
||||
"generator": {
|
||||
"teams": 1,
|
||||
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
|
||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
|
||||
"player_files_path": "Players",
|
||||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
@@ -217,11 +272,17 @@ def get_default_options() -> dict:
|
||||
},
|
||||
"minecraft_options": {
|
||||
"forge_directory": "Minecraft Forge server",
|
||||
"max_heap_size": "2G"
|
||||
"max_heap_size": "2G",
|
||||
"release_channel": "release"
|
||||
},
|
||||
"oot_options": {
|
||||
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
||||
}
|
||||
},
|
||||
"dkc3_options": {
|
||||
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
},
|
||||
}
|
||||
|
||||
return options
|
||||
@@ -249,8 +310,11 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
@cache_argsless
|
||||
def get_options() -> dict:
|
||||
if not hasattr(get_options, "options"):
|
||||
locations = ("options.yaml", "host.yaml",
|
||||
local_path("options.yaml"), local_path("host.yaml"))
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations = []
|
||||
if os.path.join(os.getcwd()) != local_path():
|
||||
locations += filenames # use files from cwd only if it's not the local_path
|
||||
locations += [user_path(filename) for filename in filenames]
|
||||
|
||||
for location in locations:
|
||||
if os.path.exists(location):
|
||||
@@ -260,7 +324,7 @@ def get_options() -> dict:
|
||||
get_options.options = update_options(get_default_options(), options, location, list())
|
||||
break
|
||||
else:
|
||||
raise FileNotFoundError(f"Could not find {locations[1]} to load options.")
|
||||
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
|
||||
return get_options.options
|
||||
|
||||
|
||||
@@ -275,7 +339,7 @@ def get_location_name_from_id(code: int) -> str:
|
||||
|
||||
|
||||
def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
||||
path = local_path("_persistent_storage.yaml")
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
storage: dict = persistent_load()
|
||||
category = storage.setdefault(category, {})
|
||||
category[key] = value
|
||||
@@ -287,7 +351,7 @@ def persistent_load() -> typing.Dict[dict]:
|
||||
storage = getattr(persistent_load, "storage", None)
|
||||
if storage:
|
||||
return storage
|
||||
path = local_path("_persistent_storage.yaml")
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
storage: dict = {}
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
@@ -301,63 +365,9 @@ def persistent_load() -> typing.Dict[dict]:
|
||||
return storage
|
||||
|
||||
|
||||
def get_adjuster_settings(romfile: str, skip_questions: bool = False) -> typing.Tuple[str, bool]:
|
||||
if hasattr(get_adjuster_settings, "adjuster_settings"):
|
||||
adjuster_settings = getattr(get_adjuster_settings, "adjuster_settings")
|
||||
else:
|
||||
adjuster_settings = persistent_load().get("adjuster", {}).get("last_settings_3", {})
|
||||
|
||||
if adjuster_settings:
|
||||
import pprint
|
||||
from worlds.alttp.Rom import get_base_rom_path
|
||||
adjuster_settings.rom = romfile
|
||||
adjuster_settings.baserom = get_base_rom_path()
|
||||
adjuster_settings.world = None
|
||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||
"uw_palettes", "sprite"}
|
||||
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
sprite_pool = {}
|
||||
for sprite in getattr(adjuster_settings, "sprite_pool"):
|
||||
if sprite in sprite_pool:
|
||||
sprite_pool[sprite] += 1
|
||||
else:
|
||||
sprite_pool[sprite] = 1
|
||||
if sprite_pool:
|
||||
printed_options["sprite_pool"] = sprite_pool
|
||||
|
||||
if hasattr(get_adjuster_settings, "adjust_wanted"):
|
||||
adjust_wanted = getattr(get_adjuster_settings, "adjust_wanted")
|
||||
elif persistent_load().get("adjuster", {}).get("never_adjust", False): # never adjust, per user request
|
||||
return romfile, False
|
||||
elif skip_questions:
|
||||
return romfile, False
|
||||
else:
|
||||
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
||||
f"{pprint.pformat(printed_options)}\n"
|
||||
f"Enter yes, no or never: ")
|
||||
if adjust_wanted and adjust_wanted.startswith("y"):
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
from LttPAdjuster import AdjusterWorld
|
||||
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
|
||||
|
||||
adjusted = True
|
||||
import LttPAdjuster
|
||||
_, romfile = LttPAdjuster.adjust(adjuster_settings)
|
||||
|
||||
if hasattr(adjuster_settings, "world"):
|
||||
delattr(adjuster_settings, "world")
|
||||
elif adjust_wanted and "never" in adjust_wanted:
|
||||
persistent_store("adjuster", "never_adjust", True)
|
||||
return romfile, False
|
||||
else:
|
||||
adjusted = False
|
||||
if not hasattr(get_adjuster_settings, "adjust_wanted"):
|
||||
logging.info(f"Skipping post-patch adjustment")
|
||||
get_adjuster_settings.adjuster_settings = adjuster_settings
|
||||
get_adjuster_settings.adjust_wanted = adjust_wanted
|
||||
return romfile, adjusted
|
||||
return romfile, False
|
||||
def get_adjuster_settings(gameName: str):
|
||||
adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {})
|
||||
return adjuster_settings
|
||||
|
||||
|
||||
@cache_argsless
|
||||
@@ -389,7 +399,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
return getattr(builtins, name)
|
||||
# used by MultiServer -> savegame/multidata
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint"}:
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
|
||||
return getattr(self.net_utils_module, name)
|
||||
# Options and Plando are unpickled by WebHost -> Generate
|
||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||
@@ -426,9 +436,10 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
|
||||
|
||||
|
||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
||||
log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""):
|
||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||
exception_logger: typing.Optional[str] = None):
|
||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||
log_folder = local_path("logs")
|
||||
log_folder = user_path("logs")
|
||||
os.makedirs(log_folder, exist_ok=True)
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
@@ -462,15 +473,143 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
|
||||
logging.info(f"Archipelago ({__version__}) logging initialized.")
|
||||
|
||||
|
||||
def stream_input(stream, queue):
|
||||
def queuer():
|
||||
while 1:
|
||||
text = stream.readline().strip()
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
try:
|
||||
text = stream.readline().strip()
|
||||
except UnicodeDecodeError as e:
|
||||
logging.exception(e)
|
||||
else:
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
|
||||
from threading import Thread
|
||||
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def tkinter_center_window(window: Tk):
|
||||
window.update()
|
||||
xPos = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
|
||||
yPos = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
|
||||
window.geometry("+{}+{}".format(xPos, yPos))
|
||||
|
||||
|
||||
class VersionException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
|
||||
text = ""
|
||||
max_label = len(labels) - 1
|
||||
while index > max_label:
|
||||
text += labels[-1]
|
||||
index -= max_label
|
||||
return labels[index] + text
|
||||
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str:
|
||||
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
|
||||
n = 0
|
||||
value = decimal.Decimal(value)
|
||||
while value >= power:
|
||||
value /= power
|
||||
n += 1
|
||||
|
||||
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
|
||||
|
||||
|
||||
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
||||
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
||||
/ max(len(word1), len(word2)))
|
||||
|
||||
|
||||
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
|
||||
-> typing.List[typing.Tuple[str, int]]:
|
||||
limit: int = limit if limit else len(wordlist)
|
||||
return list(
|
||||
map(
|
||||
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
|
||||
sorted(
|
||||
map(lambda candidate:
|
||||
(candidate, get_fuzzy_ratio(input_word, candidate)),
|
||||
wordlist),
|
||||
key=lambda element: element[1],
|
||||
reverse=True)[0:limit]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
|
||||
-> typing.Optional[str]:
|
||||
def run(*args: str):
|
||||
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
|
||||
|
||||
if is_linux:
|
||||
# prefer native dialog
|
||||
kdialog = shutil.which('kdialog')
|
||||
if kdialog:
|
||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||
return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters)
|
||||
zenity = shutil.which('zenity')
|
||||
if zenity:
|
||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||
return run(zenity, f'--title={title}', '--file-selection', *z_filters)
|
||||
|
||||
# fall back to tk
|
||||
try:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed. '
|
||||
f'This attempt was made because open_filename was used for "{title}".')
|
||||
raise e
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes))
|
||||
|
||||
|
||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
def run(*args: str):
|
||||
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
|
||||
|
||||
def is_kivy_running():
|
||||
if 'kivy' in sys.modules:
|
||||
from kivy.app import App
|
||||
return App.get_running_app() is not None
|
||||
return False
|
||||
|
||||
if is_kivy_running():
|
||||
from kvui import MessageBox
|
||||
MessageBox(title, text, error).open()
|
||||
return
|
||||
|
||||
if is_linux and not 'tkinter' in sys.modules:
|
||||
# prefer native dialog
|
||||
kdialog = shutil.which('kdialog')
|
||||
if kdialog:
|
||||
return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text)
|
||||
zenity = shutil.which('zenity')
|
||||
if zenity:
|
||||
return run(zenity, f'--title={title}', f'--text={text}', '--error' if error else '--info')
|
||||
|
||||
# fall back to tk
|
||||
try:
|
||||
import tkinter
|
||||
from tkinter.messagebox import showerror, showinfo
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed. '
|
||||
f'This attempt was made because messagebox was used for "{title}".')
|
||||
raise e
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
showerror(title, text) if error else showinfo(title, text)
|
||||
root.update()
|
||||
|
||||
64
WebHost.py
@@ -1,24 +1,32 @@
|
||||
import os
|
||||
import sys
|
||||
import multiprocessing
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
|
||||
ModuleUpdate.update()
|
||||
|
||||
# in case app gets imported by something like gunicorn
|
||||
import Utils
|
||||
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||
|
||||
from WebHostLib import app as raw_app
|
||||
from waitress import serve
|
||||
|
||||
from WebHostLib.models import db
|
||||
from WebHostLib.autolauncher import autohost
|
||||
from WebHostLib.autolauncher import autohost, autogen
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
|
||||
|
||||
def get_app():
|
||||
@@ -32,6 +40,56 @@ def get_app():
|
||||
return app
|
||||
|
||||
|
||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
||||
import json
|
||||
import shutil
|
||||
worlds = {}
|
||||
data = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||
worlds[game] = world
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs')
|
||||
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game)
|
||||
files = os.listdir(source_path)
|
||||
for file in files:
|
||||
os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True)
|
||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
||||
# build a json tutorial dict per game
|
||||
game_data = {'gameTitle': game, 'tutorials': []}
|
||||
for tutorial in world.web.tutorials:
|
||||
# build dict for the json file
|
||||
current_tutorial = {
|
||||
'name': tutorial.tutorial_name,
|
||||
'description': tutorial.description,
|
||||
'files': [{
|
||||
'language': tutorial.language,
|
||||
'filename': game + '/' + tutorial.file_name,
|
||||
'link': f'{game}/{tutorial.link}',
|
||||
'authors': tutorial.authors
|
||||
}]
|
||||
}
|
||||
|
||||
# check if the name of the current guide exists already
|
||||
for guide in game_data['tutorials']:
|
||||
if guide and tutorial.tutorial_name == guide['name']:
|
||||
guide['files'].append(current_tutorial['files'][0])
|
||||
break
|
||||
else:
|
||||
game_data['tutorials'].append(current_tutorial)
|
||||
|
||||
data.append(game_data)
|
||||
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
|
||||
generic_data = {}
|
||||
for games in data:
|
||||
if 'Archipelago' in games['gameTitle']:
|
||||
generic_data = data.pop(data.index(games))
|
||||
sorted_data = [generic_data] + sorted(data, key=lambda entry: entry["gameTitle"].lower())
|
||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||
return sorted_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method('spawn')
|
||||
@@ -43,11 +101,13 @@ if __name__ == "__main__":
|
||||
logging.warning("Could not update LttP sprites.")
|
||||
app = get_app()
|
||||
create_options_files()
|
||||
create_ordered_tutorials_file()
|
||||
if app.config["SELFLAUNCH"]:
|
||||
autohost(app.config)
|
||||
if app.config["SELFGEN"]:
|
||||
autogen(app.config)
|
||||
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()
|
||||
if app.config["DEBUG"]:
|
||||
autohost(app.config)
|
||||
app.run(debug=True, port=app.config["PORT"])
|
||||
else:
|
||||
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
|
||||
|
||||
@@ -22,9 +22,10 @@ Pony(app)
|
||||
app.jinja_env.filters['any'] = any
|
||||
app.jinja_env.filters['all'] = all
|
||||
|
||||
app.config["SELFHOST"] = True
|
||||
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||
app.config["SELFLAUNCH"] = True
|
||||
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
|
||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
@@ -45,7 +46,7 @@ app.config["PONY"] = {
|
||||
'create_db': True
|
||||
}
|
||||
app.config["MAX_ROLL"] = 20
|
||||
app.config["CACHE_TYPE"] = "simple"
|
||||
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
|
||||
app.config["JSON_AS_ASCII"] = False
|
||||
app.config["PATCH_TARGET"] = "archipelago.gg"
|
||||
|
||||
@@ -68,6 +69,16 @@ class B64UUIDConverter(BaseConverter):
|
||||
app.url_map.converters["suuid"] = B64UUIDConverter
|
||||
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
# has automatic patch integration
|
||||
import Patch
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
|
||||
|
||||
|
||||
def get_world_theme(game_name: str):
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
|
||||
|
||||
@app.before_request
|
||||
def register_session():
|
||||
@@ -88,16 +99,21 @@ def start_playing():
|
||||
return render_template(f"startPlaying.html")
|
||||
|
||||
|
||||
@app.route('/weighted-settings')
|
||||
def weighted_settings():
|
||||
return render_template(f"weighted-settings.html")
|
||||
|
||||
|
||||
# Player settings pages
|
||||
@app.route('/games/<string:game>/player-settings')
|
||||
def player_settings(game):
|
||||
return render_template(f"player-settings.html", game=game)
|
||||
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# Game Info Pages
|
||||
@app.route('/games/<string:game>/info/<string:lang>')
|
||||
def game_info(game, lang):
|
||||
return render_template('gameInfo.html', game=game, lang=lang)
|
||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# List of supported games
|
||||
@@ -106,17 +122,21 @@ def games():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world.__doc__ if world.__doc__ else "No description provided."
|
||||
worlds[game] = world
|
||||
return render_template("supportedGames.html", worlds=worlds)
|
||||
|
||||
|
||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||
def tutorial(game, file, lang):
|
||||
return render_template("tutorial.html", game=game, file=file, lang=lang)
|
||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
@app.route('/tutorial/')
|
||||
def tutorial_landing():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@@ -125,13 +145,17 @@ def faq(lang):
|
||||
return render_template("faq.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/glossary/<string:lang>/')
|
||||
def terms(lang):
|
||||
return render_template("glossary.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/seed/<suuid:seed>')
|
||||
def view_seed(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
return render_template("viewSeed.html", seed=seed,
|
||||
rooms=[room for room in seed.rooms if room.owner == session["_id"]])
|
||||
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
|
||||
|
||||
|
||||
@app.route('/new_room/<suuid:seed>')
|
||||
@@ -155,7 +179,12 @@ def _read_log(path: str):
|
||||
|
||||
@app.route('/log/<suuid:room>')
|
||||
def display_log(room: UUID):
|
||||
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if room.owner == session["_id"]:
|
||||
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
return "Access Denied", 403
|
||||
|
||||
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
@@ -166,8 +195,9 @@ def host_room(room: UUID):
|
||||
if request.method == "POST":
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
|
||||
with db_session:
|
||||
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
|
||||
@@ -186,7 +216,26 @@ def discord():
|
||||
return redirect("https://discord.gg/archipelago")
|
||||
|
||||
|
||||
@app.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackge():
|
||||
"""A pretty print version of /api/datapackage"""
|
||||
from worlds import network_data_package
|
||||
import json
|
||||
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
|
||||
|
||||
|
||||
@app.route('/index')
|
||||
@app.route('/sitemap')
|
||||
def get_sitemap():
|
||||
available_games = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
available_games.append(game)
|
||||
return render_template("siteMap.html", games=available_games)
|
||||
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats # to trigger app routing picking up on it
|
||||
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
"""API endpoints package."""
|
||||
from uuid import UUID
|
||||
from typing import List, Tuple
|
||||
|
||||
from flask import Blueprint, abort
|
||||
|
||||
from ..models import Room
|
||||
from ..models import Room, Seed
|
||||
from .. import cache
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
from . import generate, user # trigger registration
|
||||
|
||||
# unsorted/misc endpoints
|
||||
|
||||
|
||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
||||
|
||||
|
||||
@api_endpoints.route('/room_status/<suuid:room>')
|
||||
def room_info(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
return {"tracker": room.tracker,
|
||||
"players": room.seed.multidata["names"],
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout}
|
||||
return {
|
||||
"tracker": room.tracker,
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout
|
||||
}
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage')
|
||||
@@ -31,6 +36,7 @@ def get_datapackge():
|
||||
from worlds import network_data_package
|
||||
return network_data_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
def get_datapackge_versions():
|
||||
@@ -38,3 +44,6 @@ def get_datapackge_versions():
|
||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||
version_package["version"] = network_data_package["version"]
|
||||
return version_package
|
||||
|
||||
|
||||
from . import generate, user # trigger registration
|
||||
|
||||
@@ -45,7 +45,7 @@ def generate_api():
|
||||
"detail": app.config["MAX_ROLL"]}, 409
|
||||
meta = get_meta(meta_options_source)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options)
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return {"text": str(results),
|
||||
"detail": results}, 400
|
||||
@@ -65,7 +65,6 @@ def generate_api():
|
||||
return {"text": "Uncaught Exception:" + str(e)}, 500
|
||||
|
||||
|
||||
|
||||
@api_endpoints.route('/status/<suuid:seed>')
|
||||
def wait_seed_api(seed: UUID):
|
||||
seed_id = seed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from flask import session, jsonify
|
||||
|
||||
from WebHostLib.models import *
|
||||
from . import api_endpoints
|
||||
from . import api_endpoints, get_players
|
||||
|
||||
|
||||
@api_endpoints.route('/get_rooms')
|
||||
@@ -16,7 +16,6 @@ def get_rooms():
|
||||
"last_port": room.last_port,
|
||||
"timeout": room.timeout,
|
||||
"tracker": room.tracker,
|
||||
"players": room.seed.multidata["names"] if room.seed.multidata else [["Singleplayer"]],
|
||||
})
|
||||
return jsonify(response)
|
||||
|
||||
@@ -28,6 +27,6 @@ def get_seeds():
|
||||
response.append({
|
||||
"seed_id": seed.id,
|
||||
"creation_time": seed.creation_time,
|
||||
"players": seed.multidata["names"] if seed.multidata else [["Singleplayer"]],
|
||||
"players": get_players(seed.slots),
|
||||
})
|
||||
return jsonify(response)
|
||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
import json
|
||||
import multiprocessing
|
||||
import threading
|
||||
from datetime import timedelta, datetime
|
||||
import concurrent.futures
|
||||
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
@@ -17,6 +18,7 @@ from Utils import restricted_loads
|
||||
class CommonLocker():
|
||||
"""Uses a file lock to signal that something is already running"""
|
||||
lock_folder = "file_locks"
|
||||
|
||||
def __init__(self, lockname: str, folder=None):
|
||||
if folder:
|
||||
self.lock_folder = folder
|
||||
@@ -53,7 +55,7 @@ else: # unix
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.fp = open(self.lockfile, "wb")
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX)
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
@@ -110,6 +112,27 @@ def autohost(config: dict):
|
||||
def keep_running():
|
||||
try:
|
||||
with Locker("autohost"):
|
||||
run_guardian()
|
||||
while 1:
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||
for room in rooms:
|
||||
launch_room(room, config)
|
||||
|
||||
except AlreadyRunningException:
|
||||
logging.info("Autohost reports as already running, not starting another.")
|
||||
|
||||
import threading
|
||||
threading.Thread(target=keep_running, name="AP_Autohost").start()
|
||||
|
||||
|
||||
def autogen(config: dict):
|
||||
def keep_running():
|
||||
try:
|
||||
with Locker("autogen"):
|
||||
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
||||
initargs=(config["PONY"],)) as generator_pool:
|
||||
@@ -129,34 +152,30 @@ def autohost(config: dict):
|
||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||
|
||||
while 1:
|
||||
time.sleep(0.50)
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||
for room in rooms:
|
||||
launch_room(room, config)
|
||||
# for update locks the database row(s) during transaction, preventing writes from elsewhere
|
||||
to_start = select(
|
||||
generation for generation in Generation if generation.state == STATE_QUEUED)
|
||||
generation for generation in Generation
|
||||
if generation.state == STATE_QUEUED).for_update()
|
||||
for generation in to_start:
|
||||
launch_generator(generator_pool, generation)
|
||||
except AlreadyRunningException:
|
||||
pass
|
||||
logging.info("Autogen reports as already running, not starting another.")
|
||||
|
||||
import threading
|
||||
threading.Thread(target=keep_running).start()
|
||||
threading.Thread(target=keep_running, name="AP_Autogen").start()
|
||||
|
||||
|
||||
multiworlds = {}
|
||||
|
||||
guardians = concurrent.futures.ThreadPoolExecutor(2, thread_name_prefix="Guardian")
|
||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
||||
|
||||
|
||||
class MultiworldInstance():
|
||||
def __init__(self, room: Room, config: dict):
|
||||
self.room_id = room.id
|
||||
self.process: typing.Optional[multiprocessing.Process] = None
|
||||
multiworlds[self.room_id] = self
|
||||
with guardian_lock:
|
||||
multiworlds[self.room_id] = self
|
||||
self.ponyconfig = config["PONY"]
|
||||
|
||||
def start(self):
|
||||
@@ -164,21 +183,58 @@ class MultiworldInstance():
|
||||
return False
|
||||
|
||||
logging.info(f"Spinning up {self.room_id}")
|
||||
self.process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig),
|
||||
name="MultiHost")
|
||||
self.process.start()
|
||||
self.guardian = guardians.submit(self._collect)
|
||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig),
|
||||
name="MultiHost")
|
||||
process.start()
|
||||
# bind after start to prevent thread sync issues with guardian.
|
||||
self.process = process
|
||||
|
||||
def stop(self):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
self.process = None
|
||||
|
||||
def _collect(self):
|
||||
def done(self):
|
||||
return self.process and not self.process.is_alive()
|
||||
|
||||
def collect(self):
|
||||
self.process.join() # wait for process to finish
|
||||
self.process = None
|
||||
self.guardian = None
|
||||
|
||||
|
||||
guardian = None
|
||||
guardian_lock = threading.Lock()
|
||||
|
||||
|
||||
def run_guardian():
|
||||
global guardian
|
||||
global multiworlds
|
||||
with guardian_lock:
|
||||
if not guardian:
|
||||
try:
|
||||
import resource
|
||||
except ModuleNotFoundError:
|
||||
pass # unix only module
|
||||
else:
|
||||
# Each Server is another file handle, so request as many as we can from the system
|
||||
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
||||
# set soft limit to hard limit
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
||||
|
||||
def guard():
|
||||
while 1:
|
||||
time.sleep(1)
|
||||
done = []
|
||||
with guardian_lock:
|
||||
for key, instance in multiworlds.items():
|
||||
if instance.done():
|
||||
instance.collect()
|
||||
done.append(key)
|
||||
for key in done:
|
||||
del (multiworlds[key])
|
||||
|
||||
guardian = threading.Thread(name="Guardian", target=guard)
|
||||
|
||||
|
||||
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
|
||||
|
||||
@@ -12,12 +12,12 @@ def allowed_file(filename):
|
||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||
|
||||
|
||||
from Generate import roll_settings
|
||||
from Utils import parse_yaml
|
||||
from Generate import roll_settings, PlandoSettings
|
||||
from Utils import parse_yamls
|
||||
|
||||
|
||||
@app.route('/mysterycheck', methods=['GET', 'POST'])
|
||||
def mysterycheck():
|
||||
@app.route('/check', methods=['GET', 'POST'])
|
||||
def check():
|
||||
if request.method == 'POST':
|
||||
# check if the post request has the file part
|
||||
if 'file' not in request.files:
|
||||
@@ -30,10 +30,14 @@ def mysterycheck():
|
||||
else:
|
||||
results, _ = roll_options(options)
|
||||
return render_template("checkResult.html", results=results)
|
||||
|
||||
return render_template("check.html")
|
||||
|
||||
|
||||
@app.route('/mysterycheck')
|
||||
def mysterycheck():
|
||||
return redirect(url_for("check"), 301)
|
||||
|
||||
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
options = {}
|
||||
# if user does not select file, browser also
|
||||
@@ -58,21 +62,29 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
return options
|
||||
|
||||
|
||||
def roll_options(options: Dict[str, Union[dict, str]]) -> Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
plando_options = PlandoSettings.from_set(set(plando_options))
|
||||
results = {}
|
||||
rolled_results = {}
|
||||
for filename, text in options.items():
|
||||
try:
|
||||
if type(text) is dict:
|
||||
yaml_data = text
|
||||
yaml_datas = (text, )
|
||||
else:
|
||||
yaml_data = parse_yaml(text)
|
||||
yaml_datas = tuple(parse_yamls(text))
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to parse YAML data in {filename}: {e}"
|
||||
else:
|
||||
try:
|
||||
rolled_results[filename] = roll_settings(yaml_data,
|
||||
plando_options={"bosses", "items", "connections", "texts"})
|
||||
if len(yaml_datas) == 1:
|
||||
rolled_results[filename] = roll_settings(yaml_datas[0],
|
||||
plando_options=plando_options)
|
||||
else:
|
||||
for i, yaml_data in enumerate(yaml_datas):
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to generate mystery in {filename}: {e}"
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import websockets
|
||||
import asyncio
|
||||
import socket
|
||||
@@ -9,6 +8,7 @@ import threading
|
||||
import time
|
||||
import random
|
||||
import pickle
|
||||
import logging
|
||||
|
||||
import Utils
|
||||
from .models import *
|
||||
@@ -76,7 +76,7 @@ class WebHostContext(Context):
|
||||
else:
|
||||
self.port = get_random_port()
|
||||
|
||||
return self._load(self._decompress(room.seed.multidata), True)
|
||||
return self._load(self.decompress(room.seed.multidata), True)
|
||||
|
||||
@db_session
|
||||
def init_save(self, enabled: bool = True):
|
||||
@@ -128,15 +128,21 @@ def run_server_process(room_id, ponyconfig: dict):
|
||||
ping_interval=None)
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
if wssocket.family == socket.AF_INET6:
|
||||
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = socketname[1]
|
||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||
if not port:
|
||||
port = socketname[1]
|
||||
elif wssocket.family == socket.AF_INET:
|
||||
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
|
||||
port = socketname[1]
|
||||
if port:
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = port
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
@@ -146,6 +152,3 @@ def run_server_process(room_id, ponyconfig: dict):
|
||||
from .autolauncher import Locker
|
||||
with Locker(room_id):
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
from WebHostLib import LOGS_FOLDER
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import zipfile
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
from flask import send_file, Response, render_template
|
||||
from pony.orm import select
|
||||
|
||||
from Patch import update_patch_data, preferred_endings
|
||||
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
|
||||
from WebHostLib import app, Slot, Room, Seed, cache
|
||||
import zipfile
|
||||
|
||||
|
||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||
@@ -12,16 +15,34 @@ def download_patch(room_id, patch_id):
|
||||
if not patch:
|
||||
return "Patch not found"
|
||||
else:
|
||||
import io
|
||||
|
||||
room = Room.get(id=room_id)
|
||||
last_port = room.last_port
|
||||
filelike = BytesIO(patch.data)
|
||||
greater_than_version_3 = zipfile.is_zipfile(filelike)
|
||||
if greater_than_version_3:
|
||||
# Python's zipfile module cannot overwrite/delete files in a zip, so we recreate the whole thing in ram
|
||||
new_file = BytesIO()
|
||||
with zipfile.ZipFile(filelike, "a") as zf:
|
||||
with zf.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
|
||||
with zipfile.ZipFile(new_file, "w") as new_zip:
|
||||
for file in zf.infolist():
|
||||
if file.filename == "archipelago.json":
|
||||
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
||||
else:
|
||||
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
|
||||
|
||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||
patch_data = io.BytesIO(patch_data)
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
|
||||
f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}"
|
||||
new_file.seek(0)
|
||||
return send_file(new_file, as_attachment=True, attachment_filename=fname)
|
||||
else:
|
||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||
patch_data = BytesIO(patch_data)
|
||||
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||
f"{preferred_endings[patch.game]}"
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||
f"{preferred_endings[patch.game]}"
|
||||
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
|
||||
|
||||
|
||||
@@ -34,7 +55,7 @@ def download_spoiler(seed_id):
|
||||
def download_slot_file(room_id, player_id: int):
|
||||
room = Room.get(id=room_id)
|
||||
slot_data: Slot = select(patch for patch in room.seed.slots if
|
||||
patch.player_id == player_id).first()
|
||||
patch.player_id == player_id).first()
|
||||
|
||||
if not slot_data:
|
||||
return "Slot Data not found"
|
||||
@@ -50,13 +71,20 @@ def download_slot_file(room_id, player_id: int):
|
||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith("info.json"):
|
||||
fname = name.rsplit("/", 1)[0]+".zip"
|
||||
fname = name.rsplit("/", 1)[0] + ".zip"
|
||||
elif slot_data.game == "Ocarina of Time":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
elif slot_data.game == "VVVVVV":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||
elif slot_data.game == "Super Mario 64":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||
elif slot_data.game == "Dark Souls III":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
|
||||
else:
|
||||
return "Game download not supported."
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
|
||||
|
||||
|
||||
@app.route("/templates")
|
||||
@cache.cached()
|
||||
def list_yaml_templates():
|
||||
@@ -65,4 +93,4 @@ def list_yaml_templates():
|
||||
for world_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
files.append(world_name)
|
||||
return render_template("templates.html", files=files)
|
||||
return render_template("templates.html", files=files)
|
||||
|
||||
@@ -4,30 +4,41 @@ import random
|
||||
import json
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from typing import Dict, Optional as TypeOptional
|
||||
from typing import Dict, Optional, Any
|
||||
from Utils import __version__
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from Generate import handle_name
|
||||
from Generate import handle_name, PlandoSettings
|
||||
import pickle
|
||||
|
||||
from .models import *
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
|
||||
from WebHostLib import app
|
||||
from .check import get_yaml_data, roll_options
|
||||
from .upload import upload_zip_to_db
|
||||
|
||||
|
||||
def get_meta(options_source: dict) -> dict:
|
||||
meta = {
|
||||
plando_options = {
|
||||
options_source.get("plando_bosses", ""),
|
||||
options_source.get("plando_items", ""),
|
||||
options_source.get("plando_connections", ""),
|
||||
options_source.get("plando_texts", "")
|
||||
}
|
||||
plando_options -= {""}
|
||||
|
||||
server_options = {
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
||||
"remaining_mode": options_source.get("forfeit_mode", "disabled"),
|
||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
"server_password": options_source.get("server_password", None),
|
||||
}
|
||||
return meta
|
||||
return {"server_options": server_options, "plando_options": list(plando_options)}
|
||||
|
||||
|
||||
@app.route('/generate', methods=['GET', 'POST'])
|
||||
@@ -43,19 +54,18 @@ def generate(race=False):
|
||||
if type(options) == str:
|
||||
flash(options)
|
||||
else:
|
||||
results, gen_options = roll_options(options)
|
||||
# get form data -> server settings
|
||||
meta = get_meta(request.form)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
|
||||
if race:
|
||||
meta["item_cheat"] = False
|
||||
meta["remaining"] = False
|
||||
meta["server_options"]["item_cheat"] = False
|
||||
meta["server_options"]["remaining_mode"] = "disabled"
|
||||
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return render_template("checkResult.html", results=results)
|
||||
elif len(gen_options) > app.config["MAX_ROLL"]:
|
||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players for now. "
|
||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
||||
f"If you have a larger group, please generate it yourself and upload it.")
|
||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||
gen = Generation(
|
||||
@@ -78,35 +88,38 @@ def generate(race=False):
|
||||
|
||||
return redirect(url_for("view_seed", seed=seed_id))
|
||||
|
||||
return render_template("generate.html", race=race)
|
||||
return render_template("generate.html", race=race, version=__version__)
|
||||
|
||||
|
||||
def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
|
||||
def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||
if not meta:
|
||||
meta: Dict[str, object] = {}
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||
race = meta.setdefault("race", False)
|
||||
|
||||
meta.setdefault("hint_cost", 10)
|
||||
race = meta.get("race", False)
|
||||
del (meta["race"])
|
||||
try:
|
||||
target = tempfile.TemporaryDirectory()
|
||||
playercount = len(gen_options)
|
||||
seed = get_seed()
|
||||
random.seed(seed)
|
||||
|
||||
if race:
|
||||
random.seed() # reset to time-based random source
|
||||
random.seed() # use time-based random source
|
||||
else:
|
||||
random.seed(seed)
|
||||
|
||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||
|
||||
erargs = parse_arguments(['--multi', str(playercount)])
|
||||
erargs.seed = seed
|
||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwrittin in mystery
|
||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
erargs.spoiler = 0 if race else 2
|
||||
erargs.race = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
@@ -120,8 +133,9 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
|
||||
if not erargs.name[player]:
|
||||
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
ERmain(erargs, seed, baked_server_options=meta)
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||
|
||||
return upload_to_db(target.name, sid, owner, race)
|
||||
except BaseException as e:
|
||||
@@ -133,7 +147,6 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
|
||||
commit()
|
||||
raise
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import threading
|
||||
import json
|
||||
|
||||
from Utils import local_path
|
||||
from Utils import local_path, user_path
|
||||
from worlds.alttp.Rom import Sprite
|
||||
|
||||
|
||||
@@ -10,20 +10,25 @@ def update_sprites_lttp():
|
||||
from tkinter import Tk
|
||||
from LttPAdjuster import get_image_for_sprite
|
||||
from LttPAdjuster import BackgroundTaskProgress
|
||||
from LttPAdjuster import BackgroundTaskProgressNullWindow
|
||||
from LttPAdjuster import update_sprites
|
||||
|
||||
# Target directories
|
||||
input_dir = local_path("data", "sprites", "alttpr")
|
||||
output_dir = local_path("WebHostLib", "static", "generated")
|
||||
input_dir = user_path("data", "sprites", "alttpr")
|
||||
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
||||
|
||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||
# update sprites through gui.py's functions
|
||||
done = threading.Event()
|
||||
top = Tk()
|
||||
top.withdraw()
|
||||
BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||
try:
|
||||
top = Tk()
|
||||
except:
|
||||
task = BackgroundTaskProgressNullWindow(update_sprites, lambda successful, resultmessage: done.set())
|
||||
else:
|
||||
top.withdraw()
|
||||
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||
while not done.isSet():
|
||||
top.update()
|
||||
task.do_events()
|
||||
|
||||
spriteData = []
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ STATE_ERROR = -1
|
||||
class Slot(db.Entity):
|
||||
id = PrimaryKey(int, auto=True)
|
||||
player_id = Required(int)
|
||||
player_name = Required(str, 16)
|
||||
player_name = Required(str)
|
||||
data = Optional(bytes, lazy=True)
|
||||
seed = Optional('Seed')
|
||||
game = Required(str)
|
||||
@@ -27,7 +27,7 @@ class Room(db.Entity):
|
||||
seed = Required('Seed', index=True)
|
||||
multisave = Optional(buffer, lazy=True)
|
||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||
timeout = Required(int, default=lambda: 6 * 60 * 60) # seconds since last activity to shutdown
|
||||
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
||||
tracker = Optional(UUID, index=True)
|
||||
last_port = Optional(int, default=lambda: 0)
|
||||
|
||||
|
||||
@@ -1,23 +1,47 @@
|
||||
import logging
|
||||
import os
|
||||
from Utils import __version__
|
||||
from jinja2 import Template
|
||||
import yaml
|
||||
import json
|
||||
import typing
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import Options
|
||||
|
||||
target_folder = os.path.join("WebHostLib", "static", "generated")
|
||||
|
||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||
"exclude_locations"}
|
||||
|
||||
|
||||
def create():
|
||||
def dictify_range(option):
|
||||
data = {option.range_start: 0, option.range_end: 0, "random": 0, "random-low": 0, "random-high": 0,
|
||||
option.default: 50}
|
||||
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
|
||||
|
||||
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
||||
data = {}
|
||||
special = getattr(option, "special_range_cutoff", None)
|
||||
if special is not None:
|
||||
data[special] = 0
|
||||
data.update({
|
||||
option.range_start: 0,
|
||||
option.range_end: 0,
|
||||
"random": 0, "random-low": 0, "random-high": 0,
|
||||
option.default: 50
|
||||
})
|
||||
notes = {
|
||||
special: "minimum value without special meaning",
|
||||
option.range_start: "minimum value",
|
||||
option.range_end: "maximum value"
|
||||
}
|
||||
|
||||
for name, number in getattr(option, "special_range_names", {}).items():
|
||||
if number in data:
|
||||
data[name] = data[number]
|
||||
del data[number]
|
||||
else:
|
||||
data[name] = 0
|
||||
|
||||
return data, notes
|
||||
|
||||
def default_converter(default_value):
|
||||
@@ -25,15 +49,24 @@ def create():
|
||||
return list(default_value)
|
||||
return default_value
|
||||
|
||||
weighted_settings = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
"name": "Player",
|
||||
"game": {},
|
||||
},
|
||||
"games": {},
|
||||
}
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
|
||||
all_options = {**Options.per_game_common_options, **world.options}
|
||||
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
|
||||
options={**world.options, **Options.per_game_common_options},
|
||||
options=all_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range, default_converter=default_converter,
|
||||
)
|
||||
|
||||
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
|
||||
|
||||
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
|
||||
f.write(res)
|
||||
|
||||
@@ -47,11 +80,14 @@ def create():
|
||||
}
|
||||
|
||||
game_options = {}
|
||||
for option_name, option in world.options.items():
|
||||
if option.options:
|
||||
for option_name, option in all_options.items():
|
||||
if option_name in handled_in_js:
|
||||
pass
|
||||
|
||||
elif option.options:
|
||||
game_options[option_name] = this_option = {
|
||||
"type": "select",
|
||||
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"defaultValue": None,
|
||||
"options": []
|
||||
@@ -71,19 +107,65 @@ def create():
|
||||
"value": "random",
|
||||
})
|
||||
|
||||
if option.default == "random":
|
||||
this_option["defaultValue"] = "random"
|
||||
|
||||
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
|
||||
game_options[option_name] = {
|
||||
"type": "range",
|
||||
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"defaultValue": option.default if hasattr(option, "default") else option.range_start,
|
||||
"defaultValue": option.default if hasattr(
|
||||
option, "default") and option.default != "random" else option.range_start,
|
||||
"min": option.range_start,
|
||||
"max": option.range_end,
|
||||
}
|
||||
|
||||
if hasattr(option, "special_range_names"):
|
||||
game_options[option_name]["type"] = 'special_range'
|
||||
game_options[option_name]["value_names"] = {}
|
||||
for key, val in option.special_range_names.items():
|
||||
game_options[option_name]["value_names"][key] = val
|
||||
|
||||
elif getattr(option, "verify_item_name", False):
|
||||
game_options[option_name] = {
|
||||
"type": "items-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
}
|
||||
|
||||
elif getattr(option, "verify_location_name", False):
|
||||
game_options[option_name] = {
|
||||
"type": "locations-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
}
|
||||
|
||||
elif hasattr(option, "valid_keys"):
|
||||
if option.valid_keys:
|
||||
game_options[option_name] = {
|
||||
"type": "custom-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"options": list(option.valid_keys),
|
||||
}
|
||||
|
||||
else:
|
||||
logging.debug(f"{option} not exported to Web Settings.")
|
||||
|
||||
player_settings["gameOptions"] = game_options
|
||||
|
||||
os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
|
||||
|
||||
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
|
||||
f.write(json.dumps(player_settings, indent=2, separators=(',', ': ')))
|
||||
json.dump(player_settings, f, indent=2, separators=(',', ': '))
|
||||
|
||||
if not world.hidden and world.web.settings_page is True:
|
||||
weighted_settings["baseOptions"]["game"][game_name] = 0
|
||||
weighted_settings["games"][game_name] = {}
|
||||
weighted_settings["games"][game_name]["gameSettings"] = game_options
|
||||
weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names)
|
||||
weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names)
|
||||
|
||||
with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f:
|
||||
json.dump(weighted_settings, f, indent=2, separators=(',', ': '))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
flask>=2.0.2
|
||||
pony>=0.7.14
|
||||
waitress>=2.0.0
|
||||
flask-caching>=1.10.1
|
||||
Flask-Compress>=1.10.1
|
||||
Flask-Limiter>=1.4
|
||||
flask>=2.1.3
|
||||
pony>=0.7.16
|
||||
waitress>=2.1.1
|
||||
Flask-Caching>=2.0.1
|
||||
Flask-Compress>=1.12
|
||||
Flask-Limiter>=2.5.0
|
||||
bokeh>=2.4.3
|
||||
|
||||
@@ -1,52 +1,69 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
## What is a randomizer?
|
||||
A randomizer is a modification of a video game which reorganizes the items required to progress through the game.
|
||||
A normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a
|
||||
randomized game, you might first find item C, then A, then B.
|
||||
|
||||
A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
|
||||
normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized
|
||||
game, you might first find item C, then A, then B.
|
||||
|
||||
This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they
|
||||
play a randomized game. Putting items in non-standard locations can require the player to think about the game world
|
||||
and the items they encounter in new and interesting ways.
|
||||
play a randomized game. Putting items in non-standard locations can require the player to think about the game world and
|
||||
the items they encounter in new and interesting ways.
|
||||
|
||||
## What happens if an item is placed somewhere it is impossible to get?
|
||||
The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these
|
||||
rules is to ensure items necessary to complete the game will be accessible to the player. Many games also have a
|
||||
subset of rules allowing certain items to be placed in normally unreachable locations, provided the player has
|
||||
indicated they are comfortable exploiting certain glitches in the game.
|
||||
|
||||
The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these rules
|
||||
is to ensure items necessary to complete the game will be accessible to the player. Many games also have a subset of
|
||||
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
|
||||
comfortable exploiting certain glitches in the game.
|
||||
|
||||
## What is a multi-world?
|
||||
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example,
|
||||
in a two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each
|
||||
player's game, they may find items which belong to the other player. If player A finds an item which belongs to
|
||||
player B, the item will be sent to player B's world over the internet.
|
||||
|
||||
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
|
||||
two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each player's
|
||||
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
|
||||
item will be sent to player B's world over the internet.
|
||||
|
||||
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
|
||||
their game.
|
||||
|
||||
## What happens if a person has to leave early?
|
||||
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all
|
||||
the items in that game which belong to other players are sent out automatically, so other players can continue to
|
||||
play.
|
||||
|
||||
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all the
|
||||
items in that game which belong to other players are sent out automatically, so other players can continue to play.
|
||||
|
||||
## What does multi-game mean?
|
||||
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world
|
||||
allows players to randomize any of a number of supported games, and send items between them. This allows players of
|
||||
different games to interact with one another in a single multiplayer environment.
|
||||
|
||||
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows
|
||||
players to randomize any of a number of supported games, and send items between them. This allows players of different
|
||||
games to interact with one another in a single multiplayer environment.
|
||||
|
||||
## Can I generate a single-player game with Archipelago?
|
||||
|
||||
Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
|
||||
the website is not required to generate them.
|
||||
|
||||
## How do I get started?
|
||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others,
|
||||
please join our [Discord server](https://discord.gg/8Z65BR2). There are always people ready to answer any questions
|
||||
you might have.
|
||||
|
||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
||||
any questions you might have.
|
||||
|
||||
## What are some common terms I should know?
|
||||
|
||||
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
|
||||
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
|
||||
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
|
||||
|
||||
## I want to add a game to the Archipelago randomizer. How do I do that?
|
||||
The best way to get started is to take a look at our [code on GitHub](https://github.com/ArchipelagoMW/Archipelago).
|
||||
There, you will find examples of games in the [worlds](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds)
|
||||
folder, as well as some [documentation](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs) on our
|
||||
network interfaces.
|
||||
|
||||
The best way to get started is to take a look at our code on GitHub
|
||||
at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
|
||||
|
||||
There you will find examples of games in the worlds folder
|
||||
at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
|
||||
|
||||
You may also find developer documentation in the docs folder
|
||||
at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||
|
||||
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
||||
|
||||
94
WebHostLib/static/assets/faq/glossary_en.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Multiworld Glossary
|
||||
|
||||
There are a lot of common terms used when playing in different game randomizer communities and in multiworld as well.
|
||||
This document serves as a lookup for common terms that may be used by users in the community or in various other
|
||||
documentation.
|
||||
|
||||
## Item
|
||||
Items are what get shuffled around in your world or other worlds that you then receive. This could be a sword, a stat
|
||||
upgrade, a spell, or any other potential receivable for your game.
|
||||
|
||||
## Location
|
||||
Locations are where items are placed in your game. Whenever you interact with a location, you or another player will
|
||||
then receive an item. A location could be a chest, an enemy drop, a shop purchase, or any other interactable that can
|
||||
contain items in your game.
|
||||
|
||||
## Check
|
||||
A check is a common term for when you "check", or pick up, a location. In terms of Archipelago this is usually used for
|
||||
when a player goes to a location and sends its item, or "checks" the location. Players will often reference their now
|
||||
randomized locations as checks.
|
||||
|
||||
## Slot
|
||||
A slot is the player name and number assigned during generation. The number of slots is equal to the number of players,
|
||||
or "worlds", created. Each name must be unique as these are used to identify the slot user.
|
||||
|
||||
## World
|
||||
World in terms of Archipelago can mean multiple things and is used interchangeably in many situations.
|
||||
* During gameplay, a world is a single instance of a game, occupying one player "slot". However,
|
||||
Archipelago allows multiple players to connect to the same slot; then those players can share a world
|
||||
and complete it cooperatively. For games with native cooperative play, you can also play together and
|
||||
share a world that way, usually with only one player connected to the multiworld.
|
||||
* On the programming side, a world typically represents the package that integrates Archipelago with a
|
||||
particular game. For example this could be the entire `worlds/factorio` directory.
|
||||
|
||||
## RNG
|
||||
Acronym for "Random Number Generator." Archipelago uses its own custom Random object with a unique seed per generation,
|
||||
or, if running from source, a seed can be supplied and this seed will control all randomization during generation as all
|
||||
game worlds will have access to it.
|
||||
|
||||
## Seed
|
||||
A "seed" is a number used to initialize a pseudorandom number generator. Whenever you generate a new game on Archipelago
|
||||
this is a new "seed" as it has unique item placement, and you can create multiple "rooms" on the Archipelago site from a
|
||||
single seed. Using the same seed results in the random placement being the same.
|
||||
|
||||
## Room
|
||||
Whenever you generate a seed on the Archipelago website you will be put on a seed page that contains all the seed info
|
||||
with a link to the spoiler if one exists and will show how many unique rooms exist per seed. Each room has its own
|
||||
unique identifier that is separate from the seed. The room page is where you can find information to connect to the
|
||||
multiworld and download any patches if necessary. If you have a particularly fun or interesting seed, and you want to
|
||||
share it with somebody you can link them to this seed page, where they can generate a new room to play it! For seeds
|
||||
generated with race mode enabled, the seed page will only show rooms created by the unique user so the seed page is
|
||||
perfectly safe to share for racing purposes.
|
||||
|
||||
## Logic
|
||||
Base behavior of all seeds generated by Archipelago is they are expected to be completable based on the requirements of
|
||||
the settings. This is done by using "logic" in order to determine valid locations to place items while still being able
|
||||
to reach said location without this item. For the purposes of the randomizer a location is considered "in logic" if you
|
||||
can reach it with your current toolset of items or skills based on settings. Some players are able to obtain locations
|
||||
"out of logic" by performing various glitches or tricks that the settings may not account for and tend to mention this
|
||||
when sending out an item they obtained this way.
|
||||
|
||||
## Progression
|
||||
Certain items will allow access to more locations and are considered progression items as they "progress" the seed.
|
||||
|
||||
## Trash
|
||||
A term used for "filler" items that have no bearing on the generation and are either marginally useful for the player
|
||||
or useless. These items can be very useful depending on the player but are never very important and as such are usually
|
||||
termed trash.
|
||||
|
||||
## Burger King / BK Mode
|
||||
A term used in multiworlds when a player is unable to continue to progress and is awaiting an item. The term came to be
|
||||
after a player, allegedly, was unable to progress during a multiworld and went to Burger King while waiting to receive
|
||||
items from other players.
|
||||
|
||||
* "Logical BK" is when the player is unable to progress according to the settings of their game but may still be able to do
|
||||
things that would be "out of logic" by the generation.
|
||||
|
||||
* "Hard / full BK" is when the player is completely unable to progress even with tricks they may know and are unable to
|
||||
continue to play, aside from doing something like killing enemies for experience or money.
|
||||
|
||||
## Sphere
|
||||
Archipelago calculates the game playthrough by using a "sphere" system where it has a state for each player and checks
|
||||
to see what the players are able to reach with their current items. Any location that is reachable with the current
|
||||
state of items is a "sphere." For the purposes of Archipelago it starts playthrough calculation by distributing sphere 0
|
||||
items which are items that are either forced in the player's inventory by the game or placed in the `start_inventory` in
|
||||
their settings. Sphere 1 is then all accessible locations the players can reach with all the items they received from
|
||||
sphere 0, or their starting inventory. The playthrough continues in this fashion calculating a number of spheres until
|
||||
all players have completed their goal.
|
||||
|
||||
## Scouts / Scouting
|
||||
In some games there are locations that have visible items even if the item itself is unobtainable at the current time.
|
||||
Some games utilize a scouting feature where when the player "sees" the item it will give a free hint for the item in the
|
||||
client letting the players know what the exact item is, since if the item was for that game it would know but the item
|
||||
being foreign is a lot harder to represent visually.
|
||||
|
||||
@@ -14,7 +14,7 @@ window.addEventListener('load', () => {
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/gameInfo/` +
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
|
||||
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Final Fantasy 1 (NES)
|
||||
|
||||
## Where is the settings page?
|
||||
Unlike most games on Archipelago.gg, Final Fantasy 1's settings are controlled entirely by the original randomzier.
|
||||
You can find an exhaustive list of documented settings [here](https://finalfantasyrandomizer.com/)
|
||||
|
||||
## What does randomization do to this game?
|
||||
A better questions is what isn't randomized at this point. Enemies stats and spell, character spells, shop inventory
|
||||
and boss stats and spells are all commonly randomized. Unlike most other randomizers it is also most standard to shuffle
|
||||
progression items and non-progression items into separate pools and then redistribute them to their respective
|
||||
locations. So ,for example, Princess Sarah may have the CANOE instead of the LUTE; however, she will never have a Heal
|
||||
Pot or some armor. There are plenty of other things that can be randomized on our
|
||||
[main randomizer site](https://finalfantasyrandomizer.com/)
|
||||
|
||||
Some features are not currently supported by AP. A non-exhaustive list includes:
|
||||
- Shard Hunt
|
||||
- Deep Dungeon
|
||||
|
||||
## What Final Fantasy items can appear in other players' worlds?
|
||||
Currently, only progression items can appear in other players' worlds. Armor, Weapons and Consumable Items can not.
|
||||
|
||||
## What does another world's item look like in Final Fantasy
|
||||
All local and remote items appear the same. It will say that you received an item and then BOTH the client log and
|
||||
the emulator will display what was found external to the in-game text box.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Risk of Rain 2
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Risk of Rain is already a random game, by virtue of being a roguelite. The Archipelago mod implements pure multiworld
|
||||
functionality in which certain chests (made clear via a location check progress bar) will send an item out to the
|
||||
multiworld. The items that _would have been_ in those chests will be returned to the Risk of Rain player via grants
|
||||
by other players in other worlds.
|
||||
|
||||
## What Risk of Rain items can appear in other players' worlds?
|
||||
The Risk of Rain items are:
|
||||
* `Common Item` (White items)
|
||||
* `Uncommon Item` (Green items)
|
||||
* `Boss Item` (Yellow items)
|
||||
* `Legendary Item` (Red items)
|
||||
* `Lunar Item` (Blue items)
|
||||
* `Equipment` (Orange items)
|
||||
* `Dio's Best Friend` (Used if you set the YAML setting `total_revives_available` above `0`)
|
||||
|
||||
Each item grants you a random in-game item from the category it belongs to.
|
||||
|
||||
When an item is granted by another world to the Risk of Rain player (one of the items listed above) then a random
|
||||
in-game item of that tier will appear in the Risk of Rain player's inventory. If the item grant is an `Equipment`
|
||||
and the player already has an equipment item equipped then the _item that was equipped_ will be dropped on the ground
|
||||
and _the new equipment_ will take it's place. (If you want the old one back, pick it up.)
|
||||
|
||||
## What does another world's item look like in Risk of Rain?
|
||||
When the Risk of Rain player fills up their location check bar then the next spawned item will become an item grant for another
|
||||
player's world. The item in Risk of Rain will disappear in a poof of smoke and the grant will automatically go out to the multiworld.
|
||||
|
||||
## What is the item pickup step?
|
||||
The item pickup step is a YAML setting which allows you to set how many items you need to spawn before the _next_
|
||||
item that is spawned disappears (in a poof of smoke) and goes out to the multiworld.
|
||||
@@ -1,25 +0,0 @@
|
||||
# Super Metroid
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game
|
||||
is always able to be completed, but because of the item shuffle the player may need to access certain areas before
|
||||
they would in the vanilla game.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
All power-ups and ammunition can be shuffled, and all locations in the game which could contain any of those items
|
||||
may have their contents changed.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to
|
||||
limit certain items to your own world.
|
||||
|
||||
## What does another world's item look like in Super Metroid?
|
||||
A unique item sprite has been added to the game to represent items belonging to another world.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
When the player receives an item, a text box will appear to show which item was received, and from whom.
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Timespinner
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game
|
||||
is always able to be completed, but because of the item shuffle the player may need to access certain areas before
|
||||
they would in the vanilla game. All rings and spells are also randomized into those item locations, therefor you can no longer craft them at the alchemist
|
||||
|
||||
## What is the goal of Timespinner when randomized?
|
||||
The goal remains unchanged. Kill the Sandman\Nightmare!
|
||||
|
||||
## What items and locations get shuffled?
|
||||
All main inventory items, orbs, collectables, and familiers can be shuffled, and all locations in the game which could
|
||||
contain any of those items may have their contents changed.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to
|
||||
limit certain items to your own world.
|
||||
|
||||
## What does another world's item look like in Timespinner?
|
||||
Items belonging to other worlds are represented by the vanilla item [Elemental Beads](https://timespinnerwiki.com/Use_Items), Elemental Beads have no use in the randomizer
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
When the player receives an item, the same items popup will be displayed as when you would normally obtain the item
|
||||
|
||||
53
WebHostLib/static/assets/glossary.js
Normal file
@@ -0,0 +1,53 @@
|
||||
window.addEventListener('load', () => {
|
||||
const tutorialWrapper = document.getElementById('glossary-wrapper');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, the glossary page is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the glossary.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
||||
`glossary_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
@@ -6,26 +6,24 @@ window.addEventListener('load', () => {
|
||||
// Update game name on page
|
||||
document.getElementById('game-name').innerText = gameName;
|
||||
|
||||
Promise.all([fetchSettingData()]).then((results) => {
|
||||
fetchSettingData().then((results) => {
|
||||
let settingHash = localStorage.getItem(`${gameName}-hash`);
|
||||
if (!settingHash) {
|
||||
// If no hash data has been set before, set it now
|
||||
localStorage.setItem(`${gameName}-hash`, md5(results[0]));
|
||||
settingHash = md5(JSON.stringify(results));
|
||||
localStorage.setItem(`${gameName}-hash`, settingHash);
|
||||
localStorage.removeItem(gameName);
|
||||
settingHash = md5(results[0]);
|
||||
}
|
||||
|
||||
if (settingHash !== md5(results[0])) {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " +
|
||||
"them all to default.";
|
||||
userMessage.style.display = "block";
|
||||
userMessage.addEventListener('click', resetSettings);
|
||||
if (settingHash !== md5(JSON.stringify(results))) {
|
||||
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
|
||||
"them all to default.");
|
||||
document.getElementById('user-message').addEventListener('click', resetSettings);
|
||||
}
|
||||
|
||||
// Page setup
|
||||
createDefaultSettings(results[0]);
|
||||
buildUI(results[0]);
|
||||
createDefaultSettings(results);
|
||||
buildUI(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Event listeners
|
||||
@@ -38,7 +36,8 @@ window.addEventListener('load', () => {
|
||||
const nameInput = document.getElementById('player-name');
|
||||
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
|
||||
nameInput.value = playerSettings.name;
|
||||
}).catch((error) => {
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
const url = new URL(window.location.href);
|
||||
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
|
||||
})
|
||||
@@ -160,9 +159,72 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
element.appendChild(rangeVal);
|
||||
break;
|
||||
|
||||
case 'special_range':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('special-range-container');
|
||||
|
||||
// Build the select element
|
||||
let specialRangeSelect = document.createElement('select');
|
||||
specialRangeSelect.setAttribute('data-key', setting);
|
||||
Object.keys(settings[setting].value_names).forEach((presetName) => {
|
||||
let presetOption = document.createElement('option');
|
||||
presetOption.innerText = presetName;
|
||||
presetOption.value = settings[setting].value_names[presetName];
|
||||
specialRangeSelect.appendChild(presetOption);
|
||||
});
|
||||
let customOption = document.createElement('option');
|
||||
customOption.innerText = 'Custom';
|
||||
customOption.value = 'custom';
|
||||
customOption.selected = true;
|
||||
specialRangeSelect.appendChild(customOption);
|
||||
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
|
||||
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
|
||||
}
|
||||
|
||||
// Build range element
|
||||
let specialRangeWrapper = document.createElement('div');
|
||||
specialRangeWrapper.classList.add('special-range-wrapper');
|
||||
let specialRange = document.createElement('input');
|
||||
specialRange.setAttribute('type', 'range');
|
||||
specialRange.setAttribute('data-key', setting);
|
||||
specialRange.setAttribute('min', settings[setting].min);
|
||||
specialRange.setAttribute('max', settings[setting].max);
|
||||
specialRange.value = currentSettings[gameName][setting];
|
||||
|
||||
// Build rage value element
|
||||
let specialRangeVal = document.createElement('span');
|
||||
specialRangeVal.classList.add('range-value');
|
||||
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||
|
||||
// Configure select event listener
|
||||
specialRangeSelect.addEventListener('change', (event) => {
|
||||
if (event.target.value === 'custom') { return; }
|
||||
|
||||
// Update range slider
|
||||
specialRange.value = event.target.value;
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
});
|
||||
|
||||
// Configure range event handler
|
||||
specialRange.addEventListener('change', (event) => {
|
||||
// Update select element
|
||||
specialRangeSelect.value =
|
||||
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||
parseInt(event.target.value) : 'custom';
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
});
|
||||
|
||||
element.appendChild(specialRangeSelect);
|
||||
specialRangeWrapper.appendChild(specialRange);
|
||||
specialRangeWrapper.appendChild(specialRangeVal);
|
||||
element.appendChild(specialRangeWrapper);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown setting type: ${settings[setting].type}`);
|
||||
console.error(setting);
|
||||
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,7 +253,9 @@ const updateGameSetting = (event) => {
|
||||
|
||||
const exportSettings = () => {
|
||||
const settings = JSON.parse(localStorage.getItem(gameName));
|
||||
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
|
||||
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
|
||||
return showUserMessage('You must enter a player name!');
|
||||
}
|
||||
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
|
||||
};
|
||||
@@ -208,21 +272,41 @@ const download = (filename, text) => {
|
||||
};
|
||||
|
||||
const generateGame = (raceMode = false) => {
|
||||
const settings = JSON.parse(localStorage.getItem(gameName));
|
||||
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
|
||||
return showUserMessage('You must enter a player name!');
|
||||
}
|
||||
|
||||
axios.post('/api/generate', {
|
||||
weights: { player: localStorage.getItem(gameName) },
|
||||
presetData: { player: localStorage.getItem(gameName) },
|
||||
weights: { player: settings },
|
||||
presetData: { player: settings },
|
||||
playerCount: 1,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
}).catch((error) => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = 'Something went wrong and your game could not be generated.';
|
||||
let userMessage = 'Something went wrong and your game could not be generated.';
|
||||
if (error.response.data.text) {
|
||||
userMessage.innerText += ' ' + error.response.data.text;
|
||||
userMessage += ' ' + error.response.data.text;
|
||||
}
|
||||
userMessage.classList.add('visible');
|
||||
window.scrollTo(0, 0);
|
||||
showUserMessage(userMessage);
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const showUserMessage = (message) => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = message;
|
||||
userMessage.classList.add('visible');
|
||||
window.scrollTo(0, 0);
|
||||
userMessage.addEventListener('click', () => {
|
||||
userMessage.classList.remove('visible');
|
||||
userMessage.addEventListener('click', hideUserMessage);
|
||||
});
|
||||
};
|
||||
|
||||
const hideUserMessage = () => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.classList.remove('visible');
|
||||
userMessage.removeEventListener('click', hideUserMessage);
|
||||
};
|
||||
|
||||
49
WebHostLib/static/assets/supermetroidTracker.js
Normal file
@@ -0,0 +1,49 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 15 seconds
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update item tracker
|
||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||
// Update only counters in the location-table
|
||||
let counters = document.getElementsByClassName('counter');
|
||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||
for (let i = 0; i < counters.length; i++) {
|
||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||
}
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 15000)
|
||||
|
||||
// Collapsible advancement sections
|
||||
const categories = document.getElementsByClassName("location-category");
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
let hide_id = categories[i].id.split('-')[0];
|
||||
if (hide_id == 'Total') {
|
||||
continue;
|
||||
}
|
||||
categories[i].addEventListener('click', function() {
|
||||
// Toggle the advancement list
|
||||
document.getElementById(hide_id).classList.toggle("hide");
|
||||
// Change text of the header
|
||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
||||
const orig_text = tab_header.innerHTML;
|
||||
let new_text;
|
||||
if (orig_text.includes("▼")) {
|
||||
new_text = orig_text.replace("▼", "▲");
|
||||
}
|
||||
else {
|
||||
new_text = orig_text.replace("▲", "▼");
|
||||
}
|
||||
tab_header.innerHTML = new_text;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -14,7 +14,7 @@ window.addEventListener('load', () => {
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/` +
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
|
||||
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
|
||||
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
@@ -23,6 +23,7 @@ window.addEventListener('load', () => {
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
showdown.setOption('disableForced4SpacesIndentedSublists', true);
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Subnautica Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- [Subnautica](https://store.steampowered.com/app/264710/Subnautica/)
|
||||
- [QModManager4](https://www.nexusmods.com/subnautica/mods/201)
|
||||
- [Archipelago Mod for Subnautica](https://github.com/Berserker66/ArchipelagoSubnauticaModSrc/releases)
|
||||
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
1. Install QModManager4 as per its instructions.
|
||||
|
||||
2. The folder you installed QModManager4 into will now have a /QMods directory. It might appear after a start of Subnautica. You can also create this folder yourself.
|
||||
|
||||
3. Unpack the Archipelago Mod into this folder, so that Subnautica/QMods/Archipelago/ is a valid path.
|
||||
|
||||
4. Start Subnautica. You should see a Connect Menu in the topleft of your main Menu.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you don't see the connect window check that you see a qmodmanager_log-Subnautica.txt in Subnautica, if not QModManager4 is not correctly installed, otherwise open it and look for `[Info : BepInEx] Loading [Archipelago 1.0.0.0]`, version number doesn't matter. If it doesn't show this, then QModManager4 didn't find the Archipelago mod, so check your paths.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. In Host, enter the address of the server, such as archipelago.gg:38281, your server host should be able to tell you this.
|
||||
|
||||
2. In Password enter the server password if one exists, otherwise leave blank.
|
||||
|
||||
3. In PlayerName enter your "name" field from the yaml, or website config.
|
||||
|
||||
4. Hit Connect. If it says succesfully authenticated you can now create a new savegame or resume the correct savegame.
|
||||
@@ -1,160 +0,0 @@
|
||||
# Advanced Game Options Guide
|
||||
|
||||
|
||||
The Archipelago system generates games using player configuration files as input. Generally these are going to be
|
||||
YAML files and each player will have one of these containing their custom settings for the randomized game they want to play.
|
||||
On the website when you customize your settings from one of the game player settings pages which you can reach from the
|
||||
[supported games page](/games). Clicking on the export settings button at the bottom will provide you with a pre-filled out
|
||||
YAML with your options. The player settings page also has an option to download a fully filled out yaml containing every
|
||||
option with every available setting for the available options.
|
||||
|
||||
## YAML Formatting
|
||||
YAML files are a format of <span data-tooltip="Allegedly.">human-readable</span> markup config files. The basic syntax
|
||||
of a yaml file will have `root` and then different levels of `nested` text that the generator "reads" in order to determine
|
||||
your settings. To nest text, the correct syntax is **two spaces over** from its root option. A YAML file can be edited
|
||||
with whatever text editor you choose to use though I personally recommend that you use [Sublime Text](https://www.sublimetext.com/).
|
||||
This program out of the box supports the correct formatting for the YAML file, so you will be able to tab and get proper
|
||||
highlighting for any potential errors made while editing the file. If using any other text editor such as Notepad or
|
||||
Notepad++ whenever you move to nest an option that it is done with two spaces and not tabs.
|
||||
|
||||
Typical YAML format will look as follows:
|
||||
```yaml
|
||||
root_option:
|
||||
nested_option_one:
|
||||
option_one_setting_one: 1
|
||||
option_one_setting_two: 0
|
||||
nested_option_two:
|
||||
option_two_setting_one: 14
|
||||
option_two_setting_two: 43
|
||||
```
|
||||
|
||||
In Archipelago YAML options are always written out in full lowercase with underscores separating any words. The numbers
|
||||
following the colons here are weights. The generator will read the weight of every option the roll that option that many
|
||||
times, the next option as many times as its numbered and so forth. For the above example `nested_option_one` will have
|
||||
`option_one_setting_one` 1 time and `option_one_setting_two` 0 times so `option_one_setting_one` is guaranteed to occur.
|
||||
For `nested_option_two`, `option_two_setting_one` will be rolled 14 times and `option_two_setting_two` will be rolled 43
|
||||
times against each other. This means `option_two_setting_two` will be more likely to occur but it isn't guaranteed adding
|
||||
more randomness and "mystery" to your settings. Every configurable setting supports weights.
|
||||
|
||||
### Root Options
|
||||
Currently there are only a few options that are root options. Everything else should be nested within one of these root
|
||||
options or in some cases nested within other nested options. The only options that should exist in root are `description`,
|
||||
`name`, `game`, `requires`, `accessibility`, `progression_balancing`, `triggers`, and the name of the games you want
|
||||
settings for.
|
||||
* `description` is ignored by the generator and is simply a good way for you to organize if you have multiple files using
|
||||
this to detail the intention of the file.
|
||||
* `name` is the player name you would like to use and is used for your slot data to connect with most games. This can also
|
||||
be filled with multiple names each having a weight to it.
|
||||
* `game` is where either your chosen game goes or if you would like can be filled with multiple games each with different
|
||||
weights.
|
||||
* `requires` details different requirements from the generator for the YAML to work as you expect it to. Generally this
|
||||
is good for detailing the version of Archipelago this YAML was prepared for as if it is rolled on an older version may be
|
||||
missing settings and as such will not work as expected. If any plando is used in the file then requiring it here to ensure
|
||||
it will be used is good practice.
|
||||
* `accessibility` determines the level of access to the game the generation will expect you to have in order to reach your
|
||||
completion goal. This supports `items`, `locations`, and `none` and is set to `locations` by default.
|
||||
* `items` will guarantee you can acquire all items in your world but may not be able to access all locations. This mostly
|
||||
comes into play if there is any entrance shuffle in the seed as locations without items in them can be placed in areas
|
||||
that make them unreachable.
|
||||
* `none` will only guarantee that the seed is beatable. You will be guaranteed able to finish the seed logically but
|
||||
may not be able to access all locations or acquire all items. A good example of this is having a big key in the big chest
|
||||
in a dungeon in ALTTP making it impossible to get and finish the dungeon.
|
||||
* `progression_balancing` is a system the Archipelago generator uses to try and reduce "BK mode" as much as possible. This
|
||||
primarily involves moving necessary progression items into earlier logic spheres to make the games more accessible so that
|
||||
players almost always have something to do. This can be turned `on` or `off` and is `on` by default.
|
||||
* `triggers` is one of the more advanced options that allows you to create conditional adjustments. You can read more
|
||||
about this [here](/tutorial/archipelago/triggers/en).
|
||||
|
||||
### Game Options
|
||||
|
||||
One of your root settings will be the name of the game you would like to populate with settings in the format
|
||||
`GameName`. since it is possible to give a weight to any option it is possible to have one file that can generate a seed
|
||||
for you where you don't know which game you'll play. For these cases you'll want to fill the game options for every game
|
||||
that can be rolled by these settings. If a game can be rolled it **must** have a settings section even if it is empty.
|
||||
|
||||
#### Universal Game Options
|
||||
|
||||
Some options in Archipelago can be used by every game but must still be placed within the relevant game's section.
|
||||
Currently, these options are `start_inventory`, `start_hints`, `local_items`, `non_local_items`, `start_location_hints`,
|
||||
`exclude_locations`, and various [plando options](tutorial/archipelago/plando/en).
|
||||
* `start_inventory` will give any items defined here to you at the beginning of your game. The format for this must be
|
||||
the name as it appears in the game files and the amount you would like to start with. For example `Rupees(5): 6` which
|
||||
will give you 30 rupees.
|
||||
* `start_hints` gives you free server hints for the defined item/s at the beginning of the game allowing you to hint for
|
||||
the location without using any hint points.
|
||||
* `local_items` will force any items you want to be in your world instead of being in another world.
|
||||
* `non_local_items` is the inverse of `local_items` forcing any items you want to be in another world and won't be located
|
||||
in your own.
|
||||
* `start_location_hints` allows you to define a location which you can then hint for to find out what item is located in
|
||||
it to see how important the location is.
|
||||
* `exclude_locations` lets you define any locations that you don't want to do and during generation will force a "junk"
|
||||
item which isn't necessary for progression to go in these locations.
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
|
||||
description: An example using various advanced options
|
||||
name: Example Player
|
||||
game: A Link to the Past
|
||||
requires:
|
||||
version: 0.2.0
|
||||
accessibility: none
|
||||
progression_balancing: on
|
||||
A Link to the Past:
|
||||
smallkey_shuffle:
|
||||
original_dungeon: 1
|
||||
any_world: 1
|
||||
start_inventory:
|
||||
Pegasus Boots: 1
|
||||
Bombs (3): 2
|
||||
start_hints:
|
||||
- Hammer
|
||||
local_items:
|
||||
- Bombos
|
||||
- Ether
|
||||
- Quake
|
||||
non_local_items:
|
||||
- Moon Pearl
|
||||
start_location_hints:
|
||||
- Spike Cave
|
||||
exclude_locations:
|
||||
- Cave 45
|
||||
triggers:
|
||||
- option_category: A Link to the Past
|
||||
option_name: smallkey_shuffle
|
||||
option_result: any_world
|
||||
options:
|
||||
A Link to the Past:
|
||||
bigkey_shuffle: any_world
|
||||
map_shuffle: any_world
|
||||
compass_shuffle: any_world
|
||||
```
|
||||
|
||||
#### This is a fully functional yaml file that will do all the following things:
|
||||
* `description` gives us a general overview so if we pull up this file later we can understand the intent.
|
||||
* `name` is `Example Player` and this will be used in the server console when sending and receiving items.
|
||||
* `game` is set to `A Link to the Past` meaning that is what game we will play with this file.
|
||||
* `requires` is set to require release version 0.2.0 or higher.
|
||||
* `accesibility` is set to `none` which will set this seed to beatable only meaning some locations and items may be
|
||||
completely inaccessible but the seed will still be completable.
|
||||
* `progression_balancing` is set on meaning we will likely receive important items earlier increasing the chance of having
|
||||
things to do.
|
||||
* `A Link to the Past` defines a location for us to nest all the game options we would like to use for our game `A Link to the Past`.
|
||||
* `smallkey_shuffle` is an option for A Link to the Past which determines how dungeon small keys are shuffled. In this example
|
||||
we have a 1/2 chance for them to either be placed in their original dungeon and a 1/2 chance for them to be placed anywhere
|
||||
amongst the multiworld.
|
||||
* `start_inventory` defines an area for us to determine what items we would like to start the seed with. For this example
|
||||
we have:
|
||||
* `Pegasus Boots: 1` which gives us 1 copy of the Pegasus Boots
|
||||
* `Bombs (3)` gives us 2 packs of 3 bombs or 6 total bombs
|
||||
* `start_hints` gives us a starting hint for the hammer available at the beginning of the multiworld which we can use with no cost.
|
||||
* `local_items` forces the `Bombos`, `Ether`, and `Quake` medallions to all be placed within our own world, meaning we
|
||||
have to find it ourselves.
|
||||
* `non_local_items` forces the `Moon Pearl` to be placed in someone else's world, meaning we won't be able to find it.
|
||||
* `start_location_hints` gives us a starting hint for the `Spike Cave` location available at the beginning of the multiworld
|
||||
that can be used for no cost.
|
||||
* `exclude_locations` forces a not important item to be placed on the `Cave 45` location.
|
||||
* `triggers` allows us to define a trigger such that if our `smallkey_shuffle` option happens to roll the `any_world`
|
||||
result it will also ensure that `bigkey_shuffle`, `map_shuffle`, and `compass_shuffle` are also forced to the `any_world`
|
||||
result.
|
||||
@@ -1,180 +0,0 @@
|
||||
# Archipelago Plando Guide
|
||||
|
||||
## What is Plando?
|
||||
The purposes of randomizers is to randomize the items in a game to give a new experience.
|
||||
Plando takes this concept and changes it up by allowing you to plan out certain aspects of the game by placing certain
|
||||
items in certain locations, certain bosses in certain rooms, edit text for certain NPCs/signs, or even force certain region
|
||||
connections. Each of these options are going to be detailed separately as `item plando`, `boss plando`, `text plando`,
|
||||
and `connection plando`. Every game in archipelago supports item plando but the other plando options are only supported
|
||||
by certain games. Currently, Minecraft and LTTP both support connection plando, but only LTTP supports text and boss plando.
|
||||
|
||||
### Enabling Plando
|
||||
On the website plando will already be enabled. If you will be generating the game locally plando features must be enabled (opt-in).
|
||||
* To opt-in go to the archipelago installation (default: `C:\ProgramData\Archipelago`), open the host.yaml with a text
|
||||
editor and find the `plando_options` key. The available plando modules can be enabled by adding them after this such as
|
||||
`plando_options: bosses, items, texts, connections`.
|
||||
|
||||
## Item Plando
|
||||
Item plando allows a player to place an item in a specific location or specific locations, place multiple items into
|
||||
a list of specific locations both in their own game or in another player's game. **Note that there's a very good chance that
|
||||
cross-game plando could very well be broken i.e. placing on of your items in someone else's world playing a different game.**
|
||||
* The options for item plando are `from_pool`, `world`, `percentage`, `force`, and either item and location, or items and locations.
|
||||
* `from_pool` determines if the item should be taken *from* the item pool or *added* to it. This can be true or false
|
||||
and defaults to true if omitted.
|
||||
* `world` is the target world to place the item in.
|
||||
* It gets ignored if only one world is generated.
|
||||
* Can be a number, name, true, false, or null. False is the default.
|
||||
* If a number is used it targets that slot or player number in the multiworld.
|
||||
* If a name is used it will target the world with that player name.
|
||||
* If set to true it will be any player's world besides your own.
|
||||
* If set to false it will target your own world.
|
||||
* If set to null it will target a random world in the multiworld.
|
||||
* `force` determines whether the generator will fail if the item can't be placed in the location can be true, false,
|
||||
or silent. Silent is the default.
|
||||
* If set to true the item must be placed and the generator will throw an error if it is unable to do so.
|
||||
* If set to false the generator will log a warning if the placement can't be done but will still generate.
|
||||
* If set to silent and the placement fails it will be ignored entirely.
|
||||
* `percentage` is the percentage chance for the relevant block to trigger. This can be any value from 0 to 100 and if
|
||||
omitted will default to 100.
|
||||
* Single Placement is when you use a plando block to place a single item at a single location.
|
||||
* `item` is the item you would like to place and `location` is the location to place it.
|
||||
* Multi Placement uses a plando block to place multiple items in multiple locations until either list is exhausted.
|
||||
* `items` defines the items to use and a number letting you place multiple of it.
|
||||
* `locations` is a list of possible locations those items can be placed in.
|
||||
* Using the multi placement method, placements are picked randomly.
|
||||
|
||||
### Available Items
|
||||
* [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Items.py#L52)
|
||||
* [Factorio Non-Progressive](https://wiki.factorio.com/Technologies) Note that these use the *internal names*. For example, `advanced-electronics`
|
||||
* [Factorio Progressive](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/factorio/Technologies.py#L374)
|
||||
* [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Items.py#L14)
|
||||
* [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/Items.py#L61)
|
||||
* [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Items.py#L8)
|
||||
* [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Items.py#L13)
|
||||
* [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/items.json)
|
||||
* [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Items.py#L11)
|
||||
|
||||
### Available Locations
|
||||
* [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Regions.py#L429)
|
||||
* [Factorio](https://wiki.factorio.com/Technologies) Same as items
|
||||
* [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Locations.py#L18)
|
||||
* [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/LocationList.py#L38)
|
||||
* [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Locations.py#L17) This is a special
|
||||
case. The locations are "ItemPickup[number]" up to the maximum set in the yaml.
|
||||
* [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Locations.py)
|
||||
* [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/locations.json)
|
||||
* [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Locations.py#L13)
|
||||
|
||||
|
||||
A list of all available items and locations can also be found in the [server's datapackage](/api/datapackage).
|
||||
### Examples
|
||||
```yaml
|
||||
plando_items:
|
||||
# example block 1 - Timespinner
|
||||
- item:
|
||||
Empire Orb: 1
|
||||
Radiant Orb: 1
|
||||
location: Starter Chest 1
|
||||
from_pool: true
|
||||
world: true
|
||||
percentage: 50
|
||||
|
||||
# example block 2 - Ocarina of Time
|
||||
- items:
|
||||
Kokiri Sword: 1
|
||||
Biggoron Sword: 1
|
||||
Bow: 1
|
||||
Magic Meter: 1
|
||||
Progressive Strength Upgrade: 3
|
||||
Progressive Hookshot: 2
|
||||
locations:
|
||||
- Deku Tree Slingshot Chest
|
||||
- Dodongos Cavern Bomb Bag Chest
|
||||
- Jabu Jabus Belly Boomerang Chest
|
||||
- Bottom of the Well Lens of Truth Chest
|
||||
- Forest Temple Bow Chest
|
||||
- Fire Temple Megaton Hammer Chest
|
||||
- Water Temple Longshot Chest
|
||||
- Shadow Temple Hover Boots Chest
|
||||
- Spirit Temple Silver Gauntlets Chest
|
||||
world: false
|
||||
|
||||
# example block 3 - Slay the Spire
|
||||
- items:
|
||||
Boss Relic: 3
|
||||
locations:
|
||||
Boss Relic 1
|
||||
Boss Relic 2
|
||||
Boss Relic 3
|
||||
|
||||
# example block 4 - Factorio
|
||||
- items:
|
||||
progressive-electric-energy-distribution: 2
|
||||
electric-energy-accumulators: 1
|
||||
progressive-turret: 2
|
||||
locations:
|
||||
military
|
||||
gun-turret
|
||||
logistic-science-pack
|
||||
steel-processing
|
||||
percentage: 80
|
||||
force: true
|
||||
```
|
||||
1. This block has a 50% chance to occur, and if it does will place either the Empire Orb or Radiant Orb on another player's
|
||||
Starter Chest 1 and removes the chosen item from the item pool.
|
||||
2. This block will always trigger and will place the player's swords, bow, magic meter, strength upgrades, and hookshots
|
||||
in their own dungeon major item chests.
|
||||
3. This block will always trigger and will lock boss relics on the bosses.
|
||||
4. This block has an 80% chance of occuring and when it does will place all but 1 of the items randomly among the four
|
||||
locations chosen here.
|
||||
|
||||
## Boss Plando
|
||||
As this is currently only supported by A Link to the Past instead of explaining here please refer to the
|
||||
[relevant guide](/tutorial/zelda3/plando/en)
|
||||
|
||||
## Text Plando
|
||||
As this is currently only supported by A Link to the Past instead of explaining here please refer to the
|
||||
[relevant guide](/tutorial/zelda3/plando/en)
|
||||
|
||||
## Connections Plando
|
||||
This is currently only supported by Minecraft and A Link to the Past. As the way that these games interact with
|
||||
their connections is different I will only explain the basics here while more specifics for Link to the Past connection
|
||||
plando can be found in its plando guide.
|
||||
* The options for connections are `percentage`, `entrance`, `exit`, and `direction`. Each of these options support subweights.
|
||||
* `percentage` is the percentage chance for this connection from 0 to 100 and defaults to 100.
|
||||
* Every connection has an `entrance` and an `exit`. These can be unlinked like in A Link to the Past insanity entrance shuffle.
|
||||
* `direction` can be `both`, `entrance`, or `exit` and determines in which direction this connection will operate.
|
||||
|
||||
[Link to the Past connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
|
||||
|
||||
[Minecraft connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Regions.py#L62)
|
||||
|
||||
### Examples
|
||||
```yaml
|
||||
plando_connections:
|
||||
# example block 1 - Link to the Past
|
||||
- entrance: Cave Shop (Lake Hylia)
|
||||
exit: Cave 45
|
||||
direction: entrance
|
||||
- entrance: Cave 45
|
||||
exit: Cave Shop (Lake Hylia)
|
||||
direction: entrance
|
||||
- entrance: Agahnims Tower
|
||||
exit: Old Man Cave Exit (West)
|
||||
direction: exit
|
||||
|
||||
# example block 2 - Minecraft
|
||||
- entrance: Overworld Structure 1
|
||||
exit: Nether Fortress
|
||||
direction: both
|
||||
- entrance: Overworld Structure 2
|
||||
exit: Village
|
||||
direction: both
|
||||
```
|
||||
|
||||
1. These connections are decoupled so going into the lake hylia cave shop will take you to the inside of cave 45 and
|
||||
when you leave the interior you will exit to the cave 45 ledge. Going into the cave 45 entrance will then take you to the
|
||||
lake hylia cave shop. Walking into the entrance for the old man cave and Agahnim's Tower entrance will both take you to
|
||||
their locations as normal but leaving old man cave will exit at Agahnim's Tower.
|
||||
2. This will force a nether fortress and a village to be the overworld structures for your game. Note that for the Minecraft
|
||||
connection plando to work structure shuffle must be enabled.
|
||||
@@ -1,79 +0,0 @@
|
||||
# Archipelago Setup Guide
|
||||
|
||||
## Installing the Archipelago software
|
||||
The most recent public release of Archipelago can be found [here](https://github.com/ArchipelagoMW/Archipelago/releases).
|
||||
Run the exe file, and after accepting the license agreement you will be prompted on which components you would like to install.
|
||||
The generator allows you to generate multiworld games on your computer. The ROM setups are optional but are required if
|
||||
anyone in the game that you generate wants to play any of those games as they are needed to generate the relevant patch
|
||||
files. The server will allow you to host the multiworld on your machine but this also requires you to port forward. The
|
||||
default port for Archipelago is `38281`. If you are unsure how to do this there are plenty of other guides on the internet
|
||||
that will be more suited to your hardware. The `Clients` are what you use to connect your game to the multiworld. If the
|
||||
game/games you plan to play are available here go ahead and install these as well. If the game you choose to play is
|
||||
supported by Archipelago but not listed in the installation check the relevant tutorial.
|
||||
|
||||
## Generating a game
|
||||
|
||||
### Creating a YAML
|
||||
In a multiworld there must be one YAML per world. Any number of players can play on each world using either the game's
|
||||
native coop system or using archipelago's coop support. Each world will hold one slot in the multiworld and will have a
|
||||
slot name and, if the relevant game requires it, files to associate it with that multiworld. If multiple people plan to
|
||||
play in one world cooperatively then they will only need one YAML for their coop world, but if each player is planning on
|
||||
playing their own game then they will each need a YAML. These YAML files can be generated by going to the relevant game's
|
||||
player settings page, entering the name they want to use for the game, setting the options to what they would like to
|
||||
play with and then clicking on the export settings button. This will then download a YAML file that will contain all of
|
||||
these options and this can then be given to whoever is going to generate the game.
|
||||
|
||||
### Gather all player YAMLS
|
||||
All players that wish to play in the generated multiworld must have a YAML file which contains the settings that they wish to play with.
|
||||
A YAML is a file which contains human readable markup. In other words, this is a settings file kind of like an INI file or a TOML file.
|
||||
Each player can go to the game's player settings page in order to determine the settings how they want them and then download a YAML file containing these settings.
|
||||
After getting the YAML files of each participant for your multiworld game, these can all either be placed together in the
|
||||
`Archipelago\Players` folder or compressed into a ZIP folder to then be uploaded to the [website generator](/generate).
|
||||
If rolling locally ensure that the folder is clear of any files you do not wish to include in the game such as the
|
||||
included default player settings files.
|
||||
|
||||
#### Changing local host settings for generation
|
||||
Sometimes there are various settings that you may want to change before rolling a seed such as enabling race mode,
|
||||
auto-forfeit, plando support, or setting a password. All of these settings plus other options are able to be changed by
|
||||
modifying the `host.yaml` file in the base `Archipelago` folder. The settings chosen here are baked into
|
||||
the serverdata file that gets output with the other files after generation so if rolling locally ensure this file is edited
|
||||
to your liking *before* rolling the seed.
|
||||
|
||||
### Rolling the seed
|
||||
|
||||
#### On the Website
|
||||
After gathering the YAML files together in one location, select all of the files and compress them into a .zip folder.
|
||||
Next go to the [Start Playing](/start-playing) page and click on `generate a randomized game` to reach the website generator.
|
||||
Here, you can adjust some server settings such as forfeit rules and the cost for a player to use a hint before generation.
|
||||
After adjusting the host settings to your liking click on the Upload File button and using the explorer window that opens,
|
||||
navigate to the location where you zipped the player files and upload this zip. The page will generate your game and refresh
|
||||
multiple times to check on completion status. After the generation completes you will be on a Seed Info page that provides
|
||||
the seed, the date/time of creation, a link to the spoiler log, if available, and links to any rooms created from this seed.
|
||||
To begin playing, click on `Create New Room`, which will take you to the room page. From here you can navigate back to thse
|
||||
Seed Info page or to the Tracker page. Sharing the link to this page with your friends will provide them with the
|
||||
necessary info and files for them to connect to the multiworld.
|
||||
|
||||
#### Rolling using the generation program
|
||||
After gathering the YAML files together in the `Archipelago\Players` folder, run the program `ArchipelagoGenerate.exe`
|
||||
in the base `Archipelago` folder. This will then open a console window and either silently close itself or spit out an
|
||||
error. If you receive an error, it is likely due to an error in the YAML file. If the error is unhelpful in figuring
|
||||
out the issue asking in the ***#tech-support*** channel of our Discord for help with finding it is highly recommended.
|
||||
The generator will put a zip folder into your `Archipelago\output` folder with the format `AP_XXXXXXXXX`.zip.
|
||||
This contains the patch files and relevant mods for the players as well as the serverdata for the host.
|
||||
|
||||
## Hosting a multiworld
|
||||
|
||||
### Uploading the seed to the website
|
||||
The easiest and most recommended method is to generate the game on the website which will allow you to create a private
|
||||
room with all the necessary files you can share, as well as hosting the game and supporting item trackers for various games.
|
||||
If for some reason the seed was rolled on a machine, then either the resulting zip file or the resulting `AP_XXXXX.archipelago`
|
||||
inside the zip file can be uploaded to the [upload page](/uploads). This will give a page with the seed info and have a
|
||||
link to the spoiler if it exists. Click on Create New room and then share the link for the room with the other players
|
||||
so that they can download their patches or mods. The room will also have a link to a Multiworld Tracker and tell you
|
||||
what the players need to connect to from their clients.
|
||||
|
||||
### Hosting a seed locally
|
||||
For this we'll assume you have already port forwarding `38281` and have generated a seed that is still in the `outputs`
|
||||
folder. Next, you'll want to run `ArchipelagoServer.exe`. A window will open in order to open the multiworld data for the
|
||||
game. You can either use the generated zip folder or extract the .archipelago file and use it. If everything worked correctly the console window should tell you it's now hosting a game with the IP, port, and password that clients will need in order to connect.
|
||||
Extract the patch and mod files then send those to your friends, and you're done!
|
||||
@@ -1,69 +0,0 @@
|
||||
# Archipelago Triggers Guide
|
||||
|
||||
## What are triggers?
|
||||
Triggers allow you to customize your game settings by allowing you to define certain options or even a variety of
|
||||
settings to occur or "trigger" under certain conditions. These are essentially "if, then statements" for options in your game.
|
||||
A good example of what you can do with triggers is the custom
|
||||
[mercenary mode](https://github.com/alwaysintreble/Archipelago-yaml-dump/blob/main/Snippets/Mercenary%20Mode%20Snippet.yaml)
|
||||
that was created using entirely triggers and plando. For more information on plando you can reference
|
||||
[this guide](/tutorial/archipelago/plando/en) or [this guide](/tutorial/zelda3/plando/en).
|
||||
|
||||
## Trigger use
|
||||
Triggers have to be defined in the root of the yaml file meaning it must be outside of a game section.
|
||||
The best place to do this is the bottom of the yaml.
|
||||
- Triggers comprise of the trigger section and then each trigger must have an `option_category`, `option_name`, and
|
||||
`option_result` from which it will react to and then an `options` section where the definition of what will happen.
|
||||
- `option_category` is the defining section from which the option is defined in.
|
||||
- Example: `A Link to the Past`
|
||||
- This is the root category the option is located in. If the option you're triggering off of is in root then you
|
||||
would use `null`, otherwise this is the game for which you want this option trigger to activate.
|
||||
- `option_name` is the option setting from which the triggered choice is going to react to.
|
||||
- Example: `shop_item_slots`
|
||||
- This can be any option from any category defined in the yaml file in either root or a game section except for `game`.
|
||||
- `option_result` is the result of this option setting from which you would like to react.
|
||||
- Example: `15`
|
||||
- Each trigger must be used for exactly one option result. If you would like the same thing to occur with multiple
|
||||
results you would need multiple triggers for this.
|
||||
- `options` is where you define what will happen when this is detected. This can be something as simple as ensuring
|
||||
another option also gets selected or placing an item in a certain location.
|
||||
- Example:
|
||||
```yaml
|
||||
A Link to the Past:
|
||||
start_inventory:
|
||||
Rupees (300): 2
|
||||
```
|
||||
This format must be:
|
||||
|
||||
```yaml
|
||||
root option:
|
||||
option to change:
|
||||
desired result
|
||||
```
|
||||
|
||||
### Examples
|
||||
The above examples all together will end up looking like this:
|
||||
```yaml
|
||||
triggers:
|
||||
- option_category: A Link to the Past
|
||||
option_name: shop_item_slots
|
||||
option_result: 15
|
||||
options:
|
||||
A Link to the Past:
|
||||
start_inventory:
|
||||
Rupees(300): 2
|
||||
```
|
||||
|
||||
For this example if the generator happens to roll 15 shuffled in shop item slots for your game you'll be granted 600 rupees at the beginning.
|
||||
These can also be used to change other options.
|
||||
|
||||
For example:
|
||||
```yaml
|
||||
triggers:
|
||||
- option_category: Timespinner
|
||||
option_name: SpecificKeycards
|
||||
option_result: true
|
||||
options:
|
||||
Timespinner:
|
||||
Inverted: true
|
||||
```
|
||||
In this example if your world happens to roll SpecificKeycards then your game will also start in inverted.
|
||||
@@ -1,128 +0,0 @@
|
||||
# Factorio Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
##### Players
|
||||
- [Factorio](https://factorio.com) - Needed by Players and Hosts
|
||||
|
||||
##### Server Hosts
|
||||
- [Factorio](https://factorio.com) - Needed by Players and Hosts
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) - Needed by Hosts
|
||||
|
||||
## Create a Config (.yaml) File
|
||||
|
||||
### What is a config file and why do I need one?
|
||||
Your config file contains a set of configuration options which provide the generator with information about how it
|
||||
should generate your game. Each player of a multiworld will provide their own config file. This setup allows each
|
||||
player to enjoy an experience customized for their taste, and different players in the same multiworld can all have
|
||||
different options.
|
||||
|
||||
### Where do I get a config file?
|
||||
The [Player Settings](/games/Factorio/player-settings) page on the website allows you to configure
|
||||
your personal settings and export a config file from them.
|
||||
|
||||
### Verifying your config file
|
||||
If you would like to validate your config file to make sure it works, you may do so on
|
||||
the [YAML Validator](/mysterycheck) page.
|
||||
|
||||
## Connecting to Someone Else's Factorio Game
|
||||
Connecting to someone else's game is the simplest way to play Factorio with Archipelago. It allows multiple
|
||||
people to play in a single world, all contributing to the completion of the seed.
|
||||
|
||||
1. Acquire the Archipelago mod for this seed. It should be named `AP_*.zip`, where `*` is the seed number.
|
||||
2. Copy the mod file into your Factorio `mods` folder, which by default is located at:
|
||||
`C:\Users\YourName\AppData\Roaming\Factorio\mods`
|
||||
3. Get the server address from the person hosting the game you are joining
|
||||
4. Launch Factorio
|
||||
5. Click on "Multiplayer" in the main menu
|
||||
6. Click on "Connect to address"
|
||||
7. Enter the address into this box
|
||||
8. Click "Connect"
|
||||
|
||||
## Prepare to Host Your Own Factorio Game
|
||||
|
||||
### Defining Some Terms
|
||||
In Archipelago, multiple Factorio worlds may be played simultaneously. Each of these worlds must be hosted by a
|
||||
Factorio server, which is connected to the Archipelago Server via middleware.
|
||||
|
||||
This guide uses the following terms to refer to the software:
|
||||
- **Factorio Client** - The Factorio instance which will be used to play the game.
|
||||
- **Factorio Server** - The Factorio instance which will be used to host the Factorio world. Any number of
|
||||
Factorio Clients may connect to this server.
|
||||
- **Archipelago Client** - The middleware software used to connect the Factorio Server to the Archipelago Server.
|
||||
- **Archipelago Server** - The central Archipelago server, which connects all games to each other.
|
||||
|
||||
### What a Playable State Looks Like
|
||||
- An Archipelago Server
|
||||
- The generated Factorio Mod, created as a result of running `ArchipelagoGenerate.exe`
|
||||
- One running instance of `ArchipelagoFactorioClient.exe` (the Archipelago Client) per Factorio world
|
||||
- A running modded Factorio Server, which should have been started by the Archipelago Client automatically
|
||||
- A running modded Factorio Client
|
||||
|
||||
### Dedicated Server Setup
|
||||
To play Factorio with Archipelago, a dedicated server setup is required. This dedicated Factorio Server must be
|
||||
installed separately from your main Factorio Client installation. The recommended way to install two instances
|
||||
of Factorio on your computer is to download the Factorio installer file directly from
|
||||
[factorio.com](https://factorio.com/download).
|
||||
|
||||
#### If you purchased Factorio on Steam, GOG, etc.
|
||||
You can register your copy of Factorio on [factorio.com](https://factorio.com/). You will be required to
|
||||
create an account, if you have not done so already. As part of that process, you will be able to enter your
|
||||
Factorio product code. This will allow you to download the game directly from their website.
|
||||
|
||||
#### Download the Standalone Version
|
||||
It is recommended to download the standalone version of Factorio for use as a dedicated server. Doing so prevents
|
||||
any potential conflicts with your currently-installed version of Factorio. Download the file by clicking on the
|
||||
button appropriate to your operating system, and extract the folder to a convenient location (we recommend
|
||||
C:\Factorio or similar).<br />
|
||||
<img src="/static/assets/tutorial/factorio/factorio-download.png" />
|
||||
|
||||
Next, you should launch your Factorio Server by running `factorio.exe`, which is located at: `bin/x64/factorio.exe`.
|
||||
You will be asked to log-in to your Factorio account using the same credentials you used on Factorio's website.
|
||||
After you have logged in, you may close the game.
|
||||
|
||||
#### Configure your Archipelago Installation
|
||||
You must modify your `host.yaml` file inside your Archipelago installation directory so that it points to your
|
||||
standalone Factorio executable. Here is an example of the appropriate setup, note the double `\\` are required:
|
||||
```yaml
|
||||
factorio_options:
|
||||
executable: C:\\factorio\\bin\\x64\\factorio"
|
||||
```
|
||||
|
||||
With all that complete, you are now able to...
|
||||
|
||||
## Host Your Own Factorio Game
|
||||
|
||||
1. Obtain the Factorio mod for this Archipelago seed. It should be named `AP_*.zip`, where `*` is the seed number.
|
||||
2. Install the mod into your Factorio Server by copying the zip file into the `mods` folder.
|
||||
3. Install the mod into your Factorio Client by copying the zip file into the `mods` folder, which is likely located
|
||||
at `C:\Users\YourName\AppData\Roaming\Factorio\mods`.
|
||||
4. Obtain the Archipelago Server address from the website's host room, or from the server host.
|
||||
5. Run your Archipelago Client, which is named `ArchilepagoFactorioClient.exe`. This was installed along with
|
||||
Archipelago if you chose to include it during the installation process.
|
||||
6. Enter `/connect [server-address]` into the input box at the bottom of the Archipelago Client and press "Enter"
|
||||
<br /><img src="/static/assets/tutorial/factorio/connect-to-ap-server.png" />
|
||||
7. Launch your Factorio Client
|
||||
8. Click on "Multiplayer" in the main menu
|
||||
9. Click on "Connect to address"
|
||||
10. Enter `localhost` into the server address box
|
||||
11. Click "Connect"
|
||||
|
||||
For additional client features, issue the `/help` command in the Archipelago Client. Once connected to the AP
|
||||
server, you can also issue the `!help` command to learn about additional commands like `!hint`.
|
||||
|
||||
## Allowing Other People to Join Your Game
|
||||
1. Ensure your Archipelago Client is running.
|
||||
2. Ensure port `34197` is forwarded to the computer running the Archipelago Client.
|
||||
3. Obtain your IP address by visiting [this website](https://whatismyip.com/).
|
||||
4. Provide your IP address to anyone you want to join your game, and have them follow the steps for
|
||||
"Connecting to Someone Else's Factorio Game" above.
|
||||
|
||||
## Troubleshooting
|
||||
In case any problems should occur, the Archipelago Client will create a file `FactorioClient.txt` in the `/logs`.
|
||||
The contents of this file may help you troubleshoot an issue on your own and is vital for requesting help from other
|
||||
people in Archipelago.
|
||||
|
||||
## Additional Resources
|
||||
- [Alternate Tutorial by Umenen](https://docs.google.com/document/d/1yZPAaXB-QcetD8FJsmsFrenAHO5V6Y2ctMAyIoT9jS4)
|
||||
- [Factorio Speedrun Guide](https://www.youtube.com/watch?v=ExLrmK1c7tA)
|
||||
- [Factorio Wiki](https://wiki.factorio.com/)
|
||||
@@ -1,114 +0,0 @@
|
||||
# Final Fantasy 1 (NES) Multiworld Setup Guide
|
||||
|
||||
## Required Software
|
||||
- The FF1Client which is bundled with [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- The [BizHawk](http://tasvideos.org/BizHawk.html) emulator. Versions 2.3.1 and higher are supported.
|
||||
Version 2.7 is recommended
|
||||
- Your Final Fantasy (USA Edition) ROM file, probably named `Final Fantasy (USA).nes`. Neither Archipelago.gg nor the
|
||||
Final Fantasy Randomizer Community can supply you with this.
|
||||
|
||||
## Installation Procedures
|
||||
1. Download and install the latest version of [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
1. On Windows, download Setup.Archipelago.<HighestVersion>.exe and run it
|
||||
2. Assign Bizhawk version 2.3.1 or higher as your default program for launching `.nes` files.
|
||||
1. Extract your Bizhawk folder to your Desktop, or somewhere you will remember. Below are optional additional
|
||||
steps for loading ROMs more conveniently
|
||||
1. Right-click on a ROM file and select **Open with...**
|
||||
2. Check the box next to **Always use this app to open .nes files**
|
||||
3. Scroll to the bottom of the list and click the grey text **Look for another App on this PC**
|
||||
4. Browse for `EmuHawk.exe` located inside your Bizhawk folder (from step 1) and click **Open**.
|
||||
|
||||
## Playing a Multiworld
|
||||
Playing a multiworld on Archipelago.gg has 3 key components:
|
||||
1. The Server which is hosting a game for all players.
|
||||
2. The Client Program. For Final Fantasy 1, it is a standalone program but other randomizers may build it in.
|
||||
3. The Game itself, in this case running on Bizhawk, which then connects to the Client running on your computer.
|
||||
|
||||
To set this up the following steps are required:
|
||||
1. (Each Player) Generate your own yaml file and randomized ROM
|
||||
2. (Host Only) Generate a randomized game with you and 0 or more players using Archipelago
|
||||
3. (Host Only) Run the Archipelago Server
|
||||
4. (Each Player) Run your client program and connect it to the Server
|
||||
5. (Each Player) Run your game and connect it to your client program
|
||||
6. (Each Player) Play the game and have fun!
|
||||
|
||||
### Obtaining your Archipelago yaml file and randomized ROM
|
||||
Unlike most other Archipelago.gg games Final Fantasy 1 is randomized by the
|
||||
[main randomizer](https://finalfantasyrandomizer.com/). Generate a game by going to the site and performing the
|
||||
following steps:
|
||||
1. Select the randomization options (also known as `Flags` in the community) of your choice. If you do not know what
|
||||
you prefer, or it is your first time playing select the "Archipelago" preset on the main page.
|
||||
2. Go to the `Beta` tab and ensure `Archipelago` is enabled. Set your player name to any name that represents you.
|
||||
3. Upload you `Final Fantasy(USA).nes` (and click `Remember ROM` for the future!)
|
||||
4. Press the `NEW` button beside `Seed` a few times
|
||||
5. Click `GENERATE ROM`
|
||||
|
||||
It should download two files. One is the `*.nes` file which your emulator will run and the other is the yaml file
|
||||
required by Archipelago.gg
|
||||
|
||||
### Generating the Multiworld and Starting the Server
|
||||
The game can be generated locally or by Archipelago.gg.
|
||||
|
||||
#### Generating on Archipelago.gg (Recommended)
|
||||
1. Gather all yaml files
|
||||
2. Create a zip file containing all of the yaml files. Make sure it is a `*.zip` not a `*.7z` or a `*.rar`
|
||||
3. Navigate to the [Generate Page](https://archipelago.gg/generate) and click `Upload File`
|
||||
1. For your first game keep `Forfeit Permission` as `Automatic on goal completion`. Forfeiting actually means
|
||||
giving out all of the items remaining in your game in this case so you do not block anyone else.
|
||||
2. For your first game keep `Hint Cost` at 10%
|
||||
4. Select your zip file
|
||||
|
||||
#### Generating Locally
|
||||
1. Navigate to your Archipelago install directory
|
||||
2. Empty the `Players` directory then fill it with one yaml per player including your own which you got from the
|
||||
finalfantasyrandomizer website above
|
||||
3. Run `ArchipelagoGenerate.exe` (double-click it in File Explorer)
|
||||
4. You will find your generated game in the `output` directory
|
||||
|
||||
#### Starting the server
|
||||
If you generated on Archipelago.gg click `Create New Room` on the results page to start your server
|
||||
If you generated locally simply navigate to the [Host Game Page](https://archipelago.gg/uploads) and upload the file
|
||||
in the `output` directory
|
||||
|
||||
### Running the Client Program and Connecting to the Server
|
||||
1. Navigate to your Archipelago install folder and run `ArchipelagoFF1Client.exe`
|
||||
2. Notice the `/connect command` on the server hosting page (It should look like `/connect archipelago.gg:*****` where
|
||||
***** are numbers)
|
||||
3. Type the connect command into the client OR add the port to the pre-populated address on the top bar (it should
|
||||
already say `archipelago.gg`) and click `connect`
|
||||
|
||||
#### Running Your Game and Connecting to the Client Program
|
||||
1. Open Bizhawk 2.3.1 or higher and load your ROM OR
|
||||
click your ROM file if it is already associated with the extension `*.nes`
|
||||
2. Click on the Tools menu and click on **Lua Console**
|
||||
3. Click the folder button to open a new Lua script. (CTL-O or **Script** -> **Open Script**)
|
||||
4. Navigate to the location you installed Archipelago to. Open data/lua/FF1/ff1_connector.lua
|
||||
1. If it gives a `NLua.Exceptions.LuaScriptException: .\socket.lua:13: module 'socket.core' not found:` exception
|
||||
close your emulator entirely, restart it and re-run these steps
|
||||
2. If it says `Must use a version of bizhawk 2.3.1 or higher`, double-check your Bizhawk version by clicking
|
||||
**Help** -> **About**
|
||||
|
||||
### Play the game
|
||||
When the client shows both NES and server are connected you are good to go. You can check the connection status of the
|
||||
NES at any time by running `/nes`
|
||||
|
||||
### Helpful Commands
|
||||
Commands are broken into two types: `/` and `!` commands. The difference is that `/commands` are local to your machine
|
||||
and game whereas `!` commands ask the server. Most of the time you can use local commands.
|
||||
|
||||
#### Local Commands
|
||||
- `/connect <address with port number>` connect to the multiworld server
|
||||
- `/disconnect` if you accidentally connected to the wrong port run this to disconnect and then reconnect using
|
||||
- `/nes` Shows the current status of the NES connection
|
||||
- `/received` Displays all the items you have found or been sent
|
||||
- `/missing` Displays all the locations along with their current status (checked/missing)
|
||||
- Just typing anything will broadcast a message to all players
|
||||
|
||||
#### Remote Commands
|
||||
- `!hint <item name>` Tells you at which location in whose game your Item is. Note you need to have checked some locations
|
||||
to earn a hint. You can check how many you have by just running `!hint`
|
||||
- `!forfeit` If you didn't turn on auto-forfeit or you allowed forfeiting prior to goal completion. Remember that
|
||||
"forfeiting" actually means sending out your remaining items in your world
|
||||
|
||||
#### Host only (on Archipelago.gg)
|
||||
`/forfeit <Player Name>` Forfeits someone regardless of settings and game completion status
|
||||
@@ -1,58 +0,0 @@
|
||||
# Minecraft Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition) (update 1.17.1)
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) (select `Minecraft Client` during installation.)
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
### What is a YAML file and why do I need one?
|
||||
Your YAML file contains a set of configuration options which provide the generator with information about how
|
||||
it should generate your game. Each player of a multiworld will provide their own YAML file. This setup allows
|
||||
each player to enjoy an experience customized for their taste, and different players in the same multiworld
|
||||
can all have different options.
|
||||
|
||||
### Where do I get a YAML file?
|
||||
you can customize your settings by visiting the [minecraft player settings](/games/Minecraft/player-settings)
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your Minecraft data file
|
||||
**Only one yaml file needs to be submitted per minecraft world regardless of how many players play on it.**
|
||||
|
||||
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that
|
||||
is done, the host will provide you with either a link to download your data file, or with a zip file containing
|
||||
everyone's data files. Your data file should have a `.apmc` extension.
|
||||
|
||||
double-click on your `.apmc` file to have the minecraft client auto-launch the installed forge server.
|
||||
make sure to leave this window open as this is your server console.
|
||||
|
||||
### Connect to the MultiServer
|
||||
Using minecraft 1.17.1 connect to the server `localhost`.
|
||||
|
||||
Once in game type `/connect <AP-Address> (Port) (Password)` where `<AP-Address>` is the address of the
|
||||
Archipelago server. `(Port)` is only required if the Archipelago server is not using the default port of 38281. `(Password)`
|
||||
is only required if the Archipleago server you are using has a password set.
|
||||
|
||||
### Play the game
|
||||
When the console tells you that you have joined the room, you're all set. Congratulations
|
||||
on successfully joining a multiworld game! At this point any additional minecraft players may connect to your
|
||||
forge server. to star the game once everyone is ready type `/start`.
|
||||
|
||||
### Useful commands
|
||||
- `!help` displays a list all server commands
|
||||
- `!hint` will display how many hint points you have, along with any hints that have been given that are related to your game.
|
||||
- `!hint (item)` will ask the server to tell you where (item) is
|
||||
- `!hint_location (location)` will ask the server to tell you what item is on (location)
|
||||
|
||||
## Manual Installation
|
||||
it is highly recommended to ues the Archipelago installer to handle the installation of the forge server for you.
|
||||
support will not be given for those wishing to manually install forge. but for those of you who know how, and wish to
|
||||
do so the following links are the versions of the software we use.
|
||||
### Manual install Software links
|
||||
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.17.1.html)
|
||||
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
|
||||
**DO NOT INSTALL THIS ON YOUR CLIENT**
|
||||
- [Java 16](https://docs.aws.amazon.com/corretto/latest/corretto-16-ug/downloads-list.html)
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# Risk of Rain 2 Setup Guide
|
||||
|
||||
## Install using r2modman
|
||||
### Install r2modman
|
||||
Head on over to the r2modman page on Thunderstore and follow the installation instructions.
|
||||
|
||||
[https://thunderstore.io/package/ebkr/r2modman/](https://thunderstore.io/package/ebkr/r2modman/)
|
||||
|
||||
### Install Archipelago Mod using r2modman
|
||||
You can install the Archipelago mod using r2modman in one of two ways.
|
||||
|
||||
[https://thunderstore.io/package/ArchipelagoMW/Archipelago/](https://thunderstore.io/package/ArchipelagoMW/Archipelago/)
|
||||
|
||||
One, you can use the Thunderstore website and click on the "Install with Mod Manager" link.
|
||||
|
||||
You can also search for the "Archipelago" mod in the r2modman interface.
|
||||
The mod manager should automatically install all necessary dependencies as well.
|
||||
|
||||
### Running the Modded Game
|
||||
Click on the "Start modded" button in the top left in r2modman to start the game with the
|
||||
Archipelago mod installed.
|
||||
|
||||
## Joining an Archipelago Session
|
||||
There will be a menu button on the right side of the screen in the character select menu.
|
||||
Click it in order to bring up the in lobby mod config.
|
||||
From here you can expand the Archipelago sections and fill in the relevant info.
|
||||
Keep password blank if there is no password on the server.
|
||||
|
||||
Simply check `Enable Archipelago?` and when you start the run it will automatically connect.
|
||||
|
||||
## Gameplay
|
||||
The Risk of Rain 2 players send checks by causing items to spawn in-game. That means opening chests or killing bosses, generally.
|
||||
An item check is only sent out after a certain number of items are picked up. This count is configurable in the player's YAML.
|
||||
|
||||
## YAML Settings
|
||||
An example YAML would look like this:
|
||||
```yaml
|
||||
description: Ijwu-ror2
|
||||
name: Ijwu
|
||||
|
||||
game:
|
||||
Risk of Rain 2: 1
|
||||
|
||||
Risk of Rain 2:
|
||||
total_locations: 15
|
||||
total_revivals: 4
|
||||
start_with_revive: true
|
||||
item_pickup_step: 1
|
||||
enable_lunar: true
|
||||
item_weights:
|
||||
default: 50
|
||||
new: 0
|
||||
uncommon: 0
|
||||
legendary: 0
|
||||
lunartic: 0
|
||||
chaos: 0
|
||||
no_scraps: 0
|
||||
even: 0
|
||||
scraps_only: 0
|
||||
item_pool_presets: true
|
||||
# custom item weights
|
||||
green_scrap: 16
|
||||
red_scrap: 4
|
||||
yellow_scrap: 1
|
||||
white_scrap: 32
|
||||
common_item: 64
|
||||
uncommon_item: 32
|
||||
legendary_item: 8
|
||||
boss_item: 4
|
||||
lunar_item: 16
|
||||
equipment: 32
|
||||
```
|
||||
|
||||
| Name | Description | Allowed values |
|
||||
| ---- | ----------- | -------------- |
|
||||
| total_locations | The total number of location checks that will be attributed to the Risk of Rain player. This option is ALSO the total number of items in the item pool for the Risk of Rain player. | 10 - 100 |
|
||||
| total_revivals | The total number of items in the Risk of Rain player's item pool (items other players pick up for them) replaced with `Dio's Best Friend`. | 0 - 5 |
|
||||
| start_with_revive | Starts the player off with a `Dio's Best Friend`. Functionally equivalent to putting a `Dio's Best Friend` in your `starting_inventory`. | true/false |
|
||||
| item_pickup_step | The number of item pickups which you are allowed to claim before they become an Archipelago location check. | 0 - 5 |
|
||||
| enable_lunar | Allows for lunar items to be shuffled into the item pool on behalf of the Risk of Rain player. | true/false |
|
||||
| item_weights | Each option here is a preset item weight that can be used to customize your generate item pool with certain settings. | default, new, uncommon, legendary, lunartic, chaos, no_scraps, even, scraps_only |
|
||||
| item_pool_presets | A simple toggle to determine whether the item_weight presets are used or the custom item pool as defined below | true/false |
|
||||
| custom item weights | Each defined item here is a single item in the pool that will have a weight against the other items when the item pool gets generated. These values can be modified to adjust how frequently certain items appear | 0-100|
|
||||
|
||||
|
||||
Using the example YAML above: the Risk of Rain 2 player will have 15 total items which they can pick up for other players. (total_locations = 15)
|
||||
|
||||
They will have 15 items waiting for them in the item pool which will be distributed out to the multiworld. (total_locations = 15)
|
||||
|
||||
They will complete a location check every second item. (item_pickup_step = 1)
|
||||
|
||||
They will have 4 of the items which other players can grant them replaced with `Dio's Best Friend`. (total_revivals = 4)
|
||||
|
||||
The player will also start with a `Dio's Best Friend`. (start_with_revive = true)
|
||||
|
||||
The player will have lunar items shuffled into the item pool on their behalf. (enable_lunar = true)
|
||||
|
||||
The player will have the default preset generated item pool with the custom item weights being ignored. (item_weights: default and item_pool_presets: true)
|
||||
@@ -1,119 +0,0 @@
|
||||
# Secret of Evermore Setup Guide
|
||||
|
||||
## Required Software
|
||||
- [SNI](https://github.com/alttpo/sni/releases) v0.0.59 or newer (included in Archipelago 0.2.1 setup)
|
||||
- Hardware or software capable of loading and playing SNES ROM files
|
||||
- An emulator capable of connecting to SNI with ROM access
|
||||
- [snes9x-rr](https://github.com/gocha/snes9x-rr/releases) or
|
||||
- [BizHawk](http://tasvideos.org/BizHawk.html) or
|
||||
- [bsnes-plus-nwa](https://github.com/black-sliver/bsnes-plus)
|
||||
- Or SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
|
||||
- Your Secret of Evermore US ROM file, probably named `Secret of Evermore (USA).sfc`
|
||||
|
||||
## Create a Config (.yaml) File
|
||||
|
||||
### What is a config file and why do I need one?
|
||||
Your config file contains a set of configuration options which provide the generator with information about how
|
||||
it should generate your game. Each player of a multiworld will provide their own config file. This setup allows
|
||||
each player to enjoy an experience customized for their taste, and different players in the same multiworld
|
||||
can all have different options.
|
||||
|
||||
### Where do I get a config file?
|
||||
The [Player Settings](/games/Secret%20of%20Evermore/player-settings) page on the website allows you to configure your
|
||||
personal settings and export a config file from them.
|
||||
|
||||
### Verifying your config file
|
||||
If you would like to validate your config file to make sure it works, you may do so on the
|
||||
[YAML Validator](/mysterycheck) page.
|
||||
|
||||
## Generating a Single-Player Game
|
||||
Stand-alone "Evermizer" has a way of balancing single-player games, but may not always be on par feature-wise.
|
||||
Head over to [evermizer.com](https://evermizer.com) if you want to try the official stand-alone, otherwise read below.
|
||||
|
||||
1. Navigate to the [Player Settings](/games/Secret%20of%20Evermore/player-settings) page, configure your options, and
|
||||
click the "Generate Game" button.
|
||||
2. You will be presented with a "Seed Info" page.
|
||||
3. Click the "Create New Room" link.
|
||||
4. You will be presented with a server page, from which you can download your patch file.
|
||||
5. Run your patch file through [apbpatch](https://evermizer.com/apbpatch) and load it in your emulator or console.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your patch file and create your ROM
|
||||
When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that
|
||||
is done, the host will provide you with either a link to download your patch file, or with a zip file containing
|
||||
everyone's patch files. Your patch file should have a `.apsoe` extension.
|
||||
|
||||
Put your patch file on your desktop or somewhere convenient, open [apbpatch](https://evermizer.com/apbpatch) and
|
||||
generate your ROM from it. Load the ROM file in your emulator or console.
|
||||
|
||||
### Connect to SNI
|
||||
|
||||
#### With an emulator
|
||||
Start SNI either from the Archipelago install folder or the stand-alone version.
|
||||
If this is its first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
|
||||
|
||||
##### snes9x-rr
|
||||
1. Load your ROM file if it hasn't already been loaded.
|
||||
2. Click on the File menu and hover on **Lua Scripting**
|
||||
3. Click on **New Lua Script Window...**
|
||||
4. In the new window, click **Browse...**
|
||||
5. Select the `Connector.lua` file from your SNI installation:
|
||||
* `SNI/lua/x86/Connector.lua` for 32bit snes9x-rr or
|
||||
* `SNI/lua/x64/Connector.lua` for "x64" snes9x-rr
|
||||
6. Leave the Lua window open while you are playing.
|
||||
|
||||
* If the script window complains about missing `socket.dll` make sure it is in the lua directory.
|
||||
* If the script window complains about "Bad EXE format", use the other Connector above (x86 <-> x64)
|
||||
|
||||
##### BizHawk
|
||||
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
|
||||
these menu options:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Once you have changed the loaded core, you must restart BizHawk.
|
||||
2. Load your ROM file if it hasn't already been loaded.
|
||||
3. Click on the Tools menu and click on **Lua Console**
|
||||
4. Click the button to open a new Lua script.
|
||||
5. Select any `Connector.lua` file from your SNI installation
|
||||
|
||||
##### bsnes-plus-nwa
|
||||
This should automatically connect to SNI.
|
||||
If this is its first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
|
||||
|
||||
#### With hardware
|
||||
This guide assumes you have downloaded the correct firmware for your device. If you have not
|
||||
done so already, please do this now. SD2SNES and FXPak Pro users may download the appropriate firmware
|
||||
[here](https://github.com/RedGuyyyy/sd2snes/releases). Other hardware may find helpful information
|
||||
[on this page](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
1. Copy the ROM file to your SD card.
|
||||
2. Load the ROM file from the menu.
|
||||
|
||||
### Open the client
|
||||
Open [ap-soeclient](http://evermizer.com/apclient) in a modern browser. Do not switch tabs, open it in a new window
|
||||
if you want to use the browser while playing. Do not minimize the window with the client.
|
||||
|
||||
The client should automatically connect to SNI, the "SNES" status should change to green.
|
||||
|
||||
### Connect to the Archipelago Server
|
||||
Enter `/connect server:port` in the client's command prompt and press enter. You'll find `server:port` on the same page
|
||||
that had the patch file.
|
||||
|
||||
### Play the game
|
||||
When the game is loaded but not yet past the intro cutscene, the "Game" status is yellow. When the client shows "AP" as
|
||||
green and "Game" as yellow, you're ready to play. The intro can be skipped pressing the START button and "Game" should
|
||||
change to green. Congratulations on successfully joining a multiworld game!
|
||||
|
||||
## Hosting a MultiWorld game
|
||||
The recommended way to host a game is to use our [hosting service](/generate). The process is relatively simple:
|
||||
|
||||
1. Collect config files from your players.
|
||||
2. Create a zip file containing your players' config files.
|
||||
3. Upload that zip file to the website linked above.
|
||||
4. Wait a moment while the seed is generated.
|
||||
5. When the seed is generated, you will be redirected to a "Seed Info" page.
|
||||
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players,
|
||||
so they may download their patch files from there.
|
||||
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
|
||||
players in the game. Any observers may also be given the link to this page.
|
||||
8. Once all players have joined, you may begin playing.
|
||||
@@ -1,126 +0,0 @@
|
||||
# Super Metroid Setup Guide
|
||||
|
||||
## Required Software
|
||||
- [Super Metroid Client](https://github.com/ArchipelagoMW/SuperMetroidClient/releases)
|
||||
- **sniConnector.lua** (located on the client download page)
|
||||
- [SNI](https://github.com/alttpo/sni/releases) (Included in the Super Metroid Client)
|
||||
- Hardware or software capable of loading and playing SNES ROM files
|
||||
- An emulator capable of connecting to SNI
|
||||
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html))
|
||||
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
|
||||
- Your Super Metroid ROM file, probably named `Super Metroid (Japan, USA).sfc`
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
### Windows Setup
|
||||
1. Download and install the Super Metroid Client from the link above, making sure to install the most recent version.
|
||||
**The file is located in the assets section at the bottom of the version information**.
|
||||
2. During setup, you will be asked to locate your base ROM file. This is your Super Metroid ROM file.
|
||||
3. If you are using an emulator, you should assign your Lua capable emulator as your default program
|
||||
for launching ROM files.
|
||||
1. Extract your emulator's folder to your Desktop, or somewhere you will remember.
|
||||
2. Right-click on a ROM file and select **Open with...**
|
||||
3. Check the box next to **Always use this app to open .sfc files**
|
||||
4. Scroll to the bottom of the list and click the grey text **Look for another App on this PC**
|
||||
5. Browse for your emulator's `.exe` file and click **Open**. This file should be located inside
|
||||
the folder you extracted in step one.
|
||||
|
||||
### Macintosh Setup
|
||||
- We need volunteers to help fill this section! Please contact **Farrak Kilhn** on Discord if you want to help.
|
||||
|
||||
## Create a Config (.yaml) File
|
||||
|
||||
### What is a config file and why do I need one?
|
||||
Your config file contains a set of configuration options which provide the generator with information about how
|
||||
it should generate your game. Each player of a multiworld will provide their own config file. This setup allows
|
||||
each player to enjoy an experience customized for their taste, and different players in the same multiworld
|
||||
can all have different options.
|
||||
|
||||
### Where do I get a config file?
|
||||
The [Player Settings](/games/Super%20Metroid/player-settings) page on the website allows you to configure your
|
||||
personal settings and export a config file from them.
|
||||
|
||||
### Verifying your config file
|
||||
If you would like to validate your config file to make sure it works, you may do so on the
|
||||
[YAML Validator](/mysterycheck) page.
|
||||
|
||||
## Generating a Single-Player Game
|
||||
1. Navigate to the [Player Settings](/games/Super%20Metroid/player-settings) page, configure your options, and click
|
||||
the "Generate Game" button.
|
||||
2. You will be presented with a "Seed Info" page.
|
||||
3. Click the "Create New Room" link.
|
||||
4. You will be presented with a server page, from which you can download your patch file.
|
||||
5. Double-click on your patch file, and the Super Metroid Client will launch automatically, create your ROM from
|
||||
the patch file, and open your emulator for you.
|
||||
6. Since this is a single-player game, you will no longer need the client, so feel free to close it.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your patch file and create your ROM
|
||||
When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that
|
||||
is done, the host will provide you with either a link to download your patch file, or with a zip file containing
|
||||
everyone's patch files. Your patch file should have a `.apm3` extension.
|
||||
|
||||
Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically
|
||||
launch the client, and will also create your ROM in the same place as your patch file.
|
||||
|
||||
### Connect to the client
|
||||
|
||||
#### With an emulator
|
||||
When the client launched automatically, SNI should have also automatically launched in the background.
|
||||
If this is its first time launching, you may be prompted to allow it to communicate through the Windows
|
||||
Firewall.
|
||||
|
||||
##### snes9x Multitroid
|
||||
1. Load your ROM file if it hasn't already been loaded.
|
||||
2. Click on the File menu and hover on **Lua Scripting**
|
||||
3. Click on **New Lua Script Window...**
|
||||
4. In the new window, click **Browse...**
|
||||
5. Select the `sniConnector.lua` file you downloaded above
|
||||
|
||||
##### BizHawk
|
||||
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
|
||||
these menu options:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Once you have changed the loaded core, you must restart BizHawk.
|
||||
2. Load your ROM file if it hasn't already been loaded.
|
||||
3. Click on the Tools menu and click on **Lua Console**
|
||||
4. Click the button to open a new Lua script.
|
||||
5. Select the `sniConnector.lua` file you downloaded above
|
||||
|
||||
#### With hardware
|
||||
This guide assumes you have downloaded the correct firmware for your device. If you have not
|
||||
done so already, please do this now. SD2SNES and FXPak Pro users may download the appropriate firmware
|
||||
[here](https://github.com/RedGuyyyy/sd2snes/releases). Other hardware may find helpful information
|
||||
[on this page](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
1. Close your emulator, which may have auto-launched.
|
||||
2. Power on your device and load the ROM.
|
||||
|
||||
### Connect to the Archipelago Server
|
||||
The patch file which launched your client should have automatically connected you to the AP Server.
|
||||
There are a few reasons this may not happen however, including if the game is hosted on the website but
|
||||
was generated elsewhere. If the client window shows "Server Status: Not Connected", simply ask the host
|
||||
for the address of the server, and copy/paste it into the "Server" input field then press enter.
|
||||
|
||||
The client will attempt to reconnect to the new server address, and should momentarily show "Server
|
||||
Status: Connected".
|
||||
|
||||
### Play the game
|
||||
When the client shows both SNES Device and Server as connected, you're ready to begin playing. Congratulations
|
||||
on successfully joining a multiworld game!
|
||||
|
||||
## Hosting a MultiWorld game
|
||||
The recommended way to host a game is to use our [hosting service](/generate). The process is relatively simple:
|
||||
|
||||
1. Collect config files from your players.
|
||||
2. Create a zip file containing your players' config files.
|
||||
3. Upload that zip file to the website linked above.
|
||||
4. Wait a moment while the seed is generated.
|
||||
5. When the seed is generated, you will be redirected to a "Seed Info" page.
|
||||
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players,
|
||||
so they may download their patch files from there.
|
||||
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
|
||||
players in the game. Any observers may also be given the link to this page.
|
||||
8. Once all players have joined, you may begin playing.
|
||||
@@ -1,60 +0,0 @@
|
||||
# Timespinner Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- [Timespinner (steam)](https://store.steampowered.com/app/368620/Timespinner/) or [Timespinner (drm free)](https://www.humblebundle.com/store/timespinner)
|
||||
- [Timespinner Randomizer](https://github.com/JarnoWesthof/TsRandomizer)
|
||||
|
||||
## General Concept
|
||||
|
||||
The timespinner Randomizer loads Timespinner.exe from the same folder, and alters its state in memory to allow for randomization of the items
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
Download latest version of [Timespinner Randomizer](https://github.com/JarnoWesthof/TsRandomizer) you can find the .zip files on the releases page, download the zip for your current platform. Then extract the zip to the folder where your Timespinner game is installed. Then just run TsRandomizer.exe instead of Timespinner.exe to start the game in randomized mode, for more info see the [readme](https://github.com/JarnoWesthof/TsRandomizer)
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Run TsRandomizer.exe
|
||||
2. Select "New Game"
|
||||
3. Switch "<< Select Seed >>" to "<< Archiplago >>" by pressing left on the controller or keyboard
|
||||
4. Select "<< Archiplago >>" to open a new menu where you can enter your Archipelago login credentails
|
||||
* NOTE: the input fields support Ctrl + V pasting of values
|
||||
5. Select "Connect"
|
||||
6. If all went well you will be taken back the difficulty selection menu and the game will start as soon as you select a difficulty
|
||||
|
||||
## YAML Settings
|
||||
An example YAML would look like this:
|
||||
```yaml
|
||||
description: Default Timespinner Template
|
||||
name: Lunais{number} # Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
|
||||
game:
|
||||
Timespinner: 1
|
||||
requires:
|
||||
version: 0.1.8
|
||||
Timespinner:
|
||||
StartWithJewelryBox: # Start with Jewelry Box unlocked
|
||||
false: 50
|
||||
true: 0
|
||||
DownloadableItems: # With the tablet you will be able to download items at terminals
|
||||
false: 50
|
||||
true: 50
|
||||
FacebookMode: # Requires Oculus Rift(ng) to spot the weakspots in walls and floors
|
||||
false: 50
|
||||
true: 0
|
||||
StartWithMeyef: # Start with Meyef, ideal for when you want to play multiplayer
|
||||
false: 50
|
||||
true: 50
|
||||
QuickSeed: # Start with Talaria Attachment, Nyoom!
|
||||
false: 50
|
||||
true: 0
|
||||
SpecificKeycards: # Keycards can only open corresponding doors
|
||||
false: 0
|
||||
true: 50
|
||||
Inverted: # Start in the past
|
||||
false: 50
|
||||
true: 50
|
||||
```
|
||||
* All Options are either enabled or not, if values are specified for both true & false the generator will select one based on weight
|
||||
* The Timespinner Randomizer option "StinkyMaw" is currently always enabled for Archipelago generated seeds
|
||||
* The Timespinner Randomizer options "ProgressiveVerticalMovement" & "ProgressiveKeycards" are currently not supported on Archipelago generated seeds
|
||||
@@ -1,346 +0,0 @@
|
||||
[
|
||||
{
|
||||
"gameTitle": "Archipelago",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago software to generate and host multiworld games on your computer and using the website.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/setup_en.md",
|
||||
"link": "archipelago/setup/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Using Advanced Settings",
|
||||
"description": "A guide to reading yaml files and editing them to fully customize your game.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/advanced_settings_en.md",
|
||||
"link": "archipelago/advanced_settings/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Archipelago Triggers Guide",
|
||||
"description": "A guide to setting up and using triggers in your game settings.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/triggers_en.md",
|
||||
"link": "archipelago/triggers/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Archipelago Plando Guide",
|
||||
"description": "A guide to understanding and using plando for your game.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/plando_en.md",
|
||||
"link": "archipelago/plando/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "The Legend of Zelda: A Link to the Past",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago ALttP software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/multiworld_en.md",
|
||||
"link": "zelda3/multiworld/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Deutsch",
|
||||
"filename": "zelda3/multiworld_de.md",
|
||||
"link": "zelda3/multiworld/de",
|
||||
"authors": [
|
||||
"Fischfilet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Español",
|
||||
"filename": "zelda3/multiworld_es.md",
|
||||
"link": "zelda3/multiworld/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Français",
|
||||
"filename": "zelda3/multiworld_fr.md",
|
||||
"link": "zelda3/multiworld/fr",
|
||||
"authors": [
|
||||
"Coxla"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MSU-1 Setup Tutorial",
|
||||
"description": "A guide to setting up MSU-1, which allows for custom in-game music.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/msu1_en.md",
|
||||
"link": "zelda3/msu1/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Español",
|
||||
"filename": "zelda3/msu1_es.md",
|
||||
"link": "zelda3/msu1/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Français",
|
||||
"filename": "msu1_fr.md",
|
||||
"link": "zelda3/msu1/fr",
|
||||
"authors": [
|
||||
"Coxla"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Plando Tutorial",
|
||||
"description": "A guide to creating Multiworld Plandos",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/plando_en.md",
|
||||
"link": "zelda3/plando/en",
|
||||
"authors": [
|
||||
"Berserker"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "The Legend of Zelda: Ocarina of Time",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Ocarina of Time software on your computer.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda5/setup_en.md",
|
||||
"link": "zelda5/setup/en",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Spanish",
|
||||
"filename": "zelda5/setup_es.md",
|
||||
"link": "zelda5/setup/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Factorio",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Factorio software on your computer.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "factorio/setup_en.md",
|
||||
"link": "factorio/setup/en",
|
||||
"authors": [
|
||||
"Berserker",
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Minecraft",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Minecraft software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "minecraft/minecraft_en.md",
|
||||
"link": "minecraft/minecraft/en",
|
||||
"authors": [
|
||||
"Kono Tyran"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Spanish",
|
||||
"filename": "minecraft/minecraft_es.md",
|
||||
"link": "minecraft/minecraft/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Swedish",
|
||||
"filename": "minecraft/minecraft_sv.md",
|
||||
"link": "minecraft/minecraft/sv",
|
||||
"authors": [
|
||||
"Albinum"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Risk of Rain 2",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Risk of Rain 2 integration for Archipelago multiworld games.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ror2/setup_en.md",
|
||||
"link": "ror2/setup/en",
|
||||
"authors": [
|
||||
"Ijwu"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Timespinner",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Timespinner randomizer connected to an Archipelago Multiworld",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "timespinner/setup_en.md",
|
||||
"link": "timespinner/setup/en",
|
||||
"authors": [
|
||||
"Jarno"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Subnautica",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Subnautica randomizer connected to an Archipelago Multiworld",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Subnautica/setup_en.md",
|
||||
"link": "Subnautica/setup/en",
|
||||
"authors": [
|
||||
"Berserker"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Super Metroid",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Super Metroid Client on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "super-metroid/multiworld_en.md",
|
||||
"link": "super-metroid/multiworld/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Secret of Evermore",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to playing Secret of Evermore randomizer. This guide covers single-player, multiworld and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "secret-of-evermore/multiworld_en.md",
|
||||
"link": "secret-of-evermore/multiworld/en",
|
||||
"authors": [
|
||||
"Black Sliver"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Final Fantasy",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to playing Final Fantasy multiworld. This guide only covers playing multiworld.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ff1/multiworld_en.md",
|
||||
"link": "ff1/multiworld/en",
|
||||
"authors": [
|
||||
"jat2980"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,77 +0,0 @@
|
||||
# MSU-1 Setup Guide
|
||||
|
||||
## What is MSU-1?
|
||||
MSU-1 allows for the use of custom in-game music. It works on original hardware, the SuperNT, and certain emulators.
|
||||
This guide will explain how to find custom music packages, often called MSU packs, and how to configure
|
||||
them for use with original hardware, the SuperNT, and the snes9x emulator.
|
||||
|
||||
## Where to find MSU Packs
|
||||
MSU packs are constantly in development. You can find a list of completed packs, as well as in-development packs on
|
||||
[this Google Spreadsheet](https://docs.google.com/spreadsheets/d/1XRkR4Xy6S24UzYkYBAOv-VYWPKZIoUKgX04RbjF128Q).
|
||||
|
||||
## What an MSU pack should look like
|
||||
MSU packs contain many files, most of which are the music files which will be used when playing the game. These files
|
||||
should be named similarly, with a hyphenated number at the end, and with a `.pcm` extension. It does not matter what
|
||||
each music file is named, so long as they all follow the same pattern. The most popular filename you will find is
|
||||
`alttp_msu-X.pcm`, where X is replaced by a number.
|
||||
|
||||
There is one other type of file you should find inside an MSU pack's folder. This file indicates to the hardware or
|
||||
to the emulator that MSU should be enabled for this game. This file should be named similarly to the other files in
|
||||
the folder, but will have a `.msu` extension and be 0 KB in size.
|
||||
|
||||
A short example of the contents of an MSU pack folder are as follows:
|
||||
```
|
||||
List of files inside an MSU pack folder:
|
||||
alttp_msu.msu
|
||||
alttp_msu-1.pcm
|
||||
alttp_msu-2.pcm
|
||||
...
|
||||
alttp_msu-34.pcm
|
||||
```
|
||||
|
||||
## How to use an MSU Pack
|
||||
In all cases, you must rename your ROM file to match the pattern of names inside your MSU pack's folder, then place
|
||||
your ROM file inside that folder.
|
||||
|
||||
This will cause the folder contents to look like the following:
|
||||
```
|
||||
List of files inside an MSU pack folder:
|
||||
alttp_msu.msu
|
||||
alttp_msu.sfc <-- Add your ROM file
|
||||
alttp_msu-1.pcm
|
||||
alttp_msu-2.pcm
|
||||
...
|
||||
alttp_msu-34.pcm
|
||||
```
|
||||
|
||||
### With snes9x
|
||||
1. Load the ROM file from snes9x.
|
||||
|
||||
### With SD2SNES / FXPak on original hardware
|
||||
1. Load the MSU pack folder onto your SD2SNES / FXPak.
|
||||
2. Navigate into the MSU pack folder and load your ROM.
|
||||
|
||||
### With SD2SNES / FXPak on SuperNT
|
||||
1. Load the MSU pack folder onto your SD2SNES / FXPak.
|
||||
2. Power on your SuperNT and navigate to the `Settings` menu.
|
||||
3. Enter the `Audio` settings.
|
||||
4. Check the box marked `Cartridge Audio Enable.`
|
||||
5. Navigate back to the previous menu.
|
||||
6. Choose `Save/Clear Settings`.
|
||||
7. Choose `Save Settings`.
|
||||
8. Choose `Run Cartridge` from the main menu.
|
||||
9. Navigate into your MSU pack folder and load your ROM.
|
||||
|
||||
## A word of caution to streamers
|
||||
Many MSU packs use copyrighted music which is not permitted for use on platforms like Twitch and YouTube.
|
||||
If you choose to stream music from an MSU pack, please ensure you have permission to do so. If you stream
|
||||
music which has not been licensed to you, or licensed for use in a stream in general, your VOD may be muted.
|
||||
In the worst case, you may receive a DMCA take-down notice. Please be careful to only stream music for which
|
||||
you have the rights to do so.
|
||||
|
||||
##### Stream-safe MSU packs
|
||||
Below is a list of MSU packs which, so far as we know, are safe to stream. More will be added to this list as
|
||||
we learn of them. If you know of any we missed, please let us know!
|
||||
- Vanilla Game Music
|
||||
- [Smooth McGroove](https://drive.google.com/open?id=1JDa1jCKg5hG0Km6xNpmIgf4kDMOxVp3n)
|
||||
- Zelda community member Amarith assembled the following list for the purpose of competitive restreams. While we have not ourselves verified this list, all submissions required VoD proof they were not muted. Generally speaking, MSU-1 packs are less safe if they contain lyrics at any point. This list was only tested on Twitch and results for other platforms may vary. [Restream-Safe List](https://tinyurl.com/MSUsApprovedForLeagueChannels)
|
||||
@@ -23,6 +23,7 @@ window.addEventListener('load', () => {
|
||||
games.forEach((game) => {
|
||||
const gameTitle = document.createElement('h2');
|
||||
gameTitle.innerText = game.gameTitle;
|
||||
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
|
||||
tutorialDiv.appendChild(gameTitle);
|
||||
|
||||
game.tutorials.forEach((tutorial) => {
|
||||
@@ -65,7 +66,16 @@ window.addEventListener('load', () => {
|
||||
showError();
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// Check if we are on an anchor when coming in, and scroll to it.
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const offset = 128; // To account for navbar banner at top of page.
|
||||
window.scrollTo(0, 0);
|
||||
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
|
||||
window.scrollTo(rect.left, rect.top - offset);
|
||||
}
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/tutorials.json`, true);
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
1032
WebHostLib/static/assets/weighted-settings.js
Normal file
@@ -1,4 +1,3 @@
|
||||
Copyright 2020 Berserker66 (Fabian Dill)
|
||||
Copyright 2020 LegendaryLinux (Chris Wilson)
|
||||
Copyright 2022 LegendaryLinux (Chris Wilson)
|
||||
|
||||
All rights reserved.
|
||||
|
||||
BIN
WebHostLib/static/static/backgrounds/dirt.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
BIN
WebHostLib/static/static/backgrounds/grass-flowers.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
WebHostLib/static/static/backgrounds/grass.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 25 KiB |
BIN
WebHostLib/static/static/backgrounds/header/stone-header.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
WebHostLib/static/static/backgrounds/ice.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
WebHostLib/static/static/backgrounds/jungle.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB |
BIN
WebHostLib/static/static/backgrounds/party-time.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
WebHostLib/static/static/backgrounds/stone.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
3
WebHostLib/static/static/branding/LICENSE
Normal file
@@ -0,0 +1,3 @@
|
||||
Copyright 2022 LegendaryLinux (Chris Wilson)
|
||||
|
||||
All rights reserved.
|
||||